Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd686873c4 | ||
|
|
e0ab4041a2 | ||
|
|
cf3f4ebfe4 | ||
|
|
52ab774785 | ||
|
|
5031dceff8 | ||
|
|
0cefef16e4 | ||
|
|
8cba92d768 | ||
|
|
bfcdd0164c | ||
|
|
7119cd1d06 | ||
|
|
e6f965ff19 | ||
|
|
f2949cea3b | ||
|
|
40c5f25517 | ||
|
|
454cef4193 | ||
|
|
66c9054bd5 | ||
|
|
56099c079c | ||
|
|
20121981a0 | ||
|
|
e8db4ad7a3 | ||
|
|
5b99a6f81c | ||
|
|
a793fdacc4 | ||
|
|
414cf8cb8c | ||
|
|
e7c7b9b5c6 | ||
|
|
87535dc2ad | ||
|
|
3dff8a8c8e | ||
|
|
00dccd648c | ||
|
|
92e393ab03 | ||
|
|
22f745762f | ||
|
|
b3bb7c4a59 | ||
|
|
ef1c05c13b | ||
|
|
f63226b2cc | ||
|
|
f3a0c45331 | ||
|
|
8451db1142 | ||
|
|
cd0ceeab41 | ||
|
|
13699864d8 | ||
|
|
c2010e272e | ||
|
|
098c3c3d1e | ||
|
|
3d6ac578db | ||
|
|
0f9dc05774 | ||
|
|
375b8fb345 | ||
|
|
c5c2667553 | ||
|
|
f375b1172b |
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
## User settings
|
## User settings
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
|
xcshareddata/
|
||||||
|
|
||||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||||
*.xcscmblueprint
|
*.xcscmblueprint
|
||||||
|
|||||||
+1
-12
@@ -72,17 +72,6 @@ oAI/
|
|||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
### Build Scripts
|
|
||||||
|
|
||||||
| Script | Architecture | Output |
|
|
||||||
|--------|-------------|--------|
|
|
||||||
| `build.sh` | Apple Silicon (arm64) | Installs directly to `/Applications` |
|
|
||||||
| `build-dmg.sh` | Apple Silicon (arm64) | `oAI-<version>-AppleSilicon.dmg` on Desktop |
|
|
||||||
| `build-dmg-universal.sh` | Universal (arm64 + x86_64) | `oAI-<version>-Universal.dmg` on Desktop |
|
|
||||||
| `build_nb/sv/da/de/en.sh` | Apple Silicon (arm64) | Build + launch in specific language |
|
|
||||||
|
|
||||||
All scripts: find Developer ID cert, clean-build via `xcodebuild`, re-sign with `codesign --options runtime --timestamp`, verify. Version is read from `MARKETING_VERSION` in `project.pbxproj`.
|
|
||||||
|
|
||||||
### Manual Build Commands
|
### Manual Build Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -120,7 +109,7 @@ XProtect 5331 flags Debug builds (bash + IMAP + file access = RAT signature matc
|
|||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! By submitting a pull request you agree that your contribution will be licensed under the AGPL-3.0.
|
Contributions are welcome! By submitting a pull request you agree that your contribution will be licensed under the PolyForm Noncommercial License 1.0.0, and you grant Rune Olsen the right to relicense your contribution (including for commercial licensing) as part of the project.
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
|||||||
@@ -1,663 +1,146 @@
|
|||||||
oAI — Copyright (C) 2026 Rune Olsen <https://blog.rune.pm>
|
# PolyForm Noncommercial License 1.0.0
|
||||||
|
|
||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
||||||
Version 3, 19 November 2007
|
|
||||||
|
Required Notice: Copyright (C) 2026 Rune Olsen (https://oai.pm)
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
## Acceptance
|
||||||
of this license document, but changing it is not allowed.
|
|
||||||
|
In order to get any license under these terms, you must agree
|
||||||
Preamble
|
to them as both strict obligations and conditions to all
|
||||||
|
your licenses.
|
||||||
The GNU Affero General Public License is a free, copyleft license for
|
|
||||||
software and other kinds of works, specifically designed to ensure
|
## Copyright License
|
||||||
cooperation with the community in the case of network server software.
|
|
||||||
|
The licensor grants you a copyright license for the
|
||||||
The licenses for most software and other practical works are designed
|
software to do everything you might do with the software
|
||||||
to take away your freedom to share and change the works. By contrast,
|
that would otherwise infringe the licensor's copyright
|
||||||
our General Public Licenses are intended to guarantee your freedom to
|
in it for any permitted purpose. However, you may
|
||||||
share and change all versions of a program--to make sure it remains free
|
only distribute the software according to [Distribution
|
||||||
software for all its users.
|
License](#distribution-license) and make changes or new works
|
||||||
|
based on the software according to [Changes and New Works
|
||||||
When we speak of free software, we are referring to freedom, not
|
License](#changes-and-new-works-license).
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
|
||||||
have the freedom to distribute copies of free software (and charge for
|
## Distribution License
|
||||||
them if you wish), that you receive source code or can get it if you
|
|
||||||
want it, that you can change the software or use pieces of it in new
|
The licensor grants you an additional copyright license
|
||||||
free programs, and that you know you can do these things.
|
to distribute copies of the software. Your license
|
||||||
|
to distribute covers distributing the software with
|
||||||
Developers that use our General Public Licenses protect your rights
|
changes and new works permitted by [Changes and New Works
|
||||||
with two steps: (1) assert copyright on the software, and (2) offer
|
License](#changes-and-new-works-license).
|
||||||
you this License which gives you legal permission to copy, distribute
|
|
||||||
and/or modify the software.
|
## Notices
|
||||||
|
|
||||||
A secondary benefit of defending all users' freedom is that
|
You must ensure that anyone who gets a copy of any part of
|
||||||
improvements made in alternate versions of the program, if they
|
the software from you also gets a copy of these terms or the
|
||||||
receive widespread use, become available for other developers to
|
URL for them above, as well as copies of any plain-text lines
|
||||||
incorporate. Many developers of free software are heartened and
|
beginning with `Required Notice:` that the licensor provided
|
||||||
encouraged by the resulting cooperation. However, in the case of
|
with the software. For example:
|
||||||
software used on network servers, this result may fail to come about.
|
|
||||||
The GNU General Public License permits making a modified version and
|
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
||||||
letting the public access it on a server without ever releasing its
|
|
||||||
source code to the public.
|
## Changes and New Works License
|
||||||
|
|
||||||
The GNU Affero General Public License is designed specifically to
|
The licensor grants you an additional copyright license to
|
||||||
ensure that, in such cases, the modified source code becomes available
|
make changes and new works based on the software for any
|
||||||
to the community. It requires the operator of a network server to
|
permitted purpose.
|
||||||
provide the source code of the modified version running there to the
|
|
||||||
users of that server. Therefore, public use of a modified version, on
|
## Patent License
|
||||||
a publicly accessible server, gives the public access to the source
|
|
||||||
code of the modified version.
|
The licensor grants you a patent license for the software that
|
||||||
|
covers patent claims the licensor can license, or becomes able
|
||||||
An older license, called the Affero General Public License and
|
to license, that you would infringe by using the software.
|
||||||
published by Affero, was designed to accomplish similar goals. This is
|
|
||||||
a different license, not a version of the Affero GPL, but Affero has
|
## Noncommercial Purposes
|
||||||
released a new version of the Affero GPL which permits relicensing under
|
|
||||||
this license.
|
Any noncommercial purpose is a permitted purpose.
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
## Personal Uses
|
||||||
modification follow.
|
|
||||||
|
Personal use for research, experiment, and testing for
|
||||||
TERMS AND CONDITIONS
|
the benefit of public knowledge, personal study, private
|
||||||
|
entertainment, hobby projects, amateur pursuits, or religious
|
||||||
0. Definitions.
|
observance, without any anticipated commercial application,
|
||||||
|
is use for a permitted purpose.
|
||||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
|
||||||
|
## Noncommercial Organizations
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
|
||||||
works, such as semiconductor masks.
|
Use by any charitable organization, educational institution,
|
||||||
|
public research organization, public safety or health
|
||||||
"The Program" refers to any copyrightable work licensed under this
|
organization, environmental protection organization,
|
||||||
License. Each licensee is addressed as "you". "Licensees" and
|
or government institution is use for a permitted purpose
|
||||||
"recipients" may be individuals or organizations.
|
regardless of the source of funding or obligations resulting
|
||||||
|
from the funding.
|
||||||
To "modify" a work means to copy from or adapt all or part of the work
|
|
||||||
in a fashion requiring copyright permission, other than the making of an
|
## Fair Use
|
||||||
exact copy. The resulting work is called a "modified version" of the
|
|
||||||
earlier work or a work "based on" the earlier work.
|
You may have "fair use" rights for the software under the
|
||||||
|
law. These terms do not limit them.
|
||||||
A "covered work" means either the unmodified Program or a work based
|
|
||||||
on the Program.
|
## No Other Rights
|
||||||
|
|
||||||
To "propagate" a work means to do anything with it that, without
|
These terms do not allow you to sublicense or transfer any of
|
||||||
permission, would make you directly or secondarily liable for
|
your licenses to anyone else, or prevent the licensor from
|
||||||
infringement under applicable copyright law, except executing it on a
|
granting licenses to anyone else. These terms do not imply
|
||||||
computer or modifying a private copy. Propagation includes copying,
|
any other licenses.
|
||||||
distribution (with or without modification), making available to the
|
|
||||||
public, and in some countries other activities as well.
|
## Patent Defense
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other
|
If you make any written claim that the software infringes or
|
||||||
parties to make or receive copies. Mere interaction with a user through
|
contributes to infringement of any patent, your patent license
|
||||||
a computer network, with no transfer of a copy, is not conveying.
|
for the software granted under these terms ends immediately. If
|
||||||
|
your company makes such a claim, your patent license ends
|
||||||
An interactive user interface displays "Appropriate Legal Notices"
|
immediately for work on behalf of your company.
|
||||||
to the extent that it includes a convenient and prominently visible
|
|
||||||
feature that (1) displays an appropriate copyright notice, and (2)
|
## Violations
|
||||||
tells the user that there is no warranty for the work (except to the
|
|
||||||
extent that warranties are provided), that licensees may convey the
|
The first time you are notified in writing that you have
|
||||||
work under this License, and how to view a copy of this License. If
|
violated any of these terms, or done anything with the software
|
||||||
the interface presents a list of user commands or options, such as a
|
not covered by your licenses, your licenses can nonetheless
|
||||||
menu, a prominent item in the list meets this criterion.
|
continue if you come into full compliance with these terms,
|
||||||
|
and take practical steps to correct past violations, within
|
||||||
1. Source Code.
|
32 days of receiving notice. Otherwise, all your licenses
|
||||||
|
end immediately.
|
||||||
The "source code" for a work means the preferred form of the work
|
|
||||||
for making modifications to it. "Object code" means any non-source
|
## No Liability
|
||||||
form of a work.
|
|
||||||
|
***As far as the law allows, the software comes as is, without
|
||||||
A "Standard Interface" means an interface that either is an official
|
any warranty or condition, and the licensor will not be liable
|
||||||
standard defined by a recognized standards body, or, in the case of
|
to you for any damages arising out of these terms or the use
|
||||||
interfaces specified for a particular programming language, one that
|
or nature of the software, under any kind of legal claim.***
|
||||||
is widely used among developers working in that language.
|
|
||||||
|
## Definitions
|
||||||
The "System Libraries" of an executable work include anything, other
|
|
||||||
than the work as a whole, that (a) is included in the normal form of
|
The **licensor** is the individual or entity offering these
|
||||||
packaging a Major Component, but which is not part of that Major
|
terms, and the **software** is the software the licensor makes
|
||||||
Component, and (b) serves only to enable use of the work with that
|
available under these terms.
|
||||||
Major Component, or to implement a Standard Interface for which an
|
|
||||||
implementation is available to the public in source code form. A
|
**You** refers to the individual or entity agreeing to these
|
||||||
"Major Component", in this context, means a major essential component
|
terms.
|
||||||
(kernel, window system, and so on) of the specific operating system
|
|
||||||
(if any) on which the executable work runs, or a compiler used to
|
**Your company** is any legal entity, sole proprietorship,
|
||||||
produce the work, or an object code interpreter used to run it.
|
or other kind of organization that you work for, plus all
|
||||||
|
organizations that have control over, are under the control of,
|
||||||
The "Corresponding Source" for a work in object code form means all
|
or are under common control with that organization. **Control**
|
||||||
the source code needed to generate, install, and (for an executable
|
means ownership of substantially all the assets of an entity,
|
||||||
work) run the object code and to modify the work, including scripts to
|
or the power to direct its management and policies by vote,
|
||||||
control those activities. However, it does not include the work's
|
contract, or otherwise. Control can be direct or indirect.
|
||||||
System Libraries, or general-purpose tools or generally available free
|
|
||||||
programs which are used unmodified in performing those activities but
|
**Your licenses** are all the licenses granted to you for the
|
||||||
which are not part of the work. For example, Corresponding Source
|
software under these terms.
|
||||||
includes interface definition files associated with source files for
|
|
||||||
the work, and the source code for shared libraries and dynamically
|
**Use** means anything you do with the software requiring one
|
||||||
linked subprograms that the work is specifically designed to require,
|
of your licenses.
|
||||||
such as by intimate data communication or control flow between those
|
|
||||||
subprograms and other parts of the work.
|
---
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users
|
## Commercial Licensing
|
||||||
can regenerate automatically from other parts of the Corresponding
|
|
||||||
Source.
|
The Noncommercial Purposes, Personal Uses, and Noncommercial
|
||||||
|
Organizations sections above define what you may do without
|
||||||
The Corresponding Source for a work in source code form is that
|
paying. Any use intended for or directed toward commercial
|
||||||
same work.
|
advantage or monetary compensation — including selling this
|
||||||
|
software, or any part of it, standalone or bundled into another
|
||||||
2. Basic Permissions.
|
product or service — requires a separate commercial license
|
||||||
|
from the copyright holder. Contact Rune Olsen via
|
||||||
All rights granted under this License are granted for the term of
|
<https://oai.pm> to discuss commercial licensing terms.
|
||||||
copyright on the Program, and are irrevocable provided the stated
|
|
||||||
conditions are met. This License explicitly affirms your unlimited
|
|
||||||
permission to run the unmodified Program. The output from running a
|
|
||||||
covered work is covered by this License only if the output, given its
|
|
||||||
content, constitutes a covered work. This License acknowledges your
|
|
||||||
rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not
|
|
||||||
convey, without conditions so long as your license otherwise remains
|
|
||||||
in force. You may convey covered works to others for the sole purpose
|
|
||||||
of having them make modifications exclusively for you, or provide you
|
|
||||||
with facilities for running those works, provided that you comply with
|
|
||||||
the terms of this License in conveying all material for which you do
|
|
||||||
not control copyright. Those thus making or running the covered works
|
|
||||||
for you must do so exclusively on your behalf, under your direction
|
|
||||||
and control, on terms that prohibit them from making any copies of
|
|
||||||
your copyrighted material outside their relationship with you.
|
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under
|
|
||||||
the conditions stated below. Sublicensing is not allowed; section 10
|
|
||||||
makes it unnecessary.
|
|
||||||
|
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
|
||||||
|
|
||||||
No covered work shall be deemed part of an effective technological
|
|
||||||
measure under any applicable law fulfilling obligations under article
|
|
||||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
|
||||||
similar laws prohibiting or restricting circumvention of such
|
|
||||||
measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid
|
|
||||||
circumvention of technological measures to the extent such circumvention
|
|
||||||
is effected by exercising rights under this License with respect to
|
|
||||||
the covered work, and you disclaim any intention to limit operation or
|
|
||||||
modification of the work as a means of enforcing, against the work's
|
|
||||||
users, your or third parties' legal rights to forbid circumvention of
|
|
||||||
technological measures.
|
|
||||||
|
|
||||||
4. Conveying Verbatim Copies.
|
|
||||||
|
|
||||||
You may convey verbatim copies of the Program's source code as you
|
|
||||||
receive it, in any medium, provided that you conspicuously and
|
|
||||||
appropriately publish on each copy an appropriate copyright notice;
|
|
||||||
keep intact all notices stating that this License and any
|
|
||||||
non-permissive terms added in accord with section 7 apply to the code;
|
|
||||||
keep intact all notices of the absence of any warranty; and give all
|
|
||||||
recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey,
|
|
||||||
and you may offer support or warranty protection for a fee.
|
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
|
|
||||||
You may convey a work based on the Program, or the modifications to
|
|
||||||
produce it from the Program, in the form of source code under the
|
|
||||||
terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified
|
|
||||||
it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is
|
|
||||||
released under this License and any conditions added under section
|
|
||||||
7. This requirement modifies the requirement in section 4 to
|
|
||||||
"keep intact all notices".
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this
|
|
||||||
License to anyone who comes into possession of a copy. This
|
|
||||||
License will therefore apply, along with any applicable section 7
|
|
||||||
additional terms, to the whole of the work, and all its parts,
|
|
||||||
regardless of how they are packaged. This License gives no
|
|
||||||
permission to license the work in any other way, but it does not
|
|
||||||
invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display
|
|
||||||
Appropriate Legal Notices; however, if the Program has interactive
|
|
||||||
interfaces that do not display Appropriate Legal Notices, your
|
|
||||||
work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent
|
|
||||||
works, which are not by their nature extensions of the covered work,
|
|
||||||
and which are not combined with it such as to form a larger program,
|
|
||||||
in or on a volume of a storage or distribution medium, is called an
|
|
||||||
"aggregate" if the compilation and its resulting copyright are not
|
|
||||||
used to limit the access or legal rights of the compilation's users
|
|
||||||
beyond what the individual works permit. Inclusion of a covered work
|
|
||||||
in an aggregate does not cause this License to apply to the other
|
|
||||||
parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
|
|
||||||
You may convey a covered work in object code form under the terms
|
|
||||||
of sections 4 and 5, provided that you also convey the
|
|
||||||
machine-readable Corresponding Source under the terms of this License,
|
|
||||||
in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by the
|
|
||||||
Corresponding Source fixed on a durable physical medium
|
|
||||||
customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by a
|
|
||||||
written offer, valid for at least three years and valid for as
|
|
||||||
long as you offer spare parts or customer support for that product
|
|
||||||
model, to give anyone who possesses the object code either (1) a
|
|
||||||
copy of the Corresponding Source for all the software in the
|
|
||||||
product that is covered by this License, on a durable physical
|
|
||||||
medium customarily used for software interchange, for a price no
|
|
||||||
more than your reasonable cost of physically performing this
|
|
||||||
conveying of source, or (2) access to copy the
|
|
||||||
Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the
|
|
||||||
written offer to provide the Corresponding Source. This
|
|
||||||
alternative is allowed only occasionally and noncommercially, and
|
|
||||||
only if you received the object code with such an offer, in accord
|
|
||||||
with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated
|
|
||||||
place (gratis or for a charge), and offer equivalent access to the
|
|
||||||
Corresponding Source in the same way through the same place at no
|
|
||||||
further charge. You need not require recipients to copy the
|
|
||||||
Corresponding Source along with the object code. If the place to
|
|
||||||
copy the object code is a network server, the Corresponding Source
|
|
||||||
may be on a different server (operated by you or a third party)
|
|
||||||
that supports equivalent copying facilities, provided you maintain
|
|
||||||
clear directions next to the object code saying where to find the
|
|
||||||
Corresponding Source. Regardless of what server hosts the
|
|
||||||
Corresponding Source, you remain obligated to ensure that it is
|
|
||||||
available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided
|
|
||||||
you inform other peers where the object code and Corresponding
|
|
||||||
Source of the work are being offered to the general public at no
|
|
||||||
charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded
|
|
||||||
from the Corresponding Source as a System Library, need not be
|
|
||||||
included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any
|
|
||||||
tangible personal property which is normally used for personal, family,
|
|
||||||
or household purposes, or (2) anything designed or sold for incorporation
|
|
||||||
into a dwelling. In determining whether a product is a consumer product,
|
|
||||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
|
||||||
product received by a particular user, "normally used" refers to a
|
|
||||||
typical or common use of that class of product, regardless of the status
|
|
||||||
of the particular user or of the way in which the particular user
|
|
||||||
actually uses, or expects or is expected to use, the product. A product
|
|
||||||
is a consumer product regardless of whether the product has substantial
|
|
||||||
commercial, industrial or non-consumer uses, unless such uses represent
|
|
||||||
the only significant mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods,
|
|
||||||
procedures, authorization keys, or other information required to install
|
|
||||||
and execute modified versions of a covered work in that User Product from
|
|
||||||
a modified version of its Corresponding Source. The information must
|
|
||||||
suffice to ensure that the continued functioning of the modified object
|
|
||||||
code is in no case prevented or interfered with solely because
|
|
||||||
modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or
|
|
||||||
specifically for use in, a User Product, and the conveying occurs as
|
|
||||||
part of a transaction in which the right of possession and use of the
|
|
||||||
User Product is transferred to the recipient in perpetuity or for a
|
|
||||||
fixed term (regardless of how the transaction is characterized), the
|
|
||||||
Corresponding Source conveyed under this section must be accompanied
|
|
||||||
by the Installation Information. But this requirement does not apply
|
|
||||||
if neither you nor any third party retains the ability to install
|
|
||||||
modified object code on the User Product (for example, the work has
|
|
||||||
been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a
|
|
||||||
requirement to continue to provide support service, warranty, or updates
|
|
||||||
for a work that has been modified or installed by the recipient, or for
|
|
||||||
the User Product in which it has been modified or installed. Access to a
|
|
||||||
network may be denied when the modification itself materially and
|
|
||||||
adversely affects the operation of the network or violates the rules and
|
|
||||||
protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided,
|
|
||||||
in accord with this section must be in a format that is publicly
|
|
||||||
documented (and with an implementation available to the public in
|
|
||||||
source code form), and must require no special password or key for
|
|
||||||
unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
|
|
||||||
"Additional permissions" are terms that supplement the terms of this
|
|
||||||
License by making exceptions from one or more of its conditions.
|
|
||||||
Additional permissions that are applicable to the entire Program shall
|
|
||||||
be treated as though they were included in this License, to the extent
|
|
||||||
that they are valid under applicable law. If additional permissions
|
|
||||||
apply only to part of the Program, that part may be used separately
|
|
||||||
under those permissions, but the entire Program remains governed by
|
|
||||||
this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option
|
|
||||||
remove any additional permissions from that copy, or from any part of
|
|
||||||
it. (Additional permissions may be written to require their own
|
|
||||||
removal in certain cases when you modify the work.) You may place
|
|
||||||
additional permissions on material, added by you to a covered work,
|
|
||||||
for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you
|
|
||||||
add to a covered work, you may (if authorized by the copyright holders of
|
|
||||||
that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the
|
|
||||||
terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or
|
|
||||||
author attributions in that material or in the Appropriate Legal
|
|
||||||
Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or
|
|
||||||
requiring that modified versions of such material be marked in
|
|
||||||
reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or
|
|
||||||
authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some
|
|
||||||
trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that
|
|
||||||
material by anyone who conveys the material (or modified versions of
|
|
||||||
it) with contractual assumptions of liability to the recipient, for
|
|
||||||
any liability that these contractual assumptions directly impose on
|
|
||||||
those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further
|
|
||||||
restrictions" within the meaning of section 10. If the Program as you
|
|
||||||
received it, or any part of it, contains a notice stating that it is
|
|
||||||
governed by this License along with a term that is a further
|
|
||||||
restriction, you may remove that term. If a license document contains
|
|
||||||
a further restriction but permits relicensing or conveying under this
|
|
||||||
License, you may add to a covered work material governed by the terms
|
|
||||||
of that license document, provided that the further restriction does
|
|
||||||
not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you
|
|
||||||
must place, in the relevant source files, a statement of the
|
|
||||||
additional terms that apply to those files, or a notice indicating
|
|
||||||
where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the
|
|
||||||
form of a separately written license, or stated as exceptions;
|
|
||||||
the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly
|
|
||||||
provided under this License. Any attempt otherwise to propagate or
|
|
||||||
modify it is void, and will automatically terminate your rights under
|
|
||||||
this License (including any patent licenses granted under the third
|
|
||||||
paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your
|
|
||||||
license from a particular copyright holder is reinstated (a)
|
|
||||||
provisionally, unless and until the copyright holder explicitly and
|
|
||||||
finally terminates your license, and (b) permanently, if the copyright
|
|
||||||
holder fails to notify you of the violation by some reasonable means
|
|
||||||
prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is
|
|
||||||
reinstated permanently if the copyright holder notifies you of the
|
|
||||||
violation by some reasonable means, this is the first time you have
|
|
||||||
received notice of violation of this License (for any work) from that
|
|
||||||
copyright holder, and you cure the violation prior to 30 days after
|
|
||||||
your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the
|
|
||||||
licenses of parties who have received copies or rights from you under
|
|
||||||
this License. If your rights have been terminated and not permanently
|
|
||||||
reinstated, you do not qualify to receive new licenses for the same
|
|
||||||
material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or
|
|
||||||
run a copy of the Program. Ancillary propagation of a covered work
|
|
||||||
occurring solely as a consequence of using peer-to-peer transmission
|
|
||||||
to receive a copy likewise does not require acceptance. However,
|
|
||||||
nothing other than this License grants you permission to propagate or
|
|
||||||
modify any covered work. These actions infringe copyright if you do
|
|
||||||
not accept this License. Therefore, by modifying or propagating a
|
|
||||||
covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically
|
|
||||||
receives a license from the original licensors, to run, modify and
|
|
||||||
propagate that work, subject to this License. You are not responsible
|
|
||||||
for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an
|
|
||||||
organization, or substantially all assets of one, or subdividing an
|
|
||||||
organization, or merging organizations. If propagation of a covered
|
|
||||||
work results from an entity transaction, each party to that
|
|
||||||
transaction who receives a copy of the work also receives whatever
|
|
||||||
licenses to the work the party's predecessor in interest had or could
|
|
||||||
give under the previous paragraph, plus a right to possession of the
|
|
||||||
Corresponding Source of the work from the predecessor in interest, if
|
|
||||||
the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the
|
|
||||||
rights granted or affirmed under this License. For example, you may
|
|
||||||
not impose a license fee, royalty, or other charge for exercise of
|
|
||||||
rights granted under this License, and you may not initiate litigation
|
|
||||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
|
||||||
any patent claim is infringed by making, using, selling, offering for
|
|
||||||
sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this
|
|
||||||
License of the Program or a work on which the Program is based. The
|
|
||||||
work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims
|
|
||||||
owned or controlled by the contributor, whether already acquired or
|
|
||||||
hereafter acquired, that would be infringed by some manner, permitted
|
|
||||||
by this License, of making, using, or selling its contributor version,
|
|
||||||
but do not include claims that would be infringed only as a
|
|
||||||
consequence of further modification of the contributor version. For
|
|
||||||
purposes of this definition, "control" includes the right to grant
|
|
||||||
patent sublicenses in a manner consistent with the requirements of
|
|
||||||
this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
|
||||||
patent license under the contributor's essential patent claims, to
|
|
||||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
|
||||||
propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express
|
|
||||||
agreement or commitment, however denominated, not to enforce a patent
|
|
||||||
(such as an express permission to practice a patent or covenant not to
|
|
||||||
sue for patent infringement). To "grant" such a patent license to a
|
|
||||||
party means to make such an agreement or commitment not to enforce a
|
|
||||||
patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license,
|
|
||||||
and the Corresponding Source of the work is not available for anyone
|
|
||||||
to copy, free of charge and under the terms of this License, through a
|
|
||||||
publicly available network server or other readily accessible means,
|
|
||||||
then you must either (1) cause the Corresponding Source to be so
|
|
||||||
available, or (2) arrange to deprive yourself of the benefit of the
|
|
||||||
patent license for this particular work, or (3) arrange, in a manner
|
|
||||||
consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have
|
|
||||||
actual knowledge that, but for the patent license, your conveying the
|
|
||||||
covered work in a country, or your recipient's use of the covered work
|
|
||||||
in a country, would infringe one or more identifiable patents in that
|
|
||||||
country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or
|
|
||||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
|
||||||
covered work, and grant a patent license to some of the parties
|
|
||||||
receiving the covered work authorizing them to use, propagate, modify
|
|
||||||
or convey a specific copy of the covered work, then the patent license
|
|
||||||
you grant is automatically extended to all recipients of the covered
|
|
||||||
work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within
|
|
||||||
the scope of its coverage, prohibits the exercise of, or is
|
|
||||||
conditioned on the non-exercise of one or more of the rights that are
|
|
||||||
specifically granted under this License. You may not convey a covered
|
|
||||||
work if you are a party to an arrangement with a third party that is
|
|
||||||
in the business of distributing software, under which you make payment
|
|
||||||
to the third party based on the extent of your activity of conveying
|
|
||||||
the work, and under which the third party grants, to any of the
|
|
||||||
parties who would receive the covered work from you, a discriminatory
|
|
||||||
patent license (a) in connection with copies of the covered work
|
|
||||||
conveyed by you (or copies made from those copies), or (b) primarily
|
|
||||||
for and in connection with specific products or compilations that
|
|
||||||
contain the covered work, unless you entered into that arrangement,
|
|
||||||
or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting
|
|
||||||
any implied license or other defenses to infringement that may
|
|
||||||
otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot convey a
|
|
||||||
covered work so as to satisfy simultaneously your obligations under this
|
|
||||||
License and any other pertinent obligations, then as a consequence you may
|
|
||||||
not convey it at all. For example, if you agree to terms that obligate you
|
|
||||||
to collect a royalty for further conveying from those to whom you convey
|
|
||||||
the Program, the only way you could satisfy both those terms and this
|
|
||||||
License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, if you modify the
|
|
||||||
Program, your modified version must prominently offer all users
|
|
||||||
interacting with it remotely through a computer network (if your version
|
|
||||||
supports such interaction) an opportunity to receive the Corresponding
|
|
||||||
Source of your version by providing access to the Corresponding Source
|
|
||||||
from a network server at no charge, through some standard or customary
|
|
||||||
means of facilitating copying of software. This Corresponding Source
|
|
||||||
shall include the Corresponding Source for any work covered by version 3
|
|
||||||
of the GNU General Public License that is incorporated pursuant to the
|
|
||||||
following paragraph.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
|
||||||
permission to link or combine any covered work with a work licensed
|
|
||||||
under version 3 of the GNU General Public License into a single
|
|
||||||
combined work, and to convey the resulting work. The terms of this
|
|
||||||
License will continue to apply to the part which is the covered work,
|
|
||||||
but the work with which it is combined will remain governed by version
|
|
||||||
3 of the GNU General Public License.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
|
||||||
the GNU Affero General Public License from time to time. Such new versions
|
|
||||||
will be similar in spirit to the present version, but may differ in detail to
|
|
||||||
address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
|
||||||
Program specifies that a certain numbered version of the GNU Affero General
|
|
||||||
Public License "or any later version" applies to it, you have the
|
|
||||||
option of following the terms and conditions either of that numbered
|
|
||||||
version or of any later version published by the Free Software
|
|
||||||
Foundation. If the Program does not specify a version number of the
|
|
||||||
GNU Affero General Public License, you may choose any version ever published
|
|
||||||
by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
|
||||||
versions of the GNU Affero General Public License can be used, that proxy's
|
|
||||||
public statement of acceptance of a version permanently authorizes you
|
|
||||||
to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different
|
|
||||||
permissions. However, no additional obligations are imposed on any
|
|
||||||
author or copyright holder as a result of your choosing to follow a
|
|
||||||
later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
|
||||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
|
||||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
|
||||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
|
||||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
|
||||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
|
||||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
|
||||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
|
||||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
|
||||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
|
||||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
|
||||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
|
||||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
|
||||||
SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided
|
|
||||||
above cannot be given local legal effect according to their terms,
|
|
||||||
reviewing courts shall apply local law that most closely approximates
|
|
||||||
an absolute waiver of all civil liability in connection with the
|
|
||||||
Program, unless a warranty or assumption of liability accompanies a
|
|
||||||
copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
|
||||||
to attach them to the start of each source file to most effectively
|
|
||||||
state the exclusion of warranty; and each file should have at least
|
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If your software can interact with users remotely through a computer
|
|
||||||
network, you should also make sure that it provides a way for users to
|
|
||||||
get its source. For example, if your program is a web application, its
|
|
||||||
interface could display a "Source" link that leads users to an archive
|
|
||||||
of the code. There are many ways you could offer source, and different
|
|
||||||
solutions will be better for different programs; see section 13 for the
|
|
||||||
specific requirements.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
|
||||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
|
||||||
<https://www.gnu.org/licenses/>.
|
|
||||||
|
|||||||
@@ -8,19 +8,20 @@ A powerful native macOS AI chat application with support for multiple providers,
|
|||||||
|
|
||||||
### 🤖 Multi-Provider Support
|
### 🤖 Multi-Provider Support
|
||||||
- **OpenAI** - GPT models with native API support
|
- **OpenAI** - GPT models with native API support
|
||||||
- **Anthropic** - All Claude models
|
- **Anthropic** - All Claude models; prompt caching support (direct API and via OpenRouter) reduces cost on repeated system prompts/context
|
||||||
- **OpenRouter** - Access to 300+ AI models from multiple providers
|
- **OpenRouter** - Access to 300+ AI models from multiple providers
|
||||||
- **Ollama** - Local model inference for privacy
|
- **Ollama** - Local model inference for privacy
|
||||||
|
|
||||||
### 💬 Core Chat Capabilities
|
### 💬 Core Chat Capabilities
|
||||||
- **Streaming Responses** - Real-time token streaming for faster interactions
|
- **Streaming Responses** - Real-time token streaming for faster interactions
|
||||||
- **Conversation Management** - Save, load, export, and search conversations
|
- **Conversation Management** - Save, load, export, and search conversations
|
||||||
|
- **Combine Conversations** - Merge 2+ saved conversations, either by chronological concatenation or AI-assisted synthesis
|
||||||
- **File Attachments** - Support for text files, images, and PDFs
|
- **File Attachments** - Support for text files, images, and PDFs
|
||||||
- **Image Generation** - Create images with supported models (DALL-E, Flux, etc.) — renders inline in chat
|
- **Image Generation** - Create images with supported models (DALL-E, Flux, etc.) - renders inline in chat
|
||||||
- **Reasoning / Thinking Tokens** - Stream live reasoning from thinking-capable models (DeepSeek R1, Claude 3.7+, o1/o3, Qwen); configurable effort level (High/Medium/Low/Minimal); collapsible block auto-expands while thinking and collapses when the answer arrives
|
- **Reasoning / Thinking Tokens** - Stream live reasoning from thinking-capable models (DeepSeek R1, Claude 3.7+, o1/o3, Qwen); configurable effort level (High/Medium/Low/Minimal); collapsible block auto-expands while thinking and collapses when the answer arrives
|
||||||
- **Online Mode** - DuckDuckGo and Google web search integration
|
- **Online Mode** - DuckDuckGo and Google web search integration
|
||||||
- **Session Statistics** - Track token usage, costs, and response times
|
- **Session Statistics** - Track token usage, costs, and response times
|
||||||
- **Command History** - Navigate previous commands with searchable modal (⌘H)
|
- **Command History** - Navigate previous commands with searchable modal (⇧⌘H)
|
||||||
|
|
||||||
### 🧠 Enhanced Memory & Context System
|
### 🧠 Enhanced Memory & Context System
|
||||||
- **Smart Context Selection** - Automatically select relevant messages to reduce token usage by 50-80%
|
- **Smart Context Selection** - Automatically select relevant messages to reduce token usage by 50-80%
|
||||||
@@ -51,17 +52,25 @@ Seamless conversation backup and sync across devices:
|
|||||||
|
|
||||||
### ⚡ Shortcuts & Agent Skills
|
### ⚡ Shortcuts & Agent Skills
|
||||||
- **Shortcuts** - Personal slash commands that expand to prompt templates; optional `{{input}}` placeholder for inline input
|
- **Shortcuts** - Personal slash commands that expand to prompt templates; optional `{{input}}` placeholder for inline input
|
||||||
- **Agent Skills (SKILL.md)** - Markdown instruction files injected into the system prompt; compatible with skill0.io, skillsmp.com, and other SKILL.md marketplaces; import as `.md` or `.zip` bundle with attached data files
|
- **Agent Skills (SKILL.md)** - Markdown instruction files injected into the system prompt; compatible with skill0.io, skillsmp.com, and other SKILL.md marketplaces; import as `.md` or `.zip` bundle with attached data files; a skill named exactly "2nd Brain" can be marked always-trusted, skipping the bash approval prompt for its helper-script calls
|
||||||
|
|
||||||
### 📚 Anytype Integration
|
### 📚 Anytype Integration
|
||||||
Connect oAI to your local [Anytype](https://anytype.io) knowledge base:
|
Connect oAI to your local [Anytype](https://anytype.io) knowledge base:
|
||||||
- **Search** — find objects by keyword across all spaces or within a specific one
|
- **Search** - find objects by keyword across all spaces or within a specific one
|
||||||
- **Read** — open any object and read its full markdown content
|
- **Read** - open any object and read its full markdown content
|
||||||
- **Append** — add content to the end of an existing object without touching existing text or internal links (preferred over full update)
|
- **Append** - add content to the end of an existing object without touching existing text or internal links (preferred over full update)
|
||||||
- **Create** — make new notes, tasks, or pages
|
- **Create** - make new notes, tasks, or pages
|
||||||
- **Checkbox tools** — surgically toggle to-do checkboxes or set task done/undone via native relation
|
- **Checkbox tools** - surgically toggle to-do checkboxes or set task done/undone via native relation
|
||||||
- All data stays on your machine (local API, no cloud)
|
- All data stays on your machine (local API, no cloud)
|
||||||
|
|
||||||
|
### 🛰️ Jarvis Integration
|
||||||
|
Connect oAI to a self-hosted [Jarvis](https://jarvis.pm) agent-automation server:
|
||||||
|
- **Agent Management** - List, create, edit, enable/disable, run, and stop agents
|
||||||
|
- **Run History** - Expandable per-run output with status and timing
|
||||||
|
- **Usage & Credits** - Per-agent usage stats and credits balance
|
||||||
|
- **Queue Control** - Pause/resume all agents
|
||||||
|
- `/jarvis` slash command opens the Jarvis panel directly
|
||||||
|
|
||||||
### 🖥️ Power-User Features
|
### 🖥️ Power-User Features
|
||||||
- **Bash Execution** - AI can run shell commands via `/bin/zsh` (opt-in, with per-command approval prompt)
|
- **Bash Execution** - AI can run shell commands via `/bin/zsh` (opt-in, with per-command approval prompt)
|
||||||
- **iCloud Backup** - One-click settings backup to iCloud Drive; restore on any Mac; API keys excluded for security
|
- **iCloud Backup** - One-click settings backup to iCloud Drive; restore on any Mac; API keys excluded for security
|
||||||
@@ -85,9 +94,10 @@ Automated email responses powered by AI:
|
|||||||
- Footer stats display (messages, tokens, cost, sync status)
|
- Footer stats display (messages, tokens, cost, sync status)
|
||||||
- Header status indicators (MCP, Online mode, Git sync)
|
- Header status indicators (MCP, Online mode, Git sync)
|
||||||
- Responsive message layout with copy buttons
|
- Responsive message layout with copy buttons
|
||||||
- **Model Selector (⌘M)** - Filter by capability (Vision / Tools / Online / Image Gen / Thinking 🧠), sort by price or context window, search by name or description, per-row ⓘ info button; ★ favourite any model — favourites float to the top and can be filtered in one click
|
- **Model Selector (⌘M)** - Filter by capability (Vision / Tools / Online / Image Gen / Thinking 🧠) or by category (Programming, Math, Medical, Translation, Roleplay, Creative, Science, Finance, Legal), sort by price or context window, search by name or description, per-row ⓘ info button; ★ favourite any model - favourites float to the top and can be filtered in one click
|
||||||
- **Default Model** - Set a fixed startup model in Settings → General; switching models during a session does not overwrite it
|
- **Default Model** - Set a fixed startup model in Settings → General; switching models during a session does not overwrite it
|
||||||
- **Localization** - UI ~~fully translated~~ being translated into Norwegian Bokmål, Swedish, Danish, and German; follows macOS language preference automatically
|
- **Sidebar Navigation** - Collapsible sidebar for switching between conversations
|
||||||
|
- **Localization** - Fully localized into Norwegian Bokmål, Swedish, Danish, German, and French; follows macOS language preference automatically. Translations are AI-generated (machine translation), not reviewed by native speakers - if you spot an awkward or incorrect phrase, please [open an issue](https://gitlab.pm/rune/oai-swift/issues/new)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -97,8 +107,8 @@ Automated email responses powered by AI:
|
|||||||
|
|
||||||
Download the latest release from the [Releases page](https://gitlab.pm/rune/oai-swift/releases). Two builds are available:
|
Download the latest release from the [Releases page](https://gitlab.pm/rune/oai-swift/releases). Two builds are available:
|
||||||
|
|
||||||
- **oAI-x.x.x-AppleSilicon.dmg** — for Macs with an Apple Silicon chip (M1 and later)
|
- **oAI-x.x.x-AppleSilicon.dmg** - for Macs with an Apple Silicon chip (M1 and later)
|
||||||
- **oAI-x.x.x-Universal.dmg** — runs natively on both Apple Silicon and Intel Macs
|
- **oAI-x.x.x-Universal.dmg** - runs natively on both Apple Silicon and Intel Macs
|
||||||
|
|
||||||
### Installing from DMG
|
### Installing from DMG
|
||||||
|
|
||||||
@@ -107,19 +117,19 @@ Download the latest release from the [Releases page](https://gitlab.pm/rune/oai-
|
|||||||
3. Eject the DMG
|
3. Eject the DMG
|
||||||
4. Launch oAI from Applications or Spotlight
|
4. Launch oAI from Applications or Spotlight
|
||||||
|
|
||||||
### First Launch — Gatekeeper Warning
|
### First Launch - Gatekeeper Warning
|
||||||
|
|
||||||
oAI is **signed by the developer** but has **not yet been notarized by Apple**. Notarization is Apple's automated malware scan — the app itself is safe, but macOS Gatekeeper may block it on first launch with a message saying the app "cannot be opened because the developer cannot be verified."
|
oAI is **signed by the developer** but has **not yet been notarized by Apple**. Notarization is Apple's automated malware scan - the app itself is safe, but macOS Gatekeeper may block it on first launch with a message saying the app "cannot be opened because the developer cannot be verified."
|
||||||
|
|
||||||
To open the app, you have two options:
|
To open the app, you have two options:
|
||||||
|
|
||||||
**Option A — Right-click to open (quickest):**
|
**Option A - Right-click to open (quickest):**
|
||||||
1. Right-click (or Control-click) `oAI.app` in Applications
|
1. Right-click (or Control-click) `oAI.app` in Applications
|
||||||
2. Select **Open** from the context menu
|
2. Select **Open** from the context menu
|
||||||
3. Click **Open** in the dialog that appears
|
3. Click **Open** in the dialog that appears
|
||||||
4. After doing this once, the app opens normally from then on
|
4. After doing this once, the app opens normally from then on
|
||||||
|
|
||||||
**Option B — Remove the quarantine flag via Terminal:**
|
**Option B - Remove the quarantine flag via Terminal:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xattr -dr com.apple.quarantine /Applications/oAI.app
|
xattr -dr com.apple.quarantine /Applications/oAI.app
|
||||||
@@ -139,7 +149,7 @@ Add your API keys in Settings (⌘,) → General tab:
|
|||||||
- **Anthropic** - Get from [Anthropic Console](https://console.anthropic.com/) or use OAuth
|
- **Anthropic** - Get from [Anthropic Console](https://console.anthropic.com/) or use OAuth
|
||||||
- **OpenRouter** - Get from [OpenRouter Keys](https://openrouter.ai/keys)
|
- **OpenRouter** - Get from [OpenRouter Keys](https://openrouter.ai/keys)
|
||||||
- **Ollama** - Base URL (default: http://localhost:11434)
|
- **Ollama** - Base URL (default: http://localhost:11434)
|
||||||
- **Google** - API key used for Google Custom Search (web search) and Google embeddings (semantic search) — not a chat provider
|
- **Google** - API key used for Google Custom Search (web search) and Google embeddings (semantic search) - not a chat provider
|
||||||
|
|
||||||
### Essential Settings
|
### Essential Settings
|
||||||
|
|
||||||
@@ -183,7 +193,7 @@ Add your API keys in Settings (⌘,) → General tab:
|
|||||||
- `/load` or `/list` - List and load saved conversations (⌘L)
|
- `/load` or `/list` - List and load saved conversations (⌘L)
|
||||||
- `/delete <name>` - Delete a saved conversation
|
- `/delete <name>` - Delete a saved conversation
|
||||||
- `/export <md|json> [filename]` - Export conversation
|
- `/export <md|json> [filename]` - Export conversation
|
||||||
- `/history` - Open command history modal (⌘H)
|
- `/history` - Open command history modal (⇧⌘H)
|
||||||
|
|
||||||
### Provider & Settings
|
### Provider & Settings
|
||||||
- `/provider [name]` - Switch or display current provider
|
- `/provider [name]` - Switch or display current provider
|
||||||
@@ -228,7 +238,7 @@ Can you review this code? @~/project/main.swift
|
|||||||
- `⌘,` - Open settings
|
- `⌘,` - Open settings
|
||||||
- `⌘N` - New conversation
|
- `⌘N` - New conversation
|
||||||
- `⌘L` - List saved conversations
|
- `⌘L` - List saved conversations
|
||||||
- `⌘H` - Command history
|
- `⇧⌘H` - Command history
|
||||||
- `Esc` - Cancel generation / Close dropdown
|
- `Esc` - Cancel generation / Close dropdown
|
||||||
- `↑/↓` - Navigate command dropdown (when typing `/`)
|
- `↑/↓` - Navigate command dropdown (when typing `/`)
|
||||||
- `Return` - Send message
|
- `Return` - Send message
|
||||||
@@ -313,12 +323,16 @@ AI-powered email auto-responder:
|
|||||||
|
|
||||||
- [x] Vector index for faster semantic search (sqlite-vss)
|
- [x] Vector index for faster semantic search (sqlite-vss)
|
||||||
- [x] Reasoning / thinking tokens (streamed live, collapsible)
|
- [x] Reasoning / thinking tokens (streamed live, collapsible)
|
||||||
- [x] Localization (Norwegian Bokmål, Swedish, Danish, German)
|
- [x] Localization (Norwegian Bokmål, Swedish, Danish, German, French)
|
||||||
- [x] iCloud Backup (settings export/restore)
|
- [x] iCloud Backup (settings export/restore)
|
||||||
- [x] Bash execution with per-command approval
|
- [x] Bash execution with per-command approval
|
||||||
- [x] Anytype integration (read, append, create, checkbox tools)
|
- [x] Anytype integration (read, append, create, checkbox tools)
|
||||||
- [x] Model favourites (starred models, filter, float to top)
|
- [x] Model favourites (starred models, filter, float to top)
|
||||||
- [ ] SOUL.md / USER.md — living identity documents injected into system prompt
|
- [x] Jarvis integration (agent management, run history, usage/credits)
|
||||||
|
- [x] Model category filter (Programming, Math, Medical, etc.)
|
||||||
|
- [x] Combine saved conversations (concatenation or AI-assisted synthesis)
|
||||||
|
- [x] Sidebar navigation redesign
|
||||||
|
- [ ] SOUL.md / USER.md - living identity documents injected into system prompt
|
||||||
- [ ] Parallel research agents (read-only, concurrent)
|
- [ ] Parallel research agents (read-only, concurrent)
|
||||||
- [ ] Local embeddings (sentence-transformers, $0 cost)
|
- [ ] Local embeddings (sentence-transformers, $0 cost)
|
||||||
- [ ] Multi-modal conversation export (PDF, HTML)
|
- [ ] Multi-modal conversation export (PDF, HTML)
|
||||||
@@ -326,27 +340,27 @@ AI-powered email auto-responder:
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
oAI is free software licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)**.
|
oAI is source-available under the **PolyForm Noncommercial License 1.0.0**.
|
||||||
|
|
||||||
This means you are free to use, study, modify, and distribute oAI, but any modified version you run as a network service must also be made available as free software under the same license.
|
This means you are free to use, study, modify, and share oAI for any noncommercial purpose. Commercial use — including selling oAI or any part of it, standalone or bundled into another product or service — requires a separate commercial license.
|
||||||
|
|
||||||
See [LICENSE](LICENSE) for the full license text, or visit [gnu.org/licenses/agpl-3.0](https://www.gnu.org/licenses/agpl-3.0.html).
|
See [LICENSE](LICENSE) for the full license text, or visit [polyformproject.org/licenses/noncommercial/1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0). For commercial licensing inquiries, contact Rune Olsen via [oai.pm](https://oai.pm).
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
See [DEVELOPMENT.md](DEVELOPMENT.md) for project structure, build scripts, database schema, and contribution guidelines.
|
|
||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
**Rune Olsen**
|
**Rune Olsen**
|
||||||
|
|
||||||
- Website: https://mac.oai.pm
|
- Website: https://oai.pm
|
||||||
- Blog: [https://blog.rune.pm](https://blog.rune.pm)
|
- Blog: [https://blog.rune.pm](https://blog.rune.pm)
|
||||||
- Gitlab.pm: [@rune](https://gitlab.pm/rune)
|
- Gitlab.pm: [@rune](https://gitlab.pm/rune)
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Contributions are welcome! See [DEVELOPMENT.md](DEVELOPMENT.md) for build instructions and project structure.
|
---
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
oAI takes real actions on your behalf - it can send emails, write files, make calendar changes, and post Telegram messages. Review your whitelist and permission settings carefully before use. Content you send is processed by your configured AI provider (Anthropic, OpenRouter, or OpenAI). oAI-Web is provided "as is" without warranty of any kind - the author accepts no responsibility for actions taken by the agent or any consequences thereof. See LICENSE for full terms.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
da,
|
da,
|
||||||
de,
|
de,
|
||||||
sv,
|
sv,
|
||||||
|
fr,
|
||||||
);
|
);
|
||||||
mainGroup = A550A6592F3B72EA00136F2B;
|
mainGroup = A550A6592F3B72EA00136F2B;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
@@ -269,6 +270,11 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
|
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "oAI can read and create calendar events when you ask it to, if you enable Calendar access in Settings.";
|
||||||
|
INFOPLIST_KEY_NSContactsUsageDescription = "oAI can search your contacts when you ask it to, if you enable Contacts access in Settings.";
|
||||||
|
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "oAI can use your current location to answer questions, if you enable Location & Maps access in Settings.";
|
||||||
|
INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "oAI can read and create reminders when you ask it to, if you enable Reminders access in Settings.";
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||||
@@ -279,11 +285,11 @@
|
|||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 2.3.8;
|
MARKETING_VERSION = 2.4.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -313,6 +319,11 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
|
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "oAI can read and create calendar events when you ask it to, if you enable Calendar access in Settings.";
|
||||||
|
INFOPLIST_KEY_NSContactsUsageDescription = "oAI can search your contacts when you ask it to, if you enable Contacts access in Settings.";
|
||||||
|
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "oAI can use your current location to answer questions, if you enable Location & Maps access in Settings.";
|
||||||
|
INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "oAI can read and create reminders when you ask it to, if you enable Reminders access in Settings.";
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||||
@@ -323,11 +334,11 @@
|
|||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 2.3.8;
|
MARKETING_VERSION = 2.4.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "2620"
|
|
||||||
version = "1.7">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES"
|
|
||||||
buildArchitectures = "Automatic">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "A550A6612F3B72EA00136F2B"
|
|
||||||
BuildableName = "oAI.app"
|
|
||||||
BlueprintName = "oAI"
|
|
||||||
ReferencedContainer = "container:oAI.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
shouldAutocreateTestPlan = "YES">
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
language = "nb"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "A550A6612F3B72EA00136F2B"
|
|
||||||
BuildableName = "oAI.app"
|
|
||||||
BlueprintName = "oAI"
|
|
||||||
ReferencedContainer = "container:oAI.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "A550A6612F3B72EA00136F2B"
|
|
||||||
BuildableName = "oAI.app"
|
|
||||||
BlueprintName = "oAI"
|
|
||||||
ReferencedContainer = "container:oAI.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0xCA",
|
||||||
|
"green" : "0x7A",
|
||||||
|
"red" : "0x0A"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,14 @@
|
|||||||
"filename" : "AppLogo.png",
|
"filename" : "AppLogo.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|||||||
@@ -6,5 +6,13 @@
|
|||||||
<string>oAI.help</string>
|
<string>oAI.help</string>
|
||||||
<key>CFBundleHelpBookName</key>
|
<key>CFBundleHelpBookName</key>
|
||||||
<string>oAI Help</string>
|
<string>oAI Help</string>
|
||||||
|
<key>CFBundleLocalizations</key>
|
||||||
|
<array>
|
||||||
|
<string>en</string>
|
||||||
|
<string>nb</string>
|
||||||
|
<string>da</string>
|
||||||
|
<string>de</string>
|
||||||
|
<string>sv</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
+11751
-6
File diff suppressed because it is too large
Load Diff
+16
-12
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// SKILL.md-style behavioral skills — markdown instruction files injected into the system prompt
|
// SKILL.md-style behavioral skills — markdown instruction files injected into the system prompt
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -57,4 +55,10 @@ struct AgentSkill: Codable, Identifiable {
|
|||||||
}
|
}
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Matches the user's "2nd Brain" skill by name — there's no canonical skill ID,
|
||||||
|
/// so this is the only way to recognize it (used to gate the "always trust" bash setting).
|
||||||
|
var isSecondBrainSkill: Bool {
|
||||||
|
name.trimmingCharacters(in: .whitespacesAndNewlines).caseInsensitiveCompare("2nd Brain") == .orderedSame
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Model for saved conversations
|
// Model for saved conversations
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -33,7 +31,7 @@ struct Conversation: Identifiable, Codable {
|
|||||||
var updatedAt: Date
|
var updatedAt: Date
|
||||||
var primaryModel: String? // Primary model used in this conversation
|
var primaryModel: String? // Primary model used in this conversation
|
||||||
|
|
||||||
init(
|
nonisolated init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
name: String,
|
name: String,
|
||||||
messages: [Message] = [],
|
messages: [Message] = [],
|
||||||
|
|||||||
+11
-13
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Email processing log entry model
|
// Email processing log entry model
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -44,7 +42,7 @@ struct EmailLog: Identifiable, Codable, Equatable {
|
|||||||
let responseTime: TimeInterval? // Time to generate response in seconds
|
let responseTime: TimeInterval? // Time to generate response in seconds
|
||||||
let modelId: String? // Model that handled the email
|
let modelId: String? // Model that handled the email
|
||||||
|
|
||||||
init(
|
nonisolated init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
timestamp: Date = Date(),
|
timestamp: Date = Date(),
|
||||||
sender: String,
|
sender: String,
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Command history entry model
|
// Command history entry model
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
//
|
||||||
|
// JarvisModels.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// Data models for the Jarvis (oAI-Web) API integration.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Agent
|
||||||
|
|
||||||
|
struct JarvisAgent: Identifiable, Codable, Hashable, Sendable {
|
||||||
|
let id: String
|
||||||
|
var name: String
|
||||||
|
var description: String
|
||||||
|
var prompt: String
|
||||||
|
var model: String
|
||||||
|
var enabled: Bool
|
||||||
|
var schedule: String?
|
||||||
|
var canCreateSubagents: Bool
|
||||||
|
var allowedTools: [String]
|
||||||
|
var maxToolCalls: Int?
|
||||||
|
var promptMode: String
|
||||||
|
let createdAt: String?
|
||||||
|
var isRunning: Bool?
|
||||||
|
var lastRunAt: String?
|
||||||
|
var lastRunStatus: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, description, prompt, model, enabled, schedule
|
||||||
|
case canCreateSubagents = "can_create_subagents"
|
||||||
|
case allowedTools = "allowed_tools"
|
||||||
|
case maxToolCalls = "max_tool_calls"
|
||||||
|
case promptMode = "prompt_mode"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case isRunning = "is_running"
|
||||||
|
case lastRunAt = "last_run_at"
|
||||||
|
case lastRunStatus = "last_run_status"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Agent Input (create / update)
|
||||||
|
|
||||||
|
struct JarvisAgentInput: Codable, Sendable {
|
||||||
|
var name: String
|
||||||
|
var prompt: String
|
||||||
|
var model: String
|
||||||
|
var description: String = ""
|
||||||
|
var enabled: Bool = true
|
||||||
|
var schedule: String? = nil
|
||||||
|
var canCreateSubagents: Bool = false
|
||||||
|
var allowedTools: [String] = []
|
||||||
|
var maxToolCalls: Int? = nil
|
||||||
|
var promptMode: String = "combined"
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name, prompt, model, description, enabled, schedule
|
||||||
|
case canCreateSubagents = "can_create_subagents"
|
||||||
|
case allowedTools = "allowed_tools"
|
||||||
|
case maxToolCalls = "max_tool_calls"
|
||||||
|
case promptMode = "prompt_mode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Agent Run
|
||||||
|
|
||||||
|
struct JarvisAgentRun: Identifiable, Codable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let agentId: String?
|
||||||
|
let status: String // "running" | "completed" | "failed" | "stopped"
|
||||||
|
let startedAt: String?
|
||||||
|
let finishedAt: String?
|
||||||
|
let output: String?
|
||||||
|
let error: String?
|
||||||
|
let costUsd: Double?
|
||||||
|
let inputTokens: Int?
|
||||||
|
let outputTokens: Int?
|
||||||
|
let triggerType: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, status, output, error
|
||||||
|
case agentId = "agent_id"
|
||||||
|
case startedAt = "started_at"
|
||||||
|
case finishedAt = "finished_at"
|
||||||
|
case costUsd = "cost_usd"
|
||||||
|
case inputTokens = "input_tokens"
|
||||||
|
case outputTokens = "output_tokens"
|
||||||
|
case triggerType = "trigger_type"
|
||||||
|
}
|
||||||
|
|
||||||
|
var isActive: Bool { status == "running" }
|
||||||
|
|
||||||
|
var totalTokens: Int { (inputTokens ?? 0) + (outputTokens ?? 0) }
|
||||||
|
|
||||||
|
var formattedStarted: String {
|
||||||
|
guard let s = startedAt else { return "—" }
|
||||||
|
return isoFormatter.string(from: isoParser.date(from: s) ?? Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedDuration: String? {
|
||||||
|
guard let s = startedAt, let f = finishedAt,
|
||||||
|
let sd = isoParser.date(from: s), let fd = isoParser.date(from: f) else { return nil }
|
||||||
|
let secs = Int(fd.timeIntervalSince(sd))
|
||||||
|
if secs < 60 { return "\(secs)s" }
|
||||||
|
return "\(secs / 60)m \(secs % 60)s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let isoParser: ISO8601DateFormatter = {
|
||||||
|
let f = ISO8601DateFormatter()
|
||||||
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let isoFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateStyle = .short
|
||||||
|
f.timeStyle = .short
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
// MARK: - Usage
|
||||||
|
|
||||||
|
struct JarvisUsageStat: Identifiable, Codable, Sendable {
|
||||||
|
let agentId: String?
|
||||||
|
let agentName: String?
|
||||||
|
let model: String?
|
||||||
|
let runCount: Int?
|
||||||
|
let totalInputTokens: Int?
|
||||||
|
let totalOutputTokens: Int?
|
||||||
|
let totalCostUsd: Double?
|
||||||
|
|
||||||
|
var id: String { agentId ?? agentName ?? "unknown" }
|
||||||
|
var totalTokens: Int { (totalInputTokens ?? 0) + (totalOutputTokens ?? 0) }
|
||||||
|
var displayName: String { agentName ?? agentId ?? "Unknown" }
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case agentId = "agent_id"
|
||||||
|
case agentName = "agent_name"
|
||||||
|
case model
|
||||||
|
case runCount = "runs"
|
||||||
|
case totalInputTokens = "input_tokens"
|
||||||
|
case totalOutputTokens = "output_tokens"
|
||||||
|
case totalCostUsd = "cost_usd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Usage Response (top-level wrapper)
|
||||||
|
|
||||||
|
struct JarvisUsageResponse: Decodable, Sendable {
|
||||||
|
let summary: JarvisUsageSummary?
|
||||||
|
let byAgent: [JarvisUsageStat]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case summary
|
||||||
|
case byAgent = "by_agent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct JarvisUsageSummary: Codable, Sendable {
|
||||||
|
let runs: Int?
|
||||||
|
let inputTokens: Int?
|
||||||
|
let outputTokens: Int?
|
||||||
|
let costUsd: Double?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case runs
|
||||||
|
case inputTokens = "input_tokens"
|
||||||
|
case outputTokens = "output_tokens"
|
||||||
|
case costUsd = "cost_usd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Credits
|
||||||
|
|
||||||
|
struct JarvisCreditsResponse: Codable, Sendable {
|
||||||
|
let totalCredits: Double?
|
||||||
|
let totalUsage: Double?
|
||||||
|
let balance: Double?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case totalCredits = "total_credits"
|
||||||
|
case totalUsage = "total_usage"
|
||||||
|
case balance
|
||||||
|
}
|
||||||
|
|
||||||
|
var remainingBalance: Double? {
|
||||||
|
if let b = balance { return b }
|
||||||
|
if let c = totalCredits, let u = totalUsage { return c - u }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Queue / System status
|
||||||
|
|
||||||
|
struct JarvisQueueStatus: Codable, Sendable {
|
||||||
|
let paused: Bool?
|
||||||
|
let queueLength: Int?
|
||||||
|
let runningCount: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case paused
|
||||||
|
case queueLength = "queue_length"
|
||||||
|
case runningCount = "running_count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum JarvisError: LocalizedError {
|
||||||
|
case invalidURL
|
||||||
|
case noAPIKey
|
||||||
|
case invalidResponse
|
||||||
|
case serverError(Int, String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL: return "Invalid Jarvis URL"
|
||||||
|
case .noAPIKey: return "No API key configured — add one in Settings → Jarvis"
|
||||||
|
case .invalidResponse: return "Invalid server response"
|
||||||
|
case .serverError(let c, let m): return "Server error \(c): \(m)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-13
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Core message model for chat conversations
|
// Core message model for chat conversations
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -66,7 +64,7 @@ struct Message: Identifiable, Codable, Equatable {
|
|||||||
// Reasoning/thinking content (not persisted — in-memory only)
|
// Reasoning/thinking content (not persisted — in-memory only)
|
||||||
var thinkingContent: String? = nil
|
var thinkingContent: String? = nil
|
||||||
|
|
||||||
init(
|
nonisolated init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
role: MessageRole,
|
role: MessageRole,
|
||||||
content: String,
|
content: String,
|
||||||
|
|||||||
+10
-12
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Mock data for Phase 1 testing
|
// Mock data for Phase 1 testing
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
//
|
||||||
|
// ModelCategory.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// Category tags for AI models, inferred from model name/id/description.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
//
|
||||||
|
// This file is part of oAI.
|
||||||
|
//
|
||||||
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
|
//
|
||||||
|
// See the LICENSE file or
|
||||||
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
|
// the full license text. For commercial licensing, contact Rune
|
||||||
|
// Olsen via <https://oai.pm>.
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum ModelCategory: String, CaseIterable, Codable, Sendable {
|
||||||
|
case programming = "Programming"
|
||||||
|
case math = "Math"
|
||||||
|
case medical = "Medical"
|
||||||
|
case translation = "Translation"
|
||||||
|
case roleplay = "Roleplay"
|
||||||
|
case creative = "Creative"
|
||||||
|
case science = "Science"
|
||||||
|
case finance = "Finance"
|
||||||
|
case legal = "Legal"
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
switch self {
|
||||||
|
case .programming: return .blue
|
||||||
|
case .math: return .orange
|
||||||
|
case .medical: return .red
|
||||||
|
case .translation: return .teal
|
||||||
|
case .roleplay: return .pink
|
||||||
|
case .creative: return .purple
|
||||||
|
case .science: return .green
|
||||||
|
case .finance: return Color(red: 0.75, green: 0.60, blue: 0.0)
|
||||||
|
case .legal: return Color(red: 0.55, green: 0.40, blue: 0.20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .programming: return "chevron.left.forwardslash.chevron.right"
|
||||||
|
case .math: return "function"
|
||||||
|
case .medical: return "cross.fill"
|
||||||
|
case .translation: return "globe"
|
||||||
|
case .roleplay: return "theatermasks.fill"
|
||||||
|
case .creative: return "pencil.and.outline"
|
||||||
|
case .science: return "atom"
|
||||||
|
case .finance: return "chart.line.uptrend.xyaxis"
|
||||||
|
case .legal: return "building.columns.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Category Inference
|
||||||
|
|
||||||
|
/// Infer categories from a model's name, id, and description.
|
||||||
|
static func infer(name: String, id: String, description: String?) -> [ModelCategory] {
|
||||||
|
let nameId = (name + " " + id).lowercased()
|
||||||
|
let desc = description?.lowercased() ?? ""
|
||||||
|
return allCases.filter { $0.matches(nameId: nameId, desc: desc) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func matches(nameId: String, desc: String) -> Bool {
|
||||||
|
if nameKeywords.contains(where: { nameId.contains($0) }) { return true }
|
||||||
|
if desc.count > 40 && descKeywords.contains(where: { desc.contains($0) }) { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patterns matched against lowercased "name + id" string
|
||||||
|
private var nameKeywords: [String] {
|
||||||
|
switch self {
|
||||||
|
case .programming:
|
||||||
|
return ["code", "coder", "codex", "codellama", "starcoder", "phind",
|
||||||
|
"codestral", "opencoder", "swe-", "devin-", "wizard-code",
|
||||||
|
"replit-code", "qwen-coder", "deepseek-coder", "devstral",
|
||||||
|
"granite-code", "yi-coder", "artigenz", "wavecoder",
|
||||||
|
"programming", "software-", "cursor-"]
|
||||||
|
case .math:
|
||||||
|
return ["math", "mathem", "numina", "minerva-math", "wizard-math",
|
||||||
|
"deepseek-math", "qwen-math", "numinamath", "mathstral",
|
||||||
|
"qwq", "internlm-math", "mammoth", "mathcoder", "orion-math",
|
||||||
|
"abel-", "metamath"]
|
||||||
|
case .medical:
|
||||||
|
return ["medical", "meditron", "med42", "medllama", "biomed",
|
||||||
|
"health-llm", "biosage", "clinicalbert", "pubmedbert",
|
||||||
|
"clinical", "llama-med", "openbiomed", "pmc-llama",
|
||||||
|
"doctorglm", "biolm", "biomistral", "medalpaca",
|
||||||
|
"medpalm", "pharmallm", "mimic"]
|
||||||
|
case .translation:
|
||||||
|
return ["nllb", "madlad", "-aya-", "seamless", "tower-instruct",
|
||||||
|
"alma-", "bayling", "opus-mt", "m2m-100", "mbart",
|
||||||
|
"translate", "multilingual-", "xglm", "madlad-400"]
|
||||||
|
case .roleplay:
|
||||||
|
return ["roleplay", "role-play", "mytho", "capybara", "cinematika",
|
||||||
|
"manticore", "weaver-", "noromaid", "airoboros", "toppy",
|
||||||
|
"dolphin", "hermes", "openhermes", "psyfighter",
|
||||||
|
"bluemoon", "midnight", "remm", "rose-20b"]
|
||||||
|
case .creative:
|
||||||
|
return ["creative-writing", "story-writer", "storyllm", "fimbulvetr",
|
||||||
|
"rp-", "goliath", "lzlv", "mlewd"]
|
||||||
|
case .science:
|
||||||
|
return ["scibert", "biogpt", "galactica", "science-llm", "scillm",
|
||||||
|
"darwin-", "newton-", "eureka-", "sci-"]
|
||||||
|
case .finance:
|
||||||
|
return ["fingpt", "finma", "finance-llm", "financellm", "pixiu",
|
||||||
|
"flang-", "alphafin", "bloom-finance", "finbert",
|
||||||
|
"stockgpt", "traderllm"]
|
||||||
|
case .legal:
|
||||||
|
return ["lawbench", "legalbench", "legalbert", "lawgpt", "legal-llm",
|
||||||
|
"lawyerllm", "legalai", "chatlaw", "jurisllm"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phrases matched against lowercased description (only when description > 40 chars)
|
||||||
|
private var descKeywords: [String] {
|
||||||
|
switch self {
|
||||||
|
case .programming:
|
||||||
|
return ["code generation", "designed for coding", "built for code",
|
||||||
|
"coding-focused", "programming assistant", "specialized for code",
|
||||||
|
"optimized for coding", "coding tasks", "software engineering",
|
||||||
|
"for developers", "code completion", "software development",
|
||||||
|
"coding and", "and coding", "writing code"]
|
||||||
|
case .math:
|
||||||
|
return ["mathematical reasoning", "math competition", "math olympiad",
|
||||||
|
"designed for math", "theorem proving", "quantitative reasoning",
|
||||||
|
"numerical problem", "math, code", "math and code",
|
||||||
|
"mathematics and", "advanced math", "math tasks",
|
||||||
|
"solving math", "competition math", "math problems"]
|
||||||
|
case .medical:
|
||||||
|
return ["medical knowledge", "clinical reasoning", "healthcare",
|
||||||
|
"biomedical research", "medical question", "trained on medical",
|
||||||
|
"medical domain", "medical literature", "clinical decision",
|
||||||
|
"health information", "medical text", "medical imaging"]
|
||||||
|
case .translation:
|
||||||
|
return ["machine translation", "language translation",
|
||||||
|
"multilingual translation", "cross-lingual",
|
||||||
|
"translation tasks", "translation between",
|
||||||
|
"natural language translation"]
|
||||||
|
case .roleplay:
|
||||||
|
return ["designed for roleplay", "roleplay scenarios",
|
||||||
|
"creative roleplay", "interactive roleplay", "character roleplay",
|
||||||
|
"role-playing", "roleplaying", "uncensored", "nsfw",
|
||||||
|
"adult content", "creative fiction", "interactive story"]
|
||||||
|
case .creative:
|
||||||
|
return ["creative writing", "storytelling", "narrative generation",
|
||||||
|
"fiction writing", "prose generation", "story writing",
|
||||||
|
"creative text", "story generation", "write stories"]
|
||||||
|
case .science:
|
||||||
|
return ["scientific literature", "scientific research",
|
||||||
|
"chemistry tasks", "biology research", "physics",
|
||||||
|
"scientific reasoning", "science tasks", "stem tasks",
|
||||||
|
"scientific knowledge"]
|
||||||
|
case .finance:
|
||||||
|
return ["financial analysis", "quantitative finance",
|
||||||
|
"financial modeling", "market analysis", "financial reasoning",
|
||||||
|
"investment analysis", "economic analysis", "trading"]
|
||||||
|
case .legal:
|
||||||
|
return ["legal document", "case law", "legal reasoning",
|
||||||
|
"legal text", "law and legal", "legal questions",
|
||||||
|
"legal analysis", "legal research", "contract analysis"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-12
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Model information and capabilities
|
// Model information and capabilities
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -34,6 +32,7 @@ struct ModelInfo: Identifiable, Codable, Hashable {
|
|||||||
let capabilities: ModelCapabilities
|
let capabilities: ModelCapabilities
|
||||||
var architecture: Architecture? = nil
|
var architecture: Architecture? = nil
|
||||||
var topProvider: String? = nil
|
var topProvider: String? = nil
|
||||||
|
var categories: [ModelCategory] = []
|
||||||
|
|
||||||
struct Pricing: Codable, Hashable {
|
struct Pricing: Codable, Hashable {
|
||||||
let prompt: Double // per 1M tokens
|
let prompt: Double // per 1M tokens
|
||||||
@@ -46,6 +45,7 @@ struct ModelInfo: Identifiable, Codable, Hashable {
|
|||||||
let online: Bool // Web search
|
let online: Bool // Web search
|
||||||
var imageGeneration: Bool = false // Image output
|
var imageGeneration: Bool = false // Image output
|
||||||
var thinking: Bool = false // Reasoning/thinking tokens
|
var thinking: Bool = false // Reasoning/thinking tokens
|
||||||
|
var usesImagesAPI: Bool = false // OpenRouter dedicated /images endpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Architecture: Codable, Hashable {
|
struct Architecture: Codable, Hashable {
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Session statistics tracking
|
// Session statistics tracking
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
+10
-12
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Application settings and configuration
|
// Application settings and configuration
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
+10
-12
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// User-defined slash command templates (prompt shortcuts/macros)
|
// User-defined slash command templates (prompt shortcuts/macros)
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
+10
-12
@@ -1,22 +1,20 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
enum SyncAuthMethod: String, CaseIterable, Codable {
|
enum SyncAuthMethod: String, CaseIterable, Codable {
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Protocol for AI provider implementations
|
// Protocol for AI provider implementations
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -130,11 +128,37 @@ struct ChatResponse: Codable {
|
|||||||
let promptTokens: Int
|
let promptTokens: Int
|
||||||
let completionTokens: Int
|
let completionTokens: Int
|
||||||
let totalTokens: Int
|
let totalTokens: Int
|
||||||
|
let cacheCreationInputTokens: Int?
|
||||||
|
let cacheReadInputTokens: Int?
|
||||||
|
/// Direct USD cost returned by the images API (bypasses token-based calculation).
|
||||||
|
let rawCostUSD: Double?
|
||||||
|
|
||||||
|
init(promptTokens: Int, completionTokens: Int, totalTokens: Int, cacheCreationInputTokens: Int? = nil, cacheReadInputTokens: Int? = nil, rawCostUSD: Double? = nil) {
|
||||||
|
self.promptTokens = promptTokens
|
||||||
|
self.completionTokens = completionTokens
|
||||||
|
self.totalTokens = totalTokens
|
||||||
|
self.cacheCreationInputTokens = cacheCreationInputTokens
|
||||||
|
self.cacheReadInputTokens = cacheReadInputTokens
|
||||||
|
self.rawCostUSD = rawCostUSD
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawCostUSD is set programmatically, never decoded from API responses
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case promptTokens = "prompt_tokens"
|
case promptTokens = "prompt_tokens"
|
||||||
case completionTokens = "completion_tokens"
|
case completionTokens = "completion_tokens"
|
||||||
case totalTokens = "total_tokens"
|
case totalTokens = "total_tokens"
|
||||||
|
case cacheCreationInputTokens = "cache_creation_input_tokens"
|
||||||
|
case cacheReadInputTokens = "cache_read_input_tokens"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
promptTokens = try c.decode(Int.self, forKey: .promptTokens)
|
||||||
|
completionTokens = try c.decode(Int.self, forKey: .completionTokens)
|
||||||
|
totalTokens = try c.decode(Int.self, forKey: .totalTokens)
|
||||||
|
cacheCreationInputTokens = try c.decodeIfPresent(Int.self, forKey: .cacheCreationInputTokens)
|
||||||
|
cacheReadInputTokens = try c.decodeIfPresent(Int.self, forKey: .cacheReadInputTokens)
|
||||||
|
rawCostUSD = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +241,19 @@ struct Tool: Codable {
|
|||||||
let type: String
|
let type: String
|
||||||
let description: String
|
let description: String
|
||||||
let `enum`: [String]?
|
let `enum`: [String]?
|
||||||
|
let items: Items?
|
||||||
|
|
||||||
|
/// Item schema for `type: "array"` properties (e.g. an array of strings).
|
||||||
|
struct Items: Codable {
|
||||||
|
let type: String
|
||||||
|
}
|
||||||
|
|
||||||
|
init(type: String, description: String, enum: [String]? = nil, items: Items? = nil) {
|
||||||
|
self.type = type
|
||||||
|
self.description = description
|
||||||
|
self.enum = `enum`
|
||||||
|
self.items = items
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Anthropic Messages API provider with SSE streaming and tool support
|
// Anthropic Messages API provider with SSE streaming and tool support
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -77,6 +75,15 @@ class AnthropicProvider: AIProvider {
|
|||||||
/// falls back to prefix matching so newly-released model variants (e.g. "claude-sonnet-4-6-20260301")
|
/// falls back to prefix matching so newly-released model variants (e.g. "claude-sonnet-4-6-20260301")
|
||||||
/// still inherit the correct pricing tier.
|
/// still inherit the correct pricing tier.
|
||||||
private static let knownModels: [ModelInfo] = [
|
private static let knownModels: [ModelInfo] = [
|
||||||
|
// Claude Fable 5
|
||||||
|
ModelInfo(
|
||||||
|
id: "claude-fable-5",
|
||||||
|
name: "Claude Fable 5",
|
||||||
|
description: "Anthropic's creative and storytelling model",
|
||||||
|
contextLength: 200_000,
|
||||||
|
pricing: .init(prompt: 10.0, completion: 50.0),
|
||||||
|
capabilities: .init(vision: true, tools: true, online: true)
|
||||||
|
),
|
||||||
// Claude 4.x series
|
// Claude 4.x series
|
||||||
ModelInfo(
|
ModelInfo(
|
||||||
id: "claude-opus-4-6",
|
id: "claude-opus-4-6",
|
||||||
@@ -173,6 +180,7 @@ class AnthropicProvider: AIProvider {
|
|||||||
/// Pricing tiers used for fuzzy fallback matching on unknown model IDs.
|
/// Pricing tiers used for fuzzy fallback matching on unknown model IDs.
|
||||||
/// Keyed by model name prefix (longest match wins).
|
/// Keyed by model name prefix (longest match wins).
|
||||||
private static let pricingFallback: [(prefix: String, prompt: Double, completion: Double)] = [
|
private static let pricingFallback: [(prefix: String, prompt: Double, completion: Double)] = [
|
||||||
|
("claude-fable", 10.0, 50.0),
|
||||||
("claude-opus", 15.0, 75.0),
|
("claude-opus", 15.0, 75.0),
|
||||||
("claude-sonnet", 3.0, 15.0),
|
("claude-sonnet", 3.0, 15.0),
|
||||||
("claude-haiku", 0.80, 4.0),
|
("claude-haiku", 0.80, 4.0),
|
||||||
@@ -356,6 +364,19 @@ class AnthropicProvider: AIProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark the last message with a cache breakpoint so the next loop
|
||||||
|
// iteration (or next turn) can reuse everything up through this one.
|
||||||
|
if var lastMessage = conversationMessages.popLast() {
|
||||||
|
if let content = lastMessage["content"] as? String {
|
||||||
|
lastMessage["content"] = [["type": "text", "text": content, "cache_control": ["type": "ephemeral"]]]
|
||||||
|
} else if var blocks = lastMessage["content"] as? [[String: Any]], var lastBlock = blocks.popLast() {
|
||||||
|
lastBlock["cache_control"] = ["type": "ephemeral"]
|
||||||
|
blocks.append(lastBlock)
|
||||||
|
lastMessage["content"] = blocks
|
||||||
|
}
|
||||||
|
conversationMessages.append(lastMessage)
|
||||||
|
}
|
||||||
|
|
||||||
var body: [String: Any] = [
|
var body: [String: Any] = [
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": conversationMessages,
|
"messages": conversationMessages,
|
||||||
@@ -363,7 +384,9 @@ class AnthropicProvider: AIProvider {
|
|||||||
"stream": false
|
"stream": false
|
||||||
]
|
]
|
||||||
if let systemText = systemText {
|
if let systemText = systemText {
|
||||||
body["system"] = systemText
|
// Array form carries a cache breakpoint; also covers tools, which
|
||||||
|
// render before system in Anthropic's prefix order.
|
||||||
|
body["system"] = [["type": "text", "text": systemText, "cache_control": ["type": "ephemeral"]]]
|
||||||
}
|
}
|
||||||
if let temperature = temperature {
|
if let temperature = temperature {
|
||||||
body["temperature"] = temperature
|
body["temperature"] = temperature
|
||||||
@@ -430,6 +453,8 @@ class AnthropicProvider: AIProvider {
|
|||||||
var currentId = ""
|
var currentId = ""
|
||||||
var currentModel = request.model
|
var currentModel = request.model
|
||||||
var inputTokens = 0
|
var inputTokens = 0
|
||||||
|
var cacheCreationTokens: Int? = nil
|
||||||
|
var cacheReadTokens: Int? = nil
|
||||||
|
|
||||||
for try await line in bytes.lines {
|
for try await line in bytes.lines {
|
||||||
// Anthropic SSE: "event: ..." and "data: {...}"
|
// Anthropic SSE: "event: ..." and "data: {...}"
|
||||||
@@ -449,6 +474,11 @@ class AnthropicProvider: AIProvider {
|
|||||||
currentModel = message["model"] as? String ?? request.model
|
currentModel = message["model"] as? String ?? request.model
|
||||||
if let usageDict = message["usage"] as? [String: Any] {
|
if let usageDict = message["usage"] as? [String: Any] {
|
||||||
inputTokens = usageDict["input_tokens"] as? Int ?? 0
|
inputTokens = usageDict["input_tokens"] as? Int ?? 0
|
||||||
|
cacheCreationTokens = usageDict["cache_creation_input_tokens"] as? Int
|
||||||
|
cacheReadTokens = usageDict["cache_read_input_tokens"] as? Int
|
||||||
|
if cacheCreationTokens != nil || cacheReadTokens != nil {
|
||||||
|
Log.api.info("Anthropic stream cache usage: input=\(inputTokens), created=\(cacheCreationTokens ?? 0), read=\(cacheReadTokens ?? 0)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,7 +502,13 @@ class AnthropicProvider: AIProvider {
|
|||||||
var usage: ChatResponse.Usage? = nil
|
var usage: ChatResponse.Usage? = nil
|
||||||
if let usageDict = event["usage"] as? [String: Any] {
|
if let usageDict = event["usage"] as? [String: Any] {
|
||||||
let outputTokens = usageDict["output_tokens"] as? Int ?? 0
|
let outputTokens = usageDict["output_tokens"] as? Int ?? 0
|
||||||
usage = ChatResponse.Usage(promptTokens: inputTokens, completionTokens: outputTokens, totalTokens: inputTokens + outputTokens)
|
usage = ChatResponse.Usage(
|
||||||
|
promptTokens: inputTokens,
|
||||||
|
completionTokens: outputTokens,
|
||||||
|
totalTokens: inputTokens + outputTokens,
|
||||||
|
cacheCreationInputTokens: cacheCreationTokens,
|
||||||
|
cacheReadInputTokens: cacheReadTokens
|
||||||
|
)
|
||||||
}
|
}
|
||||||
continuation.yield(StreamChunk(
|
continuation.yield(StreamChunk(
|
||||||
id: currentId,
|
id: currentId,
|
||||||
@@ -582,6 +618,19 @@ class AnthropicProvider: AIProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark the last message with a cache breakpoint so the next turn can
|
||||||
|
// reuse everything up through this one as a cached prefix.
|
||||||
|
if var lastMessage = apiMessages.popLast() {
|
||||||
|
if let content = lastMessage["content"] as? String {
|
||||||
|
lastMessage["content"] = [["type": "text", "text": content, "cache_control": ["type": "ephemeral"]]]
|
||||||
|
} else if var blocks = lastMessage["content"] as? [[String: Any]], var lastBlock = blocks.popLast() {
|
||||||
|
lastBlock["cache_control"] = ["type": "ephemeral"]
|
||||||
|
blocks.append(lastBlock)
|
||||||
|
lastMessage["content"] = blocks
|
||||||
|
}
|
||||||
|
apiMessages.append(lastMessage)
|
||||||
|
}
|
||||||
|
|
||||||
var body: [String: Any] = [
|
var body: [String: Any] = [
|
||||||
"model": request.model,
|
"model": request.model,
|
||||||
"messages": apiMessages,
|
"messages": apiMessages,
|
||||||
@@ -590,7 +639,10 @@ class AnthropicProvider: AIProvider {
|
|||||||
]
|
]
|
||||||
|
|
||||||
if let systemText = systemText {
|
if let systemText = systemText {
|
||||||
body["system"] = systemText
|
// Array form (rather than a plain string) carries a cache breakpoint.
|
||||||
|
// Per Anthropic's render order (tools -> system -> messages), this
|
||||||
|
// single breakpoint caches the tool definitions too.
|
||||||
|
body["system"] = [["type": "text", "text": systemText, "cache_control": ["type": "ephemeral"]]]
|
||||||
}
|
}
|
||||||
if let temperature = request.temperature {
|
if let temperature = request.temperature {
|
||||||
body["temperature"] = temperature
|
body["temperature"] = temperature
|
||||||
@@ -665,6 +717,11 @@ class AnthropicProvider: AIProvider {
|
|||||||
let usageDict = json["usage"] as? [String: Any]
|
let usageDict = json["usage"] as? [String: Any]
|
||||||
let inputTokens = usageDict?["input_tokens"] as? Int ?? 0
|
let inputTokens = usageDict?["input_tokens"] as? Int ?? 0
|
||||||
let outputTokens = usageDict?["output_tokens"] as? Int ?? 0
|
let outputTokens = usageDict?["output_tokens"] as? Int ?? 0
|
||||||
|
let cacheCreationTokens = usageDict?["cache_creation_input_tokens"] as? Int
|
||||||
|
let cacheReadTokens = usageDict?["cache_read_input_tokens"] as? Int
|
||||||
|
if cacheCreationTokens != nil || cacheReadTokens != nil {
|
||||||
|
Log.api.info("Anthropic cache usage: input=\(inputTokens), created=\(cacheCreationTokens ?? 0), read=\(cacheReadTokens ?? 0)")
|
||||||
|
}
|
||||||
|
|
||||||
return ChatResponse(
|
return ChatResponse(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -675,7 +732,9 @@ class AnthropicProvider: AIProvider {
|
|||||||
usage: ChatResponse.Usage(
|
usage: ChatResponse.Usage(
|
||||||
promptTokens: inputTokens,
|
promptTokens: inputTokens,
|
||||||
completionTokens: outputTokens,
|
completionTokens: outputTokens,
|
||||||
totalTokens: inputTokens + outputTokens
|
totalTokens: inputTokens + outputTokens,
|
||||||
|
cacheCreationInputTokens: cacheCreationTokens,
|
||||||
|
cacheReadInputTokens: cacheReadTokens
|
||||||
),
|
),
|
||||||
created: Date(),
|
created: Date(),
|
||||||
toolCalls: toolCalls.isEmpty ? nil : toolCalls
|
toolCalls: toolCalls.isEmpty ? nil : toolCalls
|
||||||
@@ -692,6 +751,9 @@ class AnthropicProvider: AIProvider {
|
|||||||
if let enumVals = prop.enum {
|
if let enumVals = prop.enum {
|
||||||
propDict["enum"] = enumVals
|
propDict["enum"] = enumVals
|
||||||
}
|
}
|
||||||
|
if let items = prop.items {
|
||||||
|
propDict["items"] = ["type": items.type]
|
||||||
|
}
|
||||||
props[key] = propDict
|
props[key] = propDict
|
||||||
}
|
}
|
||||||
var dict: [String: Any] = [
|
var dict: [String: Any] = [
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Ollama local AI provider with JSON-lines streaming
|
// Ollama local AI provider with JSON-lines streaming
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// OpenAI API provider with SSE streaming and tool support
|
// OpenAI API provider with SSE streaming and tool support
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// OpenRouter API request and response models
|
// OpenRouter API request and response models
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -48,7 +46,12 @@ struct OpenRouterChatRequest: Codable {
|
|||||||
let toolChoice: String?
|
let toolChoice: String?
|
||||||
let modalities: [String]?
|
let modalities: [String]?
|
||||||
let reasoning: ReasoningAPIConfig?
|
let reasoning: ReasoningAPIConfig?
|
||||||
|
let cacheControl: CacheControl?
|
||||||
|
|
||||||
|
struct CacheControl: Codable {
|
||||||
|
let type: String
|
||||||
|
}
|
||||||
|
|
||||||
struct APIMessage: Codable {
|
struct APIMessage: Codable {
|
||||||
let role: String
|
let role: String
|
||||||
let content: MessageContent
|
let content: MessageContent
|
||||||
@@ -138,6 +141,7 @@ struct OpenRouterChatRequest: Codable {
|
|||||||
case toolChoice = "tool_choice"
|
case toolChoice = "tool_choice"
|
||||||
case modalities
|
case modalities
|
||||||
case reasoning
|
case reasoning
|
||||||
|
case cacheControl = "cache_control"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,11 +229,23 @@ struct OpenRouterChatResponse: Codable {
|
|||||||
let promptTokens: Int
|
let promptTokens: Int
|
||||||
let completionTokens: Int
|
let completionTokens: Int
|
||||||
let totalTokens: Int
|
let totalTokens: Int
|
||||||
|
let promptTokensDetails: PromptTokensDetails?
|
||||||
|
|
||||||
|
struct PromptTokensDetails: Codable {
|
||||||
|
let cachedTokens: Int?
|
||||||
|
let cacheWriteTokens: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case cachedTokens = "cached_tokens"
|
||||||
|
case cacheWriteTokens = "cache_write_tokens"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case promptTokens = "prompt_tokens"
|
case promptTokens = "prompt_tokens"
|
||||||
case completionTokens = "completion_tokens"
|
case completionTokens = "completion_tokens"
|
||||||
case totalTokens = "total_tokens"
|
case totalTokens = "total_tokens"
|
||||||
|
case promptTokensDetails = "prompt_tokens_details"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,6 +418,67 @@ struct ToolResultMessage: Encodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Images API — Model Discovery
|
||||||
|
|
||||||
|
struct OpenRouterImageModelsResponse: Codable {
|
||||||
|
let data: [ImageModelData]
|
||||||
|
|
||||||
|
struct ImageModelData: Codable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let description: String?
|
||||||
|
let architecture: Architecture?
|
||||||
|
let supportsStreaming: Bool?
|
||||||
|
|
||||||
|
struct Architecture: Codable {
|
||||||
|
let inputModalities: [String]?
|
||||||
|
let outputModalities: [String]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case inputModalities = "input_modalities"
|
||||||
|
case outputModalities = "output_modalities"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, description, architecture
|
||||||
|
case supportsStreaming = "supports_streaming"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Images API — Generation Response
|
||||||
|
|
||||||
|
struct OpenRouterImageGenerationResponse: Codable {
|
||||||
|
let created: Int?
|
||||||
|
let data: [ImageData]
|
||||||
|
let usage: Usage?
|
||||||
|
|
||||||
|
struct ImageData: Codable {
|
||||||
|
let b64Json: String
|
||||||
|
let mediaType: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case b64Json = "b64_json"
|
||||||
|
case mediaType = "media_type"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Usage: Codable {
|
||||||
|
let promptTokens: Int
|
||||||
|
let completionTokens: Int
|
||||||
|
let totalTokens: Int
|
||||||
|
let cost: Double?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case promptTokens = "prompt_tokens"
|
||||||
|
case completionTokens = "completion_tokens"
|
||||||
|
case totalTokens = "total_tokens"
|
||||||
|
case cost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Error Response
|
// MARK: - Error Response
|
||||||
|
|
||||||
struct OpenRouterErrorResponse: Codable {
|
struct OpenRouterErrorResponse: Codable {
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// OpenRouter AI provider implementation with SSE streaming
|
// OpenRouter AI provider implementation with SSE streaming
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -53,34 +51,20 @@ class OpenRouterProvider: AIProvider {
|
|||||||
|
|
||||||
func listModels() async throws -> [ModelInfo] {
|
func listModels() async throws -> [ModelInfo] {
|
||||||
Log.api.info("Fetching model list from OpenRouter")
|
Log.api.info("Fetching model list from OpenRouter")
|
||||||
let url = URL(string: "\(baseURL)/models")!
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
|
||||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
|
|
||||||
let (data, response) = try await session.data(for: request)
|
// Fetch chat models and image models in parallel
|
||||||
|
async let chatData = fetchRaw(path: "/models")
|
||||||
|
async let imageData = fetchRaw(path: "/images/models")
|
||||||
|
let (chatRaw, imageRaw) = try await (chatData, imageData)
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
let modelsResponse = try JSONDecoder().decode(OpenRouterModelsResponse.self, from: chatRaw)
|
||||||
Log.api.error("OpenRouter models: invalid response (not HTTP)")
|
Log.api.info("OpenRouter loaded \(modelsResponse.data.count) chat models")
|
||||||
throw ProviderError.invalidResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
guard httpResponse.statusCode == 200 else {
|
var models = modelsResponse.data.map { modelData in
|
||||||
if let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
|
|
||||||
Log.api.error("OpenRouter models HTTP \(httpResponse.statusCode): \(errorResponse.error.message)")
|
|
||||||
throw ProviderError.unknown(errorResponse.error.message)
|
|
||||||
}
|
|
||||||
Log.api.error("OpenRouter models HTTP \(httpResponse.statusCode)")
|
|
||||||
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
|
|
||||||
}
|
|
||||||
|
|
||||||
let modelsResponse = try JSONDecoder().decode(OpenRouterModelsResponse.self, from: data)
|
|
||||||
Log.api.info("OpenRouter loaded \(modelsResponse.data.count) models")
|
|
||||||
return modelsResponse.data.map { modelData in
|
|
||||||
let promptPrice = Double(modelData.pricing.prompt) ?? 0.0
|
let promptPrice = Double(modelData.pricing.prompt) ?? 0.0
|
||||||
let completionPrice = Double(modelData.pricing.completion) ?? 0.0
|
let completionPrice = Double(modelData.pricing.completion) ?? 0.0
|
||||||
|
|
||||||
return ModelInfo(
|
var info = ModelInfo(
|
||||||
id: modelData.id,
|
id: modelData.id,
|
||||||
name: modelData.name,
|
name: modelData.name,
|
||||||
description: modelData.description,
|
description: modelData.description,
|
||||||
@@ -122,9 +106,117 @@ class OpenRouterProvider: AIProvider {
|
|||||||
},
|
},
|
||||||
topProvider: modelData.id.components(separatedBy: "/").first
|
topProvider: modelData.id.components(separatedBy: "/").first
|
||||||
)
|
)
|
||||||
|
info.categories = ModelCategory.infer(
|
||||||
|
name: modelData.name,
|
||||||
|
id: modelData.id,
|
||||||
|
description: modelData.description
|
||||||
|
)
|
||||||
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge dedicated image models (these don't appear in /models)
|
||||||
|
if let imageModelsResponse = try? JSONDecoder().decode(OpenRouterImageModelsResponse.self, from: imageRaw) {
|
||||||
|
Log.api.info("OpenRouter loaded \(imageModelsResponse.data.count) image models")
|
||||||
|
let existingIds = Set(models.map { $0.id })
|
||||||
|
let imageModels = imageModelsResponse.data.compactMap { m -> ModelInfo? in
|
||||||
|
guard !existingIds.contains(m.id) else { return nil }
|
||||||
|
let acceptsImageInput = m.architecture?.inputModalities?.contains("image") ?? false
|
||||||
|
var info = ModelInfo(
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
description: m.description,
|
||||||
|
contextLength: 0,
|
||||||
|
pricing: ModelInfo.Pricing(prompt: 0, completion: 0),
|
||||||
|
capabilities: ModelInfo.ModelCapabilities(
|
||||||
|
vision: acceptsImageInput,
|
||||||
|
tools: false,
|
||||||
|
online: false,
|
||||||
|
imageGeneration: true,
|
||||||
|
thinking: false,
|
||||||
|
usesImagesAPI: true
|
||||||
|
),
|
||||||
|
topProvider: m.id.components(separatedBy: "/").first
|
||||||
|
)
|
||||||
|
info.categories = ModelCategory.infer(name: m.name, id: m.id, description: m.description)
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
models.append(contentsOf: imageModels)
|
||||||
|
}
|
||||||
|
|
||||||
|
return models
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func fetchRaw(path: String) async throws -> Data {
|
||||||
|
let url = URL(string: "\(baseURL)\(path)")!
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else { throw ProviderError.invalidResponse }
|
||||||
|
guard httpResponse.statusCode == 200 else {
|
||||||
|
if let err = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
|
||||||
|
throw ProviderError.unknown(err.error.message)
|
||||||
|
}
|
||||||
|
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Images API
|
||||||
|
|
||||||
|
func generateImage(model: String, prompt: String) async throws -> ChatResponse {
|
||||||
|
Log.api.info("OpenRouter images API: model=\(model)")
|
||||||
|
let url = URL(string: "\(baseURL)/images")!
|
||||||
|
var urlRequest = URLRequest(url: url)
|
||||||
|
urlRequest.httpMethod = "POST"
|
||||||
|
urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||||
|
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
urlRequest.addValue("https://github.com/yourusername/oAI", forHTTPHeaderField: "HTTP-Referer")
|
||||||
|
urlRequest.addValue("oAI-Swift", forHTTPHeaderField: "X-Title")
|
||||||
|
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: ["model": model, "prompt": prompt])
|
||||||
|
|
||||||
|
let (data, response) = try await session.data(for: urlRequest)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else { throw ProviderError.invalidResponse }
|
||||||
|
|
||||||
|
if httpResponse.statusCode != 200 {
|
||||||
|
if let err = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
|
||||||
|
Log.api.error("OpenRouter images HTTP \(httpResponse.statusCode): \(err.error.message)")
|
||||||
|
throw ProviderError.unknown(err.error.message)
|
||||||
|
}
|
||||||
|
Log.api.error("OpenRouter images HTTP \(httpResponse.statusCode)")
|
||||||
|
throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let rawStr = String(data: data, encoding: .utf8) {
|
||||||
|
Log.api.debug("Images API raw response (first 200 chars): \(rawStr.prefix(200))")
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageResponse = try JSONDecoder().decode(OpenRouterImageGenerationResponse.self, from: data)
|
||||||
|
let images: [Data] = imageResponse.data.compactMap { item in
|
||||||
|
Data(base64Encoded: item.b64Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
let usage: ChatResponse.Usage? = imageResponse.usage.map { u in
|
||||||
|
ChatResponse.Usage(
|
||||||
|
promptTokens: u.promptTokens,
|
||||||
|
completionTokens: u.completionTokens,
|
||||||
|
totalTokens: u.totalTokens,
|
||||||
|
rawCostUSD: u.cost
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChatResponse(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
model: model,
|
||||||
|
content: "",
|
||||||
|
role: "assistant",
|
||||||
|
finishReason: "stop",
|
||||||
|
usage: usage,
|
||||||
|
created: Date(),
|
||||||
|
generatedImages: images.isEmpty ? nil : images
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func getModel(_ id: String) async throws -> ModelInfo? {
|
func getModel(_ id: String) async throws -> ModelInfo? {
|
||||||
let models = try await listModels()
|
let models = try await listModels()
|
||||||
return models.first { $0.id == id }
|
return models.first { $0.id == id }
|
||||||
@@ -192,6 +284,11 @@ class OpenRouterProvider: AIProvider {
|
|||||||
}
|
}
|
||||||
if let maxTokens = maxTokens { body["max_tokens"] = maxTokens }
|
if let maxTokens = maxTokens { body["max_tokens"] = maxTokens }
|
||||||
if let temperature = temperature { body["temperature"] = temperature }
|
if let temperature = temperature { body["temperature"] = temperature }
|
||||||
|
// Anthropic models require an explicit cache_control opt-in on OpenRouter;
|
||||||
|
// other providers cache automatically.
|
||||||
|
if model.hasPrefix("anthropic/") {
|
||||||
|
body["cache_control"] = ["type": "ephemeral"]
|
||||||
|
}
|
||||||
|
|
||||||
var urlRequest = URLRequest(url: url)
|
var urlRequest = URLRequest(url: url)
|
||||||
urlRequest.httpMethod = "POST"
|
urlRequest.httpMethod = "POST"
|
||||||
@@ -382,6 +479,12 @@ class OpenRouterProvider: AIProvider {
|
|||||||
ReasoningAPIConfig(effort: $0.effort, exclude: $0.exclude ? true : nil)
|
ReasoningAPIConfig(effort: $0.effort, exclude: $0.exclude ? true : nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anthropic models require an explicit cache_control opt-in on OpenRouter;
|
||||||
|
// other providers (OpenAI, DeepSeek, Gemini, Grok, etc.) cache automatically.
|
||||||
|
let cacheControl: OpenRouterChatRequest.CacheControl? = effectiveModel.hasPrefix("anthropic/")
|
||||||
|
? .init(type: "ephemeral")
|
||||||
|
: nil
|
||||||
|
|
||||||
return OpenRouterChatRequest(
|
return OpenRouterChatRequest(
|
||||||
model: effectiveModel,
|
model: effectiveModel,
|
||||||
messages: apiMessages,
|
messages: apiMessages,
|
||||||
@@ -392,7 +495,8 @@ class OpenRouterProvider: AIProvider {
|
|||||||
tools: request.tools,
|
tools: request.tools,
|
||||||
toolChoice: request.tools != nil ? "auto" : nil,
|
toolChoice: request.tools != nil ? "auto" : nil,
|
||||||
modalities: request.imageGeneration ? ["text", "image"] : nil,
|
modalities: request.imageGeneration ? ["text", "image"] : nil,
|
||||||
reasoning: reasoningConfig
|
reasoning: reasoningConfig,
|
||||||
|
cacheControl: cacheControl
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,6 +514,11 @@ class OpenRouterProvider: AIProvider {
|
|||||||
let allImages = topLevelImages + blockImages
|
let allImages = topLevelImages + blockImages
|
||||||
let images: [Data]? = allImages.isEmpty ? nil : allImages
|
let images: [Data]? = allImages.isEmpty ? nil : allImages
|
||||||
|
|
||||||
|
if let details = apiResponse.usage?.promptTokensDetails,
|
||||||
|
details.cachedTokens != nil || details.cacheWriteTokens != nil {
|
||||||
|
Log.api.info("OpenRouter cache usage: model=\(apiResponse.model), created=\(details.cacheWriteTokens ?? 0), read=\(details.cachedTokens ?? 0)")
|
||||||
|
}
|
||||||
|
|
||||||
return ChatResponse(
|
return ChatResponse(
|
||||||
id: apiResponse.id,
|
id: apiResponse.id,
|
||||||
model: apiResponse.model,
|
model: apiResponse.model,
|
||||||
@@ -420,7 +529,9 @@ class OpenRouterProvider: AIProvider {
|
|||||||
ChatResponse.Usage(
|
ChatResponse.Usage(
|
||||||
promptTokens: usage.promptTokens,
|
promptTokens: usage.promptTokens,
|
||||||
completionTokens: usage.completionTokens,
|
completionTokens: usage.completionTokens,
|
||||||
totalTokens: usage.totalTokens
|
totalTokens: usage.totalTokens,
|
||||||
|
cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens,
|
||||||
|
cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)),
|
created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)),
|
||||||
@@ -440,6 +551,11 @@ class OpenRouterProvider: AIProvider {
|
|||||||
let allImages = topLevelImages + blockImages
|
let allImages = topLevelImages + blockImages
|
||||||
let images: [Data]? = allImages.isEmpty ? nil : allImages
|
let images: [Data]? = allImages.isEmpty ? nil : allImages
|
||||||
|
|
||||||
|
if let details = apiChunk.usage?.promptTokensDetails,
|
||||||
|
details.cachedTokens != nil || details.cacheWriteTokens != nil {
|
||||||
|
Log.api.info("OpenRouter stream cache usage: model=\(apiChunk.model), created=\(details.cacheWriteTokens ?? 0), read=\(details.cachedTokens ?? 0)")
|
||||||
|
}
|
||||||
|
|
||||||
return StreamChunk(
|
return StreamChunk(
|
||||||
id: apiChunk.id,
|
id: apiChunk.id,
|
||||||
model: apiChunk.model,
|
model: apiChunk.model,
|
||||||
@@ -454,7 +570,9 @@ class OpenRouterProvider: AIProvider {
|
|||||||
ChatResponse.Usage(
|
ChatResponse.Usage(
|
||||||
promptTokens: usage.promptTokens,
|
promptTokens: usage.promptTokens,
|
||||||
completionTokens: usage.completionTokens,
|
completionTokens: usage.completionTokens,
|
||||||
totalTokens: usage.totalTokens
|
totalTokens: usage.totalTokens,
|
||||||
|
cacheCreationInputTokens: usage.promptTokensDetails?.cacheWriteTokens,
|
||||||
|
cacheReadInputTokens: usage.promptTokensDetails?.cachedTokens
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Registry for managing multiple AI providers
|
// Registry for managing multiple AI providers
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -36,6 +36,9 @@
|
|||||||
<li><a href="#shortcuts">Shortcuts (Prompt Templates)</a></li>
|
<li><a href="#shortcuts">Shortcuts (Prompt Templates)</a></li>
|
||||||
<li><a href="#agent-skills">Agent Skills (SKILL.md)</a></li>
|
<li><a href="#agent-skills">Agent Skills (SKILL.md)</a></li>
|
||||||
<li><a href="#anytype">Anytype Integration</a></li>
|
<li><a href="#anytype">Anytype Integration</a></li>
|
||||||
|
<li><a href="#external-mcp">External MCP Servers</a></li>
|
||||||
|
<li><a href="#personal-data">Personal Data Tools</a></li>
|
||||||
|
<li><a href="#research-agents">Research Agents</a></li>
|
||||||
<li><a href="#bash-execution">Bash Execution</a></li>
|
<li><a href="#bash-execution">Bash Execution</a></li>
|
||||||
<li><a href="#icloud-backup">iCloud Backup</a></li>
|
<li><a href="#icloud-backup">iCloud Backup</a></li>
|
||||||
<li><a href="#reasoning">Reasoning / Thinking Tokens</a></li>
|
<li><a href="#reasoning">Reasoning / Thinking Tokens</a></li>
|
||||||
@@ -49,7 +52,7 @@
|
|||||||
<!-- Getting Started -->
|
<!-- Getting Started -->
|
||||||
<section id="getting-started">
|
<section id="getting-started">
|
||||||
<h2>Getting Started</h2>
|
<h2>Getting Started</h2>
|
||||||
<p>oAI is a powerful AI chat assistant that connects to multiple AI providers including OpenAI, Anthropic, OpenRouter, and local models via Ollama. The app is available in English, Norwegian Bokmål, Swedish, Danish, and German — it follows your macOS language preference automatically.</p>
|
<p>oAI is a powerful AI chat assistant that connects to multiple AI providers including OpenAI, Anthropic, OpenRouter, and local models via Ollama. The app is available in English, Norwegian Bokmål, Swedish, Danish, German, and French — it follows your macOS language preference automatically.</p>
|
||||||
|
|
||||||
<div class="steps">
|
<div class="steps">
|
||||||
<h3>Quick Start</h3>
|
<h3>Quick Start</h3>
|
||||||
@@ -115,6 +118,10 @@
|
|||||||
<li><strong>🧠 Thinking</strong> — models that support reasoning / thinking tokens</li>
|
<li><strong>🧠 Thinking</strong> — models that support reasoning / thinking tokens</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Note:</strong> On OpenRouter, dedicated image-generation models (e.g. Sourceful, Seedream, Flux) are fetched from OpenRouter's separate images catalog and merged into the picker automatically — you don't need to configure anything extra to see them.
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3>Sorting</h3>
|
<h3>Sorting</h3>
|
||||||
<p>Click the <strong>↑↓ Sort</strong> button to sort the list by:</p>
|
<p>Click the <strong>↑↓ Sort</strong> button to sort the list by:</p>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -1370,7 +1377,6 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Keyboard Shortcuts -->
|
|
||||||
<!-- Anytype Integration -->
|
<!-- Anytype Integration -->
|
||||||
<section id="anytype">
|
<section id="anytype">
|
||||||
<h2>Anytype Integration</h2>
|
<h2>Anytype Integration</h2>
|
||||||
@@ -1413,6 +1419,104 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- External MCP Servers -->
|
||||||
|
<section id="external-mcp">
|
||||||
|
<h2>External MCP Servers</h2>
|
||||||
|
<p>Connect any external MCP server that speaks the stdio JSON-RPC protocol (for example <code>safaridriver --mcp</code>) and give the AI access to its tools — no custom integration code needed.</p>
|
||||||
|
|
||||||
|
<h3>Adding a Server</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Press <kbd>⌘,</kbd> to open Settings</li>
|
||||||
|
<li>Go to the <strong>MCP</strong> tab → <strong>External MCP Servers</strong> section</li>
|
||||||
|
<li>Click <strong>Add Server…</strong></li>
|
||||||
|
<li>Enter a <strong>Name</strong> (used to prefix its tools, e.g. "Safari" → <code>safari_navigate_to_url</code>), the <strong>Command</strong> to launch it, and any <strong>Arguments</strong></li>
|
||||||
|
<li>Click <strong>Add</strong> — the server starts automatically and its tools are discovered</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="tip">
|
||||||
|
<strong>💡 Tip:</strong> Arguments containing spaces can be quoted, e.g. <code>--root "/Users/you/My Documents"</code>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Server Status</h3>
|
||||||
|
<p>Each configured server shows a status dot and label:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>🟢 Connected</strong> — running and its tools are available to the AI</li>
|
||||||
|
<li><strong>🟠 Connecting…</strong> — starting up or performing the initial handshake</li>
|
||||||
|
<li><strong>🔴 Error / Crashed</strong> — failed to start or exited unexpectedly</li>
|
||||||
|
<li><strong>⚪ Not started</strong> — disabled via the toggle</li>
|
||||||
|
</ul>
|
||||||
|
<p>Toggle a server off/on or delete it entirely with the trash icon. Crashed servers automatically restart up to 3 times with increasing delay (5s, 15s, 30s) before giving up.</p>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Note:</strong> Tool names from every external server are prefixed with that server's slug (derived from its Name) so they never collide with oAI's built-in tools or each other.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Personal Data Tools -->
|
||||||
|
<section id="personal-data">
|
||||||
|
<h2>Personal Data Tools <span style="font-size: 0.75em; background: #f90; color: #fff; border-radius: 4px; padding: 1px 5px; vertical-align: middle;">Beta</span></h2>
|
||||||
|
<p>Let the AI access your Calendar, Reminders, and Location & Maps to answer questions about your schedule and surroundings. Each service is opt-in and uses Apple's own frameworks (EventKit, CoreLocation, MapKit) with standard macOS permission prompts — nothing goes through a third-party service.</p>
|
||||||
|
|
||||||
|
<h3>Enabling a Service</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Press <kbd>⌘,</kbd> to open Settings</li>
|
||||||
|
<li>Go to the <strong>MCP</strong> tab → <strong>Personal Data</strong> section</li>
|
||||||
|
<li>Toggle on the services you want: <strong>Calendar</strong>, <strong>Reminders</strong>, or <strong>Location & Maps</strong></li>
|
||||||
|
<li>Click <strong>Request Access</strong> next to a service — macOS shows its standard permission prompt</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>What the AI Can Do</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Calendar</strong> — list your calendars and upcoming events, create new events</li>
|
||||||
|
<li><strong>Reminders</strong> — list reminder lists and items, create new reminders, mark reminders complete</li>
|
||||||
|
<li><strong>Location & Maps</strong> — get your current location, search for places, geocode addresses, and get directions (all read-only)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
<strong>⚠️ Write actions require approval:</strong> Creating a calendar event or reminder, or completing a reminder, shows an approval sheet first with a plain-language summary of what will happen. Choose <strong>Deny</strong>, <strong>Allow Once</strong>, or <strong>Allow for Session</strong>. Toggle this requirement off in Settings → MCP → Personal Data → Require Approval for Changes.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Note:</strong> Contacts support exists internally but is currently hidden while a macOS permission bug affecting hardened-runtime apps is worked out on Apple's side.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Example Prompts</h3>
|
||||||
|
<ul>
|
||||||
|
<li>"What's on my calendar tomorrow?"</li>
|
||||||
|
<li>"Remind me to call the dentist on Friday at 2pm"</li>
|
||||||
|
<li>"How far is the nearest coffee shop from here?"</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Research Agents -->
|
||||||
|
<section id="research-agents">
|
||||||
|
<h2>Research Agents</h2>
|
||||||
|
<p>For tasks that involve searching or reading many files, the AI can spawn several read-only research sub-agents that work in parallel instead of doing everything itself, one step at a time.</p>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
<strong>⚠️ Cost warning:</strong> Each sub-agent runs its own full chain of model calls. A single request that spawns several agents can cost several times a normal reply. Leave this off unless you specifically want that tradeoff.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Enabling Research Agents</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Press <kbd>⌘,</kbd> to open Settings</li>
|
||||||
|
<li>Go to the <strong>MCP</strong> tab → <strong>Research Agents</strong> section</li>
|
||||||
|
<li>Toggle <strong>Enable Research Agents</strong> on</li>
|
||||||
|
<li>Adjust <strong>Max Concurrent Agents</strong> (1–5, default 3) to control how many sub-agents can run at once</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>What Sub-Agents Can Do</h3>
|
||||||
|
<p>Sub-agents are intentionally limited to read-only investigation — they cannot write files, run shell commands, or spawn further sub-agents:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Read file contents</li>
|
||||||
|
<li>List directory contents</li>
|
||||||
|
<li>Search for files</li>
|
||||||
|
<li>Search the web</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="note">Intended for genuinely independent research tasks (e.g. "compare these five files" or "look into three unrelated topics"), not everyday questions — the AI is instructed to reserve this for cases that actually benefit from parallelism.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="keyboard-shortcuts">
|
<section id="keyboard-shortcuts">
|
||||||
<h2>Keyboard Shortcuts</h2>
|
<h2>Keyboard Shortcuts</h2>
|
||||||
<p>Work faster with these keyboard shortcuts.</p>
|
<p>Work faster with these keyboard shortcuts.</p>
|
||||||
@@ -1431,7 +1535,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
|||||||
<dt><kbd>⌘L</kbd></dt>
|
<dt><kbd>⌘L</kbd></dt>
|
||||||
<dd>Browse Conversations</dd>
|
<dd>Browse Conversations</dd>
|
||||||
|
|
||||||
<dt><kbd>⌘H</kbd></dt>
|
<dt><kbd>⇧⌘H</kbd></dt>
|
||||||
<dd>Command History</dd>
|
<dd>Command History</dd>
|
||||||
|
|
||||||
<dt><kbd>⌘M</kbd></dt>
|
<dt><kbd>⌘M</kbd></dt>
|
||||||
@@ -1490,6 +1594,9 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
|||||||
<li>Enable/disable write, delete, move, and bash execution permissions</li>
|
<li>Enable/disable write, delete, move, and bash execution permissions</li>
|
||||||
<li>Configure gitignore respect</li>
|
<li>Configure gitignore respect</li>
|
||||||
<li><strong>Bash Execution</strong> — enable AI shell access, set working directory, timeout, and approval behaviour (see <a href="#bash-execution">Bash Execution</a>)</li>
|
<li><strong>Bash Execution</strong> — enable AI shell access, set working directory, timeout, and approval behaviour (see <a href="#bash-execution">Bash Execution</a>)</li>
|
||||||
|
<li><strong>Research Agents</strong> — let the AI spawn parallel read-only research sub-agents (see <a href="#research-agents">Research Agents</a>)</li>
|
||||||
|
<li><strong>External MCP Servers</strong> — connect any stdio MCP server for additional tools (see <a href="#external-mcp">External MCP Servers</a>)</li>
|
||||||
|
<li><strong>Personal Data</strong> — Calendar, Reminders, and Location & Maps access (see <a href="#personal-data">Personal Data Tools</a>)</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Sync Tab</h3>
|
<h3>Sync Tab</h3>
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Manages per-skill file directories in Application Support/oAI/skills/<uuid>/
|
// Manages per-skill file directories in Application Support/oAI/skills/<uuid>/
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// OAuth 2.0 PKCE flow for Anthropic Pro/Max subscription login
|
// OAuth 2.0 PKCE flow for Anthropic Pro/Max subscription login
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Anytype MCP integration via local HTTP API at http://127.0.0.1:31009
|
// Anytype MCP integration via local HTTP API at http://127.0.0.1:31009
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// iCloud Drive backup of non-encrypted settings (Option C, v1)
|
// iCloud Drive backup of non-encrypted settings (Option C, v1)
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
//
|
||||||
|
// ContactsService.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// Read-only Contacts integration: search and "my card" lookup
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
//
|
||||||
|
// This file is part of oAI.
|
||||||
|
//
|
||||||
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
|
//
|
||||||
|
// See the LICENSE file or
|
||||||
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
|
// the full license text. For commercial licensing, contact Rune
|
||||||
|
// Olsen via <https://oai.pm>.
|
||||||
|
|
||||||
|
|
||||||
|
import Contacts
|
||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class ContactsService {
|
||||||
|
static let shared = ContactsService()
|
||||||
|
|
||||||
|
private let store = CNContactStore()
|
||||||
|
private let maxResults = 20
|
||||||
|
private let maxScan = 5000
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Authorization
|
||||||
|
|
||||||
|
var authStatus: CNAuthorizationStatus {
|
||||||
|
CNContactStore.authorizationStatus(for: .contacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorized: Bool {
|
||||||
|
authStatus == .authorized
|
||||||
|
}
|
||||||
|
|
||||||
|
var accessState: PersonalDataAccessState {
|
||||||
|
let status = authStatus
|
||||||
|
Log.mcp.debug("ContactsService.accessState -> status=\(Self.describe(status)) (raw=\(status.rawValue))")
|
||||||
|
switch status {
|
||||||
|
case .authorized: return .granted
|
||||||
|
case .notDetermined: return .notDetermined
|
||||||
|
default: return .denied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func requestAccess() async -> Bool {
|
||||||
|
let before = CNContactStore.authorizationStatus(for: .contacts)
|
||||||
|
Log.mcp.info("ContactsService.requestAccess: status before request = \(Self.describe(before)) (raw=\(before.rawValue))")
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
store.requestAccess(for: .contacts) { granted, error in
|
||||||
|
let after = CNContactStore.authorizationStatus(for: .contacts)
|
||||||
|
if let error {
|
||||||
|
Log.mcp.error("ContactsService.requestAccess: error=\(error.localizedDescription); granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||||
|
} else {
|
||||||
|
Log.mcp.info("ContactsService.requestAccess: granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||||
|
}
|
||||||
|
continuation.resume(returning: granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func describe(_ status: CNAuthorizationStatus) -> String {
|
||||||
|
switch status {
|
||||||
|
case .notDetermined: return "notDetermined"
|
||||||
|
case .restricted: return "restricted"
|
||||||
|
case .denied: return "denied"
|
||||||
|
case .authorized: return "authorized"
|
||||||
|
case .limited: return "limited"
|
||||||
|
@unknown default: return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tool Schemas
|
||||||
|
|
||||||
|
func getToolSchemas() -> [Tool] {
|
||||||
|
[
|
||||||
|
makeTool(
|
||||||
|
name: "contacts_search",
|
||||||
|
description: "Search Contacts by name, phone number, or email address. Returns matching contacts with their phone numbers and emails. This does NOT match relationship labels like 'mother' or 'spouse' — for those, call contacts_get_me first to find the related person's name, then search for that name.",
|
||||||
|
properties: [
|
||||||
|
"query": prop("string", "Name, phone number, or email fragment to search for")
|
||||||
|
],
|
||||||
|
required: ["query"]
|
||||||
|
),
|
||||||
|
makeTool(
|
||||||
|
name: "contacts_get_me",
|
||||||
|
description: "Get the user's own contact card (\"My Card\" in Contacts.app), if configured. Includes any defined relationships (e.g. mother, spouse, child) with the related person's name — use contacts_search with that name to find their phone/email.",
|
||||||
|
properties: [:],
|
||||||
|
required: []
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tool Execution
|
||||||
|
|
||||||
|
func executeTool(name: String, arguments: String) async -> [String: Any] {
|
||||||
|
Log.mcp.info("Executing Contacts tool: \(name)")
|
||||||
|
guard authorized else {
|
||||||
|
return ["error": "Contacts permission not granted. Grant access in Settings > MCP."]
|
||||||
|
}
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "contacts_search":
|
||||||
|
guard let data = arguments.data(using: .utf8),
|
||||||
|
let args = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let query = args["query"] as? String, !query.isEmpty else {
|
||||||
|
return ["error": "Missing required parameter: query"]
|
||||||
|
}
|
||||||
|
return search(query: query)
|
||||||
|
|
||||||
|
case "contacts_get_me":
|
||||||
|
return getMe()
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ["error": "Unknown Contacts tool: \(name)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Implementation
|
||||||
|
|
||||||
|
private let keysToFetch: [CNKeyDescriptor] = [
|
||||||
|
CNContactGivenNameKey as CNKeyDescriptor,
|
||||||
|
CNContactFamilyNameKey as CNKeyDescriptor,
|
||||||
|
CNContactOrganizationNameKey as CNKeyDescriptor,
|
||||||
|
CNContactPhoneNumbersKey as CNKeyDescriptor,
|
||||||
|
CNContactEmailAddressesKey as CNKeyDescriptor,
|
||||||
|
CNContactRelationsKey as CNKeyDescriptor
|
||||||
|
]
|
||||||
|
|
||||||
|
private func search(query: String) -> [String: Any] {
|
||||||
|
var matches: [CNContact] = []
|
||||||
|
|
||||||
|
// Fast path: name predicate
|
||||||
|
let namePredicate = CNContact.predicateForContacts(matchingName: query)
|
||||||
|
if let nameMatches = try? store.unifiedContacts(matching: namePredicate, keysToFetch: keysToFetch) {
|
||||||
|
matches.append(contentsOf: nameMatches)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: scan for phone/email substring matches
|
||||||
|
if matches.isEmpty {
|
||||||
|
let lowerQuery = query.lowercased()
|
||||||
|
let digitsQuery = query.filter(\.isNumber)
|
||||||
|
let request = CNContactFetchRequest(keysToFetch: keysToFetch)
|
||||||
|
var scanned = 0
|
||||||
|
try? store.enumerateContacts(with: request) { contact, stop in
|
||||||
|
scanned += 1
|
||||||
|
if scanned > self.maxScan || matches.count >= self.maxResults {
|
||||||
|
stop.pointee = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let emailMatch = contact.emailAddresses.contains {
|
||||||
|
($0.value as String).lowercased().contains(lowerQuery)
|
||||||
|
}
|
||||||
|
let phoneMatch = !digitsQuery.isEmpty && contact.phoneNumbers.contains {
|
||||||
|
$0.value.stringValue.filter(\.isNumber).contains(digitsQuery)
|
||||||
|
}
|
||||||
|
if emailMatch || phoneMatch {
|
||||||
|
matches.append(contact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let deduped = dedupContacts(matches)
|
||||||
|
let formatted = deduped.prefix(maxResults).map(formatContact)
|
||||||
|
return ["count": formatted.count, "contacts": Array(formatted)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collapses contacts that share a phone number or email — Contacts.app's "linked contacts"
|
||||||
|
/// merge doesn't catch every real-world duplicate card, so do a best-effort merge here too.
|
||||||
|
private func dedupContacts(_ contacts: [CNContact]) -> [CNContact] {
|
||||||
|
var result: [CNContact] = []
|
||||||
|
outer: for contact in contacts {
|
||||||
|
let phones = Set(contact.phoneNumbers.map { $0.value.stringValue.filter(\.isNumber) })
|
||||||
|
let emails = Set(contact.emailAddresses.map { ($0.value as String).lowercased() })
|
||||||
|
for existing in result {
|
||||||
|
let existingPhones = Set(existing.phoneNumbers.map { $0.value.stringValue.filter(\.isNumber) })
|
||||||
|
let existingEmails = Set(existing.emailAddresses.map { ($0.value as String).lowercased() })
|
||||||
|
if !phones.isDisjoint(with: existingPhones) || !emails.isDisjoint(with: existingEmails) {
|
||||||
|
continue outer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.append(contact)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getMe() -> [String: Any] {
|
||||||
|
guard let me = try? store.unifiedMeContactWithKeys(toFetch: keysToFetch) else {
|
||||||
|
return ["error": "No 'My Card' is configured in Contacts.app"]
|
||||||
|
}
|
||||||
|
return formatContact(me)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatContact(_ contact: CNContact) -> [String: Any] {
|
||||||
|
var item: [String: Any] = [
|
||||||
|
"given_name": contact.givenName,
|
||||||
|
"family_name": contact.familyName
|
||||||
|
]
|
||||||
|
if !contact.organizationName.isEmpty {
|
||||||
|
item["organization"] = contact.organizationName
|
||||||
|
}
|
||||||
|
if !contact.phoneNumbers.isEmpty {
|
||||||
|
item["phones"] = contact.phoneNumbers.map { $0.value.stringValue }
|
||||||
|
}
|
||||||
|
if !contact.emailAddresses.isEmpty {
|
||||||
|
item["emails"] = contact.emailAddresses.map { $0.value as String }
|
||||||
|
}
|
||||||
|
if !contact.contactRelations.isEmpty {
|
||||||
|
item["relations"] = contact.contactRelations.map { labeled -> [String: String] in
|
||||||
|
let label = labeled.label.map { CNLabeledValue<CNContactRelation>.localizedString(forLabel: $0) } ?? "relation"
|
||||||
|
return ["label": label, "name": labeled.value.name]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
|
||||||
|
Tool(
|
||||||
|
type: "function",
|
||||||
|
function: Tool.Function(
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
parameters: Tool.Function.Parameters(
|
||||||
|
type: "object",
|
||||||
|
properties: properties,
|
||||||
|
required: required
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property {
|
||||||
|
Tool.Function.Parameters.Property(type: type, description: description, enum: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,23 +5,21 @@
|
|||||||
// Smart context selection for AI conversations
|
// Smart context selection for AI conversations
|
||||||
// Selects relevant messages instead of sending entire history
|
// Selects relevant messages instead of sending entire history
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
//
|
||||||
|
// ConversationMergeService.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// Combine multiple saved conversations into one (simple concatenation or AI-assisted merge)
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
//
|
||||||
|
// This file is part of oAI.
|
||||||
|
//
|
||||||
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
|
//
|
||||||
|
// See the LICENSE file or
|
||||||
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
|
// the full license text. For commercial licensing, contact Rune
|
||||||
|
// Olsen via <https://oai.pm>.
|
||||||
|
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
enum CombineMode: String, Sendable {
|
||||||
|
case simple
|
||||||
|
case ai
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MergeError: LocalizedError {
|
||||||
|
case tooFewConversations
|
||||||
|
case noDefaultModel
|
||||||
|
case noAPIKey
|
||||||
|
case invalidAIResponse(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .tooFewConversations:
|
||||||
|
return "Select at least two conversations to combine."
|
||||||
|
case .noDefaultModel:
|
||||||
|
return "No default model is configured. Set one in Settings → General → Default Model."
|
||||||
|
case .noAPIKey:
|
||||||
|
return "No API key configured for the default provider. Add one in Settings."
|
||||||
|
case .invalidAIResponse(let snippet):
|
||||||
|
return "The model's response could not be parsed into a conversation: \(snippet)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConversationMergeService {
|
||||||
|
|
||||||
|
static func merge(
|
||||||
|
conversationIds: [UUID],
|
||||||
|
name: String,
|
||||||
|
mode: CombineMode,
|
||||||
|
deleteOriginals: Bool
|
||||||
|
) async throws -> Conversation {
|
||||||
|
guard conversationIds.count >= 2 else {
|
||||||
|
throw MergeError.tooFewConversations
|
||||||
|
}
|
||||||
|
|
||||||
|
let sources: [(Conversation, [Message])] = try conversationIds.compactMap { id in
|
||||||
|
try DatabaseService.shared.loadConversation(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The model used in the merged conversation should reflect the most recently used
|
||||||
|
// model across the *source* conversations — never the model that performed the merge.
|
||||||
|
let latestModelId = sources
|
||||||
|
.flatMap { $0.1 }
|
||||||
|
.filter { $0.modelId != nil }
|
||||||
|
.max { $0.timestamp < $1.timestamp }?
|
||||||
|
.modelId
|
||||||
|
|
||||||
|
let mergedMessages: [Message]
|
||||||
|
switch mode {
|
||||||
|
case .simple:
|
||||||
|
mergedMessages = simpleMerge(sources)
|
||||||
|
case .ai:
|
||||||
|
mergedMessages = try await aiMerge(sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
let newConversation = try DatabaseService.shared.saveConversation(
|
||||||
|
id: UUID(),
|
||||||
|
name: name,
|
||||||
|
messages: mergedMessages,
|
||||||
|
primaryModel: latestModelId
|
||||||
|
)
|
||||||
|
|
||||||
|
if deleteOriginals {
|
||||||
|
for id in conversationIds {
|
||||||
|
_ = try? DatabaseService.shared.deleteConversation(id: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.db.info("Combined \(conversationIds.count) conversations into '\(name)' (mode: \(mode.rawValue), deleteOriginals: \(deleteOriginals))")
|
||||||
|
|
||||||
|
return newConversation
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func simpleMerge(_ sources: [(Conversation, [Message])]) -> [Message] {
|
||||||
|
sources.flatMap { $0.1 }.sorted { $0.timestamp < $1.timestamp }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MergedTurn: Codable {
|
||||||
|
let role: String
|
||||||
|
let content: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func aiMerge(_ sources: [(Conversation, [Message])]) async throws -> [Message] {
|
||||||
|
let settings = SettingsService.shared
|
||||||
|
guard let modelId = settings.defaultModel, !modelId.isEmpty else {
|
||||||
|
throw MergeError.noDefaultModel
|
||||||
|
}
|
||||||
|
guard let provider = ProviderRegistry.shared.getProvider(for: settings.defaultProvider) else {
|
||||||
|
throw MergeError.noAPIKey
|
||||||
|
}
|
||||||
|
|
||||||
|
let transcript = sources.map { conversation, messages -> String in
|
||||||
|
let body = messages.map { msg -> String in
|
||||||
|
let label = msg.role == .user ? "**User:**" : "**Assistant:**"
|
||||||
|
return "\(label) \(msg.content)"
|
||||||
|
}.joined(separator: "\n\n")
|
||||||
|
return "### Conversation: \(conversation.name)\n\n\(body)"
|
||||||
|
}.joined(separator: "\n\n---\n\n")
|
||||||
|
|
||||||
|
let mergePrompt = """
|
||||||
|
Merge the following saved conversation transcripts into a single, coherent conversation. \
|
||||||
|
Remove redundant or duplicate exchanges, keep the most informative answer when sources overlap, \
|
||||||
|
preserve important details from each source, and do not invent facts that were not in the originals.
|
||||||
|
|
||||||
|
Respond with ONLY a JSON array of message objects in logical order, each in the form \
|
||||||
|
{"role": "user" or "assistant", "content": "..."}. Do not include any text outside the JSON array.
|
||||||
|
|
||||||
|
\(transcript)
|
||||||
|
"""
|
||||||
|
|
||||||
|
let request = ChatRequest(
|
||||||
|
messages: [Message(role: .user, content: mergePrompt)],
|
||||||
|
model: modelId,
|
||||||
|
stream: false,
|
||||||
|
maxTokens: 4000,
|
||||||
|
temperature: 0.3,
|
||||||
|
topP: nil,
|
||||||
|
systemPrompt: "You are a helpful assistant that merges chat conversation transcripts into one clean, coherent conversation.",
|
||||||
|
tools: nil,
|
||||||
|
onlineMode: false,
|
||||||
|
imageGeneration: false
|
||||||
|
)
|
||||||
|
|
||||||
|
let response: ChatResponse
|
||||||
|
do {
|
||||||
|
response = try await provider.chat(request: request)
|
||||||
|
} catch {
|
||||||
|
Log.api.error("Conversation merge AI call failed: \(error.localizedDescription)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
let turns = try parseTurns(from: response.content)
|
||||||
|
|
||||||
|
// modelId intentionally left nil here: these messages are a synthesized composite,
|
||||||
|
// not output from a single source model. The conversation's primaryModel (set by the
|
||||||
|
// caller from the source conversations) is what drives the model shown in the list.
|
||||||
|
let base = Date()
|
||||||
|
return turns.enumerated().map { index, turn in
|
||||||
|
Message(
|
||||||
|
role: turn.role == "user" ? .user : .assistant,
|
||||||
|
content: turn.content,
|
||||||
|
timestamp: base.addingTimeInterval(TimeInterval(index))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseTurns(from raw: String) throws -> [MergedTurn] {
|
||||||
|
var text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if text.hasPrefix("```") {
|
||||||
|
text = text.components(separatedBy: "\n").dropFirst().joined(separator: "\n")
|
||||||
|
if text.hasSuffix("```") {
|
||||||
|
text = String(text.dropLast(3))
|
||||||
|
}
|
||||||
|
text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
guard let data = text.data(using: .utf8),
|
||||||
|
let turns = try? JSONDecoder().decode([MergedTurn].self, from: data),
|
||||||
|
!turns.isEmpty else {
|
||||||
|
throw MergeError.invalidAIResponse(String(raw.prefix(200)))
|
||||||
|
}
|
||||||
|
return turns
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// SQLite persistence layer for conversations using GRDB
|
// SQLite persistence layer for conversations using GRDB
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -134,15 +132,29 @@ final class DatabaseService: Sendable {
|
|||||||
nonisolated static let shared = DatabaseService()
|
nonisolated static let shared = DatabaseService()
|
||||||
|
|
||||||
private let dbQueue: DatabaseQueue
|
private let dbQueue: DatabaseQueue
|
||||||
private let isoFormatter: ISO8601DateFormatter
|
|
||||||
|
|
||||||
// Command history limit - keep most recent 5000 entries
|
// Command history limit - keep most recent 5000 entries
|
||||||
private static let maxHistoryEntries = 5000
|
private nonisolated static let maxHistoryEntries = 5000
|
||||||
|
|
||||||
|
// ISO8601DateFormatter is @MainActor in macOS 27. Use Date.ISO8601FormatStyle (value type, Sendable).
|
||||||
|
private nonisolated static let isoStyle = Date.ISO8601FormatStyle(
|
||||||
|
dateSeparator: .dash,
|
||||||
|
dateTimeSeparator: .standard,
|
||||||
|
timeSeparator: .colon,
|
||||||
|
timeZoneSeparator: .colon,
|
||||||
|
includingFractionalSeconds: true,
|
||||||
|
timeZone: .gmt
|
||||||
|
)
|
||||||
|
|
||||||
|
private nonisolated static func isoString(from date: Date) -> String {
|
||||||
|
isoStyle.format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func isoDate(from string: String) -> Date? {
|
||||||
|
(try? isoStyle.parse(string)) ?? (try? Date(string, strategy: .iso8601))
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated private init() {
|
nonisolated private init() {
|
||||||
isoFormatter = ISO8601DateFormatter()
|
|
||||||
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
||||||
|
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true)
|
let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true)
|
||||||
@@ -156,7 +168,7 @@ final class DatabaseService: Sendable {
|
|||||||
try! migrator.migrate(dbQueue)
|
try! migrator.migrate(dbQueue)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var migrator: DatabaseMigrator {
|
private nonisolated var migrator: DatabaseMigrator {
|
||||||
var migrator = DatabaseMigrator()
|
var migrator = DatabaseMigrator()
|
||||||
|
|
||||||
migrator.registerMigration("v1") { db in
|
migrator.registerMigration("v1") { db in
|
||||||
@@ -375,7 +387,7 @@ final class DatabaseService: Sendable {
|
|||||||
nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws -> Conversation {
|
nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws -> Conversation {
|
||||||
Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))")
|
Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))")
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let nowString = isoFormatter.string(from: now)
|
let nowString = Self.isoString(from: now)
|
||||||
|
|
||||||
let convRecord = ConversationRecord(
|
let convRecord = ConversationRecord(
|
||||||
id: id.uuidString,
|
id: id.uuidString,
|
||||||
@@ -394,7 +406,7 @@ final class DatabaseService: Sendable {
|
|||||||
content: msg.content,
|
content: msg.content,
|
||||||
tokens: msg.tokens,
|
tokens: msg.tokens,
|
||||||
cost: msg.cost,
|
cost: msg.cost,
|
||||||
timestamp: isoFormatter.string(from: msg.timestamp),
|
timestamp: Self.isoString(from: msg.timestamp),
|
||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
modelId: msg.modelId
|
modelId: msg.modelId
|
||||||
)
|
)
|
||||||
@@ -420,7 +432,7 @@ final class DatabaseService: Sendable {
|
|||||||
|
|
||||||
/// Update an existing conversation in-place: rename it, replace all its messages.
|
/// Update an existing conversation in-place: rename it, replace all its messages.
|
||||||
nonisolated func updateConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws {
|
nonisolated func updateConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws {
|
||||||
let nowString = isoFormatter.string(from: Date())
|
let nowString = Self.isoString(from: Date())
|
||||||
|
|
||||||
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
|
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
|
||||||
guard msg.role != .system else { return nil }
|
guard msg.role != .system else { return nil }
|
||||||
@@ -431,7 +443,7 @@ final class DatabaseService: Sendable {
|
|||||||
content: msg.content,
|
content: msg.content,
|
||||||
tokens: msg.tokens,
|
tokens: msg.tokens,
|
||||||
cost: msg.cost,
|
cost: msg.cost,
|
||||||
timestamp: isoFormatter.string(from: msg.timestamp),
|
timestamp: Self.isoString(from: msg.timestamp),
|
||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
modelId: msg.modelId
|
modelId: msg.modelId
|
||||||
)
|
)
|
||||||
@@ -466,7 +478,7 @@ final class DatabaseService: Sendable {
|
|||||||
let messages = messageRecords.compactMap { record -> Message? in
|
let messages = messageRecords.compactMap { record -> Message? in
|
||||||
guard let msgId = UUID(uuidString: record.id),
|
guard let msgId = UUID(uuidString: record.id),
|
||||||
let role = MessageRole(rawValue: record.role),
|
let role = MessageRole(rawValue: record.role),
|
||||||
let timestamp = self.isoFormatter.date(from: record.timestamp)
|
let timestamp = Self.isoDate(from: record.timestamp)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
|
|
||||||
let starred = (try? MessageMetadataRecord.fetchOne(db, key: record.id))?.user_starred == 1
|
let starred = (try? MessageMetadataRecord.fetchOne(db, key: record.id))?.user_starred == 1
|
||||||
@@ -484,8 +496,8 @@ final class DatabaseService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard let convId = UUID(uuidString: convRecord.id),
|
guard let convId = UUID(uuidString: convRecord.id),
|
||||||
let createdAt = self.isoFormatter.date(from: convRecord.createdAt),
|
let createdAt = Self.isoDate(from: convRecord.createdAt),
|
||||||
let updatedAt = self.isoFormatter.date(from: convRecord.updatedAt)
|
let updatedAt = Self.isoDate(from: convRecord.updatedAt)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
|
|
||||||
let conversation = Conversation(
|
let conversation = Conversation(
|
||||||
@@ -509,8 +521,8 @@ final class DatabaseService: Sendable {
|
|||||||
|
|
||||||
return records.compactMap { record -> Conversation? in
|
return records.compactMap { record -> Conversation? in
|
||||||
guard let id = UUID(uuidString: record.id),
|
guard let id = UUID(uuidString: record.id),
|
||||||
let createdAt = self.isoFormatter.date(from: record.createdAt),
|
let createdAt = Self.isoDate(from: record.createdAt),
|
||||||
let updatedAt = self.isoFormatter.date(from: record.updatedAt)
|
let updatedAt = Self.isoDate(from: record.updatedAt)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
|
|
||||||
// Fetch message count without loading all messages
|
// Fetch message count without loading all messages
|
||||||
@@ -524,7 +536,7 @@ final class DatabaseService: Sendable {
|
|||||||
.order(Column("sortOrder").desc)
|
.order(Column("sortOrder").desc)
|
||||||
.fetchOne(db)
|
.fetchOne(db)
|
||||||
|
|
||||||
let lastDate = lastMsg.flatMap { self.isoFormatter.date(from: $0.timestamp) } ?? updatedAt
|
let lastDate = lastMsg.flatMap { Self.isoDate(from: $0.timestamp) } ?? updatedAt
|
||||||
|
|
||||||
// Derive primary model: prefer the stored field, fall back to last message's modelId
|
// Derive primary model: prefer the stored field, fall back to last message's modelId
|
||||||
let primaryModel = record.primaryModel ?? lastMsg?.modelId
|
let primaryModel = record.primaryModel ?? lastMsg?.modelId
|
||||||
@@ -574,7 +586,7 @@ final class DatabaseService: Sendable {
|
|||||||
convRecord.name = name
|
convRecord.name = name
|
||||||
}
|
}
|
||||||
|
|
||||||
convRecord.updatedAt = self.isoFormatter.string(from: Date())
|
convRecord.updatedAt = Self.isoString(from: Date())
|
||||||
try convRecord.update(db)
|
try convRecord.update(db)
|
||||||
|
|
||||||
if let messages = messages {
|
if let messages = messages {
|
||||||
@@ -589,7 +601,7 @@ final class DatabaseService: Sendable {
|
|||||||
content: msg.content,
|
content: msg.content,
|
||||||
tokens: msg.tokens,
|
tokens: msg.tokens,
|
||||||
cost: msg.cost,
|
cost: msg.cost,
|
||||||
timestamp: self.isoFormatter.string(from: msg.timestamp),
|
timestamp: Self.isoString(from: msg.timestamp),
|
||||||
sortOrder: index
|
sortOrder: index
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -610,7 +622,7 @@ final class DatabaseService: Sendable {
|
|||||||
let record = HistoryRecord(
|
let record = HistoryRecord(
|
||||||
id: UUID().uuidString,
|
id: UUID().uuidString,
|
||||||
input: input,
|
input: input,
|
||||||
timestamp: isoFormatter.string(from: now)
|
timestamp: Self.isoString(from: now)
|
||||||
)
|
)
|
||||||
|
|
||||||
try? dbQueue.write { db in
|
try? dbQueue.write { db in
|
||||||
@@ -643,7 +655,7 @@ final class DatabaseService: Sendable {
|
|||||||
.fetchAll(db)
|
.fetchAll(db)
|
||||||
|
|
||||||
return records.compactMap { record in
|
return records.compactMap { record in
|
||||||
guard let date = isoFormatter.date(from: record.timestamp) else {
|
guard let date = Self.isoDate(from: record.timestamp) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return (input: record.input, timestamp: date)
|
return (input: record.input, timestamp: date)
|
||||||
@@ -659,7 +671,7 @@ final class DatabaseService: Sendable {
|
|||||||
.fetchAll(db)
|
.fetchAll(db)
|
||||||
|
|
||||||
return records.compactMap { record in
|
return records.compactMap { record in
|
||||||
guard let date = isoFormatter.date(from: record.timestamp) else {
|
guard let date = Self.isoDate(from: record.timestamp) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return (input: record.input, timestamp: date)
|
return (input: record.input, timestamp: date)
|
||||||
@@ -672,7 +684,7 @@ final class DatabaseService: Sendable {
|
|||||||
nonisolated func saveEmailLog(_ log: EmailLog) {
|
nonisolated func saveEmailLog(_ log: EmailLog) {
|
||||||
let record = EmailLogRecord(
|
let record = EmailLogRecord(
|
||||||
id: log.id.uuidString,
|
id: log.id.uuidString,
|
||||||
timestamp: isoFormatter.string(from: log.timestamp),
|
timestamp: Self.isoString(from: log.timestamp),
|
||||||
sender: log.sender,
|
sender: log.sender,
|
||||||
subject: log.subject,
|
subject: log.subject,
|
||||||
emailContent: log.emailContent,
|
emailContent: log.emailContent,
|
||||||
@@ -698,7 +710,7 @@ final class DatabaseService: Sendable {
|
|||||||
.fetchAll(db)
|
.fetchAll(db)
|
||||||
|
|
||||||
return records.compactMap { record in
|
return records.compactMap { record in
|
||||||
guard let timestamp = isoFormatter.date(from: record.timestamp),
|
guard let timestamp = Self.isoDate(from: record.timestamp),
|
||||||
let status = EmailLogStatus(rawValue: record.status),
|
let status = EmailLogStatus(rawValue: record.status),
|
||||||
let id = UUID(uuidString: record.id) else {
|
let id = UUID(uuidString: record.id) else {
|
||||||
return nil
|
return nil
|
||||||
@@ -805,7 +817,7 @@ final class DatabaseService: Sendable {
|
|||||||
// MARK: - Embedding Operations
|
// MARK: - Embedding Operations
|
||||||
|
|
||||||
nonisolated func saveMessageEmbedding(messageId: UUID, embedding: Data, model: String, dimension: Int) throws {
|
nonisolated func saveMessageEmbedding(messageId: UUID, embedding: Data, model: String, dimension: Int) throws {
|
||||||
let now = isoFormatter.string(from: Date())
|
let now = Self.isoString(from: Date())
|
||||||
let record = MessageEmbeddingRecord(
|
let record = MessageEmbeddingRecord(
|
||||||
message_id: messageId.uuidString,
|
message_id: messageId.uuidString,
|
||||||
embedding: embedding,
|
embedding: embedding,
|
||||||
@@ -825,7 +837,7 @@ final class DatabaseService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func saveConversationEmbedding(conversationId: UUID, embedding: Data, model: String, dimension: Int) throws {
|
nonisolated func saveConversationEmbedding(conversationId: UUID, embedding: Data, model: String, dimension: Int) throws {
|
||||||
let now = isoFormatter.string(from: Date())
|
let now = Self.isoString(from: Date())
|
||||||
let record = ConversationEmbeddingRecord(
|
let record = ConversationEmbeddingRecord(
|
||||||
conversation_id: conversationId.uuidString,
|
conversation_id: conversationId.uuidString,
|
||||||
embedding: embedding,
|
embedding: embedding,
|
||||||
@@ -881,7 +893,7 @@ final class DatabaseService: Sendable {
|
|||||||
return Array(results.prefix(limit))
|
return Array(results.prefix(limit))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deserializeEmbedding(_ data: Data) -> [Float] {
|
private nonisolated func deserializeEmbedding(_ data: Data) -> [Float] {
|
||||||
var embedding: [Float] = []
|
var embedding: [Float] = []
|
||||||
embedding.reserveCapacity(data.count / 4)
|
embedding.reserveCapacity(data.count / 4)
|
||||||
|
|
||||||
@@ -905,7 +917,7 @@ final class DatabaseService: Sendable {
|
|||||||
model: String?,
|
model: String?,
|
||||||
tokenCount: Int?
|
tokenCount: Int?
|
||||||
) throws {
|
) throws {
|
||||||
let now = isoFormatter.string(from: Date())
|
let now = Self.isoString(from: Date())
|
||||||
let record = ConversationSummaryRecord(
|
let record = ConversationSummaryRecord(
|
||||||
id: UUID().uuidString,
|
id: UUID().uuidString,
|
||||||
conversation_id: conversationId.uuidString,
|
conversation_id: conversationId.uuidString,
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// AI-powered email auto-responder service
|
// AI-powered email auto-responder service
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Service for managing email handler activity logs
|
// Service for managing email handler activity logs
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// IMAP IDLE email monitoring service for AI email handler
|
// IMAP IDLE email monitoring service for AI email handler
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -5,23 +5,21 @@
|
|||||||
// Embedding generation and semantic search
|
// Embedding generation and semantic search
|
||||||
// Supports multiple providers: OpenAI, OpenRouter, Google
|
// Supports multiple providers: OpenAI, OpenRouter, Google
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -71,7 +69,7 @@ enum EmbeddingProvider {
|
|||||||
// MARK: - Embedding Service
|
// MARK: - Embedding Service
|
||||||
|
|
||||||
final class EmbeddingService {
|
final class EmbeddingService {
|
||||||
static let shared = EmbeddingService()
|
nonisolated static let shared = EmbeddingService()
|
||||||
|
|
||||||
private let settings = SettingsService.shared
|
private let settings = SettingsService.shared
|
||||||
|
|
||||||
@@ -281,7 +279,7 @@ final class EmbeddingService {
|
|||||||
// MARK: - Similarity Calculation
|
// MARK: - Similarity Calculation
|
||||||
|
|
||||||
/// Calculate cosine similarity between two embeddings
|
/// Calculate cosine similarity between two embeddings
|
||||||
func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float {
|
nonisolated func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float {
|
||||||
guard a.count == b.count else {
|
guard a.count == b.count else {
|
||||||
Log.api.error("Embedding dimension mismatch: \(a.count) vs \(b.count)")
|
Log.api.error("Embedding dimension mismatch: \(a.count) vs \(b.count)")
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|||||||
@@ -5,23 +5,21 @@
|
|||||||
// Secure encryption for sensitive data (API keys)
|
// Secure encryption for sensitive data (API keys)
|
||||||
// Uses CryptoKit with machine-specific key derivation
|
// Uses CryptoKit with machine-specific key derivation
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -29,19 +27,18 @@ import CryptoKit
|
|||||||
import IOKit
|
import IOKit
|
||||||
|
|
||||||
class EncryptionService {
|
class EncryptionService {
|
||||||
static let shared = EncryptionService()
|
nonisolated static let shared = EncryptionService()
|
||||||
|
|
||||||
private let salt = "oAI-secure-storage-v1" // App-specific salt
|
private let encryptionKey: SymmetricKey
|
||||||
private lazy var encryptionKey: SymmetricKey = {
|
|
||||||
deriveEncryptionKey()
|
|
||||||
}()
|
|
||||||
|
|
||||||
private init() {}
|
private init() {
|
||||||
|
self.encryptionKey = Self.deriveEncryptionKey()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Public Interface
|
// MARK: - Public Interface
|
||||||
|
|
||||||
/// Encrypt a string value
|
/// Encrypt a string value
|
||||||
func encrypt(_ value: String) throws -> String {
|
nonisolated func encrypt(_ value: String) throws -> String {
|
||||||
guard let data = value.data(using: .utf8) else {
|
guard let data = value.data(using: .utf8) else {
|
||||||
throw EncryptionError.invalidInput
|
throw EncryptionError.invalidInput
|
||||||
}
|
}
|
||||||
@@ -55,7 +52,7 @@ class EncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt a string value
|
/// Decrypt a string value
|
||||||
func decrypt(_ encryptedValue: String) throws -> String {
|
nonisolated func decrypt(_ encryptedValue: String) throws -> String {
|
||||||
guard let data = Data(base64Encoded: encryptedValue) else {
|
guard let data = Data(base64Encoded: encryptedValue) else {
|
||||||
throw EncryptionError.invalidInput
|
throw EncryptionError.invalidInput
|
||||||
}
|
}
|
||||||
@@ -73,19 +70,17 @@ class EncryptionService {
|
|||||||
// MARK: - Key Derivation
|
// MARK: - Key Derivation
|
||||||
|
|
||||||
/// Derive encryption key from machine-specific data
|
/// Derive encryption key from machine-specific data
|
||||||
private func deriveEncryptionKey() -> SymmetricKey {
|
private static func deriveEncryptionKey() -> SymmetricKey {
|
||||||
// Combine machine UUID + bundle ID + salt for key material
|
|
||||||
let machineUUID = getMachineUUID()
|
let machineUUID = getMachineUUID()
|
||||||
let bundleID = Bundle.main.bundleIdentifier ?? "com.oai.oAI"
|
let bundleID = Bundle.main.bundleIdentifier ?? "com.oai.oAI"
|
||||||
|
let salt = "oAI-secure-storage-v1"
|
||||||
let keyMaterial = "\(machineUUID)-\(bundleID)-\(salt)"
|
let keyMaterial = "\(machineUUID)-\(bundleID)-\(salt)"
|
||||||
|
|
||||||
// Hash to create consistent 256-bit key
|
|
||||||
let hash = SHA256.hash(data: Data(keyMaterial.utf8))
|
let hash = SHA256.hash(data: Data(keyMaterial.utf8))
|
||||||
return SymmetricKey(data: hash)
|
return SymmetricKey(data: hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get machine-specific UUID (IOPlatformUUID)
|
/// Get machine-specific UUID (IOPlatformUUID)
|
||||||
private func getMachineUUID() -> String {
|
private static func getMachineUUID() -> String {
|
||||||
// Get IOPlatformUUID from IOKit
|
// Get IOPlatformUUID from IOKit
|
||||||
let platformExpert = IOServiceGetMatchingService(
|
let platformExpert = IOServiceGetMatchingService(
|
||||||
kIOMainPortDefault,
|
kIOMainPortDefault,
|
||||||
|
|||||||
@@ -0,0 +1,591 @@
|
|||||||
|
//
|
||||||
|
// EventKitService.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// Calendar and Reminders integration via EventKit
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
//
|
||||||
|
// This file is part of oAI.
|
||||||
|
//
|
||||||
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
|
//
|
||||||
|
// See the LICENSE file or
|
||||||
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
|
// the full license text. For commercial licensing, contact Rune
|
||||||
|
// Olsen via <https://oai.pm>.
|
||||||
|
|
||||||
|
|
||||||
|
import EventKit
|
||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Shared tri-state authorization status for the Settings UI across all Personal Data services.
|
||||||
|
/// `.denied` also covers `.restricted` and (for Calendar) `.writeOnly` — states where the OS
|
||||||
|
/// will not show a request dialog again; the user must go to System Settings manually.
|
||||||
|
enum PersonalDataAccessState {
|
||||||
|
case notDetermined
|
||||||
|
case denied
|
||||||
|
case granted
|
||||||
|
}
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class EventKitService {
|
||||||
|
static let shared = EventKitService()
|
||||||
|
|
||||||
|
private let store = EKEventStore()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Authorization
|
||||||
|
|
||||||
|
var calendarAuthStatus: EKAuthorizationStatus {
|
||||||
|
let status = EKEventStore.authorizationStatus(for: .event)
|
||||||
|
Log.mcp.debug("EventKitService.calendarAuthStatus -> \(Self.describe(status)) (raw=\(status.rawValue))")
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
var reminderAuthStatus: EKAuthorizationStatus {
|
||||||
|
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||||
|
Log.mcp.debug("EventKitService.reminderAuthStatus -> \(Self.describe(status)) (raw=\(status.rawValue))")
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
var calendarAuthorized: Bool {
|
||||||
|
calendarAuthStatus == .fullAccess
|
||||||
|
}
|
||||||
|
|
||||||
|
var reminderAuthorized: Bool {
|
||||||
|
reminderAuthStatus == .fullAccess
|
||||||
|
}
|
||||||
|
|
||||||
|
var calendarAccessState: PersonalDataAccessState {
|
||||||
|
switch calendarAuthStatus {
|
||||||
|
case .fullAccess: return .granted
|
||||||
|
case .notDetermined: return .notDetermined
|
||||||
|
default: return .denied // .denied, .restricted, .writeOnly (no read access for our tools)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var reminderAccessState: PersonalDataAccessState {
|
||||||
|
switch reminderAuthStatus {
|
||||||
|
case .fullAccess: return .granted
|
||||||
|
case .notDetermined: return .notDetermined
|
||||||
|
default: return .denied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func requestCalendarAccess() async -> Bool {
|
||||||
|
let before = EKEventStore.authorizationStatus(for: .event)
|
||||||
|
Log.mcp.info("requestCalendarAccess: status before request = \(Self.describe(before)) (raw=\(before.rawValue))")
|
||||||
|
do {
|
||||||
|
let granted = try await store.requestFullAccessToEvents()
|
||||||
|
let after = EKEventStore.authorizationStatus(for: .event)
|
||||||
|
Log.mcp.info("requestCalendarAccess: API returned granted=\(granted); status after request = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||||
|
return granted
|
||||||
|
} catch {
|
||||||
|
let after = EKEventStore.authorizationStatus(for: .event)
|
||||||
|
Log.mcp.error("requestCalendarAccess: threw error: \(error.localizedDescription); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func requestReminderAccess() async -> Bool {
|
||||||
|
let before = EKEventStore.authorizationStatus(for: .reminder)
|
||||||
|
Log.mcp.info("requestReminderAccess: status before request = \(Self.describe(before)) (raw=\(before.rawValue))")
|
||||||
|
do {
|
||||||
|
let granted = try await store.requestFullAccessToReminders()
|
||||||
|
let after = EKEventStore.authorizationStatus(for: .reminder)
|
||||||
|
Log.mcp.info("requestReminderAccess: API returned granted=\(granted); status after request = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||||
|
return granted
|
||||||
|
} catch {
|
||||||
|
let after = EKEventStore.authorizationStatus(for: .reminder)
|
||||||
|
Log.mcp.error("requestReminderAccess: threw error: \(error.localizedDescription); status after = \(Self.describe(after)) (raw=\(after.rawValue))")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func describe(_ status: EKAuthorizationStatus) -> String {
|
||||||
|
switch status {
|
||||||
|
case .notDetermined: return "notDetermined"
|
||||||
|
case .restricted: return "restricted"
|
||||||
|
case .denied: return "denied"
|
||||||
|
case .fullAccess: return "fullAccess"
|
||||||
|
case .writeOnly: return "writeOnly"
|
||||||
|
@unknown default: return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tool Schemas
|
||||||
|
|
||||||
|
func getToolSchemas(calendarEnabled: Bool, remindersEnabled: Bool) -> [Tool] {
|
||||||
|
var tools: [Tool] = []
|
||||||
|
|
||||||
|
if calendarEnabled {
|
||||||
|
tools.append(makeTool(
|
||||||
|
name: "calendar_list_calendars",
|
||||||
|
description: "List all calendars available on this Mac (e.g. iCloud, Work, Home).",
|
||||||
|
properties: [:],
|
||||||
|
required: []
|
||||||
|
))
|
||||||
|
tools.append(makeTool(
|
||||||
|
name: "calendar_list_events",
|
||||||
|
description: "List calendar events within a date range. Dates are ISO8601 (e.g. '2026-06-20T00:00:00' or '2026-06-20'). Range is limited to 1 year. For open-ended queries like 'next appointment' or 'upcoming events', do NOT limit the range to just today — use a generous forward-looking window (e.g. today through +90 days) so you don't miss events further out.",
|
||||||
|
properties: [
|
||||||
|
"start_date": prop("string", "Start of the date range (ISO8601)"),
|
||||||
|
"end_date": prop("string", "End of the date range (ISO8601)"),
|
||||||
|
"calendar_name": prop("string", "Optional: only list events from this calendar")
|
||||||
|
],
|
||||||
|
required: ["start_date", "end_date"]
|
||||||
|
))
|
||||||
|
tools.append(makeTool(
|
||||||
|
name: "calendar_create_event",
|
||||||
|
description: "Create a new calendar event. Requires user approval before it is actually created.",
|
||||||
|
properties: [
|
||||||
|
"title": prop("string", "Event title"),
|
||||||
|
"start_date": prop("string", "Start date/time (ISO8601, e.g. '2026-06-20T14:00:00')"),
|
||||||
|
"end_date": prop("string", "End date/time (ISO8601)"),
|
||||||
|
"calendar_name": prop("string", "Optional: calendar to add the event to (defaults to the system default calendar)"),
|
||||||
|
"location": prop("string", "Optional: event location text"),
|
||||||
|
"notes": prop("string", "Optional: event notes"),
|
||||||
|
"all_day": prop("boolean", "Optional: whether this is an all-day event (default: false)"),
|
||||||
|
"alarm_minutes_before": prop("number", "Optional: minutes before the start time to show an alert")
|
||||||
|
],
|
||||||
|
required: ["title", "start_date", "end_date"]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if remindersEnabled {
|
||||||
|
tools.append(makeTool(
|
||||||
|
name: "reminders_list_lists",
|
||||||
|
description: "List all reminder lists available on this Mac.",
|
||||||
|
properties: [:],
|
||||||
|
required: []
|
||||||
|
))
|
||||||
|
tools.append(makeTool(
|
||||||
|
name: "reminders_list",
|
||||||
|
description: "List reminders. Omit list_name to search across ALL reminder lists in a single call — prefer this over calling once per list. Incomplete reminders only unless include_completed is true.",
|
||||||
|
properties: [
|
||||||
|
"list_name": prop("string", "Optional: only list reminders from this one list (omit to search all lists at once)"),
|
||||||
|
"include_completed": prop("boolean", "Optional: include completed reminders (default: false)")
|
||||||
|
],
|
||||||
|
required: []
|
||||||
|
))
|
||||||
|
tools.append(makeTool(
|
||||||
|
name: "reminders_create",
|
||||||
|
description: "Create a new reminder. Requires user approval before it is actually created.",
|
||||||
|
properties: [
|
||||||
|
"title": prop("string", "Reminder title"),
|
||||||
|
"list_name": prop("string", "Optional: reminder list to add to (defaults to the system default list)"),
|
||||||
|
"due_date": prop("string", "Optional: due date/time (ISO8601)"),
|
||||||
|
"priority": prop("string", "Optional: priority level", enumValues: ["none", "low", "medium", "high"]),
|
||||||
|
"notes": prop("string", "Optional: reminder notes")
|
||||||
|
],
|
||||||
|
required: ["title"]
|
||||||
|
))
|
||||||
|
tools.append(makeTool(
|
||||||
|
name: "reminders_complete",
|
||||||
|
description: "Mark a reminder as completed. Requires user approval. Use reminders_list to find the reminder_id first.",
|
||||||
|
properties: [
|
||||||
|
"reminder_id": prop("string", "The reminder's identifier, from reminders_list")
|
||||||
|
],
|
||||||
|
required: ["reminder_id"]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return tools
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Read Tool Execution
|
||||||
|
|
||||||
|
func executeTool(name: String, arguments: String) async -> [String: Any] {
|
||||||
|
Log.mcp.info("Executing EventKit tool: \(name)")
|
||||||
|
let args = Self.parseArgs(arguments)
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "calendar_list_calendars":
|
||||||
|
guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") }
|
||||||
|
return listCalendars()
|
||||||
|
|
||||||
|
case "calendar_list_events":
|
||||||
|
guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") }
|
||||||
|
guard let startStr = args["start_date"] as? String, let start = Self.parseDate(startStr) else {
|
||||||
|
return ["error": "Missing or invalid parameter: start_date"]
|
||||||
|
}
|
||||||
|
guard let endStr = args["end_date"] as? String, let end = Self.parseDate(endStr) else {
|
||||||
|
return ["error": "Missing or invalid parameter: end_date"]
|
||||||
|
}
|
||||||
|
let calendarName = args["calendar_name"] as? String
|
||||||
|
return listEvents(start: start, end: end, calendarName: calendarName)
|
||||||
|
|
||||||
|
case "reminders_list_lists":
|
||||||
|
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
|
||||||
|
return listReminderLists()
|
||||||
|
|
||||||
|
case "reminders_list":
|
||||||
|
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
|
||||||
|
let listName = args["list_name"] as? String
|
||||||
|
let includeCompleted = args["include_completed"] as? Bool ?? false
|
||||||
|
return await listReminders(listName: listName, includeCompleted: includeCompleted)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ["error": "Unknown EventKit tool: \(name)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Write Tool Execution (called only after approval)
|
||||||
|
|
||||||
|
func executeWriteTool(name: String, arguments: String) async -> [String: Any] {
|
||||||
|
Log.mcp.info("Executing EventKit write tool: \(name)")
|
||||||
|
let args = Self.parseArgs(arguments)
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "calendar_create_event":
|
||||||
|
guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") }
|
||||||
|
return createEvent(args: args)
|
||||||
|
|
||||||
|
case "reminders_create":
|
||||||
|
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
|
||||||
|
return createReminder(args: args)
|
||||||
|
|
||||||
|
case "reminders_complete":
|
||||||
|
guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") }
|
||||||
|
guard let reminderId = args["reminder_id"] as? String else {
|
||||||
|
return ["error": "Missing required parameter: reminder_id"]
|
||||||
|
}
|
||||||
|
return completeReminder(reminderId: reminderId)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ["error": "Unknown EventKit write tool: \(name)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Approval Summary
|
||||||
|
|
||||||
|
/// Human-readable description shown in the approval sheet before a write tool runs.
|
||||||
|
func approvalSummary(forTool name: String, arguments: String) -> String {
|
||||||
|
let args = Self.parseArgs(arguments)
|
||||||
|
switch name {
|
||||||
|
case "calendar_create_event":
|
||||||
|
let title = args["title"] as? String ?? "Untitled event"
|
||||||
|
let start = (args["start_date"] as? String).flatMap(Self.parseDate) ?? Date()
|
||||||
|
let end = (args["end_date"] as? String).flatMap(Self.parseDate) ?? start
|
||||||
|
return "Create calendar event \"\(title)\" from \(Self.displayFormatter.string(from: start)) to \(Self.displayFormatter.string(from: end))"
|
||||||
|
case "reminders_create":
|
||||||
|
let title = args["title"] as? String ?? "Untitled reminder"
|
||||||
|
if let dueStr = args["due_date"] as? String, let due = Self.parseDate(dueStr) {
|
||||||
|
return "Create reminder \"\(title)\" due \(Self.displayFormatter.string(from: due))"
|
||||||
|
}
|
||||||
|
return "Create reminder \"\(title)\""
|
||||||
|
case "reminders_complete":
|
||||||
|
return "Mark reminder as completed"
|
||||||
|
default:
|
||||||
|
return "Perform action: \(name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Calendar Read Implementations
|
||||||
|
|
||||||
|
private func listCalendars() -> [String: Any] {
|
||||||
|
let calendars = store.calendars(for: .event).map { cal -> [String: Any] in
|
||||||
|
[
|
||||||
|
"name": cal.title,
|
||||||
|
"type": calendarTypeDescription(cal),
|
||||||
|
"allows_modifications": cal.allowsContentModifications
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return ["calendars": calendars]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func listEvents(start: Date, end: Date, calendarName: String?) -> [String: Any] {
|
||||||
|
guard end > start else { return ["error": "end_date must be after start_date"] }
|
||||||
|
guard end.timeIntervalSince(start) <= 366 * 24 * 60 * 60 else {
|
||||||
|
return ["error": "Date range too large — limit to 1 year or less"]
|
||||||
|
}
|
||||||
|
|
||||||
|
var calendars = store.calendars(for: .event)
|
||||||
|
if let calendarName {
|
||||||
|
calendars = calendars.filter { $0.title.caseInsensitiveCompare(calendarName) == .orderedSame }
|
||||||
|
if calendars.isEmpty {
|
||||||
|
return ["error": "No calendar found named '\(calendarName)'"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars)
|
||||||
|
let events = store.events(matching: predicate)
|
||||||
|
.sorted { $0.startDate < $1.startDate }
|
||||||
|
.prefix(200)
|
||||||
|
.map { event -> [String: Any] in
|
||||||
|
var item: [String: Any] = [
|
||||||
|
"id": event.eventIdentifier ?? "",
|
||||||
|
"title": event.title ?? "Untitled",
|
||||||
|
"start": Self.isoFormatter.string(from: event.startDate),
|
||||||
|
"end": Self.isoFormatter.string(from: event.endDate),
|
||||||
|
"all_day": event.isAllDay,
|
||||||
|
"calendar": event.calendar?.title ?? ""
|
||||||
|
]
|
||||||
|
if let location = event.location, !location.isEmpty {
|
||||||
|
item["location"] = location
|
||||||
|
}
|
||||||
|
if let notes = event.notes, !notes.isEmpty {
|
||||||
|
item["notes"] = String(notes.prefix(500))
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
return ["count": events.count, "events": Array(events)]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createEvent(args: [String: Any]) -> [String: Any] {
|
||||||
|
guard let title = args["title"] as? String, !title.isEmpty else {
|
||||||
|
return ["error": "Missing required parameter: title"]
|
||||||
|
}
|
||||||
|
guard let startStr = args["start_date"] as? String, let start = Self.parseDate(startStr) else {
|
||||||
|
return ["error": "Missing or invalid parameter: start_date"]
|
||||||
|
}
|
||||||
|
guard let endStr = args["end_date"] as? String, let end = Self.parseDate(endStr) else {
|
||||||
|
return ["error": "Missing or invalid parameter: end_date"]
|
||||||
|
}
|
||||||
|
guard end >= start else {
|
||||||
|
return ["error": "end_date must not be before start_date"]
|
||||||
|
}
|
||||||
|
|
||||||
|
let event = EKEvent(eventStore: store)
|
||||||
|
event.title = title
|
||||||
|
event.startDate = start
|
||||||
|
event.endDate = end
|
||||||
|
event.isAllDay = args["all_day"] as? Bool ?? false
|
||||||
|
|
||||||
|
if let calendarName = args["calendar_name"] as? String,
|
||||||
|
let calendar = store.calendars(for: .event).first(where: { $0.title.caseInsensitiveCompare(calendarName) == .orderedSame }) {
|
||||||
|
event.calendar = calendar
|
||||||
|
} else if let defaultCalendar = store.defaultCalendarForNewEvents {
|
||||||
|
event.calendar = defaultCalendar
|
||||||
|
} else {
|
||||||
|
guard let fallback = store.calendars(for: .event).first(where: { $0.allowsContentModifications }) else {
|
||||||
|
return ["error": "No writable calendar available"]
|
||||||
|
}
|
||||||
|
event.calendar = fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
if let location = args["location"] as? String { event.location = location }
|
||||||
|
if let notes = args["notes"] as? String { event.notes = notes }
|
||||||
|
if let minutesBefore = (args["alarm_minutes_before"] as? Double) ?? (args["alarm_minutes_before"] as? Int).map(Double.init) {
|
||||||
|
event.addAlarm(EKAlarm(relativeOffset: -(minutesBefore * 60)))
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try store.save(event, span: .thisEvent, commit: true)
|
||||||
|
return ["success": true, "event_id": event.eventIdentifier ?? "", "calendar": event.calendar?.title ?? ""]
|
||||||
|
} catch {
|
||||||
|
Log.mcp.error("calendar_create_event failed: \(error.localizedDescription)")
|
||||||
|
return ["error": "Failed to create event: \(error.localizedDescription)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reminders Read Implementations
|
||||||
|
|
||||||
|
private func listReminderLists() -> [String: Any] {
|
||||||
|
let lists = store.calendars(for: .reminder).map { cal -> [String: Any] in
|
||||||
|
["name": cal.title, "allows_modifications": cal.allowsContentModifications]
|
||||||
|
}
|
||||||
|
return ["lists": lists]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func listReminders(listName: String?, includeCompleted: Bool) async -> [String: Any] {
|
||||||
|
var lists = store.calendars(for: .reminder)
|
||||||
|
if let listName {
|
||||||
|
lists = lists.filter { $0.title.caseInsensitiveCompare(listName) == .orderedSame }
|
||||||
|
if lists.isEmpty {
|
||||||
|
return ["error": "No reminder list found named '\(listName)'"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let predicate = store.predicateForReminders(in: lists)
|
||||||
|
let reminders: [EKReminder] = await withCheckedContinuation { continuation in
|
||||||
|
store.fetchReminders(matching: predicate) { results in
|
||||||
|
continuation.resume(returning: results ?? [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let filtered = reminders
|
||||||
|
.filter { includeCompleted || !$0.isCompleted }
|
||||||
|
.sorted { lhs, rhs in
|
||||||
|
let l = lhs.dueDateComponents?.date ?? .distantFuture
|
||||||
|
let r = rhs.dueDateComponents?.date ?? .distantFuture
|
||||||
|
return l < r
|
||||||
|
}
|
||||||
|
.prefix(200)
|
||||||
|
.map { reminder -> [String: Any] in
|
||||||
|
var item: [String: Any] = [
|
||||||
|
"id": reminder.calendarItemIdentifier,
|
||||||
|
"title": reminder.title ?? "Untitled",
|
||||||
|
"completed": reminder.isCompleted,
|
||||||
|
"list": reminder.calendar?.title ?? ""
|
||||||
|
]
|
||||||
|
if let due = reminder.dueDateComponents?.date {
|
||||||
|
item["due"] = Self.isoFormatter.string(from: due)
|
||||||
|
}
|
||||||
|
if reminder.priority > 0 {
|
||||||
|
item["priority"] = priorityDescription(reminder.priority)
|
||||||
|
}
|
||||||
|
if let notes = reminder.notes, !notes.isEmpty {
|
||||||
|
item["notes"] = String(notes.prefix(500))
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
return ["count": filtered.count, "reminders": Array(filtered)]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createReminder(args: [String: Any]) -> [String: Any] {
|
||||||
|
guard let title = args["title"] as? String, !title.isEmpty else {
|
||||||
|
return ["error": "Missing required parameter: title"]
|
||||||
|
}
|
||||||
|
|
||||||
|
let reminder = EKReminder(eventStore: store)
|
||||||
|
reminder.title = title
|
||||||
|
|
||||||
|
if let listName = args["list_name"] as? String,
|
||||||
|
let list = store.calendars(for: .reminder).first(where: { $0.title.caseInsensitiveCompare(listName) == .orderedSame }) {
|
||||||
|
reminder.calendar = list
|
||||||
|
} else if let defaultList = store.defaultCalendarForNewReminders() {
|
||||||
|
reminder.calendar = defaultList
|
||||||
|
} else {
|
||||||
|
guard let fallback = store.calendars(for: .reminder).first(where: { $0.allowsContentModifications }) else {
|
||||||
|
return ["error": "No writable reminder list available"]
|
||||||
|
}
|
||||||
|
reminder.calendar = fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
if let dueStr = args["due_date"] as? String, let due = Self.parseDate(dueStr) {
|
||||||
|
reminder.dueDateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: due)
|
||||||
|
}
|
||||||
|
if let notes = args["notes"] as? String { reminder.notes = notes }
|
||||||
|
if let priority = args["priority"] as? String { reminder.priority = priorityValue(priority) }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try store.save(reminder, commit: true)
|
||||||
|
return ["success": true, "reminder_id": reminder.calendarItemIdentifier, "list": reminder.calendar?.title ?? ""]
|
||||||
|
} catch {
|
||||||
|
Log.mcp.error("reminders_create failed: \(error.localizedDescription)")
|
||||||
|
return ["error": "Failed to create reminder: \(error.localizedDescription)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func completeReminder(reminderId: String) -> [String: Any] {
|
||||||
|
guard let item = store.calendarItem(withIdentifier: reminderId) as? EKReminder else {
|
||||||
|
return ["error": "No reminder found with id '\(reminderId)'"]
|
||||||
|
}
|
||||||
|
item.isCompleted = true
|
||||||
|
item.completionDate = Date()
|
||||||
|
|
||||||
|
do {
|
||||||
|
try store.save(item, commit: true)
|
||||||
|
return ["success": true, "reminder_id": reminderId, "title": item.title ?? ""]
|
||||||
|
} catch {
|
||||||
|
Log.mcp.error("reminders_complete failed: \(error.localizedDescription)")
|
||||||
|
return ["error": "Failed to complete reminder: \(error.localizedDescription)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func calendarTypeDescription(_ cal: EKCalendar) -> String {
|
||||||
|
switch cal.type {
|
||||||
|
case .local: return "local"
|
||||||
|
case .calDAV: return "caldav"
|
||||||
|
case .exchange: return "exchange"
|
||||||
|
case .subscription: return "subscription"
|
||||||
|
case .birthday: return "birthday"
|
||||||
|
@unknown default: return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func priorityDescription(_ value: Int) -> String {
|
||||||
|
switch value {
|
||||||
|
case 1...4: return "high"
|
||||||
|
case 5: return "medium"
|
||||||
|
case 6...9: return "low"
|
||||||
|
default: return "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func priorityValue(_ description: String) -> Int {
|
||||||
|
switch description.lowercased() {
|
||||||
|
case "high": return 1
|
||||||
|
case "medium": return 5
|
||||||
|
case "low": return 9
|
||||||
|
default: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
|
||||||
|
Tool(
|
||||||
|
type: "function",
|
||||||
|
function: Tool.Function(
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
parameters: Tool.Function.Parameters(
|
||||||
|
type: "object",
|
||||||
|
properties: properties,
|
||||||
|
required: required
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prop(_ type: String, _ description: String, enumValues: [String]? = nil) -> Tool.Function.Parameters.Property {
|
||||||
|
Tool.Function.Parameters.Property(type: type, description: description, enum: enumValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func parseArgs(_ arguments: String) -> [String: Any] {
|
||||||
|
guard let data = arguments.data(using: .utf8),
|
||||||
|
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func permissionError(domain: String) -> [String: Any] {
|
||||||
|
["error": "\(domain) permission not granted. Grant access in Settings > MCP."]
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated(unsafe) static let isoFormatter: ISO8601DateFormatter = {
|
||||||
|
let f = ISO8601DateFormatter()
|
||||||
|
f.formatOptions = [.withInternetDateTime]
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
nonisolated static let displayFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateStyle = .medium
|
||||||
|
f.timeStyle = .short
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
nonisolated static func parseDate(_ string: String) -> Date? {
|
||||||
|
if let date = isoFormatter.date(from: string) { return date }
|
||||||
|
|
||||||
|
let isoNoTimezone = ISO8601DateFormatter()
|
||||||
|
isoNoTimezone.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
if let date = isoNoTimezone.date(from: string) { return date }
|
||||||
|
|
||||||
|
let localDateTime = DateFormatter()
|
||||||
|
localDateTime.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||||||
|
if let date = localDateTime.date(from: string) { return date }
|
||||||
|
|
||||||
|
let dateOnly = DateFormatter()
|
||||||
|
dateOnly.dateFormat = "yyyy-MM-dd"
|
||||||
|
if let date = dateOnly.date(from: string) { return date }
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - ExternalMCPClient
|
||||||
|
|
||||||
|
/// Manages one MCP stdio server process. All state is MainActor-isolated
|
||||||
|
/// (consistent with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor project setting).
|
||||||
|
/// Background I/O runs in Task.detached; state mutations hop back to MainActor.
|
||||||
|
@MainActor
|
||||||
|
final class ExternalMCPClient {
|
||||||
|
let server: ExternalMCPServer
|
||||||
|
weak var stateDelegate: (any ExternalMCPStateDelegate)?
|
||||||
|
|
||||||
|
private var process: Process?
|
||||||
|
private var stdinHandle: FileHandle?
|
||||||
|
private var readTask: Task<Void, Never>?
|
||||||
|
private var stderrTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
private var nextRequestId: Int = 1
|
||||||
|
private var pendingCalls: [Int: CheckedContinuation<Data, Error>] = [:]
|
||||||
|
private var lineBuffer = Data()
|
||||||
|
|
||||||
|
private(set) var state: MCPClientState = .idle
|
||||||
|
private(set) var discoveredTools: [MCPToolDefinition] = []
|
||||||
|
|
||||||
|
init(server: ExternalMCPServer, stateDelegate: (any ExternalMCPStateDelegate)?) {
|
||||||
|
self.server = server
|
||||||
|
self.stateDelegate = stateDelegate
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
func start() async throws {
|
||||||
|
guard state == .idle || state == .stopped || state == .crashed else { return }
|
||||||
|
state = .connecting
|
||||||
|
stateDelegate?.clientDidChangeState(id: server.id, state: .connecting)
|
||||||
|
|
||||||
|
let proc = Process()
|
||||||
|
if server.command.hasPrefix("/") {
|
||||||
|
proc.executableURL = URL(fileURLWithPath: server.command)
|
||||||
|
proc.arguments = server.args
|
||||||
|
} else {
|
||||||
|
proc.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||||
|
proc.arguments = [server.command] + server.args
|
||||||
|
}
|
||||||
|
proc.environment = ProcessInfo.processInfo.environment
|
||||||
|
|
||||||
|
let stdinPipe = Pipe()
|
||||||
|
let stdoutPipe = Pipe()
|
||||||
|
let stderrPipe = Pipe()
|
||||||
|
proc.standardInput = stdinPipe
|
||||||
|
proc.standardOutput = stdoutPipe
|
||||||
|
proc.standardError = stderrPipe
|
||||||
|
|
||||||
|
proc.terminationHandler = { [weak self] _ in
|
||||||
|
Task { @MainActor [weak self] in self?.handleProcessTerminated() }
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try proc.run()
|
||||||
|
} catch {
|
||||||
|
state = .error(error.localizedDescription)
|
||||||
|
stateDelegate?.clientDidChangeState(id: server.id, state: .error(error.localizedDescription))
|
||||||
|
throw MCPClientError.processLaunchFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
process = proc
|
||||||
|
stdinHandle = stdinPipe.fileHandleForWriting
|
||||||
|
startReadLoop(pipe: stdoutPipe)
|
||||||
|
startStderrLoop(pipe: stderrPipe)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let _: MCPInitializeResult = try await timedRequest(seconds: 15, method: "initialize", params: [
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": [:] as [String: Any],
|
||||||
|
"clientInfo": ["name": "oAI", "version": "1.0"] as [String: Any]
|
||||||
|
])
|
||||||
|
try sendNotification(method: "notifications/initialized")
|
||||||
|
|
||||||
|
let toolsResult: MCPToolsListResult = try await timedRequest(seconds: 15, method: "tools/list", params: nil)
|
||||||
|
discoveredTools = toolsResult.tools
|
||||||
|
} catch {
|
||||||
|
state = .error(error.localizedDescription)
|
||||||
|
stateDelegate?.clientDidChangeState(id: server.id, state: .error(error.localizedDescription))
|
||||||
|
proc.terminate()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
state = .ready
|
||||||
|
stateDelegate?.clientDidBecomeReady(id: server.id, tools: discoveredTools, server: server)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
state = .stopped
|
||||||
|
readTask?.cancel()
|
||||||
|
stderrTask?.cancel()
|
||||||
|
process?.terminate()
|
||||||
|
process = nil
|
||||||
|
stdinHandle = nil
|
||||||
|
lineBuffer = Data()
|
||||||
|
for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) }
|
||||||
|
pendingCalls.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tool Execution
|
||||||
|
|
||||||
|
func callTool(originalName: String, argumentsJSON: String) async -> [String: Any] {
|
||||||
|
guard state == .ready else {
|
||||||
|
return ["error": "MCP server '\(server.name)' is not connected"]
|
||||||
|
}
|
||||||
|
guard let argData = argumentsJSON.data(using: .utf8),
|
||||||
|
let argsDict = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
|
||||||
|
return ["error": "Invalid arguments JSON for tool \(originalName)"]
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let result: MCPToolCallResult = try await timedRequest(
|
||||||
|
seconds: server.timeout,
|
||||||
|
method: "tools/call",
|
||||||
|
params: ["name": originalName, "arguments": argsDict]
|
||||||
|
)
|
||||||
|
return convertMCPResult(result)
|
||||||
|
} catch MCPClientError.timeout {
|
||||||
|
return ["error": "MCP server '\(server.name)' timed out after \(Int(server.timeout))s"]
|
||||||
|
} catch {
|
||||||
|
return ["error": "MCP call '\(originalName)' failed: \(error.localizedDescription)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - I/O Loops (detached from MainActor)
|
||||||
|
|
||||||
|
private func startReadLoop(pipe: Pipe) {
|
||||||
|
readTask = Task.detached { [weak self] in
|
||||||
|
let handle = pipe.fileHandleForReading
|
||||||
|
while true {
|
||||||
|
let data = handle.availableData
|
||||||
|
if data.isEmpty { break }
|
||||||
|
await self?.receiveData(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startStderrLoop(pipe: Pipe) {
|
||||||
|
let name = server.name
|
||||||
|
stderrTask = Task.detached {
|
||||||
|
let handle = pipe.fileHandleForReading
|
||||||
|
var buf = Data()
|
||||||
|
while true {
|
||||||
|
let data = handle.availableData
|
||||||
|
if data.isEmpty { break }
|
||||||
|
buf.append(data)
|
||||||
|
while let idx = buf.firstIndex(of: UInt8(ascii: "\n")) {
|
||||||
|
let line = String(data: buf[buf.startIndex..<idx], encoding: .utf8) ?? ""
|
||||||
|
buf = Data(buf[buf.index(after: idx)...])
|
||||||
|
if !line.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||||
|
Log.extMcp.warning("[\(name)] \(line)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Processing (MainActor)
|
||||||
|
|
||||||
|
private func receiveData(_ data: Data) {
|
||||||
|
lineBuffer.append(data)
|
||||||
|
while let idx = lineBuffer.firstIndex(of: UInt8(ascii: "\n")) {
|
||||||
|
let lineData = Data(lineBuffer[lineBuffer.startIndex..<idx])
|
||||||
|
lineBuffer = Data(lineBuffer[lineBuffer.index(after: idx)...])
|
||||||
|
processLine(lineData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processLine(_ data: Data) {
|
||||||
|
guard !data.isEmpty,
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let id = json["id"] as? Int,
|
||||||
|
let cont = pendingCalls.removeValue(forKey: id) else { return }
|
||||||
|
|
||||||
|
if let err = json["error"] as? [String: Any] {
|
||||||
|
cont.resume(throwing: MCPClientError.invalidResponse(err["message"] as? String ?? "Unknown error"))
|
||||||
|
} else if let result = json["result"],
|
||||||
|
let resultData = try? JSONSerialization.data(withJSONObject: result) {
|
||||||
|
cont.resume(returning: resultData)
|
||||||
|
} else {
|
||||||
|
cont.resume(throwing: MCPClientError.invalidResponse("Missing result field"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JSON-RPC
|
||||||
|
|
||||||
|
/// Send a JSON-RPC request with a per-call timeout. The timeout fires a cancellation
|
||||||
|
/// directly into the pending-calls table rather than using a task group (which would
|
||||||
|
/// pass the generic T through a @Sendable closure and trigger an isolated-conformance warning).
|
||||||
|
private func timedRequest<T: Decodable>(seconds: Double, method: String, params: [String: Any]?) async throws -> T {
|
||||||
|
let id = nextRequestId
|
||||||
|
nextRequestId += 1
|
||||||
|
var message: [String: Any] = ["jsonrpc": "2.0", "method": method, "id": id]
|
||||||
|
if let params { message["params"] = params }
|
||||||
|
try writeJSON(message)
|
||||||
|
|
||||||
|
// Schedule timeout: cancels the specific pending call by ID
|
||||||
|
let timeoutId = id
|
||||||
|
Task { [weak self, timeoutId] in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||||
|
self?.cancelPendingCall(id: timeoutId, with: MCPClientError.timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Await response data, then decode on MainActor
|
||||||
|
let resultData: Data = try await withCheckedThrowingContinuation { cont in
|
||||||
|
pendingCalls[id] = cont
|
||||||
|
}
|
||||||
|
return try JSONDecoder().decode(T.self, from: resultData)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelPendingCall(id: Int, with error: Error) {
|
||||||
|
pendingCalls.removeValue(forKey: id)?.resume(throwing: error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendNotification(method: String) throws {
|
||||||
|
try writeJSON(["jsonrpc": "2.0", "method": method])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeJSON(_ message: [String: Any]) throws {
|
||||||
|
guard let handle = stdinHandle, process?.isRunning == true else {
|
||||||
|
throw MCPClientError.writeFailed
|
||||||
|
}
|
||||||
|
guard let data = try? JSONSerialization.data(withJSONObject: message),
|
||||||
|
let line = String(data: data, encoding: .utf8) else {
|
||||||
|
throw MCPClientError.writeFailed
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try handle.write(contentsOf: Data((line + "\n").utf8))
|
||||||
|
} catch {
|
||||||
|
throw MCPClientError.writeFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Process termination
|
||||||
|
|
||||||
|
private func handleProcessTerminated() {
|
||||||
|
guard state != .stopped else { return }
|
||||||
|
state = .crashed
|
||||||
|
for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) }
|
||||||
|
pendingCalls.removeAll()
|
||||||
|
stateDelegate?.clientDidChangeState(id: server.id, state: .crashed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Result conversion
|
||||||
|
|
||||||
|
private func convertMCPResult(_ result: MCPToolCallResult) -> [String: Any] {
|
||||||
|
let isError = result.isError ?? false
|
||||||
|
var parts: [String] = []
|
||||||
|
for content in result.content {
|
||||||
|
switch content.type {
|
||||||
|
case "text":
|
||||||
|
if let text = content.text { parts.append(text) }
|
||||||
|
case "image":
|
||||||
|
if let base64 = content.data, let imageData = Data(base64Encoded: base64) {
|
||||||
|
parts.append("[Image saved to: \(writeTempImage(imageData, mimeType: content.mimeType))]")
|
||||||
|
}
|
||||||
|
case "resource":
|
||||||
|
if let text = content.text { parts.append(text) }
|
||||||
|
else if let uri = content.uri { parts.append("[Resource: \(uri)]") }
|
||||||
|
default:
|
||||||
|
if let text = content.text { parts.append(text) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let combined = parts.joined(separator: "\n")
|
||||||
|
return isError ? ["error": combined.isEmpty ? "Tool returned an error" : combined] : ["output": combined]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeTempImage(_ data: Data, mimeType: String?) -> String {
|
||||||
|
let ext = mimeType?.contains("png") == true ? "png" : "jpg"
|
||||||
|
let path = "/tmp/oai_mcp_\(Int(Date().timeIntervalSince1970 * 1000)).\(ext)"
|
||||||
|
try? data.write(to: URL(fileURLWithPath: path))
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - ExternalMCPManager
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class ExternalMCPManager {
|
||||||
|
nonisolated static let shared = ExternalMCPManager()
|
||||||
|
|
||||||
|
private(set) var clientStates: [UUID: MCPClientState] = [:]
|
||||||
|
private(set) var cachedToolSchemas: [Tool] = []
|
||||||
|
|
||||||
|
// Keep server config alongside client so we can access slug without await
|
||||||
|
private var clients: [UUID: ExternalMCPClient] = [:]
|
||||||
|
private var serverConfigs: [UUID: ExternalMCPServer] = [:]
|
||||||
|
private var restartTasks: [UUID: Task<Void, Never>] = [:]
|
||||||
|
private var restartAttempts: [UUID: Int] = [:]
|
||||||
|
|
||||||
|
private nonisolated init() {}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
func startAll() {
|
||||||
|
for server in SettingsService.shared.externalMCPServers where server.isEnabled {
|
||||||
|
startClient(for: server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopAll() {
|
||||||
|
for client in clients.values { client.stop() }
|
||||||
|
clients.removeAll()
|
||||||
|
serverConfigs.removeAll()
|
||||||
|
clientStates.removeAll()
|
||||||
|
cachedToolSchemas.removeAll()
|
||||||
|
for task in restartTasks.values { task.cancel() }
|
||||||
|
restartTasks.removeAll()
|
||||||
|
restartAttempts.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconfigure(servers: [ExternalMCPServer]) {
|
||||||
|
let activeIds = Set(servers.filter { $0.isEnabled }.map { $0.id })
|
||||||
|
for id in clients.keys where !activeIds.contains(id) {
|
||||||
|
clients[id]?.stop()
|
||||||
|
clients.removeValue(forKey: id)
|
||||||
|
serverConfigs.removeValue(forKey: id)
|
||||||
|
clientStates.removeValue(forKey: id)
|
||||||
|
restartTasks[id]?.cancel()
|
||||||
|
restartTasks.removeValue(forKey: id)
|
||||||
|
restartAttempts.removeValue(forKey: id)
|
||||||
|
removeCachedSchemas(for: id)
|
||||||
|
}
|
||||||
|
for server in servers where server.isEnabled && clients[server.id] == nil {
|
||||||
|
startClient(for: server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startClient(for server: ExternalMCPServer) {
|
||||||
|
// Stop any existing client for this ID before creating a new one
|
||||||
|
clients[server.id]?.stop()
|
||||||
|
let client = ExternalMCPClient(server: server, stateDelegate: self)
|
||||||
|
clients[server.id] = client
|
||||||
|
serverConfigs[server.id] = server
|
||||||
|
clientStates[server.id] = .connecting
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await client.start()
|
||||||
|
} catch MCPClientError.processLaunchFailed(let msg) {
|
||||||
|
// Process never started — termination handler won't fire, so manually trigger crashed
|
||||||
|
Log.extMcp.error("Failed to launch '\(server.name)': \(msg)")
|
||||||
|
clientDidChangeState(id: server.id, state: .crashed)
|
||||||
|
} catch {
|
||||||
|
// Handshake/other failure — proc.terminate() was called in start(), termination
|
||||||
|
// handler will fire and set .crashed, which drives the restart from one place only.
|
||||||
|
Log.extMcp.warning("'\(server.name)' start failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleRestart(for server: ExternalMCPServer, attempt: Int) {
|
||||||
|
let delays: [Double] = [5, 15, 30]
|
||||||
|
let delay = delays[min(attempt - 1, delays.count - 1)]
|
||||||
|
Log.extMcp.warning("MCP server '\(server.name)' crashed — restarting in \(Int(delay))s (attempt \(attempt)/3)")
|
||||||
|
|
||||||
|
restartTasks[server.id]?.cancel()
|
||||||
|
let id = server.id
|
||||||
|
restartTasks[id] = Task { [weak self, id] in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||||
|
guard !Task.isCancelled, let self,
|
||||||
|
self.clients[id] != nil,
|
||||||
|
SettingsService.shared.externalMCPServers.contains(where: { $0.id == id && $0.isEnabled })
|
||||||
|
else { return }
|
||||||
|
// startClient is the single place that creates and launches clients.
|
||||||
|
// It handles processLaunchFailed by calling clientDidChangeState(.crashed),
|
||||||
|
// and all other failures let the termination handler drive the .crashed callback.
|
||||||
|
self.startClient(for: server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tool Schema Integration (synchronous)
|
||||||
|
|
||||||
|
func getToolSchemas() -> [Tool] { cachedToolSchemas }
|
||||||
|
|
||||||
|
func isExternalTool(_ name: String) -> Bool {
|
||||||
|
cachedToolSchemas.contains { $0.function.name == name }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tool Execution
|
||||||
|
|
||||||
|
func executeTool(name: String, argumentsJSON: String) async -> [String: Any] {
|
||||||
|
for (id, client) in clients {
|
||||||
|
guard clientStates[id] == .ready,
|
||||||
|
let server = serverConfigs[id] else { continue }
|
||||||
|
let prefix = "\(server.slug)_"
|
||||||
|
if name.hasPrefix(prefix) {
|
||||||
|
let originalName = String(name.dropFirst(prefix.count))
|
||||||
|
return await client.callTool(originalName: originalName, argumentsJSON: argumentsJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ["error": "No external MCP server found for tool: \(name)"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Schema Cache
|
||||||
|
|
||||||
|
private func rebuildCache(for server: ExternalMCPServer, tools: [MCPToolDefinition]) {
|
||||||
|
removeCachedSchemas(for: server.id, slug: server.slug)
|
||||||
|
let prefixed = tools.compactMap { convertToolDefinition($0, server: server) }
|
||||||
|
cachedToolSchemas.append(contentsOf: prefixed)
|
||||||
|
Log.extMcp.info("[\(server.name)] cached \(prefixed.count) tools: \(prefixed.map { $0.function.name }.joined(separator: ", "))")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeCachedSchemas(for id: UUID) {
|
||||||
|
guard let server = serverConfigs[id] else { return }
|
||||||
|
removeCachedSchemas(for: id, slug: server.slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeCachedSchemas(for id: UUID, slug: String) {
|
||||||
|
cachedToolSchemas.removeAll { $0.function.name.hasPrefix("\(slug)_") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func convertToolDefinition(_ def: MCPToolDefinition, server: ExternalMCPServer) -> Tool? {
|
||||||
|
Tool(
|
||||||
|
type: "function",
|
||||||
|
function: Tool.Function(
|
||||||
|
name: "\(server.slug)_\(def.name)",
|
||||||
|
description: "[\(server.name)] \(def.description ?? "")",
|
||||||
|
parameters: convertInputSchema(def.inputSchema)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func convertInputSchema(_ schema: MCPInputSchema) -> Tool.Function.Parameters {
|
||||||
|
var properties: [String: Tool.Function.Parameters.Property] = [:]
|
||||||
|
for (key, prop) in schema.properties ?? [:] {
|
||||||
|
let normalized: String
|
||||||
|
switch prop.type ?? "string" {
|
||||||
|
case "integer": normalized = "number"
|
||||||
|
case "string", "number", "boolean", "array", "object": normalized = prop.type!
|
||||||
|
default: normalized = "string"
|
||||||
|
}
|
||||||
|
var items: Tool.Function.Parameters.Property.Items? = nil
|
||||||
|
if normalized == "array", let t = prop.items?.type { items = .init(type: t) }
|
||||||
|
properties[key] = Tool.Function.Parameters.Property(
|
||||||
|
type: normalized,
|
||||||
|
description: prop.description ?? "",
|
||||||
|
enum: prop.enum,
|
||||||
|
items: items
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Tool.Function.Parameters(type: "object", properties: properties, required: schema.required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ExternalMCPStateDelegate
|
||||||
|
|
||||||
|
extension ExternalMCPManager: ExternalMCPStateDelegate {
|
||||||
|
func clientDidBecomeReady(id: UUID, tools: [MCPToolDefinition], server: ExternalMCPServer) {
|
||||||
|
clientStates[id] = .ready
|
||||||
|
restartAttempts.removeValue(forKey: id)
|
||||||
|
rebuildCache(for: server, tools: tools)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientDidChangeState(id: UUID, state: MCPClientState) {
|
||||||
|
clientStates[id] = state
|
||||||
|
if case .crashed = state,
|
||||||
|
let server = serverConfigs[id],
|
||||||
|
SettingsService.shared.externalMCPServers.contains(where: { $0.id == id && $0.isEnabled }) {
|
||||||
|
removeCachedSchemas(for: id, slug: server.slug)
|
||||||
|
let attempt = (restartAttempts[id] ?? 0) + 1
|
||||||
|
guard attempt <= 3 else {
|
||||||
|
Log.extMcp.error("MCP server '\(server.name)' gave up after 3 restart attempts")
|
||||||
|
clientStates[id] = .error("Maximum restart attempts reached")
|
||||||
|
restartAttempts.removeValue(forKey: id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
restartAttempts[id] = attempt
|
||||||
|
scheduleRestart(for: server, attempt: attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Server Configuration
|
||||||
|
|
||||||
|
struct ExternalMCPServer: Codable, Identifiable, Sendable {
|
||||||
|
var id: UUID
|
||||||
|
var name: String
|
||||||
|
var command: String
|
||||||
|
var args: [String]
|
||||||
|
var isEnabled: Bool
|
||||||
|
var timeout: TimeInterval
|
||||||
|
var createdAt: Date
|
||||||
|
|
||||||
|
init(id: UUID = UUID(), name: String, command: String, args: [String] = [],
|
||||||
|
isEnabled: Bool = true, timeout: TimeInterval = 30, createdAt: Date = Date()) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.command = command
|
||||||
|
self.args = args
|
||||||
|
self.isEnabled = isEnabled
|
||||||
|
self.timeout = timeout
|
||||||
|
self.createdAt = createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
var slug: String { Self.makeSlug(from: name) }
|
||||||
|
|
||||||
|
static func makeSlug(from name: String) -> String {
|
||||||
|
let s = name
|
||||||
|
.lowercased()
|
||||||
|
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.joined(separator: "_")
|
||||||
|
return s.isEmpty ? "ext" : s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits a raw arguments string into tokens, respecting single/double-quoted
|
||||||
|
/// segments so arguments containing spaces (e.g. `--root "/Users/x/My Documents"`)
|
||||||
|
/// survive intact instead of being split on every space.
|
||||||
|
static func parseArguments(_ input: String) -> [String] {
|
||||||
|
var args: [String] = []
|
||||||
|
var current = ""
|
||||||
|
var inSingleQuotes = false
|
||||||
|
var inDoubleQuotes = false
|
||||||
|
|
||||||
|
for char in input {
|
||||||
|
if char == "'" && !inDoubleQuotes {
|
||||||
|
inSingleQuotes.toggle()
|
||||||
|
} else if char == "\"" && !inSingleQuotes {
|
||||||
|
inDoubleQuotes.toggle()
|
||||||
|
} else if char.isWhitespace && !inSingleQuotes && !inDoubleQuotes {
|
||||||
|
if !current.isEmpty {
|
||||||
|
args.append(current)
|
||||||
|
current = ""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current.append(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !current.isEmpty { args.append(current) }
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
static let reservedSlugs: Set<String> = [
|
||||||
|
"anytype", "paperless", "calendar", "reminders",
|
||||||
|
"contacts", "location", "maps", "bash", "web", "read", "write",
|
||||||
|
"list", "search", "edit", "delete", "create", "move", "copy", "spawn"
|
||||||
|
]
|
||||||
|
|
||||||
|
var isSlugReserved: Bool { Self.reservedSlugs.contains(slug) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Client State
|
||||||
|
|
||||||
|
enum MCPClientState: Equatable {
|
||||||
|
case idle
|
||||||
|
case connecting
|
||||||
|
case ready
|
||||||
|
case error(String)
|
||||||
|
case crashed
|
||||||
|
case stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - State Delegate (all callbacks on MainActor)
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol ExternalMCPStateDelegate: AnyObject {
|
||||||
|
func clientDidBecomeReady(id: UUID, tools: [MCPToolDefinition], server: ExternalMCPServer)
|
||||||
|
func clientDidChangeState(id: UUID, state: MCPClientState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Client Errors
|
||||||
|
|
||||||
|
enum MCPClientError: LocalizedError {
|
||||||
|
case notConnected
|
||||||
|
case invalidResponse(String)
|
||||||
|
case timeout
|
||||||
|
case processLaunchFailed(String)
|
||||||
|
case handshakeFailed(String)
|
||||||
|
case writeFailed
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notConnected: return "MCP server is not connected"
|
||||||
|
case .invalidResponse(let s): return "Invalid MCP response: \(s)"
|
||||||
|
case .timeout: return "MCP request timed out"
|
||||||
|
case .processLaunchFailed(let s): return "Failed to launch MCP server: \(s)"
|
||||||
|
case .handshakeFailed(let s): return "MCP handshake failed: \(s)"
|
||||||
|
case .writeFailed: return "Failed to write to MCP server stdin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MCP Protocol Types
|
||||||
|
|
||||||
|
struct MCPInitializeResult: Decodable {
|
||||||
|
let protocolVersion: String
|
||||||
|
let capabilities: MCPCapabilities
|
||||||
|
let serverInfo: MCPServerInfo?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MCPCapabilities: Decodable {
|
||||||
|
let tools: MCPToolsCapability?
|
||||||
|
struct MCPToolsCapability: Decodable { let listChanged: Bool? }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MCPServerInfo: Decodable {
|
||||||
|
let name: String
|
||||||
|
let version: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MCPToolsListResult: Decodable {
|
||||||
|
let tools: [MCPToolDefinition]
|
||||||
|
let nextCursor: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MCPToolDefinition: Decodable {
|
||||||
|
let name: String
|
||||||
|
let description: String?
|
||||||
|
let inputSchema: MCPInputSchema
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MCPInputSchema: Decodable {
|
||||||
|
let type: String
|
||||||
|
let properties: [String: MCPPropertySchema]?
|
||||||
|
let required: [String]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MCPPropertySchema: Decodable {
|
||||||
|
let type: String?
|
||||||
|
let description: String?
|
||||||
|
let `enum`: [String]?
|
||||||
|
let items: MCPItemsSchema?
|
||||||
|
struct MCPItemsSchema: Decodable { let type: String? }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MCPToolCallResult: Decodable {
|
||||||
|
let content: [MCPContent]
|
||||||
|
let isError: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MCPContent: Decodable {
|
||||||
|
let type: String
|
||||||
|
let text: String?
|
||||||
|
let data: String?
|
||||||
|
let mimeType: String?
|
||||||
|
let uri: String?
|
||||||
|
}
|
||||||
@@ -1,22 +1,20 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -212,7 +210,7 @@ class GitSyncService {
|
|||||||
|
|
||||||
// Check if conversation already exists (by ID)
|
// Check if conversation already exists (by ID)
|
||||||
if let existingId = UUID(uuidString: export.id) {
|
if let existingId = UUID(uuidString: export.id) {
|
||||||
if let existing = try? db.loadConversation(id: existingId) {
|
if (try? db.loadConversation(id: existingId)) != nil {
|
||||||
// Already exists - skip
|
// Already exists - skip
|
||||||
log.debug("Skipping existing conversation: \(export.name)")
|
log.debug("Skipping existing conversation: \(export.name)")
|
||||||
skipped += 1
|
skipped += 1
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Swift-native IMAP client for email monitoring
|
// Swift-native IMAP client for email monitoring
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
//
|
||||||
|
// JarvisService.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// HTTP client for the Jarvis (oAI-Web) REST API.
|
||||||
|
// Auth: Authorization: Bearer <api-key>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
final class JarvisService: Sendable {
|
||||||
|
static let shared = JarvisService()
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.oai.oAI", category: "jarvis")
|
||||||
|
|
||||||
|
private var baseURL: String { SettingsService.shared.jarvisURL }
|
||||||
|
private var apiKey: String? { SettingsService.shared.jarvisAPIKey }
|
||||||
|
|
||||||
|
// MARK: - Agents
|
||||||
|
|
||||||
|
func listAgents() async throws -> [JarvisAgent] {
|
||||||
|
try await get("/api/agents")
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAgent(_ input: JarvisAgentInput) async throws -> JarvisAgent {
|
||||||
|
try await post("/api/agents", body: input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAgent(id: String, _ input: JarvisAgentInput) async throws -> JarvisAgent {
|
||||||
|
try await put("/api/agents/\(id)", body: input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAgent(id: String) async throws {
|
||||||
|
try await voidRequest("DELETE", path: "/api/agents/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleAgent(id: String) async throws -> JarvisAgent {
|
||||||
|
try await post("/api/agents/\(id)/toggle", body: Empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAgent(id: String) async throws {
|
||||||
|
try await voidRequest("POST", path: "/api/agents/\(id)/run")
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopAgent(id: String) async throws {
|
||||||
|
try await voidRequest("POST", path: "/api/agents/\(id)/stop")
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentRuns(id: String) async throws -> [JarvisAgentRun] {
|
||||||
|
try await get("/api/agents/\(id)/runs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Usage
|
||||||
|
|
||||||
|
func usage() async throws -> [JarvisUsageStat] {
|
||||||
|
let data = try await rawData("GET", path: "/api/usage")
|
||||||
|
// API returns {summary:{...}, by_agent:[...], chat:{...}}
|
||||||
|
if let w = try? JSONDecoder().decode(JarvisUsageResponse.self, from: data) {
|
||||||
|
return w.byAgent ?? []
|
||||||
|
}
|
||||||
|
// Fallback: plain array
|
||||||
|
if let arr = try? JSONDecoder().decode([JarvisUsageStat].self, from: data) { return arr }
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
func credits() async throws -> JarvisCreditsResponse {
|
||||||
|
try await get("/api/usage/openrouter-credits")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Control
|
||||||
|
|
||||||
|
func queueStatus() async throws -> JarvisQueueStatus {
|
||||||
|
try await get("/api/status")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pauseAll() async throws {
|
||||||
|
try await voidRequest("POST", path: "/api/pause")
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumeAll() async throws {
|
||||||
|
try await voidRequest("POST", path: "/api/resume")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connection test
|
||||||
|
|
||||||
|
func testConnection() async -> Bool {
|
||||||
|
guard !baseURL.isEmpty, let key = apiKey, !key.isEmpty else { return false }
|
||||||
|
do {
|
||||||
|
let _: [JarvisAgent] = try await get("/api/agents")
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
log.warning("Jarvis connection test failed: \(error.localizedDescription)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HTTP core
|
||||||
|
|
||||||
|
private func get<T: Decodable>(_ path: String) async throws -> T {
|
||||||
|
let data = try await rawData("GET", path: path)
|
||||||
|
return try JSONDecoder().decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func post<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
||||||
|
let data = try await rawData("POST", path: path, body: body)
|
||||||
|
return try JSONDecoder().decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func put<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
||||||
|
let data = try await rawData("PUT", path: path, body: body)
|
||||||
|
return try JSONDecoder().decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func voidRequest(_ method: String, path: String) async throws {
|
||||||
|
_ = try await rawData(method, path: path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rawData<B: Encodable>(_ method: String, path: String, body: B? = nil as Empty?) async throws -> Data {
|
||||||
|
guard let url = URL(string: baseURL.trimmingCharacters(in: .whitespaces) + path) else {
|
||||||
|
throw JarvisError.invalidURL
|
||||||
|
}
|
||||||
|
guard let key = apiKey, !key.isEmpty else {
|
||||||
|
throw JarvisError.noAPIKey
|
||||||
|
}
|
||||||
|
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = method
|
||||||
|
req.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
req.timeoutInterval = 30
|
||||||
|
|
||||||
|
if let body {
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.httpBody = try JSONEncoder().encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: req)
|
||||||
|
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw JarvisError.invalidResponse
|
||||||
|
}
|
||||||
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
|
let msg = (try? JSONDecoder().decode(JarvisErrBody.self, from: data))?.detail ?? "HTTP \(http.statusCode)"
|
||||||
|
log.error("Jarvis \(method) \(path) → \(http.statusCode): \(msg)")
|
||||||
|
throw JarvisError.serverError(http.statusCode, msg)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Empty: Codable {}
|
||||||
|
private struct JarvisErrBody: Decodable { let detail: String? }
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
//
|
||||||
|
// LocationMapsService.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// Read-only Location and Maps integration via CoreLocation and MapKit
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
//
|
||||||
|
// This file is part of oAI.
|
||||||
|
//
|
||||||
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
|
//
|
||||||
|
// See the LICENSE file or
|
||||||
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
|
// the full license text. For commercial licensing, contact Rune
|
||||||
|
// Olsen via <https://oai.pm>.
|
||||||
|
|
||||||
|
|
||||||
|
import CoreLocation
|
||||||
|
import Foundation
|
||||||
|
import MapKit
|
||||||
|
import os
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class LocationMapsService: NSObject, CLLocationManagerDelegate {
|
||||||
|
static let shared = LocationMapsService()
|
||||||
|
|
||||||
|
private let locationManager = CLLocationManager()
|
||||||
|
|
||||||
|
private var authContinuation: CheckedContinuation<Bool, Never>?
|
||||||
|
private var locationContinuation: CheckedContinuation<CLLocation?, Never>?
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
locationManager.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Authorization
|
||||||
|
|
||||||
|
var authStatus: CLAuthorizationStatus {
|
||||||
|
locationManager.authorizationStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorized: Bool {
|
||||||
|
authStatus == .authorizedAlways || authStatus == .authorized
|
||||||
|
}
|
||||||
|
|
||||||
|
var accessState: PersonalDataAccessState {
|
||||||
|
let status = authStatus
|
||||||
|
Log.mcp.debug("LocationMapsService.accessState -> status=\(Self.describe(status)) (raw=\(status.rawValue))")
|
||||||
|
switch status {
|
||||||
|
case .authorizedAlways, .authorized: return .granted
|
||||||
|
case .notDetermined: return .notDetermined
|
||||||
|
default: return .denied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func requestAccess() async -> Bool {
|
||||||
|
let before = locationManager.authorizationStatus
|
||||||
|
Log.mcp.info("LocationMapsService.requestAccess: status before = \(Self.describe(before)) (raw=\(before.rawValue))")
|
||||||
|
if before != .notDetermined {
|
||||||
|
Log.mcp.info("LocationMapsService.requestAccess: skipping OS prompt (not notDetermined)")
|
||||||
|
return authorized
|
||||||
|
}
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
self.authContinuation = continuation
|
||||||
|
locationManager.requestWhenInUseAuthorization()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||||
|
let status = manager.authorizationStatus
|
||||||
|
Log.mcp.info("LocationMapsService: authorization changed -> \(Self.describe(status)) (raw=\(status.rawValue))")
|
||||||
|
authContinuation?.resume(returning: authorized)
|
||||||
|
authContinuation = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func describe(_ status: CLAuthorizationStatus) -> String {
|
||||||
|
switch status {
|
||||||
|
case .notDetermined: return "notDetermined"
|
||||||
|
case .restricted: return "restricted"
|
||||||
|
case .denied: return "denied"
|
||||||
|
case .authorizedAlways: return "authorizedAlways"
|
||||||
|
case .authorized: return "authorized"
|
||||||
|
@unknown default: return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
|
locationContinuation?.resume(returning: locations.last)
|
||||||
|
locationContinuation = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||||
|
Log.mcp.error("Location request failed: \(error.localizedDescription)")
|
||||||
|
locationContinuation?.resume(returning: nil)
|
||||||
|
locationContinuation = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentLocation() async -> CLLocation? {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
self.locationContinuation = continuation
|
||||||
|
locationManager.requestLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tool Schemas
|
||||||
|
|
||||||
|
func getToolSchemas() -> [Tool] {
|
||||||
|
[
|
||||||
|
makeTool(
|
||||||
|
name: "location_get_current",
|
||||||
|
description: "Get the device's current location, including a human-readable address.",
|
||||||
|
properties: [:],
|
||||||
|
required: []
|
||||||
|
),
|
||||||
|
makeTool(
|
||||||
|
name: "maps_search_places",
|
||||||
|
description: "Search for places (businesses, landmarks, addresses) by name or category.",
|
||||||
|
properties: [
|
||||||
|
"query": prop("string", "What to search for, e.g. 'coffee shops' or 'Eiffel Tower'"),
|
||||||
|
"near": prop("string", "Optional: an address or 'latitude,longitude' to search near")
|
||||||
|
],
|
||||||
|
required: ["query"]
|
||||||
|
),
|
||||||
|
makeTool(
|
||||||
|
name: "maps_geocode",
|
||||||
|
description: "Convert an address into geographic coordinates and a formatted address.",
|
||||||
|
properties: [
|
||||||
|
"address": prop("string", "The address to geocode")
|
||||||
|
],
|
||||||
|
required: ["address"]
|
||||||
|
),
|
||||||
|
makeTool(
|
||||||
|
name: "maps_get_directions",
|
||||||
|
description: "Get distance and estimated travel time between two locations.",
|
||||||
|
properties: [
|
||||||
|
"origin": prop("string", "Starting address or 'latitude,longitude'"),
|
||||||
|
"destination": prop("string", "Destination address or 'latitude,longitude'"),
|
||||||
|
"transport_type": prop("string", "Mode of transport", enumValues: ["driving", "walking", "transit"])
|
||||||
|
],
|
||||||
|
required: ["origin", "destination"]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tool Execution
|
||||||
|
|
||||||
|
func executeTool(name: String, arguments: String) async -> [String: Any] {
|
||||||
|
Log.mcp.info("Executing LocationMaps tool: \(name)")
|
||||||
|
let args = parseArgs(arguments)
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "location_get_current":
|
||||||
|
guard authorized else { return ["error": "Location permission not granted. Grant access in Settings > MCP."] }
|
||||||
|
return await getCurrentLocation()
|
||||||
|
|
||||||
|
case "maps_search_places":
|
||||||
|
guard let query = args["query"] as? String, !query.isEmpty else {
|
||||||
|
return ["error": "Missing required parameter: query"]
|
||||||
|
}
|
||||||
|
let near = args["near"] as? String
|
||||||
|
return await searchPlaces(query: query, near: near)
|
||||||
|
|
||||||
|
case "maps_geocode":
|
||||||
|
guard let address = args["address"] as? String, !address.isEmpty else {
|
||||||
|
return ["error": "Missing required parameter: address"]
|
||||||
|
}
|
||||||
|
return await geocode(address: address)
|
||||||
|
|
||||||
|
case "maps_get_directions":
|
||||||
|
guard let origin = args["origin"] as? String, let destination = args["destination"] as? String else {
|
||||||
|
return ["error": "Missing required parameter: origin and/or destination"]
|
||||||
|
}
|
||||||
|
let transportType = args["transport_type"] as? String ?? "driving"
|
||||||
|
return await getDirections(origin: origin, destination: destination, transportType: transportType)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ["error": "Unknown LocationMaps tool: \(name)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Implementations
|
||||||
|
|
||||||
|
private func getCurrentLocation() async -> [String: Any] {
|
||||||
|
guard let location = await currentLocation() else {
|
||||||
|
return ["error": "Could not determine current location"]
|
||||||
|
}
|
||||||
|
var result: [String: Any] = [
|
||||||
|
"latitude": location.coordinate.latitude,
|
||||||
|
"longitude": location.coordinate.longitude
|
||||||
|
]
|
||||||
|
if let mapItem = await reverseGeocode(location), let address = addressString(for: mapItem) {
|
||||||
|
result["address"] = address
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func searchPlaces(query: String, near: String?) async -> [String: Any] {
|
||||||
|
let request = MKLocalSearch.Request()
|
||||||
|
request.naturalLanguageQuery = query
|
||||||
|
if let near, let coordinate = await coordinate(for: near) {
|
||||||
|
request.region = MKCoordinateRegion(center: coordinate, latitudinalMeters: 20_000, longitudinalMeters: 20_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await MKLocalSearch(request: request).start()
|
||||||
|
let places = response.mapItems.prefix(15).map { item -> [String: Any] in
|
||||||
|
var place: [String: Any] = ["name": item.name ?? "Unknown"]
|
||||||
|
place["latitude"] = item.location.coordinate.latitude
|
||||||
|
place["longitude"] = item.location.coordinate.longitude
|
||||||
|
if let address = addressString(for: item) {
|
||||||
|
place["address"] = address
|
||||||
|
}
|
||||||
|
if let phone = item.phoneNumber { place["phone"] = phone }
|
||||||
|
return place
|
||||||
|
}
|
||||||
|
return ["count": places.count, "places": Array(places)]
|
||||||
|
} catch {
|
||||||
|
return ["error": "Search failed: \(error.localizedDescription)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func geocode(address: String) async -> [String: Any] {
|
||||||
|
guard let request = MKGeocodingRequest(addressString: address) else {
|
||||||
|
return ["error": "Invalid address: \(address)"]
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
guard let mapItem = try await request.mapItems.first else {
|
||||||
|
return ["error": "No results found for address: \(address)"]
|
||||||
|
}
|
||||||
|
var result: [String: Any] = [
|
||||||
|
"latitude": mapItem.location.coordinate.latitude,
|
||||||
|
"longitude": mapItem.location.coordinate.longitude
|
||||||
|
]
|
||||||
|
if let formatted = addressString(for: mapItem) {
|
||||||
|
result["formatted_address"] = formatted
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
return ["error": "Geocoding failed: \(error.localizedDescription)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getDirections(origin: String, destination: String, transportType: String) async -> [String: Any] {
|
||||||
|
guard let originCoordinate = await coordinate(for: origin) else {
|
||||||
|
return ["error": "Could not resolve origin: \(origin)"]
|
||||||
|
}
|
||||||
|
guard let destinationCoordinate = await coordinate(for: destination) else {
|
||||||
|
return ["error": "Could not resolve destination: \(destination)"]
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = MKDirections.Request()
|
||||||
|
request.source = MKMapItem(location: CLLocation(latitude: originCoordinate.latitude, longitude: originCoordinate.longitude), address: nil)
|
||||||
|
request.destination = MKMapItem(location: CLLocation(latitude: destinationCoordinate.latitude, longitude: destinationCoordinate.longitude), address: nil)
|
||||||
|
switch transportType {
|
||||||
|
case "walking": request.transportType = .walking
|
||||||
|
case "transit": request.transportType = .transit
|
||||||
|
default: request.transportType = .automobile
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await MKDirections(request: request).calculate()
|
||||||
|
guard let route = response.routes.first else {
|
||||||
|
return ["error": "No route found"]
|
||||||
|
}
|
||||||
|
let distanceFormatter = MKDistanceFormatter()
|
||||||
|
let durationFormatter = DateComponentsFormatter()
|
||||||
|
durationFormatter.allowedUnits = [.hour, .minute]
|
||||||
|
durationFormatter.unitsStyle = .short
|
||||||
|
|
||||||
|
return [
|
||||||
|
"distance_meters": route.distance,
|
||||||
|
"distance_text": distanceFormatter.string(fromDistance: route.distance),
|
||||||
|
"duration_seconds": route.expectedTravelTime,
|
||||||
|
"duration_text": durationFormatter.string(from: route.expectedTravelTime) ?? "",
|
||||||
|
"transport_type": transportType
|
||||||
|
]
|
||||||
|
} catch {
|
||||||
|
return ["error": "Directions failed: \(error.localizedDescription)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func coordinate(for text: String) async -> CLLocationCoordinate2D? {
|
||||||
|
let parts = text.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
if parts.count == 2, let lat = Double(parts[0]), let lon = Double(parts[1]) {
|
||||||
|
return CLLocationCoordinate2D(latitude: lat, longitude: lon)
|
||||||
|
}
|
||||||
|
guard let request = MKGeocodingRequest(addressString: text) else { return nil }
|
||||||
|
if let mapItems = try? await request.mapItems, let mapItem = mapItems.first {
|
||||||
|
return mapItem.location.coordinate
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reverseGeocode(_ location: CLLocation) async -> MKMapItem? {
|
||||||
|
guard let request = MKReverseGeocodingRequest(location: location) else { return nil }
|
||||||
|
let mapItems = try? await request.mapItems
|
||||||
|
return mapItems?.first
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addressString(for mapItem: MKMapItem) -> String? {
|
||||||
|
mapItem.address?.fullAddress
|
||||||
|
?? mapItem.addressRepresentations?.fullAddress(includingRegion: true, singleLine: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseArgs(_ arguments: String) -> [String: Any] {
|
||||||
|
guard let data = arguments.data(using: .utf8),
|
||||||
|
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
|
||||||
|
Tool(
|
||||||
|
type: "function",
|
||||||
|
function: Tool.Function(
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
parameters: Tool.Function.Parameters(
|
||||||
|
type: "object",
|
||||||
|
properties: properties,
|
||||||
|
required: required
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prop(_ type: String, _ description: String, enumValues: [String]? = nil) -> Tool.Function.Parameters.Property {
|
||||||
|
Tool.Function.Parameters.Property(type: type, description: description, enum: enumValues)
|
||||||
|
}
|
||||||
|
}
|
||||||
+302
-13
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// MCP (Model Context Protocol) service for filesystem tool execution
|
// MCP (Model Context Protocol) service for filesystem tool execution
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -98,6 +96,17 @@ class MCPService {
|
|||||||
|
|
||||||
func isPathAllowed(_ path: String) -> Bool {
|
func isPathAllowed(_ path: String) -> Bool {
|
||||||
let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath
|
let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||||
|
// Always allow the system temp directory — external MCP servers and tools write
|
||||||
|
// intermediate data there (e.g. Safari MCP page-content files, generated images).
|
||||||
|
// Check both NSTemporaryDirectory() (per-user Darwin temp dir) and /tmp (what this
|
||||||
|
// codebase's own temp files actually use, e.g. ChatViewModel's /tmp/oai_generated_*
|
||||||
|
// and ExternalMCPClient's /tmp/oai_mcp_* — they resolve to different directories.
|
||||||
|
let tmpCandidates = [
|
||||||
|
(NSTemporaryDirectory() as NSString).standardizingPath,
|
||||||
|
"/tmp",
|
||||||
|
"/private/tmp"
|
||||||
|
]
|
||||||
|
if tmpCandidates.contains(where: { resolved.hasPrefix($0) }) { return true }
|
||||||
return allowedFolders.contains { resolved.hasPrefix($0) }
|
return allowedFolders.contains { resolved.hasPrefix($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +120,9 @@ class MCPService {
|
|||||||
|
|
||||||
private let anytypeService = AnytypeMCPService.shared
|
private let anytypeService = AnytypeMCPService.shared
|
||||||
private let paperlessService = PaperlessService.shared
|
private let paperlessService = PaperlessService.shared
|
||||||
|
private let eventKitService = EventKitService.shared
|
||||||
|
private let contactsService = ContactsService.shared
|
||||||
|
private let locationMapsService = LocationMapsService.shared
|
||||||
|
|
||||||
// MARK: - Bash Approval State
|
// MARK: - Bash Approval State
|
||||||
|
|
||||||
@@ -124,6 +136,19 @@ class MCPService {
|
|||||||
private var pendingBashContinuation: CheckedContinuation<[String: Any], Never>? = nil
|
private var pendingBashContinuation: CheckedContinuation<[String: Any], Never>? = nil
|
||||||
private(set) var bashSessionApproved: Bool = false
|
private(set) var bashSessionApproved: Bool = false
|
||||||
|
|
||||||
|
// MARK: - Personal Data (Calendar/Reminders) Approval State
|
||||||
|
|
||||||
|
struct PendingPersonalDataAction: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let toolName: String
|
||||||
|
let argumentsJSON: String
|
||||||
|
let summary: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var pendingPersonalDataAction: PendingPersonalDataAction? = nil
|
||||||
|
private var pendingPersonalDataContinuation: CheckedContinuation<[String: Any], Never>? = nil
|
||||||
|
private(set) var personalDataSessionApproved: Bool = false
|
||||||
|
|
||||||
// MARK: - Tool Schema Generation
|
// MARK: - Tool Schema Generation
|
||||||
|
|
||||||
func getToolSchemas(onlineMode: Bool = false) -> [Tool] {
|
func getToolSchemas(onlineMode: Bool = false) -> [Tool] {
|
||||||
@@ -232,6 +257,24 @@ class MCPService {
|
|||||||
tools.append(contentsOf: paperlessService.getToolSchemas())
|
tools.append(contentsOf: paperlessService.getToolSchemas())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add Calendar/Reminders tools if enabled
|
||||||
|
if settings.calendarEnabled || settings.remindersEnabled {
|
||||||
|
tools.append(contentsOf: eventKitService.getToolSchemas(
|
||||||
|
calendarEnabled: settings.calendarEnabled,
|
||||||
|
remindersEnabled: settings.remindersEnabled
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Contacts tools if enabled
|
||||||
|
if settings.contactsEnabled {
|
||||||
|
tools.append(contentsOf: contactsService.getToolSchemas())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Location/Maps tools if enabled
|
||||||
|
if settings.locationMapsEnabled {
|
||||||
|
tools.append(contentsOf: locationMapsService.getToolSchemas())
|
||||||
|
}
|
||||||
|
|
||||||
// Add bash_execute tool when bash is enabled
|
// Add bash_execute tool when bash is enabled
|
||||||
if settings.bashEnabled {
|
if settings.bashEnabled {
|
||||||
let workDir = settings.bashWorkingDirectory
|
let workDir = settings.bashWorkingDirectory
|
||||||
@@ -260,6 +303,25 @@ class MCPService {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add spawn_research_agents when Research Agents are enabled
|
||||||
|
if settings.agentsEnabled {
|
||||||
|
tools.append(makeTool(
|
||||||
|
name: "spawn_research_agents",
|
||||||
|
description: "Spawn multiple READ-ONLY research sub-agents that investigate independent questions IN PARALLEL. Each sub-agent gets its own read_file/list_directory/search_files/web_search loop — no write, no bash, no nesting (sub-agents cannot call this tool). ONLY use this when you have 2 or more genuinely independent research questions that benefit from running at the same time (e.g. comparing several unrelated files, topics, or sources). Do NOT use this for a single lookup, a sequential task, or anything answerable with one direct tool call — call read_file/search_files/web_search yourself instead. Each sub-agent is its own full chain of model calls and meaningfully increases cost and latency; using this for trivial tasks is wasteful. Prefer the smallest number of tasks that actually need to run in parallel.",
|
||||||
|
properties: [
|
||||||
|
"tasks": Tool.Function.Parameters.Property(
|
||||||
|
type: "array",
|
||||||
|
description: "List of independent, self-contained research questions — one per sub-agent. Keep this list as short as the task genuinely requires.",
|
||||||
|
items: .init(type: "string")
|
||||||
|
)
|
||||||
|
],
|
||||||
|
required: ["tasks"]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tools from external MCP servers (stdio JSON-RPC protocol)
|
||||||
|
tools.append(contentsOf: ExternalMCPManager.shared.getToolSchemas())
|
||||||
|
|
||||||
return tools
|
return tools
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +346,10 @@ class MCPService {
|
|||||||
|
|
||||||
// MARK: - Tool Execution
|
// MARK: - Tool Execution
|
||||||
|
|
||||||
func executeTool(name: String, arguments: String) async -> [String: Any] {
|
/// `agentProvider`/`agentModelId` are only needed for `spawn_research_agents`, which drives
|
||||||
|
/// its own model calls — every other tool ignores them. Passed in by the caller's active
|
||||||
|
/// chat session rather than stored on MCPService, since this service has no provider state.
|
||||||
|
func executeTool(name: String, arguments: String, agentProvider: AIProvider? = nil, agentModelId: String? = nil) async -> [String: Any] {
|
||||||
Log.mcp.info("Executing tool: \(name)")
|
Log.mcp.info("Executing tool: \(name)")
|
||||||
guard let argData = arguments.data(using: .utf8),
|
guard let argData = arguments.data(using: .utf8),
|
||||||
let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
|
let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
|
||||||
@@ -399,7 +464,30 @@ class MCPService {
|
|||||||
let mapped = results.map { ["title": $0.title, "url": $0.url, "snippet": $0.snippet] }
|
let mapped = results.map { ["title": $0.title, "url": $0.url, "snippet": $0.snippet] }
|
||||||
return ["results": mapped]
|
return ["results": mapped]
|
||||||
|
|
||||||
|
case "spawn_research_agents":
|
||||||
|
guard settings.agentsEnabled else {
|
||||||
|
return ["error": "Research agents are disabled. Enable 'Research Agents' in Settings > MCP."]
|
||||||
|
}
|
||||||
|
guard let agentProvider, let agentModelId else {
|
||||||
|
return ["error": "Internal error: missing model context for spawn_research_agents"]
|
||||||
|
}
|
||||||
|
guard let tasks = args["tasks"] as? [String], !tasks.isEmpty else {
|
||||||
|
return ["error": "Missing required parameter: tasks (non-empty array of strings)"]
|
||||||
|
}
|
||||||
|
return await runResearchAgents(tasks: tasks, provider: agentProvider, modelId: agentModelId)
|
||||||
|
|
||||||
|
case "calendar_create_event", "reminders_create", "reminders_complete":
|
||||||
|
guard settings.calendarEnabled || settings.remindersEnabled else {
|
||||||
|
return ["error": "Calendar/Reminders access is disabled. Enable it in Settings > MCP."]
|
||||||
|
}
|
||||||
|
let summary = eventKitService.approvalSummary(forTool: name, arguments: arguments)
|
||||||
|
return await executePersonalDataAction(toolName: name, argumentsJSON: arguments, summary: summary)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
// Route to external MCP servers (stdio JSON-RPC)
|
||||||
|
if ExternalMCPManager.shared.isExternalTool(name) {
|
||||||
|
return await ExternalMCPManager.shared.executeTool(name: name, argumentsJSON: arguments)
|
||||||
|
}
|
||||||
// Route anytype_* tools to AnytypeMCPService
|
// Route anytype_* tools to AnytypeMCPService
|
||||||
if name.hasPrefix("anytype_") {
|
if name.hasPrefix("anytype_") {
|
||||||
return await anytypeService.executeTool(name: name, arguments: arguments)
|
return await anytypeService.executeTool(name: name, arguments: arguments)
|
||||||
@@ -408,6 +496,18 @@ class MCPService {
|
|||||||
if name.hasPrefix("paperless_") {
|
if name.hasPrefix("paperless_") {
|
||||||
return await paperlessService.executeTool(name: name, arguments: arguments)
|
return await paperlessService.executeTool(name: name, arguments: arguments)
|
||||||
}
|
}
|
||||||
|
// Route calendar_*/reminders_* read tools to EventKitService
|
||||||
|
if name.hasPrefix("calendar_") || name.hasPrefix("reminders_") {
|
||||||
|
return await eventKitService.executeTool(name: name, arguments: arguments)
|
||||||
|
}
|
||||||
|
// Route contacts_* tools to ContactsService
|
||||||
|
if name.hasPrefix("contacts_") {
|
||||||
|
return await contactsService.executeTool(name: name, arguments: arguments)
|
||||||
|
}
|
||||||
|
// Route location_*/maps_* tools to LocationMapsService
|
||||||
|
if name.hasPrefix("location_") || name.hasPrefix("maps_") {
|
||||||
|
return await locationMapsService.executeTool(name: name, arguments: arguments)
|
||||||
|
}
|
||||||
return ["error": "Unknown tool: \(name)"]
|
return ["error": "Unknown tool: \(name)"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -754,6 +854,11 @@ class MCPService {
|
|||||||
if bashSessionApproved {
|
if bashSessionApproved {
|
||||||
return await runBashCommand(command, workingDirectory: workingDirectory)
|
return await runBashCommand(command, workingDirectory: workingDirectory)
|
||||||
}
|
}
|
||||||
|
// 2nd Brain calls its helper script via bash_execute — let the user mark that
|
||||||
|
// specific traffic as always-trusted instead of approving it every time.
|
||||||
|
if isTrustedSecondBrainCommand(command) {
|
||||||
|
return await runBashCommand(command, workingDirectory: workingDirectory)
|
||||||
|
}
|
||||||
return await withCheckedContinuation { continuation in
|
return await withCheckedContinuation { continuation in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.pendingBashCommand = PendingBashCommand(command: command, workingDirectory: workingDirectory)
|
self.pendingBashCommand = PendingBashCommand(command: command, workingDirectory: workingDirectory)
|
||||||
@@ -786,6 +891,190 @@ class MCPService {
|
|||||||
bashSessionApproved = false
|
bashSessionApproved = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isTrustedSecondBrainCommand(_ command: String) -> Bool {
|
||||||
|
guard settings.trustSecondBrainSkill, command.contains(".brain_helper.py") else { return false }
|
||||||
|
return settings.agentSkills.contains { $0.isActive && $0.isSecondBrainSkill }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Research Agents (read-only, parallel)
|
||||||
|
|
||||||
|
/// Runs `tasks.count` sub-agents (capped) with bounded concurrency, each in its own
|
||||||
|
/// read-only tool loop, and returns their findings concatenated for the orchestrator.
|
||||||
|
private func runResearchAgents(tasks: [String], provider: AIProvider, modelId: String) async -> [String: Any] {
|
||||||
|
let maxConcurrent = max(1, min(5, settings.maxConcurrentAgents))
|
||||||
|
// Hard cap on total sub-agents regardless of concurrency setting, so a model
|
||||||
|
// requesting an unreasonably long task list can't run away with cost.
|
||||||
|
let cappedTasks = Array(tasks.prefix(8))
|
||||||
|
|
||||||
|
var results: [(Int, String)] = []
|
||||||
|
await withTaskGroup(of: (Int, String).self) { group in
|
||||||
|
var nextIndex = 0
|
||||||
|
func launchNext() {
|
||||||
|
guard nextIndex < cappedTasks.count else { return }
|
||||||
|
let idx = nextIndex
|
||||||
|
let task = cappedTasks[idx]
|
||||||
|
nextIndex += 1
|
||||||
|
group.addTask {
|
||||||
|
let answer = await self.runSingleResearchAgent(task: task, provider: provider, modelId: modelId)
|
||||||
|
return (idx, answer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _ in 0..<min(maxConcurrent, cappedTasks.count) { launchNext() }
|
||||||
|
for await result in group {
|
||||||
|
results.append(result)
|
||||||
|
launchNext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sorted = results.sorted { $0.0 < $1.0 }
|
||||||
|
let formatted = sorted.map { idx, answer in
|
||||||
|
"### Agent \(idx + 1): \(cappedTasks[idx])\n\(answer)"
|
||||||
|
}.joined(separator: "\n\n")
|
||||||
|
|
||||||
|
var response: [String: Any] = ["agent_count": sorted.count, "results": formatted]
|
||||||
|
if tasks.count > cappedTasks.count {
|
||||||
|
response["note"] = "Only the first \(cappedTasks.count) of \(tasks.count) requested tasks were run (per-call cap)."
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single sub-agent's self-contained tool loop. Read-only tools only; cannot write,
|
||||||
|
/// run bash, or spawn further sub-agents (spawn_research_agents is not in its tool list).
|
||||||
|
private func runSingleResearchAgent(task: String, provider: AIProvider, modelId: String) async -> String {
|
||||||
|
let readOnlyTools: [Tool] = [
|
||||||
|
makeTool(
|
||||||
|
name: "read_file",
|
||||||
|
description: "Read the contents of a file. Maximum file size is 10MB.",
|
||||||
|
properties: ["file_path": prop("string", "The absolute path to the file to read")],
|
||||||
|
required: ["file_path"]
|
||||||
|
),
|
||||||
|
makeTool(
|
||||||
|
name: "list_directory",
|
||||||
|
description: "List the contents of a directory. Skips hidden/build directories like .git, node_modules, etc.",
|
||||||
|
properties: [
|
||||||
|
"dir_path": prop("string", "The absolute path to the directory to list"),
|
||||||
|
"recursive": prop("boolean", "Whether to list recursively (default: false)")
|
||||||
|
],
|
||||||
|
required: ["dir_path"]
|
||||||
|
),
|
||||||
|
makeTool(
|
||||||
|
name: "search_files",
|
||||||
|
description: "Search for files by name pattern or content.",
|
||||||
|
properties: [
|
||||||
|
"pattern": prop("string", "Glob pattern to match filenames (e.g. '*.py', 'README*')"),
|
||||||
|
"search_path": prop("string", "Directory to search in (defaults to first allowed folder)"),
|
||||||
|
"content_search": prop("string", "Optional text to search for inside files")
|
||||||
|
],
|
||||||
|
required: ["pattern"]
|
||||||
|
),
|
||||||
|
makeTool(
|
||||||
|
name: "web_search",
|
||||||
|
description: "Search the web for current information using DuckDuckGo.",
|
||||||
|
properties: ["query": prop("string", "The search query to look up")],
|
||||||
|
required: ["query"]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
let allowedNames = Set(readOnlyTools.map { $0.function.name })
|
||||||
|
|
||||||
|
var apiMessages: [[String: Any]] = [
|
||||||
|
["role": "system", "content": "You are a read-only research sub-agent. Investigate the assigned task using the available tools and report concise findings as plain text. You cannot write, delete, or execute anything, and cannot spawn further sub-agents. Once you have enough information, respond with your final answer and stop calling tools."],
|
||||||
|
["role": "user", "content": task]
|
||||||
|
]
|
||||||
|
|
||||||
|
let maxIterations = 6
|
||||||
|
for iteration in 0..<maxIterations {
|
||||||
|
if Task.isCancelled { return "(cancelled)" }
|
||||||
|
|
||||||
|
guard let response = try? await provider.chatWithToolMessages(
|
||||||
|
model: modelId, messages: apiMessages, tools: readOnlyTools, maxTokens: nil, temperature: nil
|
||||||
|
) else {
|
||||||
|
return "(error: sub-agent request failed)"
|
||||||
|
}
|
||||||
|
|
||||||
|
let toolCalls = response.toolCalls ?? []
|
||||||
|
guard !toolCalls.isEmpty else {
|
||||||
|
return response.content.isEmpty ? "(no findings)" : response.content
|
||||||
|
}
|
||||||
|
|
||||||
|
var assistantMsg: [String: Any] = ["role": "assistant"]
|
||||||
|
if !response.content.isEmpty { assistantMsg["content"] = response.content }
|
||||||
|
assistantMsg["tool_calls"] = toolCalls.map { tc in
|
||||||
|
["id": tc.id, "type": tc.type, "function": ["name": tc.functionName, "arguments": tc.arguments]]
|
||||||
|
}
|
||||||
|
apiMessages.append(assistantMsg)
|
||||||
|
|
||||||
|
for tc in toolCalls {
|
||||||
|
let resultJSON: String
|
||||||
|
if allowedNames.contains(tc.functionName) {
|
||||||
|
let result = await executeTool(name: tc.functionName, arguments: tc.arguments)
|
||||||
|
resultJSON = serializeToolResult(result)
|
||||||
|
} else {
|
||||||
|
resultJSON = "{\"error\": \"Tool not available to research sub-agents\"}"
|
||||||
|
}
|
||||||
|
apiMessages.append([
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tc.id,
|
||||||
|
"name": tc.functionName,
|
||||||
|
"content": resultJSON
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
if iteration == maxIterations - 1 {
|
||||||
|
return "(research incomplete: sub-agent reached its iteration limit)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "(no findings)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func serializeToolResult(_ result: [String: Any], maxBytes: Int = 20_000) -> String {
|
||||||
|
guard let data = try? JSONSerialization.data(withJSONObject: result),
|
||||||
|
let str = String(data: data, encoding: .utf8) else {
|
||||||
|
return "{\"error\": \"Failed to serialize result\"}"
|
||||||
|
}
|
||||||
|
guard str.utf8.count > maxBytes, let truncated = String(str.utf8.prefix(maxBytes)) else {
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
return truncated + "\n... (result truncated)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Personal Data (Calendar/Reminders) Approval
|
||||||
|
|
||||||
|
private func executePersonalDataAction(toolName: String, argumentsJSON: String, summary: String) async -> [String: Any] {
|
||||||
|
guard settings.personalDataRequireApproval, !personalDataSessionApproved else {
|
||||||
|
return await eventKitService.executeWriteTool(name: toolName, arguments: argumentsJSON)
|
||||||
|
}
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.pendingPersonalDataAction = PendingPersonalDataAction(toolName: toolName, argumentsJSON: argumentsJSON, summary: summary)
|
||||||
|
self.pendingPersonalDataContinuation = continuation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func approvePendingPersonalDataAction(forSession: Bool = false) {
|
||||||
|
guard let pending = pendingPersonalDataAction, let cont = pendingPersonalDataContinuation else { return }
|
||||||
|
pendingPersonalDataAction = nil
|
||||||
|
pendingPersonalDataContinuation = nil
|
||||||
|
if forSession {
|
||||||
|
personalDataSessionApproved = true
|
||||||
|
}
|
||||||
|
Task.detached(priority: .userInitiated) {
|
||||||
|
let result = await self.eventKitService.executeWriteTool(name: pending.toolName, arguments: pending.argumentsJSON)
|
||||||
|
cont.resume(returning: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func denyPendingPersonalDataAction() {
|
||||||
|
guard pendingPersonalDataAction != nil else { return }
|
||||||
|
pendingPersonalDataAction = nil
|
||||||
|
pendingPersonalDataContinuation?.resume(returning: ["error": "User denied this action"])
|
||||||
|
pendingPersonalDataContinuation = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetPersonalDataSessionApproval() {
|
||||||
|
personalDataSessionApproved = false
|
||||||
|
}
|
||||||
|
|
||||||
private func runBashCommand(_ command: String, workingDirectory: String) async -> [String: Any] {
|
private func runBashCommand(_ command: String, workingDirectory: String) async -> [String: Any] {
|
||||||
let timeoutSeconds = settings.bashTimeout
|
let timeoutSeconds = settings.bashTimeout
|
||||||
let workDir = ((workingDirectory as NSString).expandingTildeInPath as NSString).standardizingPath
|
let workDir = ((workingDirectory as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Paperless-NGX integration: search, read, and upload documents via REST API
|
// Paperless-NGX integration: search, read, and upload documents via REST API
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Swift-native SMTP client for sending emails
|
// Swift-native SMTP client for sending emails
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -4,29 +4,39 @@
|
|||||||
//
|
//
|
||||||
// Settings persistence: SQLite for preferences, Keychain for API keys
|
// Settings persistence: SQLite for preferences, Keychain for API keys
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
|
/// Kill switch for the Personal Data tools (Calendar/Reminders/Contacts/Location & Maps).
|
||||||
|
/// Flip `isHiddenPendingAppleFix` back to `false` once Apple fixes the macOS 27 beta TCC bug
|
||||||
|
/// (filed with Apple). Each flag hides the relevant UI and forces the `*Enabled` getter to
|
||||||
|
/// return `false` regardless of the persisted DB value — no code deleted, just inert.
|
||||||
|
enum PersonalDataTools {
|
||||||
|
/// Hides the entire Personal Data section. Flip to `false` once all four services work.
|
||||||
|
static let isHiddenPendingAppleFix = false
|
||||||
|
/// Hides only the Contacts row. Contacts TCC still broken under hardened runtime on
|
||||||
|
/// macOS 27 beta 2 while Calendar/Reminders/Location are fixed. Flip to `false` once fixed.
|
||||||
|
static let isContactsHiddenPendingAppleFix = true
|
||||||
|
}
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
class SettingsService {
|
class SettingsService {
|
||||||
static let shared = SettingsService()
|
static let shared = SettingsService()
|
||||||
@@ -43,6 +53,7 @@ class SettingsService {
|
|||||||
static let googleSearchEngineID = "googleSearchEngineID"
|
static let googleSearchEngineID = "googleSearchEngineID"
|
||||||
static let anytypeMcpAPIKey = "anytypeMcpAPIKey"
|
static let anytypeMcpAPIKey = "anytypeMcpAPIKey"
|
||||||
static let paperlessAPIToken = "paperlessAPIToken"
|
static let paperlessAPIToken = "paperlessAPIToken"
|
||||||
|
static let jarvisAPIKey = "jarvisAPIKey"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Old keychain keys (for migration only)
|
// Old keychain keys (for migration only)
|
||||||
@@ -299,6 +310,24 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Input bar height in points — default 80
|
||||||
|
var inputBarHeight: Double {
|
||||||
|
get { cache["inputBarHeight"].flatMap(Double.init) ?? 80.0 }
|
||||||
|
set {
|
||||||
|
cache["inputBarHeight"] = String(newValue)
|
||||||
|
DatabaseService.shared.setSetting(key: "inputBarHeight", value: String(newValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the sidebar is visible — default true
|
||||||
|
var sidebarVisible: Bool {
|
||||||
|
get { cache["sidebarVisible"] != "false" }
|
||||||
|
set {
|
||||||
|
cache["sidebarVisible"] = String(newValue)
|
||||||
|
DatabaseService.shared.setSetting(key: "sidebarVisible", value: String(newValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - MCP Permissions
|
// MARK: - MCP Permissions
|
||||||
|
|
||||||
var mcpCanWriteFiles: Bool {
|
var mcpCanWriteFiles: Bool {
|
||||||
@@ -430,6 +459,48 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - External MCP Servers
|
||||||
|
|
||||||
|
var externalMCPServers: [ExternalMCPServer] {
|
||||||
|
get {
|
||||||
|
guard let json = cache["externalMCPServers"],
|
||||||
|
let data = json.data(using: .utf8) else { return [] }
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
return (try? decoder.decode([ExternalMCPServer].self, from: data)) ?? []
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
|
if let data = try? encoder.encode(newValue),
|
||||||
|
let json = String(data: data, encoding: .utf8) {
|
||||||
|
cache["externalMCPServers"] = json
|
||||||
|
DatabaseService.shared.setSetting(key: "externalMCPServers", value: json)
|
||||||
|
}
|
||||||
|
Task { @MainActor in ExternalMCPManager.shared.reconfigure(servers: newValue) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addExternalMCPServer(_ server: ExternalMCPServer) {
|
||||||
|
externalMCPServers = externalMCPServers + [server]
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateExternalMCPServer(_ server: ExternalMCPServer) {
|
||||||
|
externalMCPServers = externalMCPServers.map { $0.id == server.id ? server : $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteExternalMCPServer(id: UUID) {
|
||||||
|
externalMCPServers = externalMCPServers.filter { $0.id != id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleExternalMCPServer(id: UUID) {
|
||||||
|
externalMCPServers = externalMCPServers.map { s in
|
||||||
|
s.id == id ? ExternalMCPServer(id: s.id, name: s.name, command: s.command,
|
||||||
|
args: s.args, isEnabled: !s.isEnabled,
|
||||||
|
timeout: s.timeout, createdAt: s.createdAt) : s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Favorite Models
|
// MARK: - Favorite Models
|
||||||
|
|
||||||
var favoriteModelIds: Set<String> {
|
var favoriteModelIds: Set<String> {
|
||||||
@@ -500,6 +571,46 @@ class SettingsService {
|
|||||||
return !key.isEmpty
|
return !key.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Jarvis Settings
|
||||||
|
|
||||||
|
var jarvisEnabled: Bool {
|
||||||
|
get { cache["jarvisEnabled"] == "true" }
|
||||||
|
set {
|
||||||
|
cache["jarvisEnabled"] = String(newValue)
|
||||||
|
DatabaseService.shared.setSetting(key: "jarvisEnabled", value: String(newValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var jarvisURL: String {
|
||||||
|
get { cache["jarvisURL"] ?? "" }
|
||||||
|
set {
|
||||||
|
let trimmed = newValue.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
cache.removeValue(forKey: "jarvisURL")
|
||||||
|
DatabaseService.shared.deleteSetting(key: "jarvisURL")
|
||||||
|
} else {
|
||||||
|
cache["jarvisURL"] = trimmed
|
||||||
|
DatabaseService.shared.setSetting(key: "jarvisURL", value: trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var jarvisAPIKey: String? {
|
||||||
|
get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.jarvisAPIKey) }
|
||||||
|
set {
|
||||||
|
if let value = newValue, !value.isEmpty {
|
||||||
|
try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.jarvisAPIKey, value: value)
|
||||||
|
} else {
|
||||||
|
DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.jarvisAPIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var jarvisConfigured: Bool {
|
||||||
|
guard let key = jarvisAPIKey else { return false }
|
||||||
|
return !jarvisURL.isEmpty && !key.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Bash Execution Settings
|
// MARK: - Bash Execution Settings
|
||||||
|
|
||||||
var bashEnabled: Bool {
|
var bashEnabled: Bool {
|
||||||
@@ -518,6 +629,37 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Research Agents Settings
|
||||||
|
|
||||||
|
/// When true, the AI can call `spawn_research_agents` to run multiple read-only
|
||||||
|
/// sub-agents in parallel. Each sub-agent is its own full chain of model calls, so
|
||||||
|
/// this can noticeably increase cost — opt-in, off by default.
|
||||||
|
var agentsEnabled: Bool {
|
||||||
|
get { cache["agentsEnabled"] == "true" }
|
||||||
|
set {
|
||||||
|
cache["agentsEnabled"] = String(newValue)
|
||||||
|
DatabaseService.shared.setSetting(key: "agentsEnabled", value: String(newValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxConcurrentAgents: Int {
|
||||||
|
get { cache["maxConcurrentAgents"].flatMap(Int.init) ?? 3 }
|
||||||
|
set {
|
||||||
|
cache["maxConcurrentAgents"] = String(newValue)
|
||||||
|
DatabaseService.shared.setSetting(key: "maxConcurrentAgents", value: String(newValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When true (and an active "2nd Brain" Agent Skill is installed), bash commands that
|
||||||
|
/// invoke the 2nd Brain helper script skip the approval dialog entirely.
|
||||||
|
var trustSecondBrainSkill: Bool {
|
||||||
|
get { cache["trustSecondBrainSkill"] == "true" }
|
||||||
|
set {
|
||||||
|
cache["trustSecondBrainSkill"] = String(newValue)
|
||||||
|
DatabaseService.shared.setSetting(key: "trustSecondBrainSkill", value: String(newValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var bashWorkingDirectory: String {
|
var bashWorkingDirectory: String {
|
||||||
get { cache["bashWorkingDirectory"] ?? "~" }
|
get { cache["bashWorkingDirectory"] ?? "~" }
|
||||||
set {
|
set {
|
||||||
@@ -534,6 +676,48 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Personal Data Settings (Calendar/Reminders/Contacts/Location/Maps)
|
||||||
|
|
||||||
|
var calendarEnabled: Bool {
|
||||||
|
get { !PersonalDataTools.isHiddenPendingAppleFix && cache["calendarEnabled"] == "true" }
|
||||||
|
set {
|
||||||
|
cache["calendarEnabled"] = String(newValue)
|
||||||
|
DatabaseService.shared.setSetting(key: "calendarEnabled", value: String(newValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var remindersEnabled: Bool {
|
||||||
|
get { !PersonalDataTools.isHiddenPendingAppleFix && cache["remindersEnabled"] == "true" }
|
||||||
|
set {
|
||||||
|
cache["remindersEnabled"] = String(newValue)
|
||||||
|
DatabaseService.shared.setSetting(key: "remindersEnabled", value: String(newValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var contactsEnabled: Bool {
|
||||||
|
get { !PersonalDataTools.isHiddenPendingAppleFix && !PersonalDataTools.isContactsHiddenPendingAppleFix && cache["contactsEnabled"] == "true" }
|
||||||
|
set {
|
||||||
|
cache["contactsEnabled"] = String(newValue)
|
||||||
|
DatabaseService.shared.setSetting(key: "contactsEnabled", value: String(newValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var locationMapsEnabled: Bool {
|
||||||
|
get { !PersonalDataTools.isHiddenPendingAppleFix && cache["locationMapsEnabled"] == "true" }
|
||||||
|
set {
|
||||||
|
cache["locationMapsEnabled"] = String(newValue)
|
||||||
|
DatabaseService.shared.setSetting(key: "locationMapsEnabled", value: String(newValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var personalDataRequireApproval: Bool {
|
||||||
|
get { cache["personalDataRequireApproval"].map { $0 == "true" } ?? true }
|
||||||
|
set {
|
||||||
|
cache["personalDataRequireApproval"] = String(newValue)
|
||||||
|
DatabaseService.shared.setSetting(key: "personalDataRequireApproval", value: String(newValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Paperless-NGX Settings
|
// MARK: - Paperless-NGX Settings
|
||||||
|
|
||||||
var paperlessEnabled: Bool {
|
var paperlessEnabled: Bool {
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Fun random verbs for AI thinking/processing states
|
// Fun random verbs for AI thinking/processing states
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Checks for new releases on GitLab and surfaces an update badge in the footer
|
// Checks for new releases on GitLab and surfaces an update badge in the footer
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -35,6 +33,11 @@ final class UpdateCheckService {
|
|||||||
|
|
||||||
var updateAvailable: Bool = false
|
var updateAvailable: Bool = false
|
||||||
var latestVersion: String? = nil
|
var latestVersion: String? = nil
|
||||||
|
var downloadURL: URL? = nil
|
||||||
|
|
||||||
|
// Manual check state — drives the update alert in ContentView
|
||||||
|
var isCheckingManually: Bool = false
|
||||||
|
var manualCheckMessage: String? = nil
|
||||||
|
|
||||||
private let apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest"
|
private let apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest"
|
||||||
private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")!
|
private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")!
|
||||||
@@ -48,6 +51,24 @@ final class UpdateCheckService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Manual check triggered from the Help menu. Non-blocking — result surfaces via manualCheckMessage.
|
||||||
|
func checkForUpdatesManually() {
|
||||||
|
guard !isCheckingManually else { return }
|
||||||
|
isCheckingManually = true
|
||||||
|
Task.detached(priority: .background) {
|
||||||
|
await self.performCheck()
|
||||||
|
let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
||||||
|
await MainActor.run {
|
||||||
|
if self.updateAvailable, let v = self.latestVersion {
|
||||||
|
self.manualCheckMessage = String(localized: "Version \(v) is available.")
|
||||||
|
} else {
|
||||||
|
self.manualCheckMessage = String(localized: "You're up to date (v\(current)).")
|
||||||
|
}
|
||||||
|
self.isCheckingManually = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func performCheck() async {
|
private func performCheck() async {
|
||||||
guard let url = URL(string: apiURL) else { return }
|
guard let url = URL(string: apiURL) else { return }
|
||||||
|
|
||||||
@@ -69,9 +90,16 @@ final class UpdateCheckService {
|
|||||||
let latestVer = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName
|
let latestVer = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName
|
||||||
let currentVer = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
|
let currentVer = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
|
||||||
|
|
||||||
|
// Extract direct DMG download URL from release assets
|
||||||
|
let dmgURL: URL? = (release["assets"] as? [[String: Any]])?
|
||||||
|
.first { ($0["name"] as? String ?? "").lowercased().hasSuffix(".dmg") }
|
||||||
|
.flatMap { $0["browser_download_url"] as? String }
|
||||||
|
.flatMap { URL(string: $0) }
|
||||||
|
|
||||||
if isNewer(latestVer, than: currentVer) {
|
if isNewer(latestVer, than: currentVer) {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.latestVersion = latestVer
|
self.latestVersion = latestVer
|
||||||
|
self.downloadURL = dmgURL
|
||||||
self.updateAvailable = true
|
self.updateAvailable = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// DuckDuckGo web search for non-OpenRouter providers
|
// DuckDuckGo web search for non-OpenRouter providers
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Color scheme matching Python TUI dark theme
|
// Color scheme matching Python TUI dark theme
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// String utility extensions
|
// String utility extensions
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// SwiftUI view helpers and modifiers
|
// SwiftUI view helpers and modifiers
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|||||||
+36
-35
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Dual logging: os.Logger (unified log) + file (~Library/Logs/oAI.log)
|
// Dual logging: os.Logger (unified log) + file (~Library/Logs/oAI.log)
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -52,7 +50,7 @@ enum LogLevel: Int, Comparable, CaseIterable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func < (lhs: LogLevel, rhs: LogLevel) -> Bool {
|
nonisolated static func < (lhs: LogLevel, rhs: LogLevel) -> Bool {
|
||||||
lhs.rawValue < rhs.rawValue
|
lhs.rawValue < rhs.rawValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +58,7 @@ enum LogLevel: Int, Comparable, CaseIterable, Sendable {
|
|||||||
// MARK: - File Logger
|
// MARK: - File Logger
|
||||||
|
|
||||||
final class FileLogger: @unchecked Sendable {
|
final class FileLogger: @unchecked Sendable {
|
||||||
static let shared = FileLogger()
|
nonisolated static let shared = FileLogger()
|
||||||
|
|
||||||
private let fileHandle: FileHandle?
|
private let fileHandle: FileHandle?
|
||||||
private let queue = DispatchQueue(label: "com.oai.filelogger")
|
private let queue = DispatchQueue(label: "com.oai.filelogger")
|
||||||
@@ -70,8 +68,8 @@ final class FileLogger: @unchecked Sendable {
|
|||||||
return f
|
return f
|
||||||
}()
|
}()
|
||||||
|
|
||||||
/// Current minimum log level (read from UserDefaults for thread safety)
|
/// Current minimum log level (backed by UserDefaults — thread-safe).
|
||||||
var minimumLevel: LogLevel {
|
nonisolated var minimumLevel: LogLevel {
|
||||||
get {
|
get {
|
||||||
let raw = UserDefaults.standard.integer(forKey: "logLevel")
|
let raw = UserDefaults.standard.integer(forKey: "logLevel")
|
||||||
return LogLevel(rawValue: raw) ?? .info
|
return LogLevel(rawValue: raw) ?? .info
|
||||||
@@ -95,7 +93,7 @@ final class FileLogger: @unchecked Sendable {
|
|||||||
fileHandle?.seekToEndOfFile()
|
fileHandle?.seekToEndOfFile()
|
||||||
}
|
}
|
||||||
|
|
||||||
func write(_ level: LogLevel, category: String, message: String) {
|
nonisolated func write(_ level: LogLevel, category: String, message: String) {
|
||||||
guard level >= minimumLevel else { return }
|
guard level >= minimumLevel else { return }
|
||||||
queue.async { [weak self] in
|
queue.async { [weak self] in
|
||||||
guard let self, let fh = self.fileHandle else { return }
|
guard let self, let fh = self.fileHandle else { return }
|
||||||
@@ -114,41 +112,44 @@ final class FileLogger: @unchecked Sendable {
|
|||||||
|
|
||||||
// MARK: - App Logger (wraps os.Logger + file)
|
// MARK: - App Logger (wraps os.Logger + file)
|
||||||
|
|
||||||
struct AppLogger {
|
// os.Logger methods are @MainActor in macOS 27. AppLogger is Sendable and all methods are
|
||||||
let osLogger: Logger
|
// nonisolated — FileLogger runs on its own serial queue, os.Logger dispatches to main actor.
|
||||||
|
struct AppLogger: Sendable {
|
||||||
|
let subsystem: String
|
||||||
let category: String
|
let category: String
|
||||||
|
|
||||||
func debug(_ message: String) {
|
nonisolated func debug(_ message: String) {
|
||||||
FileLogger.shared.write(.debug, category: category, message: message)
|
FileLogger.shared.write(.debug, category: category, message: message)
|
||||||
osLogger.debug("\(message, privacy: .public)")
|
Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).debug("\(message, privacy: .public)") }
|
||||||
}
|
}
|
||||||
|
|
||||||
func info(_ message: String) {
|
nonisolated func info(_ message: String) {
|
||||||
FileLogger.shared.write(.info, category: category, message: message)
|
FileLogger.shared.write(.info, category: category, message: message)
|
||||||
osLogger.info("\(message, privacy: .public)")
|
Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).info("\(message, privacy: .public)") }
|
||||||
}
|
}
|
||||||
|
|
||||||
func warning(_ message: String) {
|
nonisolated func warning(_ message: String) {
|
||||||
FileLogger.shared.write(.warning, category: category, message: message)
|
FileLogger.shared.write(.warning, category: category, message: message)
|
||||||
osLogger.warning("\(message, privacy: .public)")
|
Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).warning("\(message, privacy: .public)") }
|
||||||
}
|
}
|
||||||
|
|
||||||
func error(_ message: String) {
|
nonisolated func error(_ message: String) {
|
||||||
FileLogger.shared.write(.error, category: category, message: message)
|
FileLogger.shared.write(.error, category: category, message: message)
|
||||||
osLogger.error("\(message, privacy: .public)")
|
Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).error("\(message, privacy: .public)") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Log Namespace
|
// MARK: - Log Namespace
|
||||||
|
|
||||||
enum Log {
|
enum Log {
|
||||||
private static let subsystem = "com.oai.oAI"
|
private nonisolated static let subsystem = "com.oai.oAI"
|
||||||
|
|
||||||
static let api = AppLogger(osLogger: Logger(subsystem: subsystem, category: "api"), category: "api")
|
nonisolated static let api = AppLogger(subsystem: subsystem, category: "api")
|
||||||
static let db = AppLogger(osLogger: Logger(subsystem: subsystem, category: "database"), category: "database")
|
nonisolated static let db = AppLogger(subsystem: subsystem, category: "database")
|
||||||
static let mcp = AppLogger(osLogger: Logger(subsystem: subsystem, category: "mcp"), category: "mcp")
|
nonisolated static let mcp = AppLogger(subsystem: subsystem, category: "mcp")
|
||||||
static let settings = AppLogger(osLogger: Logger(subsystem: subsystem, category: "settings"), category: "settings")
|
nonisolated static let settings = AppLogger(subsystem: subsystem, category: "settings")
|
||||||
static let search = AppLogger(osLogger: Logger(subsystem: subsystem, category: "search"), category: "search")
|
nonisolated static let search = AppLogger(subsystem: subsystem, category: "search")
|
||||||
static let ui = AppLogger(osLogger: Logger(subsystem: subsystem, category: "ui"), category: "ui")
|
nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui")
|
||||||
static let general = AppLogger(osLogger: Logger(subsystem: subsystem, category: "general"), category: "general")
|
nonisolated static let general = AppLogger(subsystem: subsystem, category: "general")
|
||||||
|
nonisolated static let extMcp = AppLogger(subsystem: subsystem, category: "ext-mcp")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Keyword-based syntax highlighting using AttributedString
|
// Keyword-based syntax highlighting using AttributedString
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Main chat view model
|
// Main chat view model
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -53,6 +51,7 @@ class ChatViewModel {
|
|||||||
var showHistory: Bool = false
|
var showHistory: Bool = false
|
||||||
var showShortcuts: Bool = false
|
var showShortcuts: Bool = false
|
||||||
var showSkills: Bool = false
|
var showSkills: Bool = false
|
||||||
|
var showJarvis: Bool = false
|
||||||
var modelInfoTarget: ModelInfo? = nil
|
var modelInfoTarget: ModelInfo? = nil
|
||||||
var commandHistory: [String] = []
|
var commandHistory: [String] = []
|
||||||
var historyIndex: Int = 0
|
var historyIndex: Int = 0
|
||||||
@@ -359,7 +358,6 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startAutoContinue() {
|
func startAutoContinue() {
|
||||||
showSystemMessage("↩ Continuing…")
|
|
||||||
silentContinuePrompt = "Please continue from where you left off."
|
silentContinuePrompt = "Please continue from where you left off."
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
generateAIResponse(to: "", attachments: nil)
|
generateAIResponse(to: "", attachments: nil)
|
||||||
@@ -742,6 +740,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
case "/skills":
|
case "/skills":
|
||||||
showSkills = true
|
showSkills = true
|
||||||
|
|
||||||
|
case "/jarvis":
|
||||||
|
showJarvis = true
|
||||||
|
|
||||||
case "/mcp":
|
case "/mcp":
|
||||||
handleMCPCommand(args: args)
|
handleMCPCommand(args: args)
|
||||||
|
|
||||||
@@ -795,8 +796,18 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
let mcpActive = mcpEnabled || settings.mcpEnabled
|
let mcpActive = mcpEnabled || settings.mcpEnabled
|
||||||
let anytypeActive = settings.anytypeMcpEnabled && settings.anytypeMcpConfigured
|
let anytypeActive = settings.anytypeMcpEnabled && settings.anytypeMcpConfigured
|
||||||
let bashActive = settings.bashEnabled
|
let bashActive = settings.bashEnabled
|
||||||
|
let personalDataActive = settings.calendarEnabled || settings.remindersEnabled || settings.contactsEnabled || settings.locationMapsEnabled
|
||||||
|
let researchAgentsActive = settings.agentsEnabled
|
||||||
|
let externalMCPActive = !settings.externalMCPServers.filter { $0.isEnabled }.isEmpty
|
||||||
|
// Dedicated images API path (OpenRouter /images endpoint — separate from chat completions)
|
||||||
|
if selectedModel?.capabilities.usesImagesAPI == true,
|
||||||
|
let orProvider = provider as? OpenRouterProvider {
|
||||||
|
generateImageAPIResponse(orProvider: orProvider, modelId: modelId, prompt: prompt)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let modelSupportTools = selectedModel?.capabilities.tools ?? false
|
let modelSupportTools = selectedModel?.capabilities.tools ?? false
|
||||||
if modelSupportTools && (anytypeActive || bashActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
|
if modelSupportTools && (anytypeActive || bashActive || personalDataActive || researchAgentsActive || externalMCPActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
|
||||||
generateAIResponseWithTools(provider: provider, modelId: modelId)
|
generateAIResponseWithTools(provider: provider, modelId: modelId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -930,10 +941,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
messages[index].tokens = usage.completionTokens
|
messages[index].tokens = usage.completionTokens
|
||||||
if let model = selectedModel {
|
if let model = selectedModel {
|
||||||
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
|
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
|
||||||
let cost: Double? = hasPricing
|
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||||
? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
|
|
||||||
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
|
|
||||||
: nil
|
|
||||||
messages[index].cost = cost
|
messages[index].cost = cost
|
||||||
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||||
}
|
}
|
||||||
@@ -997,10 +1005,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
messages[index].tokens = usage.completionTokens
|
messages[index].tokens = usage.completionTokens
|
||||||
if let model = selectedModel {
|
if let model = selectedModel {
|
||||||
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
|
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
|
||||||
let cost: Double? = hasPricing
|
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||||
? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
|
|
||||||
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
|
|
||||||
: nil
|
|
||||||
messages[index].cost = cost
|
messages[index].cost = cost
|
||||||
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||||
}
|
}
|
||||||
@@ -1263,6 +1268,55 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
|
|
||||||
// MARK: - AI Response with Tool Calls
|
// MARK: - AI Response with Tool Calls
|
||||||
|
|
||||||
|
// MARK: - Images API Generation
|
||||||
|
|
||||||
|
private func generateImageAPIResponse(orProvider: OpenRouterProvider, modelId: String, prompt: String) {
|
||||||
|
isGenerating = true
|
||||||
|
streamingTask?.cancel()
|
||||||
|
streamingTask = Task {
|
||||||
|
let startTime = Date()
|
||||||
|
let assistantMessage = Message(
|
||||||
|
role: .assistant,
|
||||||
|
content: ThinkingVerbs.random(),
|
||||||
|
tokens: nil,
|
||||||
|
cost: nil,
|
||||||
|
timestamp: Date(),
|
||||||
|
attachments: nil,
|
||||||
|
modelId: modelId,
|
||||||
|
isStreaming: true
|
||||||
|
)
|
||||||
|
let messageId = assistantMessage.id
|
||||||
|
messages.append(assistantMessage)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await orProvider.generateImage(model: modelId, prompt: prompt)
|
||||||
|
let responseTime = Date().timeIntervalSince(startTime)
|
||||||
|
|
||||||
|
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
||||||
|
messages[index].content = response.content
|
||||||
|
messages[index].isStreaming = false
|
||||||
|
messages[index].generatedImages = response.generatedImages
|
||||||
|
messages[index].responseTime = responseTime
|
||||||
|
|
||||||
|
if let usage = response.usage {
|
||||||
|
messages[index].tokens = usage.completionTokens
|
||||||
|
let cost = usage.rawCostUSD
|
||||||
|
messages[index].cost = cost
|
||||||
|
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = detectGoodbyePhrase(in: "")
|
||||||
|
} catch {
|
||||||
|
if let index = messages.firstIndex(where: { $0.id == messageId }) {
|
||||||
|
messages[index].content = "❌ Image generation failed: \(error.localizedDescription)"
|
||||||
|
messages[index].isStreaming = false
|
||||||
|
}
|
||||||
|
Log.api.error("Images API error: \(error)")
|
||||||
|
}
|
||||||
|
isGenerating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func generateAIResponseWithTools(provider: AIProvider, modelId: String) {
|
private func generateAIResponseWithTools(provider: AIProvider, modelId: String) {
|
||||||
let mcp = MCPService.shared
|
let mcp = MCPService.shared
|
||||||
Log.ui.info("generateAIResponseWithTools: model=\(modelId)")
|
Log.ui.info("generateAIResponseWithTools: model=\(modelId)")
|
||||||
@@ -1304,12 +1358,18 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
systemParts.append("You have access to the user's Anytype knowledge base through tool calls (anytype_* tools). You can search across all spaces, list spaces, get objects, and create or update notes, tasks, and pages. Use these tools proactively when the user asks about their notes, tasks, or knowledge base.")
|
systemParts.append("You have access to the user's Anytype knowledge base through tool calls (anytype_* tools). You can search across all spaces, list spaces, get objects, and create or update notes, tasks, and pages. Use these tools proactively when the user asks about their notes, tasks, or knowledge base.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let activeExternalServers = settings.externalMCPServers.filter { $0.isEnabled }
|
||||||
|
if !activeExternalServers.isEmpty {
|
||||||
|
let names = activeExternalServers.map { $0.name }.joined(separator: ", ")
|
||||||
|
systemParts.append("You have access to external tools from MCP servers: \(names). Their tools are prefixed with the server slug (e.g. safari_navigate_to_url). Use them proactively when the user's request relates to what those servers provide.")
|
||||||
|
}
|
||||||
|
|
||||||
var systemContent = systemParts.joined(separator: "\n\n")
|
var systemContent = systemParts.joined(separator: "\n\n")
|
||||||
|
|
||||||
// Append the complete system prompt (default + custom)
|
// Append the complete system prompt (default + custom)
|
||||||
systemContent += "\n\n---\n\n" + effectiveSystemPrompt
|
systemContent += "\n\n---\n\n" + effectiveSystemPrompt
|
||||||
|
|
||||||
var messagesToSend: [Message] = memoryEnabled
|
let messagesToSend: [Message] = memoryEnabled
|
||||||
? messages.filter { $0.role != .system }
|
? messages.filter { $0.role != .system }
|
||||||
: [messages.last(where: { $0.role == .user })].compactMap { $0 }
|
: [messages.last(where: { $0.role == .user })].compactMap { $0 }
|
||||||
|
|
||||||
@@ -1356,6 +1416,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
var didContinueAfterImages = false // Only inject temp-file continuation once
|
var didContinueAfterImages = false // Only inject temp-file continuation once
|
||||||
var totalUsage: ChatResponse.Usage?
|
var totalUsage: ChatResponse.Usage?
|
||||||
var hitIterationLimit = false // Track if we exited due to hitting the limit
|
var hitIterationLimit = false // Track if we exited due to hitting the limit
|
||||||
|
var finishedWithEmptyContent = false // Model stopped calling tools but said nothing
|
||||||
|
|
||||||
for iteration in 0..<maxIterations {
|
for iteration in 0..<maxIterations {
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
@@ -1408,6 +1469,12 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if finalContent.isEmpty {
|
||||||
|
// Some models (observed with Qwen via OpenRouter) stop calling tools
|
||||||
|
// after a long tool-call chain but return no summarizing text at all.
|
||||||
|
// Silently nudge a follow-up turn instead of showing a placeholder bubble.
|
||||||
|
finishedWithEmptyContent = true
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1454,7 +1521,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = await mcp.executeTool(name: tc.functionName, arguments: tc.arguments)
|
let result = await mcp.executeTool(name: tc.functionName, arguments: tc.arguments, agentProvider: provider, agentModelId: effectiveModelId)
|
||||||
let resultJSON: String
|
let resultJSON: String
|
||||||
if let data = try? JSONSerialization.data(withJSONObject: result),
|
if let data = try? JSONSerialization.data(withJSONObject: result),
|
||||||
let str = String(data: data, encoding: .utf8) {
|
let str = String(data: data, encoding: .utf8) {
|
||||||
@@ -1495,9 +1562,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
// If this was the last iteration, note it
|
// If this was the last iteration, note it
|
||||||
if iteration == maxIterations - 1 {
|
if iteration == maxIterations - 1 {
|
||||||
hitIterationLimit = true // We're exiting with pending tool calls
|
hitIterationLimit = true // We're exiting with pending tool calls
|
||||||
finalContent = response.content.isEmpty
|
finalContent = response.content
|
||||||
? "[Tool loop reached maximum iterations]"
|
|
||||||
: response.content
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1506,44 +1571,57 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
wasCancelled = true
|
wasCancelled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we hit the iteration limit or the model returned no text at all, silently
|
||||||
|
// nudge a follow-up turn instead of showing a placeholder/blank bubble.
|
||||||
|
let willAutoContinue = (hitIterationLimit || finishedWithEmptyContent) && !wasCancelled
|
||||||
|
|
||||||
// Display the final response as an assistant message
|
// Display the final response as an assistant message
|
||||||
let responseTime = Date().timeIntervalSince(startTime)
|
let responseTime = Date().timeIntervalSince(startTime)
|
||||||
let assistantMessage = Message(
|
if willAutoContinue && finalContent.isEmpty {
|
||||||
role: .assistant,
|
// Nothing worth showing yet — still record usage/cost for this turn.
|
||||||
content: finalContent,
|
if let usage = totalUsage, let model = selectedModel {
|
||||||
tokens: totalUsage?.completionTokens,
|
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
|
||||||
cost: nil,
|
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||||
timestamp: Date(),
|
sessionStats.addMessage(
|
||||||
attachments: nil,
|
inputTokens: usage.promptTokens,
|
||||||
responseTime: responseTime,
|
outputTokens: usage.completionTokens,
|
||||||
wasInterrupted: wasCancelled,
|
cost: cost
|
||||||
modelId: modelId,
|
)
|
||||||
generatedImages: finalImages.isEmpty ? nil : finalImages
|
|
||||||
)
|
|
||||||
messages.append(assistantMessage)
|
|
||||||
|
|
||||||
// Calculate cost
|
|
||||||
if let usage = totalUsage, let model = selectedModel {
|
|
||||||
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
|
|
||||||
let cost: Double? = hasPricing
|
|
||||||
? (Double(usage.promptTokens) * model.pricing.prompt / 1_000_000) +
|
|
||||||
(Double(usage.completionTokens) * model.pricing.completion / 1_000_000)
|
|
||||||
: nil
|
|
||||||
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
|
|
||||||
messages[index].cost = cost
|
|
||||||
}
|
}
|
||||||
sessionStats.addMessage(
|
} else {
|
||||||
inputTokens: usage.promptTokens,
|
let assistantMessage = Message(
|
||||||
outputTokens: usage.completionTokens,
|
role: .assistant,
|
||||||
cost: cost
|
content: finalContent,
|
||||||
|
tokens: totalUsage?.completionTokens,
|
||||||
|
cost: nil,
|
||||||
|
timestamp: Date(),
|
||||||
|
attachments: nil,
|
||||||
|
responseTime: responseTime,
|
||||||
|
wasInterrupted: wasCancelled,
|
||||||
|
modelId: modelId,
|
||||||
|
generatedImages: finalImages.isEmpty ? nil : finalImages
|
||||||
)
|
)
|
||||||
|
messages.append(assistantMessage)
|
||||||
|
|
||||||
|
// Calculate cost
|
||||||
|
if let usage = totalUsage, let model = selectedModel {
|
||||||
|
let hasPricing = model.pricing.prompt > 0 || model.pricing.completion > 0
|
||||||
|
let cost: Double? = hasPricing ? calculateCost(usage: usage, pricing: model.pricing) : nil
|
||||||
|
if let index = messages.lastIndex(where: { $0.id == assistantMessage.id }) {
|
||||||
|
messages[index].cost = cost
|
||||||
|
}
|
||||||
|
sessionStats.addMessage(
|
||||||
|
inputTokens: usage.promptTokens,
|
||||||
|
outputTokens: usage.completionTokens,
|
||||||
|
cost: cost
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isGenerating = false
|
isGenerating = false
|
||||||
streamingTask = nil
|
streamingTask = nil
|
||||||
|
|
||||||
// If we hit the iteration limit and weren't cancelled, start auto-continue
|
if willAutoContinue {
|
||||||
if hitIterationLimit && !wasCancelled {
|
|
||||||
startAutoContinue()
|
startAutoContinue()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1655,7 +1733,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
if isOverloaded && attempt < maxAttempts && !Task.isCancelled {
|
if isOverloaded && attempt < maxAttempts && !Task.isCancelled {
|
||||||
let delay = Double(1 << attempt) // 2s, 4s, 8s
|
let delay = Double(1 << attempt) // 2s, 4s, 8s
|
||||||
Log.api.warning("API overloaded, retrying in \(Int(delay))s (attempt \(attempt)/\(maxAttempts - 1))...")
|
Log.api.warning("API overloaded, retrying in \(Int(delay))s (attempt \(attempt)/\(maxAttempts - 1))...")
|
||||||
await MainActor.run {
|
_ = await MainActor.run {
|
||||||
showSystemMessage("⏳ API overloaded, retrying in \(Int(delay))s… (attempt \(attempt)/\(maxAttempts - 1))")
|
showSystemMessage("⏳ API overloaded, retrying in \(Int(delay))s… (attempt \(attempt)/\(maxAttempts - 1))")
|
||||||
}
|
}
|
||||||
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||||
@@ -2176,6 +2254,18 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cost for one response's usage, accounting for Anthropic-style prompt-cache
|
||||||
|
/// pricing when present: cache writes cost 1.25x the base input rate, cache
|
||||||
|
/// reads cost 0.1x. `usage.promptTokens` is already the uncached remainder —
|
||||||
|
/// it does not need cache tokens subtracted from it.
|
||||||
|
private func calculateCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double {
|
||||||
|
let inputCost = Double(usage.promptTokens) * pricing.prompt / 1_000_000
|
||||||
|
let cacheReadCost = Double(usage.cacheReadInputTokens ?? 0) * pricing.prompt * 0.1 / 1_000_000
|
||||||
|
let cacheWriteCost = Double(usage.cacheCreationInputTokens ?? 0) * pricing.prompt * 1.25 / 1_000_000
|
||||||
|
let outputCost = Double(usage.completionTokens) * pricing.completion / 1_000_000
|
||||||
|
return inputCost + cacheReadCost + cacheWriteCost + outputCost
|
||||||
|
}
|
||||||
|
|
||||||
/// Summarize a chunk of messages into a concise summary
|
/// Summarize a chunk of messages into a concise summary
|
||||||
private func summarizeMessageChunk(_ messages: [Message]) async -> String? {
|
private func summarizeMessageChunk(_ messages: [Message]) async -> String? {
|
||||||
guard let provider = providerRegistry.getProvider(for: currentProvider),
|
guard let provider = providerRegistry.getProvider(for: currentProvider),
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Main chat interface
|
// Main chat interface
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -37,12 +35,11 @@ struct ChatView: View {
|
|||||||
HeaderView(
|
HeaderView(
|
||||||
provider: viewModel.currentProvider,
|
provider: viewModel.currentProvider,
|
||||||
model: viewModel.selectedModel,
|
model: viewModel.selectedModel,
|
||||||
stats: viewModel.sessionStats,
|
|
||||||
onlineMode: viewModel.onlineMode,
|
|
||||||
mcpEnabled: viewModel.mcpEnabled,
|
|
||||||
mcpStatus: viewModel.mcpStatus,
|
|
||||||
onModelSelect: onModelSelect,
|
onModelSelect: onModelSelect,
|
||||||
onProviderChange: onProviderChange
|
onProviderChange: onProviderChange,
|
||||||
|
conversationName: viewModel.currentConversationName,
|
||||||
|
hasUnsavedChanges: viewModel.hasUnsavedChanges,
|
||||||
|
onQuickSave: viewModel.quickSave
|
||||||
)
|
)
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
@@ -85,10 +82,13 @@ struct ChatView: View {
|
|||||||
InputBar(
|
InputBar(
|
||||||
text: $viewModel.inputText,
|
text: $viewModel.inputText,
|
||||||
isGenerating: viewModel.isGenerating,
|
isGenerating: viewModel.isGenerating,
|
||||||
mcpStatus: viewModel.mcpStatus,
|
|
||||||
onlineMode: viewModel.onlineMode,
|
onlineMode: viewModel.onlineMode,
|
||||||
onSend: viewModel.sendMessage,
|
onSend: viewModel.sendMessage,
|
||||||
onCancel: viewModel.cancelGeneration
|
onCancel: viewModel.cancelGeneration,
|
||||||
|
onToggleOnline: {
|
||||||
|
viewModel.onlineMode.toggle()
|
||||||
|
SettingsService.shared.onlineMode = viewModel.onlineMode
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
@@ -96,7 +96,9 @@ struct ChatView: View {
|
|||||||
stats: viewModel.sessionStats,
|
stats: viewModel.sessionStats,
|
||||||
conversationName: viewModel.currentConversationName,
|
conversationName: viewModel.currentConversationName,
|
||||||
hasUnsavedChanges: viewModel.hasUnsavedChanges,
|
hasUnsavedChanges: viewModel.hasUnsavedChanges,
|
||||||
onQuickSave: viewModel.quickSave
|
onQuickSave: viewModel.quickSave,
|
||||||
|
onlineMode: viewModel.onlineMode,
|
||||||
|
mcpEnabled: viewModel.mcpEnabled
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.background(Color.oaiBackground)
|
.background(Color.oaiBackground)
|
||||||
@@ -106,6 +108,9 @@ struct ChatView: View {
|
|||||||
.sheet(isPresented: $viewModel.showSkills) {
|
.sheet(isPresented: $viewModel.showSkills) {
|
||||||
AgentSkillsView()
|
AgentSkillsView()
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $viewModel.showJarvis) {
|
||||||
|
JarvisView()
|
||||||
|
}
|
||||||
.sheet(item: Binding(
|
.sheet(item: Binding(
|
||||||
get: { MCPService.shared.pendingBashCommand },
|
get: { MCPService.shared.pendingBashCommand },
|
||||||
set: { _ in }
|
set: { _ in }
|
||||||
@@ -116,6 +121,16 @@ struct ChatView: View {
|
|||||||
onDeny: { MCPService.shared.denyPendingBashCommand() }
|
onDeny: { MCPService.shared.denyPendingBashCommand() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.sheet(item: Binding(
|
||||||
|
get: { MCPService.shared.pendingPersonalDataAction },
|
||||||
|
set: { _ in }
|
||||||
|
)) { pending in
|
||||||
|
PersonalDataApprovalSheet(
|
||||||
|
pending: pending,
|
||||||
|
onApprove: { forSession in MCPService.shared.approvePendingPersonalDataAction(forSession: forSession) },
|
||||||
|
onDeny: { MCPService.shared.denyPendingPersonalDataAction() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,52 +2,54 @@
|
|||||||
// ContentView.swift
|
// ContentView.swift
|
||||||
// oAI
|
// oAI
|
||||||
//
|
//
|
||||||
// Root navigation container
|
// Root navigation container — NavigationSplitView with collapsible sidebar
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if os(macOS)
|
||||||
|
import Darwin // uname, sysctlbyname
|
||||||
|
#endif
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(ChatViewModel.self) var chatViewModel
|
@Environment(ChatViewModel.self) var chatViewModel
|
||||||
|
private var updateService = UpdateCheckService.shared
|
||||||
|
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||||
|
@State private var showIntelWarning = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@Bindable var vm = chatViewModel
|
@Bindable var vm = chatViewModel
|
||||||
NavigationStack {
|
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||||
|
SidebarView()
|
||||||
|
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 340)
|
||||||
|
} detail: {
|
||||||
ChatView(
|
ChatView(
|
||||||
onModelSelect: { chatViewModel.showModelSelector = true },
|
onModelSelect: { chatViewModel.showModelSelector = true },
|
||||||
onProviderChange: { newProvider in
|
onProviderChange: { newProvider in
|
||||||
chatViewModel.changeProvider(newProvider)
|
chatViewModel.changeProvider(newProvider)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.navigationTitle("")
|
|
||||||
.toolbar {
|
|
||||||
#if os(macOS)
|
|
||||||
macOSToolbar
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(minWidth: 860, minHeight: 560)
|
.frame(minWidth: 860, minHeight: 560)
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
|
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
|
||||||
|
checkIntelWarning()
|
||||||
}
|
}
|
||||||
.onKeyPress(.return, phases: .down) { press in
|
.onKeyPress(.return, phases: .down) { press in
|
||||||
if press.modifiers.contains(.command) {
|
if press.modifiers.contains(.command) {
|
||||||
@@ -65,7 +67,6 @@ struct ContentView: View {
|
|||||||
let oldModel = chatViewModel.selectedModel
|
let oldModel = chatViewModel.selectedModel
|
||||||
chatViewModel.selectModel(model)
|
chatViewModel.selectModel(model)
|
||||||
chatViewModel.showModelSelector = false
|
chatViewModel.showModelSelector = false
|
||||||
// Trigger auto-save on model switch
|
|
||||||
Task {
|
Task {
|
||||||
await chatViewModel.onModelSwitch(from: oldModel, to: model)
|
await chatViewModel.onModelSwitch(from: oldModel, to: model)
|
||||||
}
|
}
|
||||||
@@ -113,125 +114,56 @@ struct ContentView: View {
|
|||||||
chatViewModel.inputText = input
|
chatViewModel.inputText = input
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
.alert("Intel Mac Support Ending", isPresented: $showIntelWarning) {
|
||||||
|
Button("Got It") {
|
||||||
|
UserDefaults.standard.set(true, forKey: "hasShownIntelWarning")
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("oAI v2.4 is the last version to support Intel Macs and Rosetta. Starting with macOS 28, oAI will require Apple Silicon. Consider upgrading your Mac to continue receiving updates.")
|
||||||
|
}
|
||||||
|
.alert("Software Update", isPresented: Binding(
|
||||||
|
get: { updateService.manualCheckMessage != nil },
|
||||||
|
set: { if !$0 { updateService.manualCheckMessage = nil } }
|
||||||
|
)) {
|
||||||
|
if updateService.updateAvailable {
|
||||||
|
if let url = updateService.downloadURL {
|
||||||
|
Button("Download v\(updateService.latestVersion ?? "")") {
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Release Page") { updateService.openReleasesPage() }
|
||||||
|
Button("Later", role: .cancel) { }
|
||||||
|
} else {
|
||||||
|
Button("OK", role: .cancel) { }
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(updateService.manualCheckMessage ?? "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@ToolbarContentBuilder
|
private func checkIntelWarning() {
|
||||||
private var macOSToolbar: some ToolbarContent {
|
guard !UserDefaults.standard.bool(forKey: "hasShownIntelWarning") else { return }
|
||||||
let settings = SettingsService.shared
|
guard isIntelNative || isRosetta else { return }
|
||||||
let showLabels = settings.showToolbarLabels
|
showIntelWarning = true
|
||||||
let iconSize = settings.toolbarIconSize
|
}
|
||||||
|
|
||||||
ToolbarItemGroup(placement: .automatic) {
|
private var isIntelNative: Bool {
|
||||||
// New conversation
|
var systemInfo = utsname()
|
||||||
Button(action: { chatViewModel.newConversation() }) {
|
uname(&systemInfo)
|
||||||
ToolbarLabel(title: "New Chat", systemImage: "square.and.pencil", showLabels: showLabels, iconSize: iconSize)
|
let machine = withUnsafeBytes(of: &systemInfo.machine) {
|
||||||
}
|
String(cString: $0.bindMemory(to: CChar.self).baseAddress!)
|
||||||
.keyboardShortcut("n", modifiers: .command)
|
|
||||||
.help("New conversation")
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showConversations = true }) {
|
|
||||||
ToolbarLabel(title: "Conversations", systemImage: "clock.arrow.circlepath", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.keyboardShortcut("l", modifiers: .command)
|
|
||||||
.help("Saved conversations (Cmd+L)")
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showHistory = true }) {
|
|
||||||
ToolbarLabel(title: "History", systemImage: "list.bullet", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.keyboardShortcut("h", modifiers: .command)
|
|
||||||
.help("Command history (Cmd+H)")
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showModelSelector = true }) {
|
|
||||||
ToolbarLabel(title: "Model", systemImage: "cpu", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.keyboardShortcut("m", modifiers: .command)
|
|
||||||
.help("Select AI model (Cmd+M)")
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
if let model = chatViewModel.selectedModel {
|
|
||||||
chatViewModel.modelInfoTarget = model
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
ToolbarLabel(title: "Info", systemImage: "info.circle", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.keyboardShortcut("i", modifiers: .command)
|
|
||||||
.help("Model info (Cmd+I)")
|
|
||||||
.disabled(chatViewModel.selectedModel == nil)
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showStats = true }) {
|
|
||||||
ToolbarLabel(title: "Stats", systemImage: "chart.bar", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.help("Session statistics")
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showCredits = true }) {
|
|
||||||
ToolbarLabel(title: "Credits", systemImage: "creditcard", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.help("Check API credits")
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showSettings = true }) {
|
|
||||||
ToolbarLabel(title: "Settings", systemImage: "gearshape", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.keyboardShortcut(",", modifiers: .command)
|
|
||||||
.help("Settings (Cmd+,)")
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showHelp = true }) {
|
|
||||||
ToolbarLabel(title: "Help", systemImage: "questionmark.circle", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.keyboardShortcut("/", modifiers: .command)
|
|
||||||
.help("Help & commands (Cmd+/)")
|
|
||||||
}
|
}
|
||||||
|
return machine.contains("x86_64")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isRosetta: Bool {
|
||||||
|
var ret: Int32 = 0
|
||||||
|
var size = MemoryLayout<Int32>.size
|
||||||
|
sysctlbyname("sysctl.proc_translated", &ret, &size, nil, 0)
|
||||||
|
return ret == 1
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper view for toolbar labels
|
|
||||||
struct ToolbarLabel: View {
|
|
||||||
let title: LocalizedStringKey
|
|
||||||
let systemImage: String
|
|
||||||
let showLabels: Bool
|
|
||||||
let iconSize: Double
|
|
||||||
|
|
||||||
// imageScale for the original range (≤32); explicit font size for the new extra-large range (>32)
|
|
||||||
private var scale: Image.Scale {
|
|
||||||
switch iconSize {
|
|
||||||
case ...18: return .small
|
|
||||||
case 19...24: return .medium
|
|
||||||
default: return .large
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if iconSize > 32 {
|
|
||||||
// Extra-large: explicit font size above the system .large ceiling
|
|
||||||
// Offset by 16 so slider 34→18pt, 36→20pt, 38→22pt, 40→24pt
|
|
||||||
if showLabels {
|
|
||||||
Label(title, systemImage: systemImage)
|
|
||||||
.labelStyle(.titleAndIcon)
|
|
||||||
.font(.system(size: iconSize - 16))
|
|
||||||
} else {
|
|
||||||
Label(title, systemImage: systemImage)
|
|
||||||
.labelStyle(.iconOnly)
|
|
||||||
.font(.system(size: iconSize - 16))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Original behaviour — imageScale keeps existing look intact
|
|
||||||
if showLabels {
|
|
||||||
Label(title, systemImage: systemImage)
|
|
||||||
.labelStyle(.titleAndIcon)
|
|
||||||
.imageScale(scale)
|
|
||||||
} else {
|
|
||||||
Label(title, systemImage: systemImage)
|
|
||||||
.labelStyle(.iconOnly)
|
|
||||||
.imageScale(scale)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Footer bar with session summary
|
// Footer bar with session summary
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -30,15 +28,22 @@ struct FooterView: View {
|
|||||||
let conversationName: String?
|
let conversationName: String?
|
||||||
let hasUnsavedChanges: Bool
|
let hasUnsavedChanges: Bool
|
||||||
let onQuickSave: (() -> Void)?
|
let onQuickSave: (() -> Void)?
|
||||||
|
let onlineMode: Bool
|
||||||
|
let mcpEnabled: Bool
|
||||||
|
private let settings = SettingsService.shared
|
||||||
|
|
||||||
init(stats: SessionStats,
|
init(stats: SessionStats,
|
||||||
conversationName: String? = nil,
|
conversationName: String? = nil,
|
||||||
hasUnsavedChanges: Bool = false,
|
hasUnsavedChanges: Bool = false,
|
||||||
onQuickSave: (() -> Void)? = nil) {
|
onQuickSave: (() -> Void)? = nil,
|
||||||
|
onlineMode: Bool = false,
|
||||||
|
mcpEnabled: Bool = false) {
|
||||||
self.stats = stats
|
self.stats = stats
|
||||||
self.conversationName = conversationName
|
self.conversationName = conversationName
|
||||||
self.hasUnsavedChanges = hasUnsavedChanges
|
self.hasUnsavedChanges = hasUnsavedChanges
|
||||||
self.onQuickSave = onQuickSave
|
self.onQuickSave = onQuickSave
|
||||||
|
self.onlineMode = onlineMode
|
||||||
|
self.mcpEnabled = mcpEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -71,26 +76,26 @@ struct FooterView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Save indicator (only when chat has messages)
|
// Status pills — Online, MCP, Sync
|
||||||
if stats.messageCount > 0 {
|
#if os(macOS)
|
||||||
SaveIndicator(
|
HStack(spacing: 6) {
|
||||||
conversationName: conversationName,
|
if onlineMode {
|
||||||
hasUnsavedChanges: hasUnsavedChanges,
|
StatusPill(icon: "globe", label: "Online", color: .green)
|
||||||
onSave: onQuickSave
|
}
|
||||||
)
|
if mcpEnabled {
|
||||||
|
StatusPill(icon: "folder", label: "MCP", color: .blue)
|
||||||
|
}
|
||||||
|
if settings.syncEnabled && settings.syncAutoSave {
|
||||||
|
SyncStatusPill()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// Update available badge
|
// Update available badge (shows only when an update exists — no version number)
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
UpdateBadge()
|
UpdateBadge()
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Shortcuts hint
|
|
||||||
#if os(macOS)
|
|
||||||
Text("⌘N New • ⌘M Model • ⌘S Save")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
@@ -242,7 +247,6 @@ struct SyncStatusFooter: View {
|
|||||||
|
|
||||||
struct UpdateBadge: View {
|
struct UpdateBadge: View {
|
||||||
private let updater = UpdateCheckService.shared
|
private let updater = UpdateCheckService.shared
|
||||||
private let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if updater.updateAvailable {
|
if updater.updateAvailable {
|
||||||
@@ -258,10 +262,6 @@ struct UpdateBadge: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help("A new version is available — click to open the releases page")
|
.help("A new version is available — click to open the releases page")
|
||||||
} else {
|
|
||||||
Text("v\(currentVersion)")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-169
@@ -2,25 +2,24 @@
|
|||||||
// HeaderView.swift
|
// HeaderView.swift
|
||||||
// oAI
|
// oAI
|
||||||
//
|
//
|
||||||
// Header bar with provider, model, and stats
|
// Slim header — provider, model name, star only.
|
||||||
|
// Status pills and stats live in SidebarView and FooterView respectively.
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -28,150 +27,29 @@ import SwiftUI
|
|||||||
struct HeaderView: View {
|
struct HeaderView: View {
|
||||||
let provider: Settings.Provider
|
let provider: Settings.Provider
|
||||||
let model: ModelInfo?
|
let model: ModelInfo?
|
||||||
let stats: SessionStats
|
|
||||||
let onlineMode: Bool
|
|
||||||
let mcpEnabled: Bool
|
|
||||||
let mcpStatus: String?
|
|
||||||
let onModelSelect: () -> Void
|
let onModelSelect: () -> Void
|
||||||
let onProviderChange: (Settings.Provider) -> Void
|
let onProviderChange: (Settings.Provider) -> Void
|
||||||
|
var conversationName: String? = nil
|
||||||
|
var hasUnsavedChanges: Bool = false
|
||||||
|
var onQuickSave: (() -> Void)? = nil
|
||||||
private let settings = SettingsService.shared
|
private let settings = SettingsService.shared
|
||||||
private let registry = ProviderRegistry.shared
|
private let registry = ProviderRegistry.shared
|
||||||
private let gitSync = GitSyncService.shared
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 20) {
|
ZStack {
|
||||||
// Provider picker dropdown — only shows configured providers
|
// Left: provider + model + star
|
||||||
Menu {
|
HStack(spacing: 12) {
|
||||||
ForEach(registry.configuredProviders, id: \.self) { p in
|
providerMenu
|
||||||
Button {
|
modelButton
|
||||||
onProviderChange(p)
|
starButton
|
||||||
} label: {
|
Spacer()
|
||||||
HStack {
|
|
||||||
Image(systemName: p.iconName)
|
|
||||||
Text(p.displayName)
|
|
||||||
if p == provider {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: provider.iconName)
|
|
||||||
.font(.system(size: settings.guiTextSize - 2))
|
|
||||||
Text(provider.displayName)
|
|
||||||
.font(.system(size: settings.guiTextSize - 2, weight: .medium))
|
|
||||||
Image(systemName: "chevron.up.chevron.down")
|
|
||||||
.font(.system(size: 8))
|
|
||||||
.opacity(0.7)
|
|
||||||
}
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(Color.providerColor(provider))
|
|
||||||
.cornerRadius(4)
|
|
||||||
}
|
|
||||||
.menuStyle(.borderlessButton)
|
|
||||||
.fixedSize()
|
|
||||||
.help("Switch provider")
|
|
||||||
|
|
||||||
// Model info (clickable → model selector)
|
|
||||||
Button(action: onModelSelect) {
|
|
||||||
if let model = model {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Text(model.name)
|
|
||||||
.font(.system(size: settings.guiTextSize, weight: .medium))
|
|
||||||
.foregroundColor(.oaiPrimary)
|
|
||||||
|
|
||||||
// Capability badges
|
|
||||||
HStack(spacing: 3) {
|
|
||||||
if model.capabilities.vision {
|
|
||||||
Image(systemName: "eye")
|
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
|
||||||
if model.capabilities.tools {
|
|
||||||
Image(systemName: "wrench")
|
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
|
||||||
if model.capabilities.online {
|
|
||||||
Image(systemName: "globe")
|
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
|
||||||
if model.capabilities.imageGeneration {
|
|
||||||
Image(systemName: "paintbrush")
|
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Image(systemName: "chevron.down")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text("No model selected")
|
|
||||||
.font(.system(size: settings.guiTextSize))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
|
|
||||||
Image(systemName: "chevron.down")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.help("Select model")
|
|
||||||
|
|
||||||
if let model = model {
|
|
||||||
let isFav = settings.favoriteModelIds.contains(model.id)
|
|
||||||
Button(action: { settings.toggleFavoriteModel(model.id) }) {
|
|
||||||
Image(systemName: isFav ? "star.fill" : "star")
|
|
||||||
.font(.system(size: settings.guiTextSize - 3))
|
|
||||||
.foregroundColor(isFav ? .yellow : .oaiSecondary)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.help(isFav ? "Remove from favorites" : "Add to favorites")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
// Center: conversation title (macOS document-title style)
|
||||||
|
conversationTitle
|
||||||
// Status indicators
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
if model?.capabilities.imageGeneration == true {
|
|
||||||
StatusPill(icon: "paintbrush", label: "Image", color: .purple)
|
|
||||||
}
|
|
||||||
if onlineMode {
|
|
||||||
StatusPill(icon: "globe", label: "Online", color: .green)
|
|
||||||
}
|
|
||||||
if mcpEnabled {
|
|
||||||
StatusPill(icon: "folder", label: "MCP", color: .blue)
|
|
||||||
}
|
|
||||||
if settings.syncEnabled && settings.syncAutoSave {
|
|
||||||
SyncStatusPill()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Divider between status and stats
|
|
||||||
if onlineMode || mcpEnabled || model?.capabilities.imageGeneration == true || (settings.syncEnabled && settings.syncAutoSave) {
|
|
||||||
Divider()
|
|
||||||
.frame(height: 16)
|
|
||||||
.opacity(0.5)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quick stats
|
|
||||||
HStack(spacing: 16) {
|
|
||||||
StatItem(icon: "message", value: "\(stats.messageCount)")
|
|
||||||
StatItem(icon: "arrow.up.arrow.down", value: stats.totalTokensDisplay)
|
|
||||||
StatItem(icon: "dollarsign", value: stats.totalCostDisplay)
|
|
||||||
}
|
|
||||||
.font(.caption)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 8)
|
||||||
.background(.ultraThinMaterial)
|
.background(.ultraThinMaterial)
|
||||||
.overlay(
|
.overlay(
|
||||||
Rectangle()
|
Rectangle()
|
||||||
@@ -180,25 +58,123 @@ struct HeaderView: View {
|
|||||||
alignment: .bottom
|
alignment: .bottom
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
struct StatItem: View {
|
// MARK: - Conversation title (center)
|
||||||
let icon: String
|
|
||||||
let value: String
|
|
||||||
private let settings = SettingsService.shared
|
|
||||||
|
|
||||||
var body: some View {
|
@ViewBuilder
|
||||||
HStack(spacing: 4) {
|
private var conversationTitle: some View {
|
||||||
Image(systemName: icon)
|
if let name = conversationName {
|
||||||
.font(.system(size: settings.guiTextSize - 3))
|
Button(action: { if hasUnsavedChanges { onQuickSave?() } }) {
|
||||||
.foregroundColor(.oaiSecondary)
|
HStack(spacing: 5) {
|
||||||
Text(value)
|
if hasUnsavedChanges {
|
||||||
.font(.system(size: settings.guiTextSize - 1, weight: .medium))
|
Circle()
|
||||||
.foregroundColor(.oaiPrimary)
|
.fill(Color.orange)
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
}
|
||||||
|
Text(name)
|
||||||
|
.font(.system(size: settings.guiTextSize - 1, weight: .medium))
|
||||||
|
.foregroundColor(.oaiPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(maxWidth: 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!hasUnsavedChanges)
|
||||||
|
.help(hasUnsavedChanges ? "Unsaved changes — click to save" : "Saved")
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: hasUnsavedChanges)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subviews (extracted so ZStack stays readable)
|
||||||
|
|
||||||
|
private var providerMenu: some View {
|
||||||
|
Menu {
|
||||||
|
ForEach(registry.configuredProviders, id: \.self) { p in
|
||||||
|
Button {
|
||||||
|
onProviderChange(p)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: p.iconName)
|
||||||
|
Text(p.displayName)
|
||||||
|
if p == provider { Image(systemName: "checkmark") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: provider.iconName)
|
||||||
|
.font(.system(size: settings.guiTextSize - 2))
|
||||||
|
Text(provider.displayName)
|
||||||
|
.font(.system(size: settings.guiTextSize - 2, weight: .medium))
|
||||||
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.opacity(0.7)
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.providerColor(provider))
|
||||||
|
.cornerRadius(4)
|
||||||
|
}
|
||||||
|
.menuStyle(.borderlessButton)
|
||||||
|
.fixedSize()
|
||||||
|
.help("Switch provider")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var modelButton: some View {
|
||||||
|
Button(action: onModelSelect) {
|
||||||
|
if let model = model {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(model.name)
|
||||||
|
.font(.system(size: settings.guiTextSize, weight: .medium))
|
||||||
|
.foregroundColor(.oaiPrimary)
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
if model.capabilities.vision {
|
||||||
|
Image(systemName: "eye").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
||||||
|
}
|
||||||
|
if model.capabilities.tools {
|
||||||
|
Image(systemName: "wrench").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
||||||
|
}
|
||||||
|
if model.capabilities.online {
|
||||||
|
Image(systemName: "globe").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
||||||
|
}
|
||||||
|
if model.capabilities.imageGeneration {
|
||||||
|
Image(systemName: "paintbrush").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text("No model selected")
|
||||||
|
.font(.system(size: settings.guiTextSize))
|
||||||
|
.foregroundColor(.oaiSecondary)
|
||||||
|
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Select model")
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var starButton: some View {
|
||||||
|
if let model = model {
|
||||||
|
let isFav = settings.favoriteModelIds.contains(model.id)
|
||||||
|
Button(action: { settings.toggleFavoriteModel(model.id) }) {
|
||||||
|
Image(systemName: isFav ? "star.fill" : "star")
|
||||||
|
.font(.system(size: settings.guiTextSize - 3))
|
||||||
|
.foregroundColor(isFav ? .yellow : .oaiSecondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help(isFav ? "Remove from favorites" : "Add to favorites")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Status Pills (used by SidebarView)
|
||||||
|
|
||||||
struct StatusPill: View {
|
struct StatusPill: View {
|
||||||
let icon: String
|
let icon: String
|
||||||
let label: LocalizedStringKey
|
let label: LocalizedStringKey
|
||||||
@@ -284,15 +260,6 @@ struct SyncStatusPill: View {
|
|||||||
HeaderView(
|
HeaderView(
|
||||||
provider: .openrouter,
|
provider: .openrouter,
|
||||||
model: ModelInfo.mockModels.first,
|
model: ModelInfo.mockModels.first,
|
||||||
stats: SessionStats(
|
|
||||||
totalInputTokens: 125,
|
|
||||||
totalOutputTokens: 434,
|
|
||||||
totalCost: 0.00111,
|
|
||||||
messageCount: 4
|
|
||||||
),
|
|
||||||
onlineMode: true,
|
|
||||||
mcpEnabled: true,
|
|
||||||
mcpStatus: "MCP",
|
|
||||||
onModelSelect: {},
|
onModelSelect: {},
|
||||||
onProviderChange: { _ in }
|
onProviderChange: { _ in }
|
||||||
)
|
)
|
||||||
|
|||||||
+138
-144
@@ -2,46 +2,55 @@
|
|||||||
// InputBar.swift
|
// InputBar.swift
|
||||||
// oAI
|
// oAI
|
||||||
//
|
//
|
||||||
// Message input bar with status indicators
|
// Message input bar with resizable height and online toggle
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
struct InputBar: View {
|
struct InputBar: View {
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
let isGenerating: Bool
|
let isGenerating: Bool
|
||||||
let mcpStatus: String?
|
|
||||||
let onlineMode: Bool
|
let onlineMode: Bool
|
||||||
let onSend: () -> Void
|
let onSend: () -> Void
|
||||||
let onCancel: () -> Void
|
let onCancel: () -> Void
|
||||||
|
let onToggleOnline: () -> Void
|
||||||
|
|
||||||
private let settings = SettingsService.shared
|
private let settings = SettingsService.shared
|
||||||
|
|
||||||
|
// Resizable input height — persisted to settings
|
||||||
|
@State private var inputHeight: CGFloat = CGFloat(SettingsService.shared.inputBarHeight)
|
||||||
|
@State private var dragStartHeight: CGFloat = CGFloat(SettingsService.shared.inputBarHeight)
|
||||||
|
|
||||||
@State private var showCommandDropdown = false
|
@State private var showCommandDropdown = false
|
||||||
@State private var selectedSuggestionIndex: Int = 0
|
@State private var selectedSuggestionIndex: Int = 0
|
||||||
@FocusState private var isInputFocused: Bool
|
@State private var isInputFocused: Bool = false
|
||||||
|
|
||||||
|
private static let minInputHeight: CGFloat = 56
|
||||||
|
private static let maxInputHeight: CGFloat = 320
|
||||||
|
|
||||||
/// Commands that execute immediately without additional arguments
|
/// Commands that execute immediately without additional arguments
|
||||||
private static let immediateCommands: Set<String> = [
|
private static let immediateCommands: Set<String> = [
|
||||||
"/help", "/history", "/model", "/clear", "/retry", "/stats", "/config",
|
"/help", "/history", "/model", "/clear", "/retry", "/stats", "/config",
|
||||||
"/settings", "/credits", "/list", "/load", "/shortcuts", "/skills",
|
"/settings", "/credits", "/list", "/load", "/shortcuts", "/skills", "/jarvis",
|
||||||
"/memory on", "/memory off", "/online on", "/online off",
|
"/memory on", "/memory off", "/online on", "/online off",
|
||||||
"/mcp on", "/mcp off", "/mcp status", "/mcp list",
|
"/mcp on", "/mcp off", "/mcp status", "/mcp list",
|
||||||
"/mcp write on", "/mcp write off",
|
"/mcp write on", "/mcp write off",
|
||||||
@@ -56,121 +65,108 @@ struct InputBar: View {
|
|||||||
CommandSuggestionsView(
|
CommandSuggestionsView(
|
||||||
searchText: text,
|
searchText: text,
|
||||||
selectedIndex: selectedSuggestionIndex,
|
selectedIndex: selectedSuggestionIndex,
|
||||||
onSelect: { command in
|
onSelect: selectCommand
|
||||||
selectCommand(command)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
.frame(width: 400)
|
.frame(width: 400)
|
||||||
.frame(maxHeight: 200)
|
.frame(maxHeight: 200)
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.leading, 96) // Align with input box (status badges + spacing)
|
.padding(.leading, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input area
|
// Drag-to-resize handle
|
||||||
HStack(alignment: .bottom, spacing: 12) {
|
dragHandle
|
||||||
// Status indicators
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
if let mcp = mcpStatus {
|
|
||||||
StatusBadge(text: mcp, color: .blue)
|
|
||||||
}
|
|
||||||
if onlineMode {
|
|
||||||
StatusBadge(text: "🌐", color: .green)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 80, alignment: .leading)
|
|
||||||
|
|
||||||
// Text input
|
// Input row
|
||||||
|
HStack(alignment: .bottom, spacing: 12) {
|
||||||
|
// Text input with globe toggle in bottom-left corner
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
|
// Placeholder
|
||||||
if text.isEmpty {
|
if text.isEmpty {
|
||||||
Text("Type a message or / for commands...")
|
Text("Type a message or / for commands...")
|
||||||
.font(.system(size: settings.inputTextSize))
|
.font(.system(size: settings.inputTextSize))
|
||||||
.foregroundColor(.oaiSecondary)
|
.foregroundColor(.oaiSecondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 10)
|
.padding(.top, 10)
|
||||||
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
TextEditor(text: $text)
|
// Editor — fills the fixed-height box, bottom area reserved for globe
|
||||||
.font(.system(size: settings.inputTextSize))
|
NativeTextEditor(
|
||||||
.foregroundColor(.oaiPrimary)
|
text: $text,
|
||||||
.scrollContentBackground(.hidden)
|
font: .systemFont(ofSize: settings.inputTextSize),
|
||||||
.frame(minHeight: 44, maxHeight: 120)
|
textColor: NSColor(Color.oaiPrimary),
|
||||||
.padding(.horizontal, 8)
|
isFocused: isInputFocused,
|
||||||
.padding(.vertical, 6)
|
onReturn: {
|
||||||
.focused($isInputFocused)
|
|
||||||
.onChange(of: text) {
|
|
||||||
showCommandDropdown = text.hasPrefix("/")
|
|
||||||
selectedSuggestionIndex = 0
|
|
||||||
}
|
|
||||||
#if os(macOS)
|
|
||||||
.onKeyPress(.upArrow) {
|
|
||||||
// Navigate command dropdown
|
|
||||||
if showCommandDropdown && selectedSuggestionIndex > 0 {
|
|
||||||
selectedSuggestionIndex -= 1
|
|
||||||
return .handled
|
|
||||||
}
|
|
||||||
return .ignored
|
|
||||||
}
|
|
||||||
.onKeyPress(.downArrow) {
|
|
||||||
// Navigate command dropdown
|
|
||||||
if showCommandDropdown {
|
|
||||||
let count = CommandSuggestionsView.filteredCommands(for: text).count
|
|
||||||
if selectedSuggestionIndex < count - 1 {
|
|
||||||
selectedSuggestionIndex += 1
|
|
||||||
return .handled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return .ignored
|
|
||||||
}
|
|
||||||
.onKeyPress(.escape) {
|
|
||||||
// If command dropdown is showing, close it
|
|
||||||
if showCommandDropdown {
|
|
||||||
showCommandDropdown = false
|
|
||||||
return .handled
|
|
||||||
}
|
|
||||||
// If model is generating, cancel it
|
|
||||||
if isGenerating {
|
|
||||||
onCancel()
|
|
||||||
return .handled
|
|
||||||
}
|
|
||||||
return .ignored
|
|
||||||
}
|
|
||||||
.onKeyPress(.return, phases: .down) { press in
|
|
||||||
// Shift+Return: always insert newline (let system handle)
|
|
||||||
if press.modifiers.contains(.shift) {
|
|
||||||
return .ignored
|
|
||||||
}
|
|
||||||
|
|
||||||
// If command dropdown is showing, select the highlighted command
|
|
||||||
if showCommandDropdown {
|
if showCommandDropdown {
|
||||||
let suggestions = CommandSuggestionsView.filteredCommands(for: text)
|
let suggestions = CommandSuggestionsView.filteredCommands(for: text)
|
||||||
if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count {
|
if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count {
|
||||||
selectCommand(suggestions[selectedSuggestionIndex].command)
|
selectCommand(suggestions[selectedSuggestionIndex].command)
|
||||||
return .handled
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Return (plain or with Cmd): send message
|
if !text.isEmpty { onSend(); return true }
|
||||||
if !text.isEmpty {
|
return true
|
||||||
onSend()
|
},
|
||||||
return .handled
|
onEscape: {
|
||||||
|
if showCommandDropdown { showCommandDropdown = false; return true }
|
||||||
|
if isGenerating { onCancel(); return true }
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
onUpArrow: {
|
||||||
|
if showCommandDropdown && selectedSuggestionIndex > 0 {
|
||||||
|
selectedSuggestionIndex -= 1; return true
|
||||||
}
|
}
|
||||||
// Empty text: do nothing
|
return false
|
||||||
return .handled
|
},
|
||||||
|
onDownArrow: {
|
||||||
|
if showCommandDropdown {
|
||||||
|
let count = CommandSuggestionsView.filteredCommands(for: text).count
|
||||||
|
if selectedSuggestionIndex < count - 1 {
|
||||||
|
selectedSuggestionIndex += 1; return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
onFocusChange: { focused in isInputFocused = focused }
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.onChange(of: text) {
|
||||||
|
showCommandDropdown = text.hasPrefix("/")
|
||||||
|
selectedSuggestionIndex = 0
|
||||||
|
}
|
||||||
|
.padding(.bottom, 30)
|
||||||
|
|
||||||
|
// Online / offline toggle — bottom-left of the text box
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
HStack {
|
||||||
|
Button(action: onToggleOnline) {
|
||||||
|
Image(systemName: onlineMode ? "globe" : "network.slash")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(onlineMode ? Color.green : Color.secondary)
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help(onlineMode
|
||||||
|
? "Online mode on — click to go offline"
|
||||||
|
: "Offline — click to go online")
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
#endif
|
}
|
||||||
}
|
}
|
||||||
|
.frame(height: inputHeight)
|
||||||
.background(Color.oaiSurface)
|
.background(Color.oaiSurface)
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 8)
|
RoundedRectangle(cornerRadius: 8)
|
||||||
.stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1)
|
.stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Action buttons
|
// Send / stop + attach buttons
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// File attach button
|
|
||||||
Button(action: pickFile) {
|
Button(action: pickFile) {
|
||||||
Image(systemName: "paperclip")
|
Image(systemName: "paperclip")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
@@ -209,21 +205,47 @@ struct InputBar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Drag handle
|
||||||
|
|
||||||
|
private var dragHandle: some View {
|
||||||
|
Color.clear
|
||||||
|
.frame(height: 8)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.overlay {
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.secondary.opacity(0.25))
|
||||||
|
.frame(width: 36, height: 3)
|
||||||
|
}
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 1)
|
||||||
|
.onChanged { value in
|
||||||
|
let proposed = dragStartHeight - value.translation.height
|
||||||
|
inputHeight = max(Self.minInputHeight, min(Self.maxInputHeight, proposed))
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
dragStartHeight = inputHeight
|
||||||
|
settings.inputBarHeight = Double(inputHeight)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
#if os(macOS)
|
||||||
|
.onHover { hovering in
|
||||||
|
if hovering { NSCursor.resizeUpDown.push() } else { NSCursor.pop() }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func selectCommand(_ command: String) {
|
private func selectCommand(_ command: String) {
|
||||||
showCommandDropdown = false
|
showCommandDropdown = false
|
||||||
if Self.immediateCommands.contains(command) {
|
if Self.immediateCommands.contains(command) {
|
||||||
// Execute immediately
|
|
||||||
text = command
|
text = command
|
||||||
onSend()
|
onSend()
|
||||||
} else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) {
|
} else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) {
|
||||||
if shortcut.needsInput {
|
text = shortcut.needsInput ? command + " " : command
|
||||||
text = command + " "
|
if !shortcut.needsInput { onSend() }
|
||||||
} else {
|
|
||||||
text = command
|
|
||||||
onSend()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Put in input for user to complete
|
|
||||||
text = command + " "
|
text = command + " "
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,36 +257,14 @@ struct InputBar: View {
|
|||||||
panel.canChooseDirectories = false
|
panel.canChooseDirectories = false
|
||||||
panel.canChooseFiles = true
|
panel.canChooseFiles = true
|
||||||
panel.message = "Select files to attach"
|
panel.message = "Select files to attach"
|
||||||
|
|
||||||
guard panel.runModal() == .OK else { return }
|
guard panel.runModal() == .OK else { return }
|
||||||
|
let attachmentText = panel.urls.map { "@<\($0.path)>" }.joined(separator: " ")
|
||||||
let paths = panel.urls.map { $0.path }
|
text = text.isEmpty ? attachmentText + " " : text + " " + attachmentText
|
||||||
// Use @<path> format (angle brackets) to safely handle paths with spaces
|
|
||||||
let attachmentText = paths.map { "@<\($0)>" }.joined(separator: " ")
|
|
||||||
|
|
||||||
if text.isEmpty {
|
|
||||||
text = attachmentText + " "
|
|
||||||
} else {
|
|
||||||
text += " " + attachmentText
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StatusBadge: View {
|
// MARK: - Command suggestions
|
||||||
let text: String
|
|
||||||
let color: Color
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Text(text)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(color)
|
|
||||||
.padding(.horizontal, 6)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.background(color.opacity(0.15))
|
|
||||||
.cornerRadius(4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CommandSuggestionsView: View {
|
struct CommandSuggestionsView: View {
|
||||||
let searchText: String
|
let searchText: String
|
||||||
@@ -279,6 +279,7 @@ struct CommandSuggestionsView: View {
|
|||||||
("/retry", "Retry last message"),
|
("/retry", "Retry last message"),
|
||||||
("/shortcuts", "Manage your prompt shortcuts"),
|
("/shortcuts", "Manage your prompt shortcuts"),
|
||||||
("/skills", "Manage your agent skills"),
|
("/skills", "Manage your agent skills"),
|
||||||
|
("/jarvis", "Open Jarvis agent manager"),
|
||||||
("/memory on", "Enable conversation memory"),
|
("/memory on", "Enable conversation memory"),
|
||||||
("/memory off", "Disable conversation memory"),
|
("/memory off", "Disable conversation memory"),
|
||||||
("/online on", "Enable web search"),
|
("/online on", "Enable web search"),
|
||||||
@@ -303,10 +304,9 @@ struct CommandSuggestionsView: View {
|
|||||||
]
|
]
|
||||||
|
|
||||||
static func allCommands() -> [(command: String, description: LocalizedStringKey)] {
|
static func allCommands() -> [(command: String, description: LocalizedStringKey)] {
|
||||||
let shortcuts = SettingsService.shared.userShortcuts.map { s in
|
SettingsService.shared.userShortcuts.map { s in
|
||||||
(s.command, LocalizedStringKey("⚡ \(s.description)"))
|
(s.command, LocalizedStringKey("⚡ \(s.description)"))
|
||||||
}
|
} + builtInCommands
|
||||||
return builtInCommands + shortcuts
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func filteredCommands(for searchText: String) -> [(command: String, description: LocalizedStringKey)] {
|
static func filteredCommands(for searchText: String) -> [(command: String, description: LocalizedStringKey)] {
|
||||||
@@ -343,26 +343,20 @@ struct CommandSuggestionsView: View {
|
|||||||
.id(suggestion.command)
|
.id(suggestion.command)
|
||||||
|
|
||||||
if index < suggestions.count - 1 {
|
if index < suggestions.count - 1 {
|
||||||
Divider()
|
Divider().background(Color.oaiBorder)
|
||||||
.background(Color.oaiBorder)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: selectedIndex) {
|
.onChange(of: selectedIndex) {
|
||||||
if selectedIndex < suggestions.count {
|
if selectedIndex < suggestions.count {
|
||||||
withAnimation {
|
withAnimation { proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center) }
|
||||||
proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.oaiSurface)
|
.background(Color.oaiSurface)
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
.overlay(
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.oaiBorder, lineWidth: 1))
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.stroke(Color.oaiBorder, lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,10 +366,10 @@ struct CommandSuggestionsView: View {
|
|||||||
InputBar(
|
InputBar(
|
||||||
text: .constant(""),
|
text: .constant(""),
|
||||||
isGenerating: false,
|
isGenerating: false,
|
||||||
mcpStatus: "📁 Files",
|
|
||||||
onlineMode: true,
|
onlineMode: true,
|
||||||
onSend: {},
|
onSend: {},
|
||||||
onCancel: {}
|
onCancel: {},
|
||||||
|
onToggleOnline: {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.background(Color.oaiBackground)
|
.background(Color.oaiBackground)
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Renders markdown content with syntax-highlighted code blocks
|
// Renders markdown content with syntax-highlighted code blocks
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -61,8 +59,15 @@ struct MarkdownContentView: View {
|
|||||||
.markdownBlockStyle(\.paragraph) { configuration in
|
.markdownBlockStyle(\.paragraph) { configuration in
|
||||||
configuration.label
|
configuration.label
|
||||||
.markdownMargin(top: 0, bottom: 8)
|
.markdownMargin(top: 0, bottom: 8)
|
||||||
|
// MarkdownUI builds mixed-style paragraphs (bold/italic runs alongside
|
||||||
|
// plain text) as concatenated Text(+) segments, which on macOS report
|
||||||
|
// their ideal (unwrapped, single-line) size instead of wrapping to the
|
||||||
|
// width actually available — truncating with "…" mid-word. Forcing the
|
||||||
|
// height to be recomputed for the given (flexible) width fixes it.
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Parsing
|
// MARK: - Parsing
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Individual message display
|
// Individual message display
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -185,6 +183,7 @@ struct MessageRow: View {
|
|||||||
.foregroundColor(.oaiSecondary)
|
.foregroundColor(.oaiSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.background(Color.messageBackground(for: message.role))
|
.background(Color.messageBackground(for: message.role))
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
//
|
||||||
|
// NativeTextEditor.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// NSViewRepresentable text editor with correct Enter-key semantics:
|
||||||
|
// plain Enter → send, Shift+Enter or Cmd+Enter → newline.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
struct NativeTextEditor: NSViewRepresentable {
|
||||||
|
@Binding var text: String
|
||||||
|
var font: NSFont
|
||||||
|
var textColor: NSColor
|
||||||
|
var isFocused: Bool
|
||||||
|
|
||||||
|
/// Plain Enter (no modifiers). Return true if the event was consumed.
|
||||||
|
var onReturn: () -> Bool
|
||||||
|
/// Escape key. Return true if consumed.
|
||||||
|
var onEscape: () -> Bool
|
||||||
|
/// Up arrow. Return true if consumed.
|
||||||
|
var onUpArrow: () -> Bool
|
||||||
|
/// Down arrow. Return true if consumed.
|
||||||
|
var onDownArrow: () -> Bool
|
||||||
|
/// Called when the view gains or loses first-responder status.
|
||||||
|
var onFocusChange: (Bool) -> Void
|
||||||
|
|
||||||
|
// MARK: - NSViewRepresentable
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> NSScrollView {
|
||||||
|
let scrollView = NSScrollView()
|
||||||
|
scrollView.hasVerticalScroller = false
|
||||||
|
scrollView.hasHorizontalScroller = false
|
||||||
|
scrollView.drawsBackground = false
|
||||||
|
scrollView.borderType = .noBorder
|
||||||
|
|
||||||
|
let tv = context.coordinator.textView
|
||||||
|
tv.delegate = context.coordinator
|
||||||
|
tv.isEditable = true
|
||||||
|
tv.isRichText = false
|
||||||
|
tv.drawsBackground = false
|
||||||
|
tv.backgroundColor = .clear
|
||||||
|
tv.isAutomaticQuoteSubstitutionEnabled = false
|
||||||
|
tv.isAutomaticDashSubstitutionEnabled = false
|
||||||
|
tv.isAutomaticSpellingCorrectionEnabled = true
|
||||||
|
tv.isContinuousSpellCheckingEnabled = true
|
||||||
|
tv.allowsUndo = true
|
||||||
|
tv.isVerticallyResizable = true
|
||||||
|
tv.isHorizontallyResizable = false
|
||||||
|
tv.autoresizingMask = [.width]
|
||||||
|
tv.textContainer?.widthTracksTextView = true
|
||||||
|
tv.textContainerInset = NSSize(width: 8, height: 6)
|
||||||
|
|
||||||
|
scrollView.documentView = tv
|
||||||
|
return scrollView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||||
|
let tv = context.coordinator.textView
|
||||||
|
let coord = context.coordinator
|
||||||
|
|
||||||
|
// Update text only when it differs (avoids caret-jumping on every keystroke)
|
||||||
|
if tv.string != text {
|
||||||
|
let sel = tv.selectedRanges
|
||||||
|
tv.string = text
|
||||||
|
let len = (tv.string as NSString).length
|
||||||
|
tv.selectedRanges = sel.map { v in
|
||||||
|
let r = v.rangeValue
|
||||||
|
let loc = min(r.location, len)
|
||||||
|
let length = min(r.length, max(0, len - loc))
|
||||||
|
return NSValue(range: NSRange(location: loc, length: length))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tv.font != font { tv.font = font }
|
||||||
|
if tv.textColor != textColor { tv.textColor = textColor }
|
||||||
|
|
||||||
|
// Keep coordinator callbacks current with each SwiftUI render
|
||||||
|
coord.textBinding = $text
|
||||||
|
coord.onReturn = onReturn
|
||||||
|
coord.onEscape = onEscape
|
||||||
|
coord.onUpArrow = onUpArrow
|
||||||
|
coord.onDownArrow = onDownArrow
|
||||||
|
coord.onFocusChange = onFocusChange
|
||||||
|
|
||||||
|
if isFocused {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let window = tv.window, window.firstResponder !== tv else { return }
|
||||||
|
window.makeFirstResponder(tv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||||
|
|
||||||
|
// MARK: - Coordinator
|
||||||
|
|
||||||
|
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||||
|
let textView = KeyableNSTextView()
|
||||||
|
|
||||||
|
// Updated on every SwiftUI render via updateNSView
|
||||||
|
var textBinding: Binding<String>?
|
||||||
|
var onReturn: () -> Bool = { false }
|
||||||
|
var onEscape: () -> Bool = { false }
|
||||||
|
var onUpArrow: () -> Bool = { false }
|
||||||
|
var onDownArrow: () -> Bool = { false }
|
||||||
|
var onFocusChange: (Bool) -> Void = { _ in }
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
textView.coordinator = self
|
||||||
|
}
|
||||||
|
|
||||||
|
func textDidChange(_ notification: Notification) {
|
||||||
|
guard let tv = notification.object as? NSTextView else { return }
|
||||||
|
textBinding?.wrappedValue = tv.string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - KeyableNSTextView
|
||||||
|
|
||||||
|
/// NSTextView that routes Return / Escape / arrow keys to the SwiftUI
|
||||||
|
/// coordinator before the AppKit default handling runs.
|
||||||
|
final class KeyableNSTextView: NSTextView {
|
||||||
|
weak var coordinator: NativeTextEditor.Coordinator?
|
||||||
|
|
||||||
|
override func keyDown(with event: NSEvent) {
|
||||||
|
guard let coord = coordinator else { super.keyDown(with: event); return }
|
||||||
|
|
||||||
|
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||||
|
let shift = flags.contains(.shift)
|
||||||
|
let cmd = flags.contains(.command)
|
||||||
|
|
||||||
|
switch event.keyCode {
|
||||||
|
case 36: // Return
|
||||||
|
if shift || cmd {
|
||||||
|
// Shift+Enter or Cmd+Enter → literal newline
|
||||||
|
insertNewlineIgnoringFieldEditor(nil)
|
||||||
|
} else {
|
||||||
|
// Plain Enter → let SwiftUI decide (send or select dropdown item)
|
||||||
|
if !coord.onReturn() {
|
||||||
|
insertNewlineIgnoringFieldEditor(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 53: // Escape
|
||||||
|
if !coord.onEscape() { super.keyDown(with: event) }
|
||||||
|
case 126: // Up arrow
|
||||||
|
if !coord.onUpArrow() { super.keyDown(with: event) }
|
||||||
|
case 125: // Down arrow
|
||||||
|
if !coord.onDownArrow() { super.keyDown(with: event) }
|
||||||
|
default:
|
||||||
|
super.keyDown(with: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func becomeFirstResponder() -> Bool {
|
||||||
|
let ok = super.becomeFirstResponder()
|
||||||
|
if ok { coordinator?.onFocusChange(true) }
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
override func resignFirstResponder() -> Bool {
|
||||||
|
let ok = super.resignFirstResponder()
|
||||||
|
if ok { coordinator?.onFocusChange(false) }
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
//
|
||||||
|
// SidebarView.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// Collapsible sidebar: new chat, conversation list, status pills
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
//
|
||||||
|
// This file is part of oAI.
|
||||||
|
//
|
||||||
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
|
//
|
||||||
|
// See the LICENSE file or
|
||||||
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
|
// the full license text. For commercial licensing, contact Rune
|
||||||
|
// Olsen via <https://oai.pm>.
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct SidebarView: View {
|
||||||
|
@Environment(ChatViewModel.self) private var chatViewModel
|
||||||
|
@State private var conversations: [Conversation] = []
|
||||||
|
@State private var searchText = ""
|
||||||
|
|
||||||
|
private var filteredConversations: [Conversation] {
|
||||||
|
guard !searchText.isEmpty else { return conversations }
|
||||||
|
return conversations.filter { $0.name.lowercased().contains(searchText.lowercased()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// New Chat button
|
||||||
|
Button(action: { chatViewModel.newConversation() }) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "square.and.pencil")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
Text("New Chat")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.foregroundColor(.oaiPrimary)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
// Search field
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("Search conversations…", text: $searchText)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
if !searchText.isEmpty {
|
||||||
|
Button {
|
||||||
|
searchText = ""
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
Divider().frame(height: 12)
|
||||||
|
Button {
|
||||||
|
chatViewModel.showConversations = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "slider.horizontal.3")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Advanced search — semantic search, bulk delete, export")
|
||||||
|
}
|
||||||
|
.padding(7)
|
||||||
|
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 6))
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Conversation list
|
||||||
|
if filteredConversations.isEmpty {
|
||||||
|
Spacer()
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: searchText.isEmpty ? "tray" : "magnifyingglass")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
Text(searchText.isEmpty ? "No Saved Conversations" : "No Matches")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(filteredConversations) { conversation in
|
||||||
|
SidebarConversationRow(conversation: conversation)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
chatViewModel.loadConversation(conversation)
|
||||||
|
}
|
||||||
|
.listRowBackground(
|
||||||
|
chatViewModel.currentConversationName == conversation.name
|
||||||
|
? Color.oaiAccent.opacity(0.15)
|
||||||
|
: Color.clear
|
||||||
|
)
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
deleteConversation(conversation)
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
renameConversation(conversation)
|
||||||
|
} label: {
|
||||||
|
Label("Rename", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
.tint(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.sidebar)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
.onAppear { loadConversations() }
|
||||||
|
.onChange(of: chatViewModel.currentConversationName) { loadConversations() }
|
||||||
|
.onChange(of: chatViewModel.messages.count) { loadConversations() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadConversations() {
|
||||||
|
conversations = (try? DatabaseService.shared.listConversations()) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteConversation(_ conversation: Conversation) {
|
||||||
|
_ = try? DatabaseService.shared.deleteConversation(id: conversation.id)
|
||||||
|
withAnimation {
|
||||||
|
conversations.removeAll { $0.id == conversation.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func renameConversation(_ conversation: Conversation) {
|
||||||
|
#if os(macOS)
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = "Rename Conversation"
|
||||||
|
alert.addButton(withTitle: "Rename")
|
||||||
|
alert.addButton(withTitle: "Cancel")
|
||||||
|
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
|
||||||
|
input.stringValue = conversation.name
|
||||||
|
input.selectText(nil)
|
||||||
|
alert.accessoryView = input
|
||||||
|
alert.window.initialFirstResponder = input
|
||||||
|
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||||
|
let newName = input.stringValue.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !newName.isEmpty, newName != conversation.name else { return }
|
||||||
|
do {
|
||||||
|
_ = try DatabaseService.shared.updateConversation(id: conversation.id, name: newName, messages: nil)
|
||||||
|
if let i = conversations.firstIndex(where: { $0.id == conversation.id }) {
|
||||||
|
conversations[i].name = newName
|
||||||
|
conversations[i].updatedAt = Date()
|
||||||
|
}
|
||||||
|
chatViewModel.didRenameConversation(id: conversation.id, newName: newName)
|
||||||
|
} catch {
|
||||||
|
Log.db.error("Failed to rename conversation: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sidebar conversation row
|
||||||
|
|
||||||
|
struct SidebarConversationRow: View {
|
||||||
|
let conversation: Conversation
|
||||||
|
|
||||||
|
private var formattedDate: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "dd.MM.yyyy"
|
||||||
|
return formatter.string(from: conversation.updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(conversation.name)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text("^[\(conversation.messageCount) message](inflect: true)")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
Text("·")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
Text(formattedDate)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SidebarView()
|
||||||
|
.environment(ChatViewModel())
|
||||||
|
.frame(width: 240, height: 600)
|
||||||
|
}
|
||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Git sync status indicator (bottom-right corner)
|
// Git sync status indicator (bottom-right corner)
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// About modal with app icon and version info
|
// About modal with app icon and version info
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -66,7 +64,7 @@ struct AboutView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
Text("[GNU Affero General Public License v3.0](https://www.gnu.org/licenses/agpl-3.0.html)")
|
Text("[PolyForm Noncommercial License 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Create or edit a SKILL.md-style agent skill, with optional support files
|
// Create or edit a SKILL.md-style agent skill, with optional support files
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Modal for managing SKILL.md-style agent skills (opened via /skills command)
|
// Modal for managing SKILL.md-style agent skills (opened via /skills command)
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -113,7 +111,9 @@ struct AgentSkillsView: View {
|
|||||||
onToggle: { settings.toggleAgentSkill(id: skill.id) },
|
onToggle: { settings.toggleAgentSkill(id: skill.id) },
|
||||||
onEdit: { editContext = SkillEditContext(skill: skill) },
|
onEdit: { editContext = SkillEditContext(skill: skill) },
|
||||||
onExport: { exportOne(skill) },
|
onExport: { exportOne(skill) },
|
||||||
onDelete: { settings.deleteAgentSkill(id: skill.id) }
|
onDelete: { settings.deleteAgentSkill(id: skill.id) },
|
||||||
|
isTrusted: settings.trustSecondBrainSkill,
|
||||||
|
onToggleTrust: { settings.trustSecondBrainSkill.toggle() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,6 +349,8 @@ private struct AgentSkillRow: View {
|
|||||||
let onEdit: () -> Void
|
let onEdit: () -> Void
|
||||||
let onExport: () -> Void
|
let onExport: () -> Void
|
||||||
let onDelete: () -> Void
|
let onDelete: () -> Void
|
||||||
|
var isTrusted: Bool = false
|
||||||
|
var onToggleTrust: (() -> Void)? = nil
|
||||||
|
|
||||||
private var fileCount: Int {
|
private var fileCount: Int {
|
||||||
AgentSkillFilesService.shared.listFiles(for: skill.id).count
|
AgentSkillFilesService.shared.listFiles(for: skill.id).count
|
||||||
@@ -378,6 +380,14 @@ private struct AgentSkillRow: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
// 2nd Brain: let the user mark bash calls to its helper script as always-trusted,
|
||||||
|
// skipping the bash approval dialog. Only shown for this specific skill while active.
|
||||||
|
if skill.isActive, skill.isSecondBrainSkill, let onToggleTrust {
|
||||||
|
Toggle("Trust", isOn: Binding(get: { isTrusted }, set: { _ in onToggleTrust() }))
|
||||||
|
.toggleStyle(.switch).controlSize(.small)
|
||||||
|
.help("When on, bash commands that call the 2nd Brain helper script run without asking for approval each time.")
|
||||||
|
}
|
||||||
|
|
||||||
// File count badge
|
// File count badge
|
||||||
if fileCount > 0 {
|
if fileCount > 0 {
|
||||||
Label("^[\(fileCount) file](inflect: true)", systemImage: "doc")
|
Label("^[\(fileCount) file](inflect: true)", systemImage: "doc")
|
||||||
@@ -479,7 +489,9 @@ struct AgentSkillsTabContent: View {
|
|||||||
onToggle: { settings.toggleAgentSkill(id: skill.id) },
|
onToggle: { settings.toggleAgentSkill(id: skill.id) },
|
||||||
onEdit: { editContext = SkillEditContext(skill: skill) },
|
onEdit: { editContext = SkillEditContext(skill: skill) },
|
||||||
onExport: { exportOne(skill) },
|
onExport: { exportOne(skill) },
|
||||||
onDelete: { settings.deleteAgentSkill(id: skill.id) }
|
onDelete: { settings.deleteAgentSkill(id: skill.id) },
|
||||||
|
isTrusted: settings.trustSecondBrainSkill,
|
||||||
|
onToggleTrust: { settings.trustSecondBrainSkill.toggle() }
|
||||||
)
|
)
|
||||||
if idx < settings.agentSkills.count - 1 { Divider() }
|
if idx < settings.agentSkills.count - 1 { Divider() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Approval UI for AI-requested bash commands
|
// Approval UI for AI-requested bash commands
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
//
|
||||||
|
// CombineConversationsSheet.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// Combine 2+ saved conversations into one, optionally using AI to merge content
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
//
|
||||||
|
// This file is part of oAI.
|
||||||
|
//
|
||||||
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
|
//
|
||||||
|
// See the LICENSE file or
|
||||||
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
|
// the full license text. For commercial licensing, contact Rune
|
||||||
|
// Olsen via <https://oai.pm>.
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CombineConversationsSheet: View {
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
let conversations: [Conversation]
|
||||||
|
var onCompleted: (Conversation) -> Void
|
||||||
|
|
||||||
|
@State private var name: String
|
||||||
|
@State private var mode: CombineMode = .simple
|
||||||
|
@State private var deleteOriginals = false
|
||||||
|
@State private var isProcessing = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
private let settings = SettingsService.shared
|
||||||
|
|
||||||
|
init(conversations: [Conversation], onCompleted: @escaping (Conversation) -> Void) {
|
||||||
|
self.conversations = conversations
|
||||||
|
self.onCompleted = onCompleted
|
||||||
|
let joined = conversations.map(\.name).joined(separator: " + ")
|
||||||
|
_name = State(initialValue: String(joined.prefix(80)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var defaultModelLabel: String? {
|
||||||
|
guard let model = settings.defaultModel, !model.isEmpty else { return nil }
|
||||||
|
return "\(settings.defaultProvider.displayName) / \(model)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isValid: Bool {
|
||||||
|
!name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
&& conversations.count >= 2
|
||||||
|
&& (mode == .simple || defaultModelLabel != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text("Combine Conversations")
|
||||||
|
.font(.system(size: 18, weight: .bold))
|
||||||
|
Spacer()
|
||||||
|
Button { dismiss() } label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.title2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.keyboardShortcut(.escape, modifiers: [])
|
||||||
|
.disabled(isProcessing)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24).padding(.top, 20).padding(.bottom, 16)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Combining \(conversations.count) conversations").font(.system(size: 13, weight: .semibold))
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(conversations) { conversation in
|
||||||
|
Label("\(conversation.name) (\(conversation.messageCount) messages)", systemImage: "bubble.left.and.bubble.right")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("New conversation name").font(.system(size: 13, weight: .semibold))
|
||||||
|
TextField("Name", text: $name)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.disabled(isProcessing)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Merge method").font(.system(size: 13, weight: .semibold))
|
||||||
|
Picker("", selection: $mode) {
|
||||||
|
Text("Simple Merge").tag(CombineMode.simple)
|
||||||
|
Text("AI-Assisted Merge").tag(CombineMode.ai)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.labelsHidden()
|
||||||
|
.disabled(isProcessing)
|
||||||
|
|
||||||
|
if mode == .simple {
|
||||||
|
Text("Messages from all selected conversations are combined in chronological order.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("A model reads all the source messages and rewrites them into one coherent, de-duplicated conversation.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
if let label = defaultModelLabel {
|
||||||
|
Label("Uses your default model: \(label)", systemImage: "cpu")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Label("No default model configured — set one in Settings → General.", systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.font(.caption).foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle("Delete original conversations after combining", isOn: $deleteOriginals)
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
.disabled(isProcessing)
|
||||||
|
|
||||||
|
if let errorMessage {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Image(systemName: "xmark.octagon.fill").foregroundStyle(.red)
|
||||||
|
Text(errorMessage).font(.caption)
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24).padding(.vertical, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(isProcessing)
|
||||||
|
Spacer()
|
||||||
|
if isProcessing {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
Text("Combining…").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Button("Combine") {
|
||||||
|
combine()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(!isValid || isProcessing)
|
||||||
|
.keyboardShortcut(.return, modifiers: [.command])
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24).padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 520, idealWidth: 560, minHeight: 460, idealHeight: 520)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func combine() {
|
||||||
|
isProcessing = true
|
||||||
|
errorMessage = nil
|
||||||
|
let ids = conversations.map(\.id)
|
||||||
|
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
||||||
|
let selectedMode = mode
|
||||||
|
let shouldDeleteOriginals = deleteOriginals
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let newConversation = try await ConversationMergeService.merge(
|
||||||
|
conversationIds: ids,
|
||||||
|
name: trimmedName,
|
||||||
|
mode: selectedMode,
|
||||||
|
deleteOriginals: shouldDeleteOriginals
|
||||||
|
)
|
||||||
|
await MainActor.run {
|
||||||
|
isProcessing = false
|
||||||
|
onCompleted(newConversation)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
isProcessing = false
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Saved conversations list
|
// Saved conversations list
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -36,6 +34,7 @@ struct ConversationListView: View {
|
|||||||
@State private var semanticResults: [Conversation] = []
|
@State private var semanticResults: [Conversation] = []
|
||||||
@State private var isSearching = false
|
@State private var isSearching = false
|
||||||
@State private var selectedIndex: Int = 0
|
@State private var selectedIndex: Int = 0
|
||||||
|
@State private var showCombineSheet = false
|
||||||
@FocusState private var searchFocused: Bool
|
@FocusState private var searchFocused: Bool
|
||||||
private let settings = SettingsService.shared
|
private let settings = SettingsService.shared
|
||||||
var onLoad: ((Conversation) -> Void)?
|
var onLoad: ((Conversation) -> Void)?
|
||||||
@@ -70,6 +69,18 @@ struct ConversationListView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if selectedConversations.count >= 2 {
|
||||||
|
Button {
|
||||||
|
showCombineSheet = true
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "arrow.triangle.merge")
|
||||||
|
Text("Combine (\(selectedConversations.count))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
if !selectedConversations.isEmpty {
|
if !selectedConversations.isEmpty {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
deleteSelected()
|
deleteSelected()
|
||||||
@@ -298,6 +309,16 @@ struct ConversationListView: View {
|
|||||||
searchFocused = true
|
searchFocused = true
|
||||||
}
|
}
|
||||||
.frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600)
|
.frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600)
|
||||||
|
.sheet(isPresented: $showCombineSheet) {
|
||||||
|
CombineConversationsSheet(
|
||||||
|
conversations: conversations.filter { selectedConversations.contains($0.id) },
|
||||||
|
onCompleted: { _ in
|
||||||
|
loadConversations()
|
||||||
|
selectedConversations.removeAll()
|
||||||
|
isSelecting = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadConversations() {
|
private func loadConversations() {
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Account credits and balance
|
// Account credits and balance
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Email handler activity log viewer
|
// Email handler activity log viewer
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Help and commands reference with expandable detail and search
|
// Help and commands reference with expandable detail and search
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -52,7 +50,7 @@ private let helpCategories: [CommandCategory] = [
|
|||||||
brief: "View command history",
|
brief: "View command history",
|
||||||
detail: "Opens a searchable modal showing all your previous messages with timestamps in European format (dd.MM.yyyy HH:mm:ss). Search by text content or date to find specific messages. Click any entry to reuse it.",
|
detail: "Opens a searchable modal showing all your previous messages with timestamps in European format (dd.MM.yyyy HH:mm:ss). Search by text content or date to find specific messages. Click any entry to reuse it.",
|
||||||
examples: ["/history"],
|
examples: ["/history"],
|
||||||
shortcut: "⌘H"
|
shortcut: "⇧⌘H"
|
||||||
),
|
),
|
||||||
CommandDetail(
|
CommandDetail(
|
||||||
command: "/clear",
|
command: "/clear",
|
||||||
@@ -180,6 +178,14 @@ private let helpCategories: [CommandCategory] = [
|
|||||||
examples: ["/mcp write on", "/mcp write off"]
|
examples: ["/mcp write on", "/mcp write off"]
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
CommandCategory(name: "Integrations", icon: "server.rack", commands: [
|
||||||
|
CommandDetail(
|
||||||
|
command: "/jarvis",
|
||||||
|
brief: "Open Jarvis agent manager",
|
||||||
|
detail: "Opens the Jarvis panel where you can list, create, run, and monitor agents on your Jarvis (oAI-Web) server, and view usage and cost statistics. Configure the server URL and API key in Settings → Jarvis.",
|
||||||
|
examples: ["/jarvis"]
|
||||||
|
),
|
||||||
|
]),
|
||||||
CommandCategory(name: "Settings & Stats", icon: "gearshape", commands: [
|
CommandCategory(name: "Settings & Stats", icon: "gearshape", commands: [
|
||||||
CommandDetail(
|
CommandDetail(
|
||||||
command: "/config",
|
command: "/config",
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Command history viewer with search
|
// Command history viewer with search
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|||||||
@@ -0,0 +1,866 @@
|
|||||||
|
//
|
||||||
|
// JarvisView.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// Main modal for managing Jarvis (oAI-Web) agents and usage.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Editor context (avoids .sheet timing bug)
|
||||||
|
|
||||||
|
private struct AgentEditContext: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let agent: JarvisAgent? // nil → new
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JarvisView
|
||||||
|
|
||||||
|
struct JarvisView: View {
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@State private var selectedTab = 0
|
||||||
|
@State private var agents: [JarvisAgent] = []
|
||||||
|
@State private var runs: [String: [JarvisAgentRun]] = [:]
|
||||||
|
@State private var usageStats: [JarvisUsageStat] = []
|
||||||
|
@State private var credits: JarvisCreditsResponse? = nil
|
||||||
|
@State private var queueStatus: JarvisQueueStatus? = nil
|
||||||
|
@State private var isLoadingAgents = false
|
||||||
|
@State private var isLoadingUsage = false
|
||||||
|
@State private var selectedAgent: JarvisAgent? = nil
|
||||||
|
@State private var editContext: AgentEditContext? = nil
|
||||||
|
@State private var errorMessage: String? = nil
|
||||||
|
@State private var actionInProgress: Set<String> = []
|
||||||
|
|
||||||
|
private let service = JarvisService.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
Label("Jarvis", systemImage: "server.rack")
|
||||||
|
.font(.system(size: 18, weight: .bold))
|
||||||
|
Spacer()
|
||||||
|
if let qs = queueStatus {
|
||||||
|
queueStatusBadge(qs)
|
||||||
|
}
|
||||||
|
Button { dismiss() } label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.title2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.keyboardShortcut(.escape, modifiers: [])
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.top, 20)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
|
||||||
|
// Tab picker
|
||||||
|
Picker("", selection: $selectedTab) {
|
||||||
|
Text("Agents").tag(0)
|
||||||
|
Text("Usage").tag(1)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
if let err = errorMessage {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
|
||||||
|
Text(err).font(.callout).foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Button("Dismiss") { errorMessage = nil }.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
|
||||||
|
switch selectedTab {
|
||||||
|
case 0: agentsTab
|
||||||
|
default: usageTab
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 840, idealWidth: 960, minHeight: 560, idealHeight: 720)
|
||||||
|
.task { await loadAll() }
|
||||||
|
.sheet(item: $editContext) { ctx in
|
||||||
|
JarvisAgentEditorSheet(agent: ctx.agent, onSave: { input in
|
||||||
|
await saveAgent(existing: ctx.agent, input: input)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Agents Tab
|
||||||
|
|
||||||
|
private var agentsTab: some View {
|
||||||
|
NavigationSplitView {
|
||||||
|
agentList
|
||||||
|
} detail: {
|
||||||
|
if let agent = selectedAgent {
|
||||||
|
agentDetailView(agent)
|
||||||
|
} else {
|
||||||
|
ContentUnavailableView("Select an Agent", systemImage: "server.rack",
|
||||||
|
description: Text("Choose an agent from the list to view details and run history"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationSplitViewStyle(.balanced)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var agentList: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Toolbar
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button {
|
||||||
|
editContext = AgentEditContext(agent: nil)
|
||||||
|
} label: {
|
||||||
|
Label("New Agent", systemImage: "plus")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if isLoadingAgents {
|
||||||
|
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button { Task { await loadAgents() } } label: {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Refresh agents")
|
||||||
|
|
||||||
|
// Queue control
|
||||||
|
if let qs = queueStatus {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
if qs.paused == true { try await service.resumeAll() }
|
||||||
|
else { try await service.pauseAll() }
|
||||||
|
await loadQueueStatus()
|
||||||
|
} catch { errorMessage = error.localizedDescription }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: qs.paused == true ? "play.fill" : "pause.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help(qs.paused == true ? "Resume queue" : "Pause queue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
if agents.isEmpty && !isLoadingAgents {
|
||||||
|
ContentUnavailableView("No Agents", systemImage: "server.rack")
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
List(agents, id: \.id, selection: $selectedAgent) { agent in
|
||||||
|
agentRow(agent)
|
||||||
|
.tag(agent)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func agentRow(_ agent: JarvisAgent) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
// Status dot
|
||||||
|
Circle()
|
||||||
|
.fill(agent.isRunning == true ? Color.green : Color.secondary.opacity(0.4))
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(agent.name)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
if let schedule = agent.schedule, !schedule.isEmpty {
|
||||||
|
Text(schedule)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
// Enable toggle
|
||||||
|
Toggle("", isOn: Binding(
|
||||||
|
get: { agent.enabled },
|
||||||
|
set: { _ in Task { await toggleAgent(agent) } }
|
||||||
|
))
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.controlSize(.mini)
|
||||||
|
.labelsHidden()
|
||||||
|
|
||||||
|
// Run / Stop button
|
||||||
|
if agent.isRunning == true {
|
||||||
|
Button {
|
||||||
|
Task { await stopAgent(agent) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "stop.fill")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.disabled(actionInProgress.contains(agent.id))
|
||||||
|
.help("Stop agent")
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
Task { await runAgent(agent) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: actionInProgress.contains(agent.id) ? "ellipsis" : "play.fill")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.disabled(actionInProgress.contains(agent.id) || !agent.enabled)
|
||||||
|
.help("Run agent now")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func agentDetailView(_ agent: JarvisAgent) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
// Agent info header
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(agent.isRunning == true ? Color.green : Color.secondary.opacity(0.4))
|
||||||
|
.frame(width: 10, height: 10)
|
||||||
|
Text(agent.name)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
if !agent.enabled {
|
||||||
|
Text("Disabled")
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||||
|
.background(Color.orange.opacity(0.15))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.cornerRadius(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(agent.model)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
editContext = AgentEditContext(agent: agent)
|
||||||
|
} label: {
|
||||||
|
Label("Edit", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task { await deleteAgent(agent) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !agent.description.isEmpty {
|
||||||
|
Text(agent.description)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let schedule = agent.schedule, !schedule.isEmpty {
|
||||||
|
Label(schedule, systemImage: "clock")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Prompt preview
|
||||||
|
DisclosureGroup("Prompt") {
|
||||||
|
ScrollView {
|
||||||
|
Text(agent.prompt)
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 120)
|
||||||
|
.background(Color.gray.opacity(0.06))
|
||||||
|
.cornerRadius(6)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Run history
|
||||||
|
Text("Run History")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
let agentRuns = runs[agent.id] ?? []
|
||||||
|
if agentRuns.isEmpty {
|
||||||
|
Text("No runs yet")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||||
|
} else {
|
||||||
|
List(agentRuns) { run in
|
||||||
|
RunHistoryRow(run: run)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: agent.id) {
|
||||||
|
await loadRuns(for: agent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Usage Tab
|
||||||
|
|
||||||
|
private var usageTab: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Credits card
|
||||||
|
if let creds = credits, let balance = creds.remainingBalance {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("OpenRouter Balance")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(String(format: "$%.4f", balance))
|
||||||
|
.font(.system(size: 22, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundStyle(balance < 1.0 ? .orange : .primary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if let total = creds.totalCredits {
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
Text("Total Credits")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
Text(String(format: "$%.2f", total))
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let used = creds.totalUsage {
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
Text("Total Used")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
Text(String(format: "$%.4f", used))
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.blue.opacity(0.06))
|
||||||
|
.cornerRadius(10)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLoadingUsage {
|
||||||
|
ProgressView("Loading usage…")
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if usageStats.isEmpty {
|
||||||
|
ContentUnavailableView("No Usage Data", systemImage: "chart.bar",
|
||||||
|
description: Text("Run some agents to see usage statistics"))
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
// Header row
|
||||||
|
HStack {
|
||||||
|
Text("Agent")
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Text("Runs")
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
Text("Input")
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
Text("Output")
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
Text("Cost")
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
}
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(usageStats) { stat in
|
||||||
|
usageRow(stat)
|
||||||
|
Divider().padding(.leading, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Totals row
|
||||||
|
let totalRuns = usageStats.compactMap(\.runCount).reduce(0, +)
|
||||||
|
let totalInput = usageStats.compactMap(\.totalInputTokens).reduce(0, +)
|
||||||
|
let totalOutput = usageStats.compactMap(\.totalOutputTokens).reduce(0, +)
|
||||||
|
let totalCost = usageStats.compactMap(\.totalCostUsd).reduce(0, +)
|
||||||
|
HStack {
|
||||||
|
Text("Total")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Text("\(totalRuns)")
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
Text(formatTokens(totalInput))
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
Text(formatTokens(totalOutput))
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
Text(String(format: "$%.4f", totalCost))
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color.gray.opacity(0.07))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button { Task { await loadUsage() } } label: {
|
||||||
|
Label("Refresh", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func usageRow(_ stat: JarvisUsageStat) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(stat.displayName)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text("\(stat.runCount ?? 0)")
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(formatTokens(stat.totalInputTokens ?? 0))
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(formatTokens(stat.totalOutputTokens ?? 0))
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(stat.totalCostUsd.map { String(format: "$%.4f", $0) } ?? "—")
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
.font(.system(size: 13, design: .monospaced))
|
||||||
|
}
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Queue Status Badge
|
||||||
|
|
||||||
|
private func queueStatusBadge(_ qs: JarvisQueueStatus) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if qs.paused == true {
|
||||||
|
Label("Paused", systemImage: "pause.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
} else if let running = qs.runningCount, running > 0 {
|
||||||
|
Label("^[\(running) running](inflect: true)", systemImage: "circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
} else {
|
||||||
|
Label("Idle", systemImage: "checkmark.circle")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
.cornerRadius(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Loading
|
||||||
|
|
||||||
|
private func loadAll() async {
|
||||||
|
await withTaskGroup(of: Void.self) { group in
|
||||||
|
group.addTask { await self.loadAgents() }
|
||||||
|
group.addTask { await self.loadUsage() }
|
||||||
|
group.addTask { await self.loadQueueStatus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadAgents() async {
|
||||||
|
isLoadingAgents = true
|
||||||
|
do {
|
||||||
|
let fetched = try await service.listAgents()
|
||||||
|
agents = fetched
|
||||||
|
if let current = selectedAgent {
|
||||||
|
selectedAgent = fetched.first(where: { $0.id == current.id })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoadingAgents = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadRuns(for agent: JarvisAgent) async {
|
||||||
|
do {
|
||||||
|
let fetched = try await service.agentRuns(id: agent.id)
|
||||||
|
runs[agent.id] = fetched
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadUsage() async {
|
||||||
|
isLoadingUsage = true
|
||||||
|
do {
|
||||||
|
async let statsTask = service.usage()
|
||||||
|
async let creditsTask = service.credits()
|
||||||
|
usageStats = try await statsTask
|
||||||
|
credits = try? await creditsTask
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoadingUsage = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadQueueStatus() async {
|
||||||
|
queueStatus = try? await service.queueStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Agent Actions
|
||||||
|
|
||||||
|
private func toggleAgent(_ agent: JarvisAgent) async {
|
||||||
|
do {
|
||||||
|
let updated = try await service.toggleAgent(id: agent.id)
|
||||||
|
updateAgent(updated)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runAgent(_ agent: JarvisAgent) async {
|
||||||
|
actionInProgress.insert(agent.id)
|
||||||
|
do {
|
||||||
|
try await service.runAgent(id: agent.id)
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
await loadAgents()
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
actionInProgress.remove(agent.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopAgent(_ agent: JarvisAgent) async {
|
||||||
|
do {
|
||||||
|
try await service.stopAgent(id: agent.id)
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
await loadAgents()
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteAgent(_ agent: JarvisAgent) async {
|
||||||
|
do {
|
||||||
|
try await service.deleteAgent(id: agent.id)
|
||||||
|
agents.removeAll { $0.id == agent.id }
|
||||||
|
if selectedAgent?.id == agent.id { selectedAgent = nil }
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveAgent(existing: JarvisAgent?, input: JarvisAgentInput) async {
|
||||||
|
do {
|
||||||
|
let result: JarvisAgent
|
||||||
|
if let existing {
|
||||||
|
result = try await service.updateAgent(id: existing.id, input)
|
||||||
|
} else {
|
||||||
|
result = try await service.createAgent(input)
|
||||||
|
}
|
||||||
|
updateAgent(result)
|
||||||
|
selectedAgent = result
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateAgent(_ updated: JarvisAgent) {
|
||||||
|
if let idx = agents.firstIndex(where: { $0.id == updated.id }) {
|
||||||
|
agents[idx] = updated
|
||||||
|
} else {
|
||||||
|
agents.append(updated)
|
||||||
|
}
|
||||||
|
if selectedAgent?.id == updated.id {
|
||||||
|
selectedAgent = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Formatting
|
||||||
|
|
||||||
|
private func formatTokens(_ n: Int) -> String {
|
||||||
|
if n >= 1_000_000 { return String(format: "%.1fM", Double(n) / 1_000_000) }
|
||||||
|
if n >= 1_000 { return String(format: "%.1fK", Double(n) / 1_000) }
|
||||||
|
return "\(n)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Run History Row
|
||||||
|
|
||||||
|
private struct RunHistoryRow: View {
|
||||||
|
let run: JarvisAgentRun
|
||||||
|
@State private var expanded = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
statusIcon
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(run.formattedStarted)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
if let dur = run.formattedDuration {
|
||||||
|
Text("· \(dur)")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if run.totalTokens > 0 {
|
||||||
|
Text("^[\(run.totalTokens) token](inflect: true)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if let cost = run.costUsd, cost > 0 {
|
||||||
|
Text(String(format: "$%.5f", cost))
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if run.output != nil || run.error != nil {
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: expanded ? "chevron.up" : "chevron.down")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if expanded {
|
||||||
|
if let err = run.error {
|
||||||
|
Text(err)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.padding(8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.red.opacity(0.06))
|
||||||
|
.cornerRadius(6)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
} else if let out = run.output {
|
||||||
|
Text(out)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.padding(8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.gray.opacity(0.07))
|
||||||
|
.cornerRadius(6)
|
||||||
|
.lineLimit(20)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var statusIcon: some View {
|
||||||
|
switch run.status {
|
||||||
|
case "running":
|
||||||
|
ProgressView().scaleEffect(0.6).frame(width: 14, height: 14)
|
||||||
|
case "completed":
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
case "failed":
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
case "stopped":
|
||||||
|
Image(systemName: "stop.circle.fill")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
default:
|
||||||
|
Image(systemName: "circle")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Agent Editor Sheet
|
||||||
|
|
||||||
|
struct JarvisAgentEditorSheet: View {
|
||||||
|
let agent: JarvisAgent?
|
||||||
|
let onSave: (JarvisAgentInput) async -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@State private var name: String
|
||||||
|
@State private var description: String
|
||||||
|
@State private var model: String
|
||||||
|
@State private var prompt: String
|
||||||
|
@State private var schedule: String
|
||||||
|
@State private var enabled: Bool
|
||||||
|
@State private var maxToolCalls: String
|
||||||
|
@State private var isSaving = false
|
||||||
|
|
||||||
|
init(agent: JarvisAgent?, onSave: @escaping (JarvisAgentInput) async -> Void) {
|
||||||
|
self.agent = agent
|
||||||
|
self.onSave = onSave
|
||||||
|
_name = State(initialValue: agent?.name ?? "")
|
||||||
|
_description = State(initialValue: agent?.description ?? "")
|
||||||
|
_model = State(initialValue: agent?.model ?? "")
|
||||||
|
_prompt = State(initialValue: agent?.prompt ?? "")
|
||||||
|
_schedule = State(initialValue: agent?.schedule ?? "")
|
||||||
|
_enabled = State(initialValue: agent?.enabled ?? true)
|
||||||
|
_maxToolCalls = State(initialValue: agent.flatMap(\.maxToolCalls).map(String.init) ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
var isValid: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty && !model.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header
|
||||||
|
HStack {
|
||||||
|
Text(agent == nil ? "New Agent" : "Edit Agent")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
Spacer()
|
||||||
|
Button { dismiss() } label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.title3).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Group {
|
||||||
|
editorField("Name") {
|
||||||
|
TextField("Agent name", text: $name)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
editorField("Description") {
|
||||||
|
TextField("Optional description", text: $description)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
editorField("Model") {
|
||||||
|
TextField("e.g. anthropic/claude-sonnet-4-5", text: $model)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
editorField("Schedule") {
|
||||||
|
TextField("Cron expression (e.g. 0 * * * *)", text: $schedule)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
editorField("Max Tool Calls") {
|
||||||
|
TextField("Leave blank for default", text: $maxToolCalls)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: 160)
|
||||||
|
}
|
||||||
|
editorField("Enabled") {
|
||||||
|
Toggle("", isOn: $enabled)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Prompt")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
TextEditor(text: $prompt)
|
||||||
|
.font(.system(size: 13, design: .monospaced))
|
||||||
|
.frame(minHeight: 160)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.strokeBorder(Color.gray.opacity(0.3))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
.keyboardShortcut(.escape, modifiers: [])
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
isSaving = true
|
||||||
|
let input = JarvisAgentInput(
|
||||||
|
name: name.trimmingCharacters(in: .whitespaces),
|
||||||
|
prompt: prompt,
|
||||||
|
model: model.trimmingCharacters(in: .whitespaces),
|
||||||
|
description: description,
|
||||||
|
enabled: enabled,
|
||||||
|
schedule: schedule.isEmpty ? nil : schedule,
|
||||||
|
maxToolCalls: Int(maxToolCalls)
|
||||||
|
)
|
||||||
|
await onSave(input)
|
||||||
|
isSaving = false
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
if isSaving {
|
||||||
|
ProgressView().scaleEffect(0.8).frame(width: 16, height: 16)
|
||||||
|
} else {
|
||||||
|
Text(agent == nil ? "Create" : "Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(!isValid || isSaving)
|
||||||
|
.keyboardShortcut(.return, modifiers: .command)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 560, idealWidth: 620, minHeight: 560, idealHeight: 700)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func editorField<Content: View>(_ label: LocalizedStringKey, @ViewBuilder content: () -> Content) -> some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.frame(width: 100, alignment: .trailing)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Rich model information modal
|
// Rich model information modal
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -30,6 +28,7 @@ struct ModelInfoView: View {
|
|||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@Bindable private var settings = SettingsService.shared
|
@Bindable private var settings = SettingsService.shared
|
||||||
|
@State private var isDescriptionExpanded = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -78,8 +77,18 @@ struct ModelInfoView: View {
|
|||||||
Text(desc)
|
Text(desc)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.lineLimit(isDescriptionExpanded ? nil : 4)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
|
if desc.count > 250 {
|
||||||
|
Button(isDescriptionExpanded ? "Less" : "More…") {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
isDescriptionExpanded.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
}
|
}
|
||||||
@@ -145,6 +154,32 @@ struct ModelInfoView: View {
|
|||||||
capabilityBadge(icon: "brain", label: "Thinking", active: model.capabilities.thinking)
|
capabilityBadge(icon: "brain", label: "Thinking", active: model.capabilities.thinking)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Categories (if any)
|
||||||
|
if !model.categories.isEmpty {
|
||||||
|
Divider()
|
||||||
|
sectionHeader("Categories")
|
||||||
|
FlowLayout(spacing: 8) {
|
||||||
|
ForEach(model.categories, id: \.rawValue) { cat in
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Image(systemName: cat.systemImage)
|
||||||
|
.font(.caption2)
|
||||||
|
Text(LocalizedStringKey(cat.rawValue))
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(cat.color.opacity(0.12))
|
||||||
|
.foregroundColor(cat.color)
|
||||||
|
.cornerRadius(6)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.strokeBorder(cat.color.opacity(0.35), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.leading, 4)
|
||||||
|
}
|
||||||
|
|
||||||
// Architecture (if available)
|
// Architecture (if available)
|
||||||
if let arch = model.architecture {
|
if let arch = model.architecture {
|
||||||
Divider()
|
Divider()
|
||||||
@@ -238,6 +273,50 @@ struct ModelInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Flow Layout
|
||||||
|
|
||||||
|
struct FlowLayout: Layout {
|
||||||
|
var spacing: CGFloat = 8
|
||||||
|
|
||||||
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
|
||||||
|
let rows = rows(for: subviews, width: proposal.width ?? .infinity)
|
||||||
|
let height = rows.map { row in
|
||||||
|
row.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
|
||||||
|
}.reduce(0, +) + CGFloat(max(0, rows.count - 1)) * spacing
|
||||||
|
return CGSize(width: proposal.width ?? 0, height: height)
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
|
||||||
|
let rows = rows(for: subviews, width: bounds.width)
|
||||||
|
var y = bounds.minY
|
||||||
|
for row in rows {
|
||||||
|
var x = bounds.minX
|
||||||
|
let rowH = row.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
|
||||||
|
for sub in row {
|
||||||
|
let size = sub.sizeThatFits(.unspecified)
|
||||||
|
sub.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||||
|
x += size.width + spacing
|
||||||
|
}
|
||||||
|
y += rowH + spacing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rows(for subviews: Subviews, width: CGFloat) -> [[LayoutSubview]] {
|
||||||
|
var rows: [[LayoutSubview]] = [[]]
|
||||||
|
var rowWidth: CGFloat = 0
|
||||||
|
for sub in subviews {
|
||||||
|
let w = sub.sizeThatFits(.unspecified).width
|
||||||
|
if rowWidth + w > width, !rows.last!.isEmpty {
|
||||||
|
rows.append([])
|
||||||
|
rowWidth = 0
|
||||||
|
}
|
||||||
|
rows[rows.count - 1].append(sub)
|
||||||
|
rowWidth += w + spacing
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ModelInfoView(model: ModelInfo(
|
ModelInfoView(model: ModelInfo(
|
||||||
id: "anthropic/claude-sonnet-4",
|
id: "anthropic/claude-sonnet-4",
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Model selection screen
|
// Model selection screen
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -38,11 +36,17 @@ struct ModelSelectorView: View {
|
|||||||
@State private var filterImageGen = false
|
@State private var filterImageGen = false
|
||||||
@State private var filterThinking = false
|
@State private var filterThinking = false
|
||||||
@State private var filterFavorites = false
|
@State private var filterFavorites = false
|
||||||
|
@State private var selectedCategory: ModelCategory? = nil
|
||||||
|
@State private var showCategoryPicker = false
|
||||||
@State private var keyboardIndex: Int = -1
|
@State private var keyboardIndex: Int = -1
|
||||||
@State private var sortOrder: ModelSortOrder = .default
|
@State private var sortOrder: ModelSortOrder = .default
|
||||||
@State private var selectedInfoModel: ModelInfo? = nil
|
@State private var selectedInfoModel: ModelInfo? = nil
|
||||||
@Bindable private var settings = SettingsService.shared
|
@Bindable private var settings = SettingsService.shared
|
||||||
|
|
||||||
|
private var categoriesWithModels: Set<ModelCategory> {
|
||||||
|
Set(models.flatMap(\.categories))
|
||||||
|
}
|
||||||
|
|
||||||
private var filteredModels: [ModelInfo] {
|
private var filteredModels: [ModelInfo] {
|
||||||
let q = searchText.lowercased()
|
let q = searchText.lowercased()
|
||||||
let filtered = models.filter { model in
|
let filtered = models.filter { model in
|
||||||
@@ -57,8 +61,9 @@ struct ModelSelectorView: View {
|
|||||||
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
|
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
|
||||||
let matchesThinking = !filterThinking || model.capabilities.thinking
|
let matchesThinking = !filterThinking || model.capabilities.thinking
|
||||||
let matchesFavorites = !filterFavorites || settings.favoriteModelIds.contains(model.id)
|
let matchesFavorites = !filterFavorites || settings.favoriteModelIds.contains(model.id)
|
||||||
|
let matchesCategory = selectedCategory == nil || model.categories.contains(selectedCategory!)
|
||||||
|
|
||||||
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking && matchesFavorites
|
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking && matchesFavorites && matchesCategory
|
||||||
}
|
}
|
||||||
|
|
||||||
let favIds = settings.favoriteModelIds
|
let favIds = settings.favoriteModelIds
|
||||||
@@ -100,6 +105,40 @@ struct ModelSelectorView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
// Category picker (only shown when at least one category has models)
|
||||||
|
if !categoriesWithModels.isEmpty {
|
||||||
|
Button(action: { showCategoryPicker.toggle() }) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if let cat = selectedCategory {
|
||||||
|
Circle().fill(cat.color).frame(width: 7, height: 7)
|
||||||
|
Text(LocalizedStringKey(cat.rawValue))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "tag")
|
||||||
|
Text("Category")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(selectedCategory != nil ? selectedCategory!.color.opacity(0.18) : Color.gray.opacity(0.1))
|
||||||
|
.foregroundColor(selectedCategory != nil ? selectedCategory!.color : .secondary)
|
||||||
|
.cornerRadius(6)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.strokeBorder(selectedCategory != nil ? selectedCategory!.color.opacity(0.4) : Color.clear, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Filter by category")
|
||||||
|
.popover(isPresented: $showCategoryPicker, arrowEdge: .bottom) {
|
||||||
|
CategoryPickerPopover(
|
||||||
|
categoriesWithModels: categoriesWithModels,
|
||||||
|
selectedCategory: $selectedCategory,
|
||||||
|
onSelect: { keyboardIndex = -1 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} // end if !categoriesWithModels.isEmpty
|
||||||
|
|
||||||
// Favorites filter star
|
// Favorites filter star
|
||||||
Button(action: { filterFavorites.toggle(); keyboardIndex = -1 }) {
|
Button(action: { filterFavorites.toggle(); keyboardIndex = -1 }) {
|
||||||
Image(systemName: filterFavorites ? "star.fill" : "star")
|
Image(systemName: filterFavorites ? "star.fill" : "star")
|
||||||
@@ -180,7 +219,7 @@ struct ModelSelectorView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minWidth: 600, minHeight: 500)
|
.frame(minWidth: 740, minHeight: 500)
|
||||||
.navigationTitle("Select Model")
|
.navigationTitle("Select Model")
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.onKeyPress(.downArrow) {
|
.onKeyPress(.downArrow) {
|
||||||
@@ -255,6 +294,7 @@ struct FilterToggle: View {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(icon)
|
Text(icon)
|
||||||
Text(label)
|
Text(label)
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
@@ -264,6 +304,68 @@ struct FilterToggle: View {
|
|||||||
.cornerRadius(6)
|
.cornerRadius(6)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.fixedSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Category Picker Popover
|
||||||
|
|
||||||
|
struct CategoryPickerPopover: View {
|
||||||
|
let categoriesWithModels: Set<ModelCategory>
|
||||||
|
@Binding var selectedCategory: ModelCategory?
|
||||||
|
let onSelect: () -> Void
|
||||||
|
|
||||||
|
private let columns = [GridItem(.adaptive(minimum: 130), spacing: 8)]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Text("Filter by Category")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
if selectedCategory != nil {
|
||||||
|
Button("Clear") {
|
||||||
|
selectedCategory = nil
|
||||||
|
onSelect()
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyVGrid(columns: columns, spacing: 8) {
|
||||||
|
ForEach(ModelCategory.allCases.filter { categoriesWithModels.contains($0) }, id: \.rawValue) { cat in
|
||||||
|
let isSelected = selectedCategory == cat
|
||||||
|
Button {
|
||||||
|
selectedCategory = isSelected ? nil : cat
|
||||||
|
onSelect()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: cat.systemImage)
|
||||||
|
.font(.caption)
|
||||||
|
.frame(width: 14)
|
||||||
|
Text(LocalizedStringKey(cat.rawValue))
|
||||||
|
.font(.caption)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(isSelected ? cat.color.opacity(0.18) : Color.gray.opacity(0.08))
|
||||||
|
.foregroundColor(isSelected ? cat.color : .primary)
|
||||||
|
.cornerRadius(6)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.strokeBorder(isSelected ? cat.color.opacity(0.45) : Color.clear, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(minWidth: 360)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +426,7 @@ struct ModelRowView: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { onSelect() }
|
.onTapGesture { onSelect() }
|
||||||
|
|
||||||
// Right side: capabilities + info button
|
// Right side: capabilities + category dots + info button
|
||||||
VStack(alignment: .trailing, spacing: 6) {
|
VStack(alignment: .trailing, spacing: 6) {
|
||||||
// Capability icons
|
// Capability icons
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
@@ -335,6 +437,18 @@ struct ModelRowView: View {
|
|||||||
if model.capabilities.thinking { Text("\u{1F9E0}").font(.caption) }
|
if model.capabilities.thinking { Text("\u{1F9E0}").font(.caption) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Category dots
|
||||||
|
if !model.categories.isEmpty {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
ForEach(model.categories, id: \.rawValue) { cat in
|
||||||
|
Circle()
|
||||||
|
.fill(cat.color)
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
.help(cat.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Info button
|
// Info button
|
||||||
Button(action: onInfo) {
|
Button(action: onInfo) {
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "info.circle")
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// PersonalDataApprovalSheet.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// Approval UI for AI-requested Calendar/Reminders write actions
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
//
|
||||||
|
// This file is part of oAI.
|
||||||
|
//
|
||||||
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
|
//
|
||||||
|
// See the LICENSE file or
|
||||||
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
|
// the full license text. For commercial licensing, contact Rune
|
||||||
|
// Olsen via <https://oai.pm>.
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PersonalDataApprovalSheet: View {
|
||||||
|
let pending: MCPService.PendingPersonalDataAction
|
||||||
|
let onApprove: (_ forSession: Bool) -> Void
|
||||||
|
let onDeny: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
// Header
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "calendar.badge.exclamationmark")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Allow This Action?")
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
Text("The AI wants to make a change to your calendar or reminders")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action description
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("ACTION")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(pending.summary)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.secondary.opacity(0.08))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button("Deny") {
|
||||||
|
onDeny()
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.red)
|
||||||
|
.keyboardShortcut(.escape, modifiers: [])
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Allow Once") {
|
||||||
|
onApprove(false)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.orange)
|
||||||
|
|
||||||
|
Button("Allow for Session") {
|
||||||
|
onApprove(true)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.orange)
|
||||||
|
.keyboardShortcut(.return, modifiers: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.frame(width: 480)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Settings and configuration screen
|
// Settings and configuration screen
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -65,9 +63,30 @@ struct SettingsView: View {
|
|||||||
@State private var isTestingAnytype = false
|
@State private var isTestingAnytype = false
|
||||||
@State private var anytypeTestResult: String?
|
@State private var anytypeTestResult: String?
|
||||||
|
|
||||||
|
// Jarvis state
|
||||||
|
@State private var jarvisURL = ""
|
||||||
|
@State private var jarvisAPIKey = ""
|
||||||
|
@State private var showJarvisKey = false
|
||||||
|
@State private var isTestingJarvis = false
|
||||||
|
@State private var jarvisTestResult: String?
|
||||||
|
|
||||||
// Default model picker state
|
// Default model picker state
|
||||||
@State private var showDefaultModelPicker = false
|
@State private var showDefaultModelPicker = false
|
||||||
|
|
||||||
|
// External MCP Servers state
|
||||||
|
@State private var showAddExternalMCPServer = false
|
||||||
|
@State private var newMCPServerName = ""
|
||||||
|
@State private var newMCPServerCommand = ""
|
||||||
|
@State private var newMCPServerArgs = ""
|
||||||
|
@State private var newMCPServerTimeout: Double = 30
|
||||||
|
private var externalMCPManager = ExternalMCPManager.shared
|
||||||
|
|
||||||
|
// Personal Data state (Calendar/Reminders/Contacts/Location/Maps)
|
||||||
|
@State private var calendarAccessState = EventKitService.shared.calendarAccessState
|
||||||
|
@State private var remindersAccessState = EventKitService.shared.reminderAccessState
|
||||||
|
@State private var contactsAccessState = ContactsService.shared.accessState
|
||||||
|
@State private var locationAccessState = LocationMapsService.shared.accessState
|
||||||
|
|
||||||
// Paperless-NGX state
|
// Paperless-NGX state
|
||||||
@State private var paperlessURL = ""
|
@State private var paperlessURL = ""
|
||||||
@State private var paperlessToken = ""
|
@State private var paperlessToken = ""
|
||||||
@@ -155,6 +174,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
tabButton(8, icon: "doc.text", label: "Paperless", beta: true)
|
tabButton(8, icon: "doc.text", label: "Paperless", beta: true)
|
||||||
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
|
tabButton(9, icon: "icloud.and.arrow.up", label: "Backup")
|
||||||
tabButton(10, icon: "square.stack.3d.up", label: "Anytype")
|
tabButton(10, icon: "square.stack.3d.up", label: "Anytype")
|
||||||
|
tabButton(11, icon: "server.rack", label: "Jarvis")
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
@@ -186,6 +206,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
backupTab
|
backupTab
|
||||||
case 10:
|
case 10:
|
||||||
anytypeTab
|
anytypeTab
|
||||||
|
case 11:
|
||||||
|
jarvisTab
|
||||||
default:
|
default:
|
||||||
generalTab
|
generalTab
|
||||||
}
|
}
|
||||||
@@ -742,6 +764,367 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Research Agents
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "person.3.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.indigo)
|
||||||
|
Text("Research Agents")
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
}
|
||||||
|
Text("Let the AI spawn read-only research sub-agents to investigate multiple things in parallel (read files, list/search directories, search the web — no writing, no bash). Intended for genuinely independent research tasks, not everyday questions.")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
sectionHeader("Status")
|
||||||
|
formSection {
|
||||||
|
row("Enable Research Agents") {
|
||||||
|
Toggle("", isOn: $settingsService.agentsEnabled)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(alignment: .top, spacing: 6) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(.top, 1)
|
||||||
|
Text("Cost warning: each sub-agent runs its own full chain of model calls. A single request that spawns several agents can cost several times a normal reply. The AI is instructed to only use this for genuinely parallel research, but model behavior can vary — leave this off unless you want that tradeoff.")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
|
||||||
|
if settingsService.agentsEnabled {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
sectionHeader("Settings")
|
||||||
|
formSection {
|
||||||
|
row("Max Concurrent Agents") {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Stepper("", value: $settingsService.maxConcurrentAgents, in: 1...5)
|
||||||
|
.labelsHidden()
|
||||||
|
Text("\(settingsService.maxConcurrentAgents)")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 24, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: External MCP Servers
|
||||||
|
Divider()
|
||||||
|
externalMCPSection
|
||||||
|
|
||||||
|
// MARK: Personal Data
|
||||||
|
// isHiddenPendingAppleFix hides the entire section (macOS 27 beta TCC bug).
|
||||||
|
// isContactsHiddenPendingAppleFix hides just the Contacts row (still broken in beta 2
|
||||||
|
// under hardened runtime while Calendar/Reminders/Location are fixed). Flip each flag
|
||||||
|
// to false once Apple ships a fix.
|
||||||
|
if !PersonalDataTools.isHiddenPendingAppleFix {
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "person.crop.circle.badge.checkmark")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.teal)
|
||||||
|
Text("Personal Data")
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
if PersonalDataTools.isContactsHiddenPendingAppleFix {
|
||||||
|
Text("β")
|
||||||
|
.font(.system(size: 11, weight: .bold))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(.horizontal, 5)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.orange.opacity(0.15))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||||
|
.help("Beta Feature. May change.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Let the AI access your Calendar, Reminders, and Location & Maps to answer questions about your schedule and surroundings. Each service is opt-in and uses standard macOS permission prompts. This functionality is in beta and may change.")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
.onAppear {
|
||||||
|
// Permission status can change outside the app (System Settings, or a prior
|
||||||
|
// request elsewhere) — re-read it fresh every time this tab appears rather than
|
||||||
|
// trusting the one-time @State initializer.
|
||||||
|
calendarAccessState = EventKitService.shared.calendarAccessState
|
||||||
|
remindersAccessState = EventKitService.shared.reminderAccessState
|
||||||
|
contactsAccessState = ContactsService.shared.accessState
|
||||||
|
locationAccessState = LocationMapsService.shared.accessState
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
sectionHeader("Services")
|
||||||
|
formSection {
|
||||||
|
personalDataRow(
|
||||||
|
title: "Calendar",
|
||||||
|
isEnabled: $settingsService.calendarEnabled,
|
||||||
|
state: calendarAccessState,
|
||||||
|
systemSettingsAnchor: "Privacy_Calendars",
|
||||||
|
requestAccess: { calendarAccessState = await EventKitService.shared.requestCalendarAccess() ? .granted : EventKitService.shared.calendarAccessState }
|
||||||
|
)
|
||||||
|
rowDivider()
|
||||||
|
personalDataRow(
|
||||||
|
title: "Reminders",
|
||||||
|
isEnabled: $settingsService.remindersEnabled,
|
||||||
|
state: remindersAccessState,
|
||||||
|
systemSettingsAnchor: "Privacy_Reminders",
|
||||||
|
requestAccess: { remindersAccessState = await EventKitService.shared.requestReminderAccess() ? .granted : EventKitService.shared.reminderAccessState }
|
||||||
|
)
|
||||||
|
rowDivider()
|
||||||
|
if !PersonalDataTools.isContactsHiddenPendingAppleFix {
|
||||||
|
personalDataRow(
|
||||||
|
title: "Contacts",
|
||||||
|
isEnabled: $settingsService.contactsEnabled,
|
||||||
|
state: contactsAccessState,
|
||||||
|
systemSettingsAnchor: "Privacy_Contacts",
|
||||||
|
requestAccess: { contactsAccessState = await ContactsService.shared.requestAccess() ? .granted : ContactsService.shared.accessState }
|
||||||
|
)
|
||||||
|
rowDivider()
|
||||||
|
}
|
||||||
|
personalDataRow(
|
||||||
|
title: "Location & Maps",
|
||||||
|
isEnabled: $settingsService.locationMapsEnabled,
|
||||||
|
state: locationAccessState,
|
||||||
|
systemSettingsAnchor: "Privacy_LocationServices",
|
||||||
|
requestAccess: { locationAccessState = await LocationMapsService.shared.requestAccess() ? .granted : LocationMapsService.shared.accessState }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if settingsService.calendarEnabled || settingsService.remindersEnabled {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
sectionHeader("Write Actions")
|
||||||
|
formSection {
|
||||||
|
row("Require Approval for Changes") {
|
||||||
|
Toggle("", isOn: $settingsService.personalDataRequireApproval)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Creating calendar events or reminders, and completing reminders, will ask for your approval first.")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - External MCP Servers Section
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var externalMCPSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "server.rack")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.purple)
|
||||||
|
Text("External MCP Servers")
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
}
|
||||||
|
Text("Connect any stdio MCP server (e.g. safaridriver --mcp) to give the AI access to its tools. Tool names are prefixed with the server slug.")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
sectionHeader("Configured Servers")
|
||||||
|
formSection {
|
||||||
|
if settingsService.externalMCPServers.isEmpty {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: "server.rack")
|
||||||
|
.font(.system(size: 32))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
Text("No external servers configured")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Add a server below, e.g.: safaridriver --mcp")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
} else {
|
||||||
|
ForEach(settingsService.externalMCPServers) { server in
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Circle()
|
||||||
|
.fill(mcpStatusColor(externalMCPManager.clientStates[server.id]))
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(server.name)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
Text(([server.command] + server.args).joined(separator: " "))
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(mcpStatusLabel(externalMCPManager.clientStates[server.id]))
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Toggle("", isOn: Binding(
|
||||||
|
get: { server.isEnabled },
|
||||||
|
set: { _ in settingsService.toggleExternalMCPServer(id: server.id) }
|
||||||
|
))
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.labelsHidden()
|
||||||
|
Button {
|
||||||
|
settingsService.deleteExternalMCPServer(id: server.id)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash.fill")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
if server.id != settingsService.externalMCPServers.last?.id {
|
||||||
|
rowDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
newMCPServerName = ""
|
||||||
|
newMCPServerCommand = ""
|
||||||
|
newMCPServerArgs = ""
|
||||||
|
newMCPServerTimeout = 30
|
||||||
|
showAddExternalMCPServer = true
|
||||||
|
} label: {
|
||||||
|
Label("Add Server…", systemImage: "plus")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(Color.purple)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showAddExternalMCPServer) {
|
||||||
|
addExternalMCPServerSheet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var addExternalMCPServerSheet: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
Text("Add External MCP Server")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
|
||||||
|
formSection {
|
||||||
|
row("Name") {
|
||||||
|
TextField("Safari", text: $newMCPServerName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 240)
|
||||||
|
}
|
||||||
|
rowDivider()
|
||||||
|
row("Command") {
|
||||||
|
TextField("safaridriver", text: $newMCPServerCommand)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.system(size: 13, design: .monospaced))
|
||||||
|
.frame(width: 240)
|
||||||
|
}
|
||||||
|
rowDivider()
|
||||||
|
row("Arguments") {
|
||||||
|
TextField("--mcp", text: $newMCPServerArgs)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.system(size: 13, design: .monospaced))
|
||||||
|
.frame(width: 240)
|
||||||
|
.help("Space-separated arguments")
|
||||||
|
}
|
||||||
|
rowDivider()
|
||||||
|
row("Timeout") {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Stepper("", value: $newMCPServerTimeout, in: 5...120, step: 5)
|
||||||
|
.labelsHidden()
|
||||||
|
Text("\(Int(newMCPServerTimeout))s")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 32, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !newMCPServerName.isEmpty {
|
||||||
|
let slug = ExternalMCPServer.makeSlug(from: newMCPServerName)
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("Tool prefix:")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("\(slug)_")
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if ExternalMCPServer.reservedSlugs.contains(slug) {
|
||||||
|
Text("'\(slug)' is a reserved prefix. Choose a different name.")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") { showAddExternalMCPServer = false }
|
||||||
|
Spacer()
|
||||||
|
Button("Add") {
|
||||||
|
let args = ExternalMCPServer.parseArguments(newMCPServerArgs)
|
||||||
|
let server = ExternalMCPServer(
|
||||||
|
name: newMCPServerName,
|
||||||
|
command: newMCPServerCommand,
|
||||||
|
args: args,
|
||||||
|
timeout: newMCPServerTimeout
|
||||||
|
)
|
||||||
|
settingsService.addExternalMCPServer(server)
|
||||||
|
showAddExternalMCPServer = false
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(newMCPServerName.isEmpty || newMCPServerCommand.isEmpty ||
|
||||||
|
ExternalMCPServer.reservedSlugs.contains(ExternalMCPServer.makeSlug(from: newMCPServerName)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.frame(minWidth: 460, minHeight: 320)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mcpStatusColor(_ state: MCPClientState?) -> Color {
|
||||||
|
switch state {
|
||||||
|
case .ready: return .green
|
||||||
|
case .connecting: return .orange
|
||||||
|
case .error, .crashed: return .red
|
||||||
|
default: return Color(nsColor: .tertiaryLabelColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mcpStatusLabel(_ state: MCPClientState?) -> LocalizedStringKey {
|
||||||
|
switch state {
|
||||||
|
case .ready: return "Connected"
|
||||||
|
case .connecting: return "Connecting…"
|
||||||
|
case .error: return "Error"
|
||||||
|
case .crashed: return "Crashed"
|
||||||
|
default: return "Not started"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Appearance Tab
|
// MARK: - Appearance Tab
|
||||||
@@ -2047,6 +2430,107 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Jarvis Tab
|
||||||
|
|
||||||
|
private var jarvisTab: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
sectionHeader("Jarvis")
|
||||||
|
formSection {
|
||||||
|
row("Enable Jarvis") {
|
||||||
|
Toggle("", isOn: $settingsService.jarvisEnabled)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if settingsService.jarvisEnabled {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
sectionHeader("Connection")
|
||||||
|
formSection {
|
||||||
|
row("Server URL") {
|
||||||
|
TextField("https://jarvis.example.com", text: $jarvisURL)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: 300)
|
||||||
|
.onSubmit { settingsService.jarvisURL = jarvisURL }
|
||||||
|
.onChange(of: jarvisURL) { _, new in settingsService.jarvisURL = new }
|
||||||
|
}
|
||||||
|
rowDivider()
|
||||||
|
row("API Key") {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if showJarvisKey {
|
||||||
|
TextField("", text: $jarvisAPIKey)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: 240)
|
||||||
|
.onSubmit { settingsService.jarvisAPIKey = jarvisAPIKey.isEmpty ? nil : jarvisAPIKey }
|
||||||
|
.onChange(of: jarvisAPIKey) { _, new in
|
||||||
|
settingsService.jarvisAPIKey = new.isEmpty ? nil : new
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SecureField("", text: $jarvisAPIKey)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: 240)
|
||||||
|
.onSubmit { settingsService.jarvisAPIKey = jarvisAPIKey.isEmpty ? nil : jarvisAPIKey }
|
||||||
|
.onChange(of: jarvisAPIKey) { _, new in
|
||||||
|
settingsService.jarvisAPIKey = new.isEmpty ? nil : new
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(showJarvisKey ? "Hide" : "Show") {
|
||||||
|
showJarvisKey.toggle()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rowDivider()
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: { Task { await testJarvisConnection() } }) {
|
||||||
|
HStack {
|
||||||
|
if isTestingJarvis {
|
||||||
|
ProgressView().scaleEffect(0.7).frame(width: 14, height: 14)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "checkmark.circle")
|
||||||
|
}
|
||||||
|
Text("Test Connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isTestingJarvis || !settingsService.jarvisConfigured)
|
||||||
|
if let result = jarvisTestResult {
|
||||||
|
Text(result)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(result.hasPrefix("✓") ? .green : .red)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Generate an API key in your Jarvis settings and paste it above.")
|
||||||
|
}
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
jarvisURL = settingsService.jarvisURL
|
||||||
|
jarvisAPIKey = settingsService.jarvisAPIKey ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func testJarvisConnection() async {
|
||||||
|
isTestingJarvis = true
|
||||||
|
jarvisTestResult = nil
|
||||||
|
let ok = await JarvisService.shared.testConnection()
|
||||||
|
await MainActor.run {
|
||||||
|
jarvisTestResult = ok ? "✓ Connected" : "✗ Connection failed"
|
||||||
|
isTestingJarvis = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func testAnytypeConnection() async {
|
private func testAnytypeConnection() async {
|
||||||
isTestingAnytype = true
|
isTestingAnytype = true
|
||||||
anytypeTestResult = nil
|
anytypeTestResult = nil
|
||||||
@@ -2265,7 +2749,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
|
.foregroundStyle(selectedTab == tag ? .blue : .secondary)
|
||||||
}
|
}
|
||||||
.frame(minWidth: 60)
|
.frame(minWidth: 55)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
.background(selectedTab == tag ? Color.blue.opacity(0.1) : Color.clear)
|
.background(selectedTab == tag ? Color.blue.opacity(0.1) : Color.clear)
|
||||||
@@ -2286,6 +2770,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
case 7: return "Skills"
|
case 7: return "Skills"
|
||||||
case 8: return "Paperless"
|
case 8: return "Paperless"
|
||||||
case 9: return "Backup"
|
case 9: return "Backup"
|
||||||
|
case 10: return "Anytype"
|
||||||
|
case 11: return "Jarvis"
|
||||||
default: return "Settings"
|
default: return "Settings"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2333,6 +2819,57 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
Divider().padding(.leading, 16)
|
Divider().padding(.leading, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func personalDataRow(title: LocalizedStringKey, isEnabled: Binding<Bool>, state: PersonalDataAccessState, systemSettingsAnchor: String, requestAccess: @escaping () async -> Void) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
Text(title).font(.system(size: 14))
|
||||||
|
Spacer()
|
||||||
|
Toggle("", isOn: isEnabled)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
}
|
||||||
|
if isEnabled.wrappedValue {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: state == .granted ? "checkmark.circle.fill" : (state == .denied ? "exclamationmark.circle.fill" : "circle"))
|
||||||
|
.foregroundStyle(state == .granted ? .green : (state == .denied ? .orange : .secondary))
|
||||||
|
.font(.system(size: 12))
|
||||||
|
Text(statusText(for: state))
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
if state == .notDetermined {
|
||||||
|
Button("Request Access") {
|
||||||
|
Task { await requestAccess() }
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
} else if state == .denied {
|
||||||
|
Button("Open System Settings") {
|
||||||
|
openPrivacySettings(anchor: systemSettingsAnchor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusText(for state: PersonalDataAccessState) -> LocalizedStringKey {
|
||||||
|
switch state {
|
||||||
|
case .granted: return "Access granted"
|
||||||
|
case .denied: return "Access denied — enable in System Settings"
|
||||||
|
case .notDetermined: return "Access not granted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openPrivacySettings(anchor: String) {
|
||||||
|
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(anchor)") else { return }
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
|
||||||
private func abbreviatePath(_ path: String) -> String {
|
private func abbreviatePath(_ path: String) -> String {
|
||||||
let home = NSHomeDirectory()
|
let home = NSHomeDirectory()
|
||||||
if path.hasPrefix(home) {
|
if path.hasPrefix(home) {
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Create or edit a user-defined shortcut (prompt template)
|
// Create or edit a user-defined shortcut (prompt template)
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Modal for managing user-defined shortcuts (opened via /shortcuts command)
|
// Modal for managing user-defined shortcuts (opened via /shortcuts command)
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|||||||
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Session statistics screen
|
// Session statistics screen
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|||||||
@@ -36,6 +36,54 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"NSCalendarsFullAccessUsageDescription" : {
|
||||||
|
"comment" : "Privacy - Calendars Full Access Usage Description",
|
||||||
|
"extractionState" : "extracted_with_value",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "oAI can read and create calendar events when you ask it to, if you enable Calendar access in Settings."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"NSContactsUsageDescription" : {
|
||||||
|
"comment" : "Privacy - Contacts Usage Description",
|
||||||
|
"extractionState" : "extracted_with_value",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "oAI can search your contacts when you ask it to, if you enable Contacts access in Settings."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"NSLocationWhenInUseUsageDescription" : {
|
||||||
|
"comment" : "Privacy - Location When In Use Usage Description",
|
||||||
|
"extractionState" : "extracted_with_value",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "oAI can use your current location to answer questions, if you enable Location & Maps access in Settings."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"NSRemindersFullAccessUsageDescription" : {
|
||||||
|
"comment" : "Privacy - Reminders Full Access Usage Description",
|
||||||
|
"extractionState" : "extracted_with_value",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "oAI can read and create reminders when you ask it to, if you enable Reminders access in Settings."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.1"
|
"version" : "1.1"
|
||||||
|
|||||||
@@ -8,5 +8,13 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.server</key>
|
<key>com.apple.security.network.server</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>com.apple.security.personal-information.calendars</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.personal-information.reminders</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.personal-information.contacts</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.personal-information.location</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
+50
-13
@@ -4,23 +4,21 @@
|
|||||||
//
|
//
|
||||||
// Main app entry point
|
// Main app entry point
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
//
|
//
|
||||||
// This file is part of oAI.
|
// This file is part of oAI.
|
||||||
//
|
//
|
||||||
// oAI is free software: you can redistribute it and/or modify
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
||||||
// it under the terms of the GNU Affero General Public License as
|
// You may use, study, modify, and share it for any noncommercial
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
// purpose. Commercial use — including selling oAI or any part of
|
||||||
// License, or (at your option) any later version.
|
// it, standalone or bundled into another product or service —
|
||||||
|
// requires a separate commercial license from the copyright holder.
|
||||||
//
|
//
|
||||||
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
// See the LICENSE file or
|
||||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
||||||
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
// the full license text. For commercial licensing, contact Rune
|
||||||
// Public License for more details.
|
// Olsen via <https://oai.pm>.
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public
|
|
||||||
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -37,6 +35,9 @@ struct oAIApp: App {
|
|||||||
// Start email handler on app launch
|
// Start email handler on app launch
|
||||||
EmailHandlerService.shared.start()
|
EmailHandlerService.shared.start()
|
||||||
|
|
||||||
|
// Start external MCP servers
|
||||||
|
Task { @MainActor in ExternalMCPManager.shared.startAll() }
|
||||||
|
|
||||||
// Sync Git changes on app launch (pull + import)
|
// Sync Git changes on app launch (pull + import)
|
||||||
Task {
|
Task {
|
||||||
await GitSyncService.shared.syncOnStartup()
|
await GitSyncService.shared.syncOnStartup()
|
||||||
@@ -56,7 +57,7 @@ struct oAIApp: App {
|
|||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
||||||
// Trigger auto-save on app quit
|
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
|
||||||
Task {
|
Task {
|
||||||
await chatViewModel.onAppWillTerminate()
|
await chatViewModel.onAppWillTerminate()
|
||||||
}
|
}
|
||||||
@@ -92,6 +93,8 @@ struct oAIApp: App {
|
|||||||
CommandGroup(after: .newItem) {
|
CommandGroup(after: .newItem) {
|
||||||
Button("Open Chat…") { chatViewModel.showConversations = true }
|
Button("Open Chat…") { chatViewModel.showConversations = true }
|
||||||
.keyboardShortcut("o", modifiers: .command)
|
.keyboardShortcut("o", modifiers: .command)
|
||||||
|
Button("Search Conversations") { chatViewModel.showConversations = true }
|
||||||
|
.keyboardShortcut("l", modifiers: .command)
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandGroup(replacing: .saveItem) {
|
CommandGroup(replacing: .saveItem) {
|
||||||
@@ -113,10 +116,44 @@ struct oAIApp: App {
|
|||||||
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── View menu ─────────────────────────────────────────────────
|
||||||
|
CommandMenu("View") {
|
||||||
|
Button("Select Model") { chatViewModel.showModelSelector = true }
|
||||||
|
.keyboardShortcut("m", modifiers: .command)
|
||||||
|
|
||||||
|
Button("Model Info") {
|
||||||
|
chatViewModel.modelInfoTarget = chatViewModel.selectedModel
|
||||||
|
}
|
||||||
|
.keyboardShortcut("i", modifiers: .command)
|
||||||
|
.disabled(chatViewModel.selectedModel == nil)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button("Command History") { chatViewModel.showHistory = true }
|
||||||
|
.keyboardShortcut("h", modifiers: [.command, .shift])
|
||||||
|
|
||||||
|
Button("In-App Help") { chatViewModel.showHelp = true }
|
||||||
|
.keyboardShortcut("/", modifiers: .command)
|
||||||
|
|
||||||
|
Button("Credits") { chatViewModel.showCredits = true }
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button(chatViewModel.onlineMode ? "Online Mode: On" : "Online Mode: Off") {
|
||||||
|
chatViewModel.onlineMode.toggle()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("o", modifiers: [.command, .shift])
|
||||||
|
}
|
||||||
|
|
||||||
// ── Help menu ─────────────────────────────────────────────────
|
// ── Help menu ─────────────────────────────────────────────────
|
||||||
CommandGroup(replacing: .help) {
|
CommandGroup(replacing: .help) {
|
||||||
Button("oAI Help") { openHelp() }
|
Button("oAI Help") { openHelp() }
|
||||||
.keyboardShortcut("?", modifiers: .command)
|
.keyboardShortcut("?", modifiers: .command)
|
||||||
|
Divider()
|
||||||
|
Button(UpdateCheckService.shared.isCheckingManually ? "Checking…" : "Check for Updates…") {
|
||||||
|
UpdateCheckService.shared.checkForUpdatesManually()
|
||||||
|
}
|
||||||
|
.disabled(UpdateCheckService.shared.isCheckingManually)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
Reference in New Issue
Block a user