mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] merge eric/onboarding into eric/dev; version 1.6.0
This commit is contained in:
@@ -244,3 +244,68 @@ jobs:
|
||||
electron/dist/squirrel-windows/latest.yml
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
# Dispatch-only live smoke of the exact signed artifact this run just built (Squirrel layout: the
|
||||
# root OpenSwarm.exe is a stub, the real app lives in app-<version>\; python.exe appearing is the
|
||||
# install-done signal). Publish runs skip this: tags ship through the draft flow.
|
||||
verify-windows:
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.publish != 'true'
|
||||
needs: build-windows
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Download the signed installer built by this run
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: openswarm-windows-x64
|
||||
path: installer
|
||||
|
||||
- name: silent install (Squirrel)
|
||||
shell: pwsh
|
||||
run: |
|
||||
Start-Process -FilePath "installer\OpenSwarm-Setup-x64.exe" -ArgumentList "--silent"
|
||||
$deadline = (Get-Date).AddMinutes(10)
|
||||
do {
|
||||
Start-Sleep -Seconds 5
|
||||
$py = Get-ChildItem "$env:LOCALAPPDATA\openswarm\app-*\resources\python-env\python.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
} until ($py -or (Get-Date) -gt $deadline)
|
||||
if (-not $py) { Get-ChildItem "$env:LOCALAPPDATA\openswarm" -Recurse -Depth 2 -ErrorAction SilentlyContinue | Select-Object FullName -First 40; throw "installed python-env not found" }
|
||||
$appDir = $py.FullName -replace '\\resources\\python-env\\python\.exe$', ''
|
||||
echo "APP_EXE=$appDir\OpenSwarm.exe" >> $env:GITHUB_ENV
|
||||
echo "APP_DIR=$appDir" >> $env:GITHUB_ENV
|
||||
echo "installed at $appDir"
|
||||
|
||||
- name: native uiohook binary shipped outside the asar
|
||||
shell: pwsh
|
||||
run: |
|
||||
$node = Get-ChildItem $env:APP_DIR -Recurse -Filter "uiohook-napi.node" -ErrorAction SilentlyContinue | Where-Object { $_.FullName -match "win32-x64" } | Select-Object -First 1
|
||||
if (-not $node) { throw "uiohook-napi win32-x64 prebuild not found on disk (asar swallowed it => keyboard hold-to-talk silently dead)" }
|
||||
echo "uiohook prebuild: $($node.FullName)"
|
||||
|
||||
- name: bundled python + claude CLI run on real Windows x64
|
||||
shell: pwsh
|
||||
run: |
|
||||
$py = Join-Path $env:APP_DIR "resources\python-env\python.exe"
|
||||
& $py --version
|
||||
if ($LASTEXITCODE -ne 0) { throw "python --version failed" }
|
||||
& $py -c "import fastapi, anthropic, pydantic, httpx, jsonschema, claude_agent_sdk; print('deps ok')"
|
||||
if ($LASTEXITCODE -ne 0) { throw "import smoke failed" }
|
||||
$cli = Get-ChildItem (Join-Path $env:APP_DIR "resources\python-env") -Recurse -Filter "claude*" -ErrorAction SilentlyContinue | Where-Object { $_.Directory.Name -eq "_bundled" } | Select-Object -First 1
|
||||
if (-not $cli) { throw "bundled claude CLI not found" }
|
||||
& $cli.FullName --version
|
||||
if ($LASTEXITCODE -ne 0) { throw "claude --version failed" }
|
||||
|
||||
- name: boot the installed app, poll backend health
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:OPENSWARM_E2E = "1"
|
||||
Start-Process -FilePath $env:APP_EXE
|
||||
$code = 0
|
||||
foreach ($i in 1..60) {
|
||||
Start-Sleep -Seconds 3
|
||||
try { $code = (Invoke-WebRequest -Uri "http://127.0.0.1:8324/api/health/check" -UseBasicParsing -TimeoutSec 2).StatusCode } catch { $code = 0 }
|
||||
if ($code -eq 200) { break }
|
||||
}
|
||||
echo "health=$code"
|
||||
Stop-Process -Name "OpenSwarm" -Force -ErrorAction SilentlyContinue
|
||||
if ($code -ne 200) { throw "backend never became healthy" }
|
||||
|
||||
@@ -1,21 +1,661 @@
|
||||
MIT License
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (c) 2026 Haik Decie
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Preamble
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
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
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
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
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
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
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
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
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
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/>.
|
||||
|
||||
@@ -29,6 +29,8 @@ from backend.apps.agents.manager.MockAgent import MockAgent
|
||||
from backend.apps.agents.manager.RunSupport import RunSupport
|
||||
from backend.apps.agents.manager.run.handle_run_error import handle_run_error
|
||||
from backend.apps.agents.manager.run.TurnRunner import TurnRunner
|
||||
from backend.apps.agents.manager.run.client_pool import ClientHandle
|
||||
from backend.apps.agents.manager.streaming.HookContext import HookContext
|
||||
from backend.apps.agents.manager.run.RunOptions import RunOptions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -49,9 +51,9 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
|
||||
# Per-session cancel signal: the loop stashes its asyncio.Event here so a stop/close can set it. Lives on the manager, not the AgentSession model, so it stays out of serialization (an Event can't be model_dump'd).
|
||||
self.cancel_events: Dict[str, asyncio.Event] = {}
|
||||
# Persistent-client pool (lever A, flag-gated): one live CLI per session, reused across turns.
|
||||
self.client_pool: Dict[str, object] = {}
|
||||
self.client_pool: Dict[str, ClientHandle] = {}
|
||||
# Per-SESSION hook context + stderr buffer, updated in place each turn: a persistent client's hooks/stderr callback were bound at connect, so they must read stable objects, not per-turn rebuilds.
|
||||
self.hook_ctxs: Dict[str, object] = {}
|
||||
self.hook_ctxs: Dict[str, HookContext] = {}
|
||||
self.stderr_buffers: Dict[str, List[str]] = {}
|
||||
# Admission gate: one shared semaphore caps concurrent ROOT turns (children bypass). (Re)created per running loop by get_turn_admission so it never binds to a dead loop across a uvicorn reload or a test's asyncio.run.
|
||||
self.p_turn_admission_sema: Optional[asyncio.Semaphore] = None
|
||||
|
||||
@@ -67,6 +67,14 @@ async def list_sessions(dashboard_id: str = ""):
|
||||
sessions = agent_manager.get_all_sessions(dashboard_id=dashboard_id or None)
|
||||
return {"sessions": [p_session_list_item(s) for s in sessions]}
|
||||
|
||||
@agents.router.get("/predict-prompts")
|
||||
async def predict_prompts_route(count: int = 5):
|
||||
"""Guess a few prompts the user might type next, in their own voice, from what they've already
|
||||
worked on. Drives the composer's ghost-text suggestion. Fails open to [] (no signal / no
|
||||
provider / error), so the composer just keeps its static placeholder."""
|
||||
from backend.apps.agents.manager.predict_prompts import predict_prompts
|
||||
return {"suggestions": await predict_prompts(count=max(1, min(count, 8)))}
|
||||
|
||||
@agents.router.get("/activity")
|
||||
async def agent_activity():
|
||||
"""How many agent tasks are live right now, plus seconds until the next scheduled
|
||||
|
||||
@@ -17,6 +17,10 @@ class AgentConfig(BaseModel):
|
||||
workflow_edit_id: Optional[str] = None
|
||||
# App cards the user picked to edit. When exactly one resolves, launch binds the chat's cwd to that app instead of seeding a new "Untitled App".
|
||||
selected_app_output_ids: Optional[list[str]] = None
|
||||
# Onboarding auto-launches an audit over the user's REAL files with nobody watching, so it runs
|
||||
# read-only: Read/Grep/Glob + Write (its one report) allowed, Edit/Bash/NotebookEdit hard-blocked
|
||||
# so "modify or delete an existing file" is unrepresentable, not just discouraged by the prompt.
|
||||
read_only: bool = False
|
||||
|
||||
class ApprovalRequest(BaseModel):
|
||||
id: str = Field(default_factory=lambda: uuid4().hex)
|
||||
@@ -84,6 +88,8 @@ class AgentSession(BaseModel):
|
||||
sdk_session_id: Optional[str] = None
|
||||
system_prompt: Optional[str] = None
|
||||
allowed_tools: list[str] = Field(default_factory=list)
|
||||
# Hard-block the mutation/exec tools for this session (onboarding's unattended audit); see AgentConfig.read_only.
|
||||
read_only: bool = False
|
||||
max_turns: Optional[int] = None
|
||||
cwd: Optional[str] = None
|
||||
# Resolved at session start so resume reattaches to the same repo even after the user cd's elsewhere.
|
||||
|
||||
@@ -110,6 +110,7 @@ class AgentLaunch(AgentManagerProtocol):
|
||||
mode=config.mode,
|
||||
system_prompt=config.system_prompt,
|
||||
allowed_tools=tools,
|
||||
read_only=config.read_only,
|
||||
max_turns=config.max_turns,
|
||||
cwd=effective_cwd,
|
||||
repo_url=repo_url,
|
||||
|
||||
@@ -13,10 +13,12 @@ just sits once in the MRO. Re-enables the linter's pyright reportAttributeAccess
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING, Any, Dict
|
||||
from typing import TYPE_CHECKING, Any, Dict, List
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.apps.agents.core.models import AgentSession
|
||||
from backend.apps.agents.manager.run.client_pool import ClientHandle
|
||||
from backend.apps.agents.manager.streaming.HookContext import HookContext
|
||||
from backend.apps.agents.manager.streaming.PartialReply import PartialReply
|
||||
|
||||
|
||||
@@ -26,6 +28,9 @@ class AgentManagerProtocol:
|
||||
tasks: Dict[str, asyncio.Task]
|
||||
live_partial: Dict[str, PartialReply]
|
||||
cancel_events: Dict[str, asyncio.Event]
|
||||
client_pool: Dict[str, ClientHandle]
|
||||
hook_ctxs: Dict[str, HookContext]
|
||||
stderr_buffers: Dict[str, List[str]]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Methods implemented on sibling mixins / AgentManager itself and called cross-mixin. Loose signatures on purpose: typeCheckingMode is off, so this only has to assert the names exist, not pin their call shapes.
|
||||
|
||||
@@ -19,6 +19,10 @@ from backend.apps.tools_lib.tools_lib import (
|
||||
sanitize_server_name as sanitize_server_name,
|
||||
)
|
||||
|
||||
# Mutation/exec tools a read-only session must never reach: Edit (rewrites files), Bash (rm/mv/overwrite),
|
||||
# NotebookEdit (rewrites notebooks). Write is intentionally NOT here, the audit needs its one report.
|
||||
READ_ONLY_BLOCKED_TOOLS = ("Edit", "Bash", "NotebookEdit")
|
||||
|
||||
|
||||
@typechecked
|
||||
def build_effective_tool_lists(
|
||||
@@ -76,6 +80,15 @@ def build_effective_tool_lists(
|
||||
effective_disallowed.append("mcp__openswarm-skill__Skill")
|
||||
continue
|
||||
|
||||
if name == "openswarm-ui":
|
||||
policy = builtin_perms.get("ShowUI", "always_allow")
|
||||
for ui_tool in ("ShowUI", "AskUI"):
|
||||
if policy == "always_allow":
|
||||
effective_allowed.append(f"mcp__openswarm-ui__{ui_tool}")
|
||||
else:
|
||||
effective_disallowed.append(f"mcp__openswarm-ui__{ui_tool}")
|
||||
continue
|
||||
|
||||
if name == "openswarm-web":
|
||||
# Expose our DDG-backed web tools under an MCP prefix. Honor existing WebSearch/WebFetch permission policy, if the user disabled them in Settings, don't offer the MCP variants either.
|
||||
for wt in ("WebSearch", "WebFetch"):
|
||||
@@ -109,6 +122,14 @@ def build_effective_tool_lists(
|
||||
for wt_name in ("WebSearch", "WebFetch"):
|
||||
if wt_name not in effective_disallowed:
|
||||
effective_disallowed.append(wt_name)
|
||||
# With the openswarm-ui server live, the built-in AskUserQuestion is swapped for AskUI (same
|
||||
# Agent->SpawnAgent playbook: prompt nudges lose to the trained prior, a hard deny doesn't).
|
||||
# AskUI's option-list/question-flow cover the flat-choice cases; denying the built-in is what
|
||||
# actually routes questions through the rich components.
|
||||
if "openswarm-ui" in mcp_servers:
|
||||
effective_allowed = [t for t in effective_allowed if t != "AskUserQuestion"]
|
||||
if "AskUserQuestion" not in effective_disallowed:
|
||||
effective_disallowed.append("AskUserQuestion")
|
||||
# Claude's internal Cron* scheduler is denied in favour of the visible native one; withhold it from the SDK so the model doesn't even reach for it.
|
||||
for bt in path_gate.CLAUDE_INTERNAL_SCHEDULER_TOOLS:
|
||||
if bt not in effective_disallowed:
|
||||
@@ -116,4 +137,13 @@ def build_effective_tool_lists(
|
||||
# The claude_code preset ships its own bare `Skill` tool that reads ~/.claude/skills directly; always withhold it so skills only ever load through our provider-agnostic mcp__openswarm-skill__Skill (or not at all).
|
||||
if "Skill" not in effective_disallowed:
|
||||
effective_disallowed.append("Skill")
|
||||
# Read-only session (onboarding's unattended audit over the user's real files): the mutation/exec
|
||||
# tools are HARD-blocked, not just left out of allowed, so a background agent can never modify or
|
||||
# delete an existing file. Write stays permitted for its single report. Also drop them from allowed
|
||||
# in case a preset seeded them, disallowed wins in the SDK but keep the two lists coherent.
|
||||
if getattr(session, "read_only", False):
|
||||
for dt in READ_ONLY_BLOCKED_TOOLS:
|
||||
if dt not in effective_disallowed:
|
||||
effective_disallowed.append(dt)
|
||||
effective_allowed = [t for t in effective_allowed if t not in READ_ONLY_BLOCKED_TOOLS]
|
||||
return effective_allowed, effective_disallowed
|
||||
|
||||
@@ -6,6 +6,7 @@ are the claude_agent_sdk hook protocol (hookSpecificOutput), not internal state.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Dict, Optional, Union
|
||||
|
||||
@@ -67,6 +68,22 @@ async def pre_tool_hook(ctx: HookContext, input_data: dict, tool_use_id: Optiona
|
||||
tool_name = input_data.get("tool_name", "")
|
||||
hook_event = input_data.get("hook_event_name", "PreToolUse")
|
||||
|
||||
# Read-only sessions (onboarding's unattended audit) block Edit/Bash/NotebookEdit at the tool-list
|
||||
# level, but Write survives so the audit can drop its ONE report file. Write also CLOBBERS an
|
||||
# existing path, though, so without this a read-only agent could overwrite ~/Downloads/taxes.pdf.
|
||||
# Deny Write when its target already exists: new report file yes, destroying an existing file no.
|
||||
if getattr(ctx.session, "read_only", False) and tool_name == "Write":
|
||||
p = (input_data.get("tool_input") or {}).get("file_path", "")
|
||||
if p and os.path.exists(os.path.expanduser(p)):
|
||||
note_tool_used(ctx.session_id, tool_name, False)
|
||||
return {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": hook_event,
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": "This is a read-only audit: it may create a new report but must never overwrite an existing file. Choose a filename that does not exist yet.",
|
||||
}
|
||||
}
|
||||
|
||||
# ToolSearch loop-breaker. Gated MCP servers are withheld from the SDK until MCPActivate, so the CLI's native ToolSearch can never find them; small models thrash (empty ToolSearch, retry) for minutes until the user pauses. Let the first couple through, then redirect to the gate. Any non-ToolSearch call is real progress, so the counter resets. Gated-server lookup is deferred behind the threshold so the common (non-looping) path stays free.
|
||||
if tool_name == "ToolSearch":
|
||||
ctx.ts_loop_count += 1
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Aux-LLM prompt prediction: guess a few prompts the user might type next, in their own voice,
|
||||
from what they've already worked on (recent chat topics + onboarding starters). Provider-agnostic
|
||||
(cheap tier of whichever provider is connected); fail-open to [] so the composer just falls back to
|
||||
its static placeholder when there is no signal, no provider, or the call errors."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import List
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.apps.agents.core.aux_llm import aux_max_tokens_for
|
||||
from backend.apps.agents.manager.session.session_store import load_all_session_data
|
||||
from backend.apps.settings.settings import load_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_TOPICS = 24
|
||||
MAX_SUGGESTIONS = 5
|
||||
# Don't predict someone's next prompt until we ACTUALLY know their patterns. Below this many real
|
||||
# past chats, any guess is just noise (onboarding starters alone are what they browsed at setup, not
|
||||
# a read on what they want now), so we stay silent and let the neutral placeholder stand.
|
||||
MIN_REAL_TOPICS = 4
|
||||
# Names the aux title-gen hands out for empty/greeting chats; they carry no topic signal.
|
||||
P_SKIP_NAMES = {"untitled", "new chat", "greeting", "chat", ""}
|
||||
|
||||
|
||||
def p_recent_topics(limit: int = MAX_TOPICS) -> List[str]:
|
||||
"""Recent chat topic titles (the aux-distilled 2-4 word names), newest first, deduped."""
|
||||
data = load_all_session_data()
|
||||
data.sort(
|
||||
key=lambda pair: pair[1].get("closed_at") or pair[1].get("created_at") or "",
|
||||
reverse=True,
|
||||
)
|
||||
topics: List[str] = []
|
||||
seen = set()
|
||||
for _, d in data:
|
||||
name = (d.get("name") or "").strip()
|
||||
low = name.lower()
|
||||
if low in P_SKIP_NAMES or low in seen:
|
||||
continue
|
||||
seen.add(low)
|
||||
topics.append(name)
|
||||
if len(topics) >= limit:
|
||||
break
|
||||
return topics
|
||||
|
||||
|
||||
def p_parse_lines(raw: str, count: int) -> List[str]:
|
||||
"""One suggestion per line; strip bullets/numbering/quotes, drop empties, cap at count."""
|
||||
out: List[str] = []
|
||||
for line in raw.splitlines():
|
||||
s = line.strip()
|
||||
s = re.sub(r"^\s*(?:[-*•]|\d+[.)])\s*", "", s).strip()
|
||||
s = s.strip('"“”‘’')
|
||||
if s and len(s) <= 140:
|
||||
out.append(s)
|
||||
if len(out) >= count:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
@typechecked
|
||||
async def predict_prompts(count: int = MAX_SUGGESTIONS) -> List[str]:
|
||||
"""Predict up to `count` short prompts the user might type next, in their style. [] on any miss."""
|
||||
try:
|
||||
from backend.apps.settings.credentials import get_anthropic_client_for_model
|
||||
from backend.apps.agents.providers.registry import resolve_aux_model
|
||||
|
||||
global_settings = load_settings()
|
||||
topics = p_recent_topics()
|
||||
starters = [
|
||||
(s.prompt or "").strip()
|
||||
for s in (global_settings.personalized_starters or [])
|
||||
if getattr(s, "prompt", None)
|
||||
]
|
||||
# Only predict once there's a real track record. Onboarding starters can enrich a prediction
|
||||
# but never trigger one on their own: a brand-new user hasn't shown us what they want yet.
|
||||
if len(topics) < MIN_REAL_TOPICS:
|
||||
return []
|
||||
|
||||
aux_model = (await resolve_aux_model(global_settings, preferred_tier="haiku"))[0]
|
||||
client = get_anthropic_client_for_model(global_settings, aux_model)
|
||||
|
||||
name = (global_settings.user_name or "").strip()
|
||||
signal_lines: List[str] = []
|
||||
if topics:
|
||||
signal_lines.append("Recent things they worked on: " + "; ".join(topics))
|
||||
if starters:
|
||||
signal_lines.append("Tasks they were interested in: " + "; ".join(starters[:6]))
|
||||
signal = "\n".join(signal_lines)
|
||||
|
||||
system_prompt = (
|
||||
"You predict what a user is likely to type next into their AI agent platform, based on "
|
||||
"what they already work on. You NEVER answer or explain; you only produce plausible next "
|
||||
"prompts in the USER'S voice (imperative, first person, the way someone types to their "
|
||||
"own assistant), matching their topics and phrasing.\n\n"
|
||||
f"Return exactly {count} short prompts, one per line, no numbering, no quotes, no preamble. "
|
||||
"Each is a single line under ~90 characters, concrete and immediately actionable. Vary "
|
||||
"them across the topics; do not repeat a task they clearly just finished verbatim."
|
||||
)
|
||||
user_turn = (
|
||||
(f"The user's name is {name}.\n" if name else "")
|
||||
+ "Here is what this user works on:\n<signal>\n"
|
||||
+ signal
|
||||
+ f"\n</signal>\n\nPredict {count} prompts they might type next."
|
||||
)
|
||||
|
||||
chunks: List[str] = []
|
||||
async with client.messages.stream(
|
||||
model=aux_model,
|
||||
max_tokens=aux_max_tokens_for(aux_model, base=300),
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_turn}],
|
||||
) as stream:
|
||||
async for text in stream.text_stream:
|
||||
chunks.append(text)
|
||||
return p_parse_lines("".join(chunks), count)
|
||||
except Exception as e:
|
||||
logger.info(f"[predict-prompts] fail-open ([]): {e}")
|
||||
return []
|
||||
@@ -62,7 +62,9 @@ def compose_turn_system_prompt(
|
||||
"<current_time>\n"
|
||||
f"Today is {now_local.strftime('%A, %B %-d, %Y')}.\n"
|
||||
f"Local time: {now_local.strftime('%-I:%M %p')} {tz_abbr} ({tz_name}).\n"
|
||||
"Use this as ground truth for any date/time/day-of-week question.\n"
|
||||
"Use this as ground truth for any date/time/day-of-week question. The timezone also "
|
||||
"gives the user's coarse region; when they say 'here' or 'near me' without a place, "
|
||||
"infer the likely city from it (say you inferred it) instead of claiming you can't know.\n"
|
||||
"</current_time>"
|
||||
)
|
||||
composed_prompt = (composed_prompt + "\n\n" + time_ctx) if composed_prompt else time_ctx
|
||||
@@ -89,6 +91,35 @@ def compose_turn_system_prompt(
|
||||
)
|
||||
composed_prompt = f"{composed_prompt}\n\n{apps_note}" if composed_prompt else apps_note
|
||||
|
||||
# Default-on nudge to actually REACH for the rich components; the tool descriptions alone
|
||||
# under-trigger. Skipped entirely when the user disabled ShowUI so we never advertise a dead tool.
|
||||
try:
|
||||
from backend.apps.tools_lib.tools_lib import load_builtin_permissions
|
||||
if load_builtin_permissions().get("ShowUI", "always_allow") != "deny":
|
||||
rich_ui_note = (
|
||||
"<rich_ui>\n"
|
||||
"Strongly prefer rendering rich UI over prose, every time the content fits. The tools "
|
||||
"are mcp__openswarm-ui__ShowUI and mcp__openswarm-ui__AskUI; they are always available "
|
||||
"this session, so call them DIRECTLY by that name, no ToolSearch step needed.\n"
|
||||
"- ShowUI for any structured result. Use the EXACT component name: a table = data-table, "
|
||||
"stats = stats-display, links = link-preview, a plan = plan, steps = progress-tracker, "
|
||||
"code = code-block, a diff = code-diff, a chart = chart, a map = geo-map, media = "
|
||||
"image/image-gallery/video/audio, a social post = x-post/linkedin-post/instagram-post, "
|
||||
"a receipt = order-summary. Render the component, then add one line of text.\n"
|
||||
"- For multi-step work, render a progress-tracker FIRST and re-call ShowUI with the SAME "
|
||||
"props.id after each step so the card advances live; same-id re-calls update in place.\n"
|
||||
"- AskUI for ANY question with enumerable choices, an approval, or tunable values: render "
|
||||
"it (option-list, question-flow, parameter-slider, preferences-panel, approval-card) and "
|
||||
"wait for the answer instead of asking in prose. Flat choices = option-list; a "
|
||||
"multi-question form = one AskUI call per question in sequence. The user can always "
|
||||
"answer off-list in free text (result action 'free_text').\n"
|
||||
"Describing structured data in plain text when a component fits is the worse answer.\n"
|
||||
"</rich_ui>"
|
||||
)
|
||||
composed_prompt = f"{composed_prompt}\n\n{rich_ui_note}" if composed_prompt else rich_ui_note
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# App cards the user picked via the dashboard element picker: give the agent each app's on-disk path + meta + SKILL.md pointer so it can edit them in place (the dashboard card's runtime live-reloads). Additive and independent of view-builder mode above.
|
||||
app_ctx = build_selected_app_context(selected_app_output_ids)
|
||||
if app_ctx:
|
||||
|
||||
@@ -159,6 +159,23 @@ def register_builtin_mcp_servers(
|
||||
"type": "stdio",
|
||||
}
|
||||
|
||||
# ShowUI renders rich inline components from the tool_call input (display only, server just
|
||||
# validates); AskUI renders an interactive component and BLOCKS on /api/ui-requests/wait until
|
||||
# the user answers in the transcript. Gated on the ShowUI builtin perm.
|
||||
show_ui_denied = builtin_perms.get("ShowUI", "always_allow") == "deny"
|
||||
if not show_ui_denied:
|
||||
show_ui_server_path = os.path.join(agents_dir, "show_ui_mcp_server.py")
|
||||
mcp_servers["openswarm-ui"] = {
|
||||
"command": sys.executable,
|
||||
"args": [show_ui_server_path],
|
||||
"env": {
|
||||
"OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"),
|
||||
"OPENSWARM_AUTH_TOKEN": get_auth_token(),
|
||||
"OPENSWARM_PARENT_SESSION_ID": session.id,
|
||||
},
|
||||
"type": "stdio",
|
||||
}
|
||||
|
||||
# Always-on schedule server: ScheduleWorkflow + CRUD + AddWorkflowStep/EditWorkflowStep so the agent (and the workflow Edit Agent) can build and schedule recurring work via the native scheduler instead of cron/launchctl. The 4 scheduling tools are force-gated in path_gate; Cron* is denied in build_effective_tool_lists.
|
||||
schedule_server_path = os.path.join(
|
||||
agents_dir, "schedule_mcp_server.py"
|
||||
|
||||
@@ -133,7 +133,12 @@ class RunOptions(AgentManagerProtocol):
|
||||
)
|
||||
if need_web_mcp:
|
||||
# browser_ok gates the search-dead fallback nudge: never tell the model to call CreateBrowserAgent in a session where browser delegation is denied.
|
||||
register_web_mcp_server(mcp_servers, p_m, browser_ok=bool(browser_delegation_tools))
|
||||
# rich_ui_ok plants the render-as-component reminder inside web results: the system-prompt nudge alone loses to the prose prior (live-proven on haiku).
|
||||
register_web_mcp_server(
|
||||
mcp_servers, p_m,
|
||||
browser_ok=bool(browser_delegation_tools),
|
||||
rich_ui_ok="openswarm-ui" in mcp_servers,
|
||||
)
|
||||
|
||||
effective_allowed, effective_disallowed = build_effective_tool_lists(
|
||||
session, mcp_servers, builtin_perms, need_web_mcp,
|
||||
|
||||
@@ -6,7 +6,7 @@ except-handlers can still read them after a mid-stream failure."""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, List, Union
|
||||
from typing import Dict, List, Union, cast
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.apps.agents.core.models import AgentSession
|
||||
@@ -17,6 +17,7 @@ from backend.apps.agents.manager.streaming.handle_stream_event import handle_str
|
||||
from backend.apps.agents.manager.streaming.handle_assistant_message import handle_assistant_message
|
||||
from backend.apps.agents.manager.streaming.handle_result_message import handle_result_message
|
||||
from backend.apps.agents.manager.run.client_pool import (
|
||||
SdkClientLike,
|
||||
acquire_client,
|
||||
boot_fingerprint,
|
||||
dispose_client,
|
||||
@@ -132,8 +133,9 @@ class TurnRunner(AgentManagerProtocol):
|
||||
async with handle.lock:
|
||||
handle.turns_served += 1
|
||||
try:
|
||||
await handle.client.query(prompt_stream())
|
||||
await p_run_streaming_turn(p_stream=handle.client.receive_response())
|
||||
sdk = cast(SdkClientLike, handle.client)
|
||||
await sdk.query(prompt_stream())
|
||||
await p_run_streaming_turn(p_stream=sdk.receive_response())
|
||||
# LRU by turn-END so a session mid-long-turn isn't first cap-evicted the instant it finishes.
|
||||
handle.last_used = time.monotonic()
|
||||
except BaseException:
|
||||
|
||||
@@ -14,7 +14,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Awaitable, Callable, Dict, List, Optional
|
||||
from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Optional, Protocol, cast
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, InstanceOf
|
||||
from typeguard import typechecked
|
||||
@@ -58,6 +58,15 @@ def boot_fingerprint(options_kwargs: Dict, session: AgentSession) -> str:
|
||||
return hashlib.sha256(blob.encode()).hexdigest()
|
||||
|
||||
|
||||
class SdkClientLike(Protocol):
|
||||
"""The slice of claude_agent_sdk.ClaudeSDKClient the pool touches. The real class can't be
|
||||
module-imported here (mock-mode must import the manager without the SDK), so callers cast."""
|
||||
|
||||
async def query(self, prompt: Any) -> None: ...
|
||||
def receive_response(self) -> AsyncIterator[Any]: ...
|
||||
async def disconnect(self) -> None: ...
|
||||
|
||||
|
||||
class ClientHandle(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
@@ -150,7 +159,7 @@ async def dispose_client(pool: Dict[str, ClientHandle], session_id: str) -> None
|
||||
if handle is None:
|
||||
return
|
||||
try:
|
||||
await handle.client.disconnect()
|
||||
await cast(SdkClientLike, handle.client).disconnect()
|
||||
except Exception:
|
||||
logger.exception(f"[client-pool] {session_id}: disconnect failed (subprocess may already be dead)")
|
||||
|
||||
@@ -164,7 +173,7 @@ def dispose_client_soon(pool: Dict[str, ClientHandle], session_id: str) -> None:
|
||||
|
||||
async def p_bg() -> None:
|
||||
try:
|
||||
await handle.client.disconnect()
|
||||
await cast(SdkClientLike, handle.client).disconnect()
|
||||
except Exception:
|
||||
logger.exception(f"[client-pool] {session_id}: background disconnect failed")
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ def set_framework_overhead(session: AgentSession, composed_prompt: Optional[str]
|
||||
|
||||
|
||||
@typechecked
|
||||
def register_web_mcp_server(mcp_servers: Dict, p_m: str, browser_ok: bool = False) -> None:
|
||||
def register_web_mcp_server(mcp_servers: Dict, p_m: str, browser_ok: bool = False, rich_ui_ok: bool = False) -> None:
|
||||
"""Register the DDG-backed openswarm-web stdio MCP into the server set when the primary has no
|
||||
reliable native web path. The server script lives in the agents package (not here), so resolve
|
||||
it off that package dir, not __file__."""
|
||||
@@ -125,6 +125,7 @@ def register_web_mcp_server(mcp_servers: Dict, p_m: str, browser_ok: bool = Fals
|
||||
"OPENSWARM_AUTH_TOKEN": p_get_auth_token3(),
|
||||
"OPENSWARM_PRIMARY_API": p_primary_hint,
|
||||
"OPENSWARM_BROWSER_OK": "1" if browser_ok else "0",
|
||||
"OPENSWARM_RICH_UI_OK": "1" if rich_ui_ok else "0",
|
||||
},
|
||||
"type": "stdio",
|
||||
}
|
||||
|
||||
@@ -91,23 +91,28 @@ async def post_tool_hook(ctx: HookContext, input_data: dict, tool_use_id, contex
|
||||
"yarn add", "yarn install", "yarn remove",
|
||||
))
|
||||
|
||||
if session.mode == "view-builder" and (wrote_frontend_file or installed_pkg):
|
||||
view_builder_dirty_sessions.add(session.id)
|
||||
if wrote_frontend_file or installed_pkg:
|
||||
try:
|
||||
from backend.apps.outputs.runtime import (
|
||||
manager as outputs_runtime_manager,
|
||||
)
|
||||
outputs_runtime_manager.reset_render_state_for_workspace(session.id)
|
||||
# ANY session building an app has a preview runtime attached: the dedicated view-builder AND
|
||||
# a plain agent using CreateApp (how onboarding builds its dashboard). Gate on the runtime,
|
||||
# not the mode, so the Stop render-gate covers agent-mode app builds too (a plain /frontend/
|
||||
# write with no runtime, e.g. editing OpenSwarm's own source, has none and is skipped).
|
||||
if outputs_runtime_manager.get(session.id) is not None:
|
||||
view_builder_dirty_sessions.add(session.id)
|
||||
outputs_runtime_manager.reset_render_state_for_workspace(session.id)
|
||||
if installed_pkg:
|
||||
# Tell the app card this turn changed deps so its turn-finish reload restarts Vite; a soft webview reload can't pick up newly installed packages.
|
||||
try:
|
||||
await ws_manager.send_to_session(session.id, "agent:app_deps_changed", {
|
||||
"session_id": session.id,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
if installed_pkg:
|
||||
# Tell the app card this turn changed deps so its turn-finish reload restarts Vite; a soft webview reload can't pick up newly installed packages.
|
||||
try:
|
||||
await ws_manager.send_to_session(session.id, "agent:app_deps_changed", {
|
||||
"session_id": session.id,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
# Every write drains, App Builder included. This was an `elif` on the branch above, so a view-builder frontend write took that branch and skipped the drain: the one agent whose whole job is the app never saw its own vite/babel/tsc errors.
|
||||
if wrote_files and file_path:
|
||||
errs: list[str] = []
|
||||
|
||||
@@ -26,8 +26,9 @@ async def stop_hook(ctx: HookContext, input_data: dict, tool_use_id, context) ->
|
||||
to render, blocks with the error so the agent fixes it, up to
|
||||
MAX_RETRIES then lets the stop through."""
|
||||
session = ctx.session
|
||||
if session.mode != "view-builder":
|
||||
return {}
|
||||
# Gate on the DIRTY set (any app-building session that wrote a frontend file with a runtime
|
||||
# attached), not the mode: onboarding builds its dashboard in a plain agent session via CreateApp,
|
||||
# which must be render-gated too, not just the dedicated view-builder.
|
||||
if session.id not in view_builder_dirty_sessions:
|
||||
return {}
|
||||
from backend.apps.outputs.runtime import (
|
||||
|
||||
@@ -36,7 +36,10 @@ NINEROUTER_MODEL_PREFIXES = ("cc/", "cx/", "gc/", "ag/", "gemini/", "openrouter/
|
||||
# Entry fields: value, label, context_window, model_id, router_model_id, api, subscription_only, reasoning, route ("cc"|"api"|"openrouter"|None). 9Router prefixes: cc/ Claude sub (dashes), cx/ Codex sub (dots), gc/ Gemini CLI.
|
||||
BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
|
||||
"Anthropic": [
|
||||
# Opus 4.8 (released 2026-05-28): Anthropic's flagship, recommended for the most complex work. Adaptive thinking (not extended), effort param defaults to high. 1M ctx, 128k max output, $5/$25. Verified live on the cc sub route (this app runs on it) and the API.
|
||||
# Opus 5 (added 2026-07-26): drop-in Opus 4.8 successor, same $5/$25, 1M ctx, 128k out. Thinking is ON by default; explicit thinking:disabled is only valid at effort<=high, which is all this app ever sends (run_options_helpers caps at "high"), so the off toggle stays safe.
|
||||
{"value": "opus-5", "label": "Claude Opus 5", "context_window": 1_000_000,
|
||||
"model_id": "claude-opus-5", "router_model_id": "cc/claude-opus-5", "api": "anthropic", "reasoning": True},
|
||||
# Opus 4.8 (released 2026-05-28): previous Opus flagship. Adaptive thinking (not extended), effort param defaults to high. 1M ctx, 128k max output, $5/$25. Verified live on the cc sub route (this app runs on it) and the API.
|
||||
{"value": "opus-4-8", "label": "Claude Opus 4.8", "context_window": 1_000_000,
|
||||
"model_id": "claude-opus-4-8", "router_model_id": "cc/claude-opus-4-8", "api": "anthropic", "reasoning": True},
|
||||
# Opus 4.7: SDK currently strips plaintext thinking deltas (encrypted only) so the live "Thought for Ns" pill loses mid-turn text. Final answer + tokens fine.
|
||||
@@ -52,6 +55,8 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
|
||||
{"value": "haiku", "label": "Claude Haiku 4.5", "context_window": 200_000,
|
||||
"model_id": "claude-haiku-4-5", "router_model_id": "cc/claude-haiku-4-5-20251001", "api": "anthropic", "reasoning": True},
|
||||
# cc/ pins the user's Claude sub regardless of connection_mode.
|
||||
{"value": "opus-5-cc", "label": "Claude Opus 5", "context_window": 1_000_000,
|
||||
"model_id": "claude-opus-5", "router_model_id": "cc/claude-opus-5", "api": "anthropic", "reasoning": True, "route": "cc"},
|
||||
{"value": "opus-4-8-cc", "label": "Claude Opus 4.8", "context_window": 1_000_000,
|
||||
"model_id": "claude-opus-4-8", "router_model_id": "cc/claude-opus-4-8", "api": "anthropic", "reasoning": True, "route": "cc"},
|
||||
{"value": "opus-4-7-cc", "label": "Claude Opus 4.7", "context_window": 1_000_000,
|
||||
@@ -70,6 +75,8 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
|
||||
"model_id": "claude-fable-5", "router_model_id": "cc/claude-fable-5", "api": "anthropic", "reasoning": True, "route": "cc"},
|
||||
{"value": "fable-5-api", "label": "Claude Fable 5 (API key)", "context_window": 1_000_000,
|
||||
"model_id": "claude-fable-5", "router_model_id": "claude-fable-5", "api": "anthropic", "reasoning": True, "route": "api"},
|
||||
{"value": "opus-5-api", "label": "Claude Opus 5 (API key)", "context_window": 1_000_000,
|
||||
"model_id": "claude-opus-5", "router_model_id": "claude-opus-5", "api": "anthropic", "reasoning": True, "route": "api"},
|
||||
{"value": "opus-4-8-api", "label": "Claude Opus 4.8 (API key)", "context_window": 1_000_000,
|
||||
"model_id": "claude-opus-4-8", "router_model_id": "claude-opus-4-8", "api": "anthropic", "reasoning": True, "route": "api"},
|
||||
{"value": "opus-4-7-api", "label": "Claude Opus 4.7 (API key)", "context_window": 1_000_000,
|
||||
@@ -85,7 +92,21 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
|
||||
],
|
||||
|
||||
"OpenAI": [
|
||||
# GPT-5.5 subscription entry PULLED: cx/gpt-5.5 404s on 9Router 0.3.60 (our pin), so a Codex user who picked it (the newest, top OpenAI option) 404'd every turn = "codex is broken". Same treatment as gemini-3.1-pro (no working lane = not offered). The API-key route (gpt-5.5-api below) works; restore a cx entry only after the pin moves and cx/gpt-5.5 resolves.
|
||||
# Codex sub lanes re-probed 2026-07-26: cx/gpt-5.6-{sol,terra,luna} AND the previously pulled
|
||||
# cx/gpt-5.5 all return real completions on the pinned 0.3.60 (the old 404 healed upstream;
|
||||
# the cx translator forwards to the ChatGPT Responses backend, which now serves them).
|
||||
{"value": "gpt-5.6", "label": "GPT-5.6 Sol",
|
||||
"context_window": 1_000_000, "router_model_id": "cx/gpt-5.6-sol",
|
||||
"api": "codex", "subscription_only": True, "reasoning": True},
|
||||
{"value": "gpt-5.6-terra", "label": "GPT-5.6 Terra",
|
||||
"context_window": 1_000_000, "router_model_id": "cx/gpt-5.6-terra",
|
||||
"api": "codex", "subscription_only": True, "reasoning": True},
|
||||
{"value": "gpt-5.6-luna", "label": "GPT-5.6 Luna",
|
||||
"context_window": 1_000_000, "router_model_id": "cx/gpt-5.6-luna",
|
||||
"api": "codex", "subscription_only": True, "reasoning": True},
|
||||
{"value": "gpt-5.5", "label": "GPT-5.5",
|
||||
"context_window": 1_000_000, "router_model_id": "cx/gpt-5.5",
|
||||
"api": "codex", "subscription_only": True, "reasoning": True},
|
||||
{"value": "gpt-5.4", "label": "GPT-5.4",
|
||||
"context_window": 1_000_000, "router_model_id": "cx/gpt-5.4",
|
||||
"api": "codex", "subscription_only": True, "reasoning": True},
|
||||
@@ -93,13 +114,20 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
|
||||
"context_window": 400_000, "router_model_id": "cx/gpt-5.4-mini",
|
||||
"api": "codex", "subscription_only": True, "reasoning": True},
|
||||
# gpt-5.3-codex (+ high/xhigh) removed: superseded by GPT-5.5 as OpenAI's recommended Codex model, and high/xhigh were never separate models (just reasoning-effort variants), so they were redundant clutter. API-key entries: route through 9Router's `cp-openai` provider-node (registered by sync_openai_api_key) so 9Router's translator dispatches to our local openai-passthrough proxy. The passthrough renames `max_tokens` → `max_completion_tokens` before forwarding to api.openai.com, fixing OpenAI's GPT-5 family 400. The bare router_model_id (e.g. "gpt-5.5") still appears in the request body; only the routing prefix changes.
|
||||
# GPT-5.6 (Sol / Terra / Luna, 2026-07) is HELD, not offered: it is Responses-API-only
|
||||
# (api model ids gpt-5.6-sol [alias gpt-5.6], gpt-5.6-terra, gpt-5.6-luna; per-1M in/out
|
||||
# $5/$30, $2.50/$15, $1/$6). Our lane goes user -> 9Router 0.3.60 -> cp-openai passthrough,
|
||||
# and 0.3.60 only speaks /chat/completions, so a gpt-5.6 request hits the wrong endpoint and
|
||||
# OpenAI rejects it (plus it is a trusted-partner limited preview, so most keys 404 anyway).
|
||||
# No working lane = not offered (same rule as gpt-5.5's cx entry). Enable all three tiers
|
||||
# once 9Router can translate to /v1/responses AND the model is generally available.
|
||||
# GPT-5.6 (Sol / Terra / Luna) hit GA 2026-07-09 on ChatGPT, Codex, AND /chat/completions,
|
||||
# so the old Responses-only HOLD is lifted for the API-key lane: the ids ride the proven
|
||||
# cp-openai passthrough, whose scrubs prefix-match "gpt-5" (max_tokens rename, sampling strip,
|
||||
# and the reasoning_effort-with-tools drop that 5.6 still requires on /chat/completions).
|
||||
# 1M+ ctx, 128k out; $5/$30 Sol, $2.50/$15 Terra, $1/$6 Luna per 1M.
|
||||
{"value": "gpt-5.6-api", "label": "GPT-5.6 Sol (API key)",
|
||||
"context_window": 1_000_000, "router_model_id": "cp-openai/gpt-5.6-sol", "model_id": "gpt-5.6-sol",
|
||||
"api": "openai", "reasoning": True, "route": "api"},
|
||||
{"value": "gpt-5.6-terra-api", "label": "GPT-5.6 Terra (API key)",
|
||||
"context_window": 1_000_000, "router_model_id": "cp-openai/gpt-5.6-terra", "model_id": "gpt-5.6-terra",
|
||||
"api": "openai", "reasoning": True, "route": "api"},
|
||||
{"value": "gpt-5.6-luna-api", "label": "GPT-5.6 Luna (API key)",
|
||||
"context_window": 1_000_000, "router_model_id": "cp-openai/gpt-5.6-luna", "model_id": "gpt-5.6-luna",
|
||||
"api": "openai", "reasoning": True, "route": "api"},
|
||||
{"value": "gpt-5.5-api", "label": "GPT-5.5 (API key)",
|
||||
"context_window": 1_000_000, "router_model_id": "cp-openai/gpt-5.5", "model_id": "gpt-5.5",
|
||||
"api": "openai", "reasoning": True, "route": "api"},
|
||||
@@ -118,6 +146,15 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
|
||||
"api": "gemini-cli", "subscription_only": True, "reasoning": True},
|
||||
# gemini-3-pro removed 2026-03-09 and gemini-3-flash removed 2026-07-03: gemini-3-flash-preview aged out upstream (API-key route hangs with no fail-fast; only an Antigravity sub still masked it). 3.5-flash / 3.1-flash-lite cover the slots.
|
||||
# API-key entries: bypass 9Router, call generativelanguage.googleapis.com.
|
||||
# Gemini 3.6 Flash + 3.5 Flash-Lite (both GA 2026-07-21, changelog-verified ids) are API-key
|
||||
# only for the same reason as 3.5 Flash: the pinned 0.3.60 gc/ registry predates them. No 3.5
|
||||
# Pro exists yet (delayed upstream), don't invent a row for it.
|
||||
{"value": "gemini-3.6-flash-api", "label": "Gemini 3.6 Flash (API key)",
|
||||
"context_window": 1_000_000, "router_model_id": "gemini-3.6-flash", "model_id": "gemini-3.6-flash",
|
||||
"api": "gemini", "reasoning": True, "route": "api"},
|
||||
{"value": "gemini-3.5-flash-lite-api", "label": "Gemini 3.5 Flash Lite (API key)",
|
||||
"context_window": 1_000_000, "router_model_id": "gemini-3.5-flash-lite", "model_id": "gemini-3.5-flash-lite",
|
||||
"api": "gemini", "reasoning": True, "route": "api"},
|
||||
{"value": "gemini-3.5-flash-api", "label": "Gemini 3.5 Flash (API key)",
|
||||
"context_window": 1_000_000, "router_model_id": "gemini-3.5-flash", "model_id": "gemini-3.5-flash",
|
||||
"api": "gemini", "reasoning": True, "route": "api"},
|
||||
@@ -372,9 +409,17 @@ COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = {
|
||||
("Anthropic", "opus"): (5.0, 25.0),
|
||||
("Anthropic", "opus-4-7"): (5.0, 25.0),
|
||||
("Anthropic", "opus-4-8"): (5.0, 25.0),
|
||||
("Anthropic", "opus-5"): (5.0, 25.0),
|
||||
("Anthropic", "fable-5-api"): (10.0, 50.0),
|
||||
("Anthropic", "haiku"): (1.0, 5.0),
|
||||
# OpenAI API-key rates (GPT-5.6 tiers, GA 2026-07-09)
|
||||
("OpenAI", "gpt-5.6-api"): (5.0, 30.0),
|
||||
("OpenAI", "gpt-5.6-terra-api"): (2.5, 15.0),
|
||||
("OpenAI", "gpt-5.6-luna-api"): (1.0, 6.0),
|
||||
# OpenAI; Codex subscription path, user pays nothing per token
|
||||
("OpenAI", "gpt-5.6"): (0.0, 0.0),
|
||||
("OpenAI", "gpt-5.6-terra"): (0.0, 0.0),
|
||||
("OpenAI", "gpt-5.6-luna"): (0.0, 0.0),
|
||||
("OpenAI", "gpt-5.5"): (0.0, 0.0),
|
||||
("OpenAI", "gpt-5.4"): (0.0, 0.0),
|
||||
("OpenAI", "gpt-5.4-mini"): (0.0, 0.0),
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stdio MCP server exposing ShowUI: render a rich inline component in the chat transcript.
|
||||
|
||||
Display-only. The frontend renders the component straight from the tool_call input it already
|
||||
has in the transcript, so this server just validates the payload and acknowledges; there is no
|
||||
backend round-trip and nothing here can mutate state.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
|
||||
PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
|
||||
WAIT_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/ui-requests/wait"
|
||||
ASK_TIMEOUT_S = 600
|
||||
|
||||
MAX_PROPS_BYTES = 20_000
|
||||
|
||||
# Hints + JSON Schemas for the vendored tool-ui set are GENERATED from the shipped zod contracts
|
||||
# (frontend/scripts/gen-toolui-hints.ts writes toolui_schemas.json next to this file). Loading them
|
||||
# here means the tool description and the server-side validation can never drift from what renders.
|
||||
def p_load_generated():
|
||||
try:
|
||||
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "toolui_schemas.json")) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
GENERATED = p_load_generated()
|
||||
|
||||
COMPONENT_SPECS = {
|
||||
"weather": "props: {id?: str, location: str, temp: number, unit?: 'F'|'C', high?: number, low?: number, condition?: str, forecast?: [{day: str, condition?: str, high?: number, low?: number}] (max 7)}",
|
||||
"stats": "props: {title?: str, stats: [{label: str, value: str, delta?: str, direction?: 'up'|'down'}] (max 8)}",
|
||||
"links": "props: {links: [{title: str, url: str, description?: str}] (max 10)}",
|
||||
}
|
||||
|
||||
COMPONENT_SPECS.update({name: entry["hint"] for name, entry in GENERATED.items()})
|
||||
|
||||
|
||||
INTERACTIVE_COMPONENTS = (
|
||||
"option-list", "question-flow", "parameter-slider", "preferences-panel", "approval-card",
|
||||
)
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "AskUI",
|
||||
"description": (
|
||||
"Render an INTERACTIVE component in the chat and WAIT for the user's answer (up to 10 "
|
||||
"minutes); the tool result is their response. Use this instead of plain-text questions "
|
||||
"when the choice fits a component. Components: "
|
||||
+ ", ".join(f"'{name}'" for name in INTERACTIVE_COMPONENTS)
|
||||
+ ". Props follow the same shapes as ShowUI (props.id is REQUIRED, it correlates the "
|
||||
"answer). The response contains the action taken and the user's selection/values. "
|
||||
"The user may also answer in their own words instead of picking an option; then the "
|
||||
"result is {action: 'free_text', value: {text}}, treat that text as their answer."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"component": {
|
||||
"type": "string",
|
||||
"enum": list(INTERACTIVE_COMPONENTS),
|
||||
"description": "Which interactive component to render.",
|
||||
},
|
||||
"props": {
|
||||
"type": "object",
|
||||
"description": "Data for the component; must include a stable string id.",
|
||||
},
|
||||
},
|
||||
"required": ["component", "props"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "ShowUI",
|
||||
"description": (
|
||||
"Render a rich inline UI component in the chat instead of describing data as text. "
|
||||
"Use it whenever a result fits a component. Components: "
|
||||
+ ", ".join(COMPONENT_SPECS.keys())
|
||||
+ ". Call it with the component name and a props object; if your props are off, the tool "
|
||||
"returns that component's exact required shape so you can fix and re-call. The component "
|
||||
"renders in place of raw text; still give a one-line text summary after. "
|
||||
"LIVE UPDATES: calling ShowUI again with the SAME component and props.id updates that "
|
||||
"card in place. Use this to advance progress-tracker/plan step statuses AS you complete "
|
||||
"each step of real work, or to refresh data; never mint a new id for an update. Before "
|
||||
"ending your turn, send a final same-id update with truthful terminal statuses; never "
|
||||
"leave a step marked in-progress for work you are not actually doing."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"component": {
|
||||
"type": "string",
|
||||
"enum": list(COMPONENT_SPECS.keys()),
|
||||
"description": "Which component to render.",
|
||||
},
|
||||
"props": {
|
||||
"type": "object",
|
||||
"description": "Data for the component, matching its documented shape.",
|
||||
},
|
||||
},
|
||||
"required": ["component", "props"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def send_response(id_, result=None, error=None):
|
||||
msg = {"jsonrpc": "2.0", "id": id_}
|
||||
if error is not None:
|
||||
msg["error"] = error
|
||||
else:
|
||||
msg["result"] = result
|
||||
sys.stdout.write(json.dumps(msg) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def validate(component: str, props: dict) -> str:
|
||||
if component not in COMPONENT_SPECS:
|
||||
return f"Unknown component {component!r}. Supported: {', '.join(COMPONENT_SPECS)}."
|
||||
try:
|
||||
size = len(json.dumps(props))
|
||||
except (TypeError, ValueError):
|
||||
return "props must be JSON-serializable."
|
||||
if size > MAX_PROPS_BYTES:
|
||||
return f"props too large ({size} bytes; max {MAX_PROPS_BYTES})."
|
||||
if component == "weather" and not (isinstance(props.get("location"), str) and isinstance(props.get("temp"), (int, float))):
|
||||
return f"weather needs at least location + temp. {COMPONENT_SPECS['weather']}"
|
||||
if component == "stats" and not (isinstance(props.get("stats"), list) and props["stats"]):
|
||||
return f"stats needs a non-empty stats list. {COMPONENT_SPECS['stats']}"
|
||||
if component == "links" and not (isinstance(props.get("links"), list) and props["links"]):
|
||||
return f"links needs a non-empty links list. {COMPONENT_SPECS['links']}"
|
||||
# Vendored components: validate against the GENERATED JSON Schema so a bad payload comes back
|
||||
# as a teaching error the model can fix in-turn, instead of a dead render it never hears about.
|
||||
# jsonschema gives full-constraint parity with the client zod gate (minimum/minLength/minItems
|
||||
# slipped through the hand walker: question-flow step>=1 rendered server-side, died client-side).
|
||||
entry = GENERATED.get(component)
|
||||
if entry and isinstance(entry.get("schema"), dict):
|
||||
errors = p_full_validate(props, entry["schema"])
|
||||
if errors is None:
|
||||
errors = []
|
||||
p_check(props, entry["schema"], "props", errors)
|
||||
if errors:
|
||||
return (
|
||||
f"{component} payload invalid: " + "; ".join(errors[:4])
|
||||
+ f". Full shape: {COMPONENT_SPECS[component]}. Fix the props and call the tool again."
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def p_full_validate(props: dict, schema: dict):
|
||||
"""Full JSON Schema validation via jsonschema; None = library unavailable (fallback walker runs)."""
|
||||
try:
|
||||
import jsonschema
|
||||
except ImportError:
|
||||
return None
|
||||
try:
|
||||
validator = jsonschema.Draft202012Validator(schema)
|
||||
out = []
|
||||
for err in sorted(validator.iter_errors(props), key=lambda e: len(e.path)):
|
||||
where = "props" + "".join(f".{p}" if isinstance(p, str) else f"[{p}]" for p in err.path)
|
||||
out.append(f"{where}: {err.message[:90]}")
|
||||
if len(out) >= 6:
|
||||
break
|
||||
return out
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def p_type_ok(value, t: str) -> bool:
|
||||
if t == "string":
|
||||
return isinstance(value, str)
|
||||
if t in ("number", "integer"):
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
if t == "boolean":
|
||||
return isinstance(value, bool)
|
||||
if t == "object":
|
||||
return isinstance(value, dict)
|
||||
if t == "array":
|
||||
return isinstance(value, list)
|
||||
if t == "null":
|
||||
return value is None
|
||||
return True
|
||||
|
||||
|
||||
def p_check(value, schema: dict, path: str, errors: list) -> None:
|
||||
"""Minimal JSON Schema walk: required keys, primitive types, enums, anyOf. Anything it can't
|
||||
interpret passes; the client zod contract stays the deep authority."""
|
||||
if len(errors) >= 6 or not isinstance(schema, dict):
|
||||
return
|
||||
branches = schema.get("anyOf")
|
||||
if isinstance(branches, list) and branches:
|
||||
for branch in branches:
|
||||
trial = []
|
||||
p_check(value, branch, path, trial)
|
||||
if not trial:
|
||||
return
|
||||
errors.append(f"{path} matches none of its allowed shapes")
|
||||
return
|
||||
enum = schema.get("enum")
|
||||
if isinstance(enum, list) and enum and value not in enum:
|
||||
errors.append(f"{path} must be one of {enum[:6]}")
|
||||
return
|
||||
t = schema.get("type")
|
||||
if isinstance(t, str) and not p_type_ok(value, t):
|
||||
errors.append(f"{path} must be a {t}")
|
||||
return
|
||||
if t == "object" and isinstance(value, dict):
|
||||
for key in schema.get("required", []) or []:
|
||||
if key not in value:
|
||||
errors.append(f"{path}.{key} is required")
|
||||
props = schema.get("properties") or {}
|
||||
for key, sub in props.items():
|
||||
if key in value:
|
||||
p_check(value[key], sub, f"{path}.{key}", errors)
|
||||
elif t == "array" and isinstance(value, list):
|
||||
items = schema.get("items")
|
||||
if isinstance(items, dict):
|
||||
for i, item in enumerate(value):
|
||||
p_check(item, items, f"{path}[{i}]", errors)
|
||||
|
||||
|
||||
def p_post(url: str, body: dict, timeout: float) -> dict:
|
||||
payload = json.dumps(body).encode()
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if BACKEND_AUTH:
|
||||
headers["Authorization"] = f"Bearer {BACKEND_AUTH}"
|
||||
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
body_txt = e.read().decode(errors="replace") if e.fp else str(e)
|
||||
return {"error": f"HTTP {e.code}: {body_txt[:300]}"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def handle_ask_ui(arguments: dict) -> dict:
|
||||
component = str(arguments.get("component", "")).strip()
|
||||
props = arguments.get("props")
|
||||
if not isinstance(props, dict):
|
||||
return {"content": [{"type": "text", "text": "props must be an object."}], "isError": True}
|
||||
if component not in INTERACTIVE_COMPONENTS:
|
||||
return {"content": [{"type": "text", "text": f"AskUI only supports: {', '.join(INTERACTIVE_COMPONENTS)}. Use ShowUI for display-only components."}], "isError": True}
|
||||
component_id = str(props.get("id", "")).strip()
|
||||
if not component_id:
|
||||
return {"content": [{"type": "text", "text": "props.id (a stable string) is required so the answer can be correlated."}], "isError": True}
|
||||
problem = validate(component, props)
|
||||
if problem:
|
||||
return {"content": [{"type": "text", "text": f"Not rendered: {problem}"}], "isError": True}
|
||||
r = p_post(WAIT_URL, {"session_id": PARENT_SESSION_ID, "component_id": component_id, "timeout_s": ASK_TIMEOUT_S}, timeout=ASK_TIMEOUT_S + 20)
|
||||
if "error" in r:
|
||||
return {"content": [{"type": "text", "text": f"AskUI failed: {r['error']}"}], "isError": True}
|
||||
if not r.get("ok"):
|
||||
return {"content": [{"type": "text", "text": "The user didn't respond within 10 minutes. Continue without their input or ask again."}], "isError": True}
|
||||
return {"content": [{"type": "text", "text": json.dumps(r.get("response"))}]}
|
||||
|
||||
|
||||
def handle_tool_call(tool_name: str, arguments: dict) -> dict:
|
||||
if tool_name == "AskUI":
|
||||
return handle_ask_ui(arguments)
|
||||
if tool_name != "ShowUI":
|
||||
return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}
|
||||
component = str(arguments.get("component", "")).strip()
|
||||
props = arguments.get("props")
|
||||
if not isinstance(props, dict):
|
||||
return {"content": [{"type": "text", "text": "props must be an object."}], "isError": True}
|
||||
problem = validate(component, props)
|
||||
if problem:
|
||||
return {"content": [{"type": "text", "text": f"Not rendered: {problem}"}], "isError": True}
|
||||
return {"content": [{"type": "text", "text": f"Rendered a '{component}' component inline."}]}
|
||||
|
||||
|
||||
def main():
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
method = msg.get("method")
|
||||
id_ = msg.get("id")
|
||||
params = msg.get("params", {}) or {}
|
||||
|
||||
if method == "initialize":
|
||||
send_response(id_, {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {
|
||||
"name": "openswarm-ui",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
})
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
elif method == "tools/list":
|
||||
send_response(id_, {"tools": TOOLS})
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name", "")
|
||||
arguments = params.get("arguments", {}) or {}
|
||||
result = handle_tool_call(tool_name, arguments)
|
||||
send_response(id_, result)
|
||||
elif method == "ping":
|
||||
send_response(id_, {})
|
||||
elif id_ is not None:
|
||||
send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
"""Blocking bridge for interactive tool-ui components: AskUI parks here until the user
|
||||
answers in the transcript (or the wait times out). Keyed by (session_id, component props.id),
|
||||
so the frontend can respond without ever learning a server-side request id."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, InstanceOf
|
||||
from typeguard import typechecked
|
||||
|
||||
MAX_PENDING = 50
|
||||
MAX_WAIT_SECONDS = 600.0
|
||||
|
||||
|
||||
class PendingUiRequest(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
event: InstanceOf[asyncio.Event]
|
||||
response: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
p_pending: Dict[Tuple[str, str], PendingUiRequest] = {}
|
||||
|
||||
|
||||
@typechecked
|
||||
async def wait_for_ui_response(session_id: str, component_id: str, timeout_s: float) -> Optional[Dict[str, Any]]:
|
||||
"""Registers the request and blocks until respond_to_ui_request fires it; None on timeout."""
|
||||
if len(p_pending) >= MAX_PENDING:
|
||||
raise ValueError("too many pending UI requests")
|
||||
key = (session_id, component_id)
|
||||
# A retried tool call for the same component replaces the stale wait; the old waiter times out.
|
||||
pending = PendingUiRequest(event=asyncio.Event())
|
||||
p_pending[key] = pending
|
||||
try:
|
||||
await asyncio.wait_for(pending.event.wait(), timeout=min(timeout_s, MAX_WAIT_SECONDS))
|
||||
return pending.response
|
||||
except asyncio.TimeoutError:
|
||||
return None
|
||||
finally:
|
||||
if p_pending.get(key) is pending:
|
||||
p_pending.pop(key, None)
|
||||
|
||||
|
||||
@typechecked
|
||||
def respond_to_ui_request(session_id: str, component_id: str, response: Dict[str, Any]) -> bool:
|
||||
"""Delivers the user's answer to the parked wait; False when nothing is waiting."""
|
||||
pending = p_pending.get((session_id, component_id))
|
||||
if pending is None:
|
||||
return False
|
||||
pending.response = response
|
||||
pending.event.set()
|
||||
return True
|
||||
@@ -16,6 +16,15 @@ FETCH_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/web/fetch"
|
||||
PRIMARY_HINT = os.environ.get("OPENSWARM_PRIMARY_API", "") or None
|
||||
# Whether this session actually has browser-delegation tools; gates the backend's "fall back to the browser" nudge.
|
||||
BROWSER_OK = os.environ.get("OPENSWARM_BROWSER_OK", "0") == "1"
|
||||
# Whether the openswarm-ui server is live this session. The render-as-component reminder rides the
|
||||
# tool RESULT because that's what the model reads right before answering; the system-prompt nudge
|
||||
# alone loses to the prose prior (live-proven on haiku).
|
||||
RICH_UI_OK = os.environ.get("OPENSWARM_RICH_UI_OK", "0") == "1"
|
||||
RICH_UI_HINT = (
|
||||
"\n\n[presentation] When you answer the user with this data, render it with the ShowUI tool "
|
||||
"(weather for forecasts, data-table for rows, stats-display for metrics, links for sources, "
|
||||
"chart for series) and keep prose to one line. Answer in plain text only if no component fits."
|
||||
)
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
@@ -115,6 +124,8 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict:
|
||||
results = r.get("results", "")
|
||||
if not results:
|
||||
results = f"No results for: {query}"
|
||||
elif RICH_UI_OK:
|
||||
results += RICH_UI_HINT
|
||||
return {"content": [{"type": "text", "text": results}]}
|
||||
|
||||
if tool_name == "WebFetch":
|
||||
@@ -135,6 +146,8 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict:
|
||||
content = r.get("content", "")
|
||||
if not content:
|
||||
content = f"No content returned from {url}"
|
||||
elif RICH_UI_OK:
|
||||
content += RICH_UI_HINT
|
||||
return {"content": [{"type": "text", "text": content}]}
|
||||
|
||||
return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}
|
||||
|
||||
@@ -20,6 +20,8 @@ class ViewCardPosition(BaseModel):
|
||||
y: float = 0
|
||||
width: float = 480
|
||||
height: float = 360
|
||||
# Chat session this app preview lives inside (renders over the chat's dock slot); None = free card.
|
||||
docked_to: Optional[str] = None
|
||||
|
||||
|
||||
class BrowserTab(BaseModel):
|
||||
@@ -44,6 +46,8 @@ class BrowserCardPosition(BaseModel):
|
||||
keep_open: bool = False
|
||||
# The dashboard this card calls home. Persisted so the home survives a save; without it the card reloads untagged and renders on EVERY dashboard (the cross-dashboard bleed).
|
||||
dashboard_id: Optional[str] = None
|
||||
# Chat session this browser lives inside (renders over the chat's dock slot); None = free card.
|
||||
docked_to: Optional[str] = None
|
||||
|
||||
|
||||
class NotePosition(BaseModel):
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Diagnostic bundle for bug reports: one folder a user can drag into a GitHub issue.
|
||||
|
||||
Everything is assembled LOCALLY and only revealed in the file manager; nothing uploads
|
||||
anywhere by itself. Contents are deliberately allowlisted (identity, versions, feature
|
||||
booleans, provider KINDS, counts, log tail) so no secret or API key can ever ride along.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator, List
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.config.Apps import SubApp
|
||||
from backend.config.paths import DATA_ROOT, SESSIONS_DIR
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def help_lifespan() -> AsyncIterator[None]:
|
||||
yield
|
||||
|
||||
|
||||
help_app = SubApp("help", help_lifespan)
|
||||
|
||||
DIAG_DIR = os.path.join(DATA_ROOT, "diagnostics")
|
||||
LOG_TAIL_LINES = 200
|
||||
MAX_ATTACHMENTS = 6
|
||||
MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024
|
||||
# Key-shaped strings never belong in a shareable report, even from free-text log lines.
|
||||
P_SECRET_RE = re.compile(r"(sk-[A-Za-z0-9\-]{8,}|Bearer\s+\S+|api[_-]?key[\"']?\s*[:=]\s*\S+)", re.IGNORECASE)
|
||||
|
||||
|
||||
class BundleAttachment(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
name: str
|
||||
data_b64: str
|
||||
|
||||
|
||||
class BundleRequest(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
kind: str = "bug"
|
||||
description: str = ""
|
||||
attachments: List[BundleAttachment] = Field(default_factory=list)
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_safe_name(name: str) -> str:
|
||||
base = os.path.basename(name or "attachment")
|
||||
return re.sub(r"[^A-Za-z0-9._-]", "_", base)[:80] or "attachment"
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_scrub(text: str) -> str:
|
||||
return P_SECRET_RE.sub("[redacted]", text)
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_log_tail() -> str:
|
||||
"""Last lines of the backend log when packaged (Electron writes backend.log next to the data
|
||||
root); dev runs log to the terminal, so a missing file just yields an honest note."""
|
||||
candidates = [
|
||||
os.path.join(os.path.dirname(DATA_ROOT), "backend.log"),
|
||||
os.path.join(DATA_ROOT, "backend.log"),
|
||||
]
|
||||
for p in candidates:
|
||||
try:
|
||||
if os.path.isfile(p):
|
||||
with open(p, "r", errors="replace") as fh:
|
||||
lines = fh.readlines()[-LOG_TAIL_LINES:]
|
||||
return p_scrub("".join(lines))
|
||||
except Exception:
|
||||
continue
|
||||
return "(no backend.log found; dev runs log to the terminal)"
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_count_dir(path: str) -> int:
|
||||
try:
|
||||
return len(os.listdir(path))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_build_report(req: BundleRequest) -> str:
|
||||
from backend.apps.settings.store import load_settings
|
||||
|
||||
s = load_settings()
|
||||
provider_kinds: List[str] = []
|
||||
if getattr(s, "anthropic_api_key", None):
|
||||
provider_kinds.append("anthropic-key")
|
||||
if getattr(s, "openai_api_key", None):
|
||||
provider_kinds.append("openai-key")
|
||||
if getattr(s, "free_trial_token", None):
|
||||
provider_kinds.append("free-trial")
|
||||
facts = {
|
||||
"kind": req.kind,
|
||||
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"app_version": os.environ.get("OPENSWARM_APP_VERSION", "dev"),
|
||||
"platform": f"{platform.system()} {platform.release()} ({platform.machine()})",
|
||||
"python": sys.version.split()[0],
|
||||
"packaged": os.environ.get("OPENSWARM_PACKAGED") == "1",
|
||||
"installation_id": getattr(s, "installation_id", None),
|
||||
"user_email": getattr(s, "user_email", None),
|
||||
"signin_method": getattr(s, "signin_method", None),
|
||||
"default_model": getattr(s, "default_model", None),
|
||||
"connection_mode": getattr(s, "connection_mode", None),
|
||||
"provider_kinds": provider_kinds,
|
||||
"session_count": p_count_dir(SESSIONS_DIR),
|
||||
"theme": getattr(s, "theme", None),
|
||||
}
|
||||
lines = [
|
||||
f"# OpenSwarm {('bug report' if req.kind == 'bug' else 'feature request')}",
|
||||
"",
|
||||
"## What the user reported",
|
||||
req.description.strip() or "(no description)",
|
||||
"",
|
||||
"## Environment",
|
||||
"```json",
|
||||
json.dumps(facts, indent=2, default=str),
|
||||
"```",
|
||||
"",
|
||||
"## Recent backend log",
|
||||
"```",
|
||||
p_log_tail(),
|
||||
"```",
|
||||
"",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@help_app.router.post("/bundle")
|
||||
@typechecked
|
||||
async def build_bundle(body: BundleRequest) -> dict:
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
folder = os.path.join(DIAG_DIR, f"report-{stamp}")
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
report_path = os.path.join(folder, "diagnostic-report.md")
|
||||
with open(report_path, "w") as fh:
|
||||
fh.write(p_build_report(body))
|
||||
saved: List[str] = []
|
||||
for att in body.attachments[:MAX_ATTACHMENTS]:
|
||||
try:
|
||||
raw = base64.b64decode(att.data_b64)
|
||||
if len(raw) > MAX_ATTACHMENT_BYTES:
|
||||
continue
|
||||
dest = os.path.join(folder, p_safe_name(att.name))
|
||||
with open(dest, "wb") as fh:
|
||||
fh.write(raw)
|
||||
saved.append(os.path.basename(dest))
|
||||
except Exception:
|
||||
continue
|
||||
return {"folder": folder, "report": report_path, "attachments": saved}
|
||||
@@ -21,6 +21,18 @@ SCAN_FOLDERS = ("Downloads", "Desktop", "Documents")
|
||||
REPO_PARENT_CANDIDATES = ("dev", "code", "projects", "src", "repos", "Documents/GitHub")
|
||||
SCREENSHOT_PREFIXES = ("screenshot", "screen shot", "screen recording")
|
||||
|
||||
# The high-signal tools that actually tell prep who this person is (an IDE, a design app, a DAW) so the greeting can lead with a confident read instead of drowning in Calculator + System Settings. Matched by exact name or "name " prefix so "Arc" never trips on "Search".
|
||||
NOTABLE_APP_KEYWORDS = (
|
||||
"xcode", "visual studio code", "cursor", "zed", "sublime text", "intellij idea",
|
||||
"pycharm", "webstorm", "android studio", "docker", "orbstack", "postman", "iterm",
|
||||
"warp", "ghostty", "tableplus", "github desktop", "figma", "sketch",
|
||||
"adobe photoshop", "adobe illustrator", "adobe xd", "affinity", "framer", "blender",
|
||||
"cinema 4d", "final cut pro", "davinci resolve", "adobe premiere pro",
|
||||
"adobe after effects", "screenflow", "capcut", "obs", "logic pro", "ableton live",
|
||||
"fl studio", "garageband", "unity", "unreal engine", "godot", "notion", "obsidian",
|
||||
"ulysses", "scrivener", "craft", "linear", "tableau", "rstudio", "ollama", "lm studio", "arc",
|
||||
)
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_list_apps() -> List[str]:
|
||||
@@ -66,6 +78,16 @@ def p_summarize_folder(folder: Path) -> FolderSummary:
|
||||
return summary
|
||||
|
||||
|
||||
@typechecked
|
||||
def detect_signal_apps(apps: List[str]) -> List[str]:
|
||||
out: List[str] = []
|
||||
for name in apps:
|
||||
low = name.lower()
|
||||
if any(low == kw or low.startswith(kw + " ") for kw in NOTABLE_APP_KEYWORDS):
|
||||
out.append(name)
|
||||
return out[:20]
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_count_git_repos(home: Path) -> int:
|
||||
count = 0
|
||||
@@ -90,8 +112,10 @@ def p_count_git_repos(home: Path) -> int:
|
||||
@typechecked
|
||||
def run_local_scan(home: Path) -> ScanResult:
|
||||
folders = [p_summarize_folder(home / name) for name in SCAN_FOLDERS]
|
||||
apps = p_list_apps()
|
||||
return ScanResult(
|
||||
apps=p_list_apps(),
|
||||
apps=apps,
|
||||
signal_apps=detect_signal_apps(apps),
|
||||
folders=folders,
|
||||
git_repo_count=p_count_git_repos(home),
|
||||
has_gitconfig=(home / ".gitconfig").is_file(),
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from backend.apps.settings.models import PersonalizedStarter
|
||||
from backend.apps.settings.models import PersonalizedAutomation, PersonalizedMenu, PersonalizedStarter
|
||||
|
||||
|
||||
class ProviderIdentity(BaseModel):
|
||||
@@ -35,6 +35,8 @@ class ScanResult(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
apps: List[str] = Field(default_factory=list)
|
||||
# The high-signal subset of apps (IDEs, design/creative tools); the profile leans on these.
|
||||
signal_apps: List[str] = Field(default_factory=list)
|
||||
folders: List[FolderSummary] = Field(default_factory=list)
|
||||
git_repo_count: int = 0
|
||||
has_gitconfig: bool = False
|
||||
@@ -45,10 +47,35 @@ class PrepRequest(BaseModel):
|
||||
|
||||
scan: Optional[ScanResult] = None
|
||||
picked_apps: List[str] = Field(default_factory=list)
|
||||
identity: List[ProviderIdentity] = Field(default_factory=list)
|
||||
usage_summary: str = ""
|
||||
|
||||
|
||||
class PrepResponse(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
# A punchy <=10-word identity hook, read at a glance in the reveal's focal beat (the greeting is the
|
||||
# longer warm read for the chat; the headline is the scannable one-liner most people actually read).
|
||||
headline: str = ""
|
||||
# 2-4 word identity titles tailored to this user; the Swarm Card leads with these over the static list.
|
||||
epithets: List[str] = Field(default_factory=list)
|
||||
greeting: str = ""
|
||||
starters: List[PersonalizedStarter] = Field(default_factory=list)
|
||||
app_title: str = ""
|
||||
app_prompt: str = ""
|
||||
app_reason: str = ""
|
||||
# The "looked into this for you" card: a live web-research task aimed at the ONE thing this user
|
||||
# keeps asking their AI about, so the reveal shows OpenSwarm going and finding it, not just planning.
|
||||
research_title: str = ""
|
||||
research_prompt: str = ""
|
||||
research_reason: str = ""
|
||||
# The "watch it drive a real browser" card: an agent opens a public site and does a multi-step task
|
||||
# live, so the reveal shows off browser control alongside app-building, research, and scheduling.
|
||||
browser_title: str = ""
|
||||
browser_prompt: str = ""
|
||||
browser_reason: str = ""
|
||||
automations: List[PersonalizedAutomation] = Field(default_factory=list)
|
||||
# The hero's two-level menu (4 categories x 4 tailored starters); None only if prep never ran.
|
||||
menu: Optional[PersonalizedMenu] = None
|
||||
# False = scan-grounded fallback (no aux lane at call time); lets the pipeline re-run prep once a real connect lands.
|
||||
used_llm: bool = False
|
||||
|
||||
@@ -5,15 +5,29 @@ the user's machine and providers; nothing here mutates state or leaves the box
|
||||
except the prep call, which goes to the user's own configured model.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Awaitable
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
# Hard cap on any single provider harvest so a wedged endpoint can't hold the whole reveal hostage
|
||||
# (the raw-httpx paths already carry a 20s per-request timeout; this bounds the multi-request loop).
|
||||
P_HARVEST_BUDGET_S = 26.0
|
||||
|
||||
|
||||
async def p_budgeted(coro: Awaitable[str]) -> str:
|
||||
"""Await a harvest under the budget; any failure (timeout, provider down) fails open to ''."""
|
||||
try:
|
||||
return await asyncio.wait_for(coro, timeout=P_HARVEST_BUDGET_S)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
from backend.apps.onboarding.identity import build_identity
|
||||
from backend.apps.onboarding.local_scan import run_local_scan
|
||||
from backend.apps.onboarding.models import PrepRequest
|
||||
from backend.apps.onboarding.prep import build_prep
|
||||
from backend.apps.onboarding.prep.build_prep import build_prep
|
||||
from backend.config.Apps import SubApp
|
||||
|
||||
|
||||
@@ -43,6 +57,28 @@ def post_scan() -> dict:
|
||||
@onboarding.router.post("/prep")
|
||||
@typechecked
|
||||
async def post_prep(body: PrepRequest) -> dict:
|
||||
from backend.apps.onboarding.usage.chatgpt_usage import harvest_chatgpt_usage
|
||||
from backend.apps.onboarding.usage.claude_usage import harvest_claude_usage
|
||||
from backend.apps.settings.store import load_settings
|
||||
|
||||
# ALWAYS read the ENTIRE recent conversations (not just titles) from the rich providers, ChatGPT via
|
||||
# the codex connect token (platform-independent), Claude via the user's own browser session cookies,
|
||||
# and PREFER that over whatever the frontend read. The frontend reads only the single connected
|
||||
# provider, which for a Gemini/antigravity user is a titles-only DOM scrape that can surface a stale
|
||||
# topic (the "skincare app" the user hasn't touched in ages). Multiple providers connected? We take
|
||||
# all the rich ones and let the clustering pass merge them. Each fails open to "", so a missing one
|
||||
# drops. Harvested in PARALLEL under a budget: stacked awaits added a ~15s ChatGPT pull ON TOP of a
|
||||
# ~8s Claude pull for ~23s of dead reveal time; gathered they overlap to the slower of the two.
|
||||
chatgpt, claude = await asyncio.gather(
|
||||
p_budgeted(harvest_chatgpt_usage()),
|
||||
p_budgeted(harvest_claude_usage()),
|
||||
)
|
||||
parts: list[str] = []
|
||||
if chatgpt:
|
||||
parts.append("ChatGPT conversations:\n" + chatgpt)
|
||||
if claude:
|
||||
parts.append("Claude conversations:\n" + claude)
|
||||
if parts:
|
||||
body.usage_summary = "\n\n".join(parts) # entire-chat content wins over the frontend's titles
|
||||
|
||||
return (await build_prep(load_settings(), body)).model_dump()
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
"""Turn the local scan + app picks into a personalized greeting and starters.
|
||||
|
||||
One cheap aux call on whatever lane the user just connected; every failure path
|
||||
returns the static fallback so the reveal can never be an error card.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.apps.agents.core.aux_llm import aux_max_tokens_for, safe_resp_text
|
||||
from backend.apps.onboarding.models import PrepRequest, PrepResponse
|
||||
from backend.apps.settings.models import AppSettings, PersonalizedStarter
|
||||
|
||||
FALLBACK_STARTERS: List[PersonalizedStarter] = [
|
||||
PersonalizedStarter(title="Clean up Downloads", prompt="Sort my Downloads folder into tidy subfolders. Show me the plan before moving anything."),
|
||||
PersonalizedStarter(title="Research something", prompt="Research the best noise-cancelling headphones under $300 and give me a comparison table."),
|
||||
PersonalizedStarter(title="Build a tiny app", prompt="Build me a simple habit tracker app I can use right now."),
|
||||
PersonalizedStarter(title="Plan a trip", prompt="Plan a 3-day weekend trip itinerary and turn it into a printable page."),
|
||||
]
|
||||
|
||||
P_SYSTEM = (
|
||||
"You write first-run starter tasks for OpenSwarm, a desktop AI agent platform that can "
|
||||
"organize local files, browse the web in a real browser, build small apps, and run agents in parallel. "
|
||||
"Given facts about the user's machine and the apps they picked, respond with STRICT JSON only: "
|
||||
'{"greeting": string, "starters": [{"title": string, "prompt": string}]}. '
|
||||
"Exactly 4 starters. Each title is 2-5 words. Each prompt is a concrete, safe, immediately runnable task "
|
||||
"referencing the user's real folders, file counts, or picked apps when possible; never invent facts, never "
|
||||
"propose deleting anything without review. The greeting is one warm sentence that names 2-3 specific things "
|
||||
"found on the machine. No markdown, no commentary, JSON only."
|
||||
)
|
||||
|
||||
|
||||
@typechecked
|
||||
def parse_prep(text: str) -> Optional[PrepResponse]:
|
||||
match = re.search(r"\{.*\}", text, re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(match.group(0))
|
||||
starters = [
|
||||
PersonalizedStarter(title=str(s.get("title", "")).strip(), prompt=str(s.get("prompt", "")).strip())
|
||||
for s in data.get("starters", [])
|
||||
if isinstance(s, dict) and str(s.get("title", "")).strip() and str(s.get("prompt", "")).strip()
|
||||
]
|
||||
if not starters:
|
||||
return None
|
||||
return PrepResponse(greeting=str(data.get("greeting", "")).strip(), starters=starters[:4])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@typechecked
|
||||
async def build_prep(settings: AppSettings, request: PrepRequest) -> PrepResponse:
|
||||
facts = request.model_dump()
|
||||
try:
|
||||
from backend.apps.agents.providers.registry import resolve_aux_model
|
||||
from backend.apps.settings.credentials import get_anthropic_client_for_model
|
||||
|
||||
aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
|
||||
client = get_anthropic_client_for_model(settings, aux_model)
|
||||
resp = await client.messages.create(
|
||||
model=aux_model,
|
||||
max_tokens=aux_max_tokens_for(aux_model, base=700),
|
||||
system=P_SYSTEM,
|
||||
messages=[{"role": "user", "content": json.dumps(facts)}],
|
||||
)
|
||||
parsed = parse_prep(safe_resp_text(resp))
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
except Exception:
|
||||
pass
|
||||
return PrepResponse(greeting="", starters=list(FALLBACK_STARTERS))
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Turn the local scan + app picks into a personalized greeting and starters.
|
||||
|
||||
One cheap aux call on whatever lane the user just connected; every failure path
|
||||
returns the static fallback so the reveal can never be an error card.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.apps.agents.core.aux_llm import aux_max_tokens_for, safe_resp_text
|
||||
from backend.apps.onboarding.models import PrepRequest, PrepResponse
|
||||
from backend.apps.onboarding.prep.menu import build_menu
|
||||
from backend.apps.onboarding.prep.parse_helpers import strip_dashes
|
||||
from backend.apps.onboarding.prep.parse_prep import parse_prep
|
||||
from backend.apps.onboarding.prep.prompts import PROFILE_SYSTEM, PREP_SYSTEM
|
||||
from backend.apps.onboarding.prep.scan_fallback import scan_grounded_fallback
|
||||
from backend.apps.settings.models import AppSettings
|
||||
|
||||
# Below this the usage text is just titles/memories (thin); above it there is real conversation content
|
||||
# worth a distill pass. Keep the distill input bounded so the cheap call stays a couple cents.
|
||||
P_PROFILE_DISTILL_THRESHOLD = 1500
|
||||
P_PROFILE_INPUT_CAP = 140000
|
||||
|
||||
|
||||
@typechecked
|
||||
async def p_distill_profile(settings: AppSettings, usage_text: str) -> str:
|
||||
"""One cheap aux call: raw chat content -> a tight 'who is this person' profile. "" on any failure,
|
||||
so build_prep just falls back to feeding the raw usage text (today's behavior)."""
|
||||
try:
|
||||
from backend.apps.agents.providers.registry import resolve_aux_model
|
||||
from backend.apps.settings.credentials import get_anthropic_client_for_model
|
||||
|
||||
aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
|
||||
client = get_anthropic_client_for_model(settings, aux_model)
|
||||
resp = await client.messages.create(
|
||||
model=aux_model,
|
||||
max_tokens=aux_max_tokens_for(aux_model, base=600),
|
||||
system=PROFILE_SYSTEM,
|
||||
messages=[{"role": "user", "content": usage_text[:P_PROFILE_INPUT_CAP]}],
|
||||
timeout=45.0,
|
||||
)
|
||||
return strip_dashes(safe_resp_text(resp).strip())
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
@typechecked
|
||||
async def build_prep(settings: AppSettings, request: PrepRequest) -> PrepResponse:
|
||||
from datetime import date
|
||||
|
||||
facts = request.model_dump()
|
||||
# The aux otherwise assumes its training-cutoff year and writes stale ranges like "2024-2025"
|
||||
# into research prompts; telling it today's date keeps "current" meaning current.
|
||||
facts["today"] = date.today().isoformat()
|
||||
# If the usage text carries real conversation content, distill it to a tight profile FIRST so the
|
||||
# reveal call reasons over who this person is, not raw logs (and stays in budget). Fail-open: a blank
|
||||
# profile just leaves the raw text in place, which is today's behavior.
|
||||
usage = str(facts.get("usage_summary", ""))
|
||||
if len(usage) > P_PROFILE_DISTILL_THRESHOLD:
|
||||
profile = await p_distill_profile(settings, usage)
|
||||
if profile:
|
||||
facts["usage_summary"] = profile
|
||||
# The hero's 4x4 drill-in menu rides its own parallel aux call; build_menu never raises.
|
||||
menu_task = asyncio.create_task(build_menu(settings, facts, request.scan))
|
||||
try:
|
||||
from backend.apps.agents.providers.registry import resolve_aux_model
|
||||
from backend.apps.settings.credentials import get_anthropic_client_for_model
|
||||
|
||||
aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
|
||||
client = get_anthropic_client_for_model(settings, aux_model)
|
||||
resp = await client.messages.create(
|
||||
model=aux_model,
|
||||
# The full shape (greeting + 4 starters w/ prompts + app + 3 automations) runs ~1.5-2K
|
||||
# tokens for a rich user; 1100 truncated the JSON mid-object so parse silently fell back.
|
||||
max_tokens=aux_max_tokens_for(aux_model, base=2200),
|
||||
system=PREP_SYSTEM,
|
||||
messages=[{"role": "user", "content": json.dumps(facts)}],
|
||||
# Bound the wait: the SDK default is ~10min, so a wedged router/provider would hang the
|
||||
# auto-launch (which awaits this) instead of degrading to the static starters.
|
||||
timeout=45.0,
|
||||
)
|
||||
parsed = parse_prep(safe_resp_text(resp))
|
||||
if parsed is not None:
|
||||
parsed.menu = await menu_task
|
||||
parsed.used_llm = True
|
||||
return parsed
|
||||
except Exception:
|
||||
pass
|
||||
# Aux unusable (empty gemini/codex response on 0.3.60, provider down, no anthropic lane): still
|
||||
# ground the reveal in the real scan rather than shipping generic stubs.
|
||||
fallback = scan_grounded_fallback(request)
|
||||
fallback.menu = await menu_task
|
||||
return fallback
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Build the hero's two-level menu: 4 general categories x 4 starters tailored to this user.
|
||||
|
||||
Runs as its own cheap aux call BESIDE the main prep call, so a failure here can never cost
|
||||
the reveal, and every failure path fills from scan-grounded + static rows, so the menu the
|
||||
dashboard hero drills into is always complete.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import List, Optional
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.apps.agents.core.aux_llm import aux_max_tokens_for, safe_resp_text
|
||||
from backend.apps.onboarding.models import ScanResult
|
||||
from backend.apps.onboarding.prep.parse_helpers import build_starters, load_json_object, normalize_json_text
|
||||
from backend.apps.settings.models import AppSettings, PersonalizedMenu, PersonalizedStarter
|
||||
|
||||
MENU_CATEGORIES = ("computer", "research", "web", "build")
|
||||
MENU_SIZE = 4
|
||||
|
||||
P_MENU_SYSTEM = (
|
||||
"You write starter tasks for OpenSwarm, a desktop AI agent platform that can organize local files, "
|
||||
"browse the web in a real browser, build small apps, and run agents in parallel. Given facts about the "
|
||||
"user (their machine scan, picked apps, and usage_summary, a profile distilled from their real AI "
|
||||
"conversations, the STRONGEST signal), respond with STRICT JSON only: "
|
||||
'{"computer": [{"title": string, "prompt": string}], "research": [{"title": string, "prompt": string}], '
|
||||
'"web": [{"title": string, "prompt": string}], "build": [{"title": string, "prompt": string}]}. '
|
||||
"EXACTLY 4 items per category, 16 total, every one tailored to THIS person. "
|
||||
"computer: tasks on THIS machine's real files and folders (use their actual folder names and file kinds "
|
||||
"from the scan); each READS real files and writes ONE new artifact, and must NEVER modify or delete an "
|
||||
"existing file. "
|
||||
"research: live web research on topics THIS user actually cares about (from usage_summary); each demands "
|
||||
"current information with dated sources and produces an actual answer or comparison, never a plan. "
|
||||
"web: the agent OPENS a named real public website and does a genuinely multi-step task there (navigate, "
|
||||
"search, click through, compare, report); read-only public browsing, never log in, buy, post, or act on "
|
||||
"the user's behalf. "
|
||||
"build: small working tools for this person's real needs; each prompt starts with 'Build me', is fully "
|
||||
"client-side and self-contained with deterministic logic only (math, parsing, formatting, charts), and "
|
||||
"must NEVER call an AI model or any network API. "
|
||||
"Span the person's DISTINCT life threads: within each category at most ONE item may touch their work or "
|
||||
"company; the rest come from their other real threads (sport, food, hobbies, curiosities, practical "
|
||||
"needs) actually present in the input. "
|
||||
"Titles are 2-5 plain words saying what the task does, never clever or punny. Prompts are concrete, "
|
||||
"immediately runnable instructions; never invent facts not in the input. "
|
||||
"Never use em-dashes or en-dashes. No markdown, no commentary, JSON only."
|
||||
)
|
||||
|
||||
P_STATIC_MENU: dict[str, List[PersonalizedStarter]] = {
|
||||
"computer": [
|
||||
PersonalizedStarter(title="Clean up Downloads", prompt="Sort my Downloads folder into tidy subfolders. Show me the plan before moving anything."),
|
||||
PersonalizedStarter(title="Find my biggest files", prompt="Scan my home folder for the largest files and folders and write me a reviewable page listing them with sizes. Do not delete anything."),
|
||||
PersonalizedStarter(title="Index my documents", prompt="Look through my Documents folder and build one searchable index page listing files by name and date so I can find things fast. Write only the new page."),
|
||||
PersonalizedStarter(title="Find duplicate files", prompt="Look for duplicate files across my Downloads and Desktop and write a report listing them side by side. Never delete anything without my review."),
|
||||
],
|
||||
"research": [
|
||||
PersonalizedStarter(title="Compare before I buy", prompt="Ask me what I'm shopping for, then research current options and give me a tight comparison table with dated sources."),
|
||||
PersonalizedStarter(title="What's new in AI", prompt="Search the web for the most useful AI tools and model releases from the past month and summarize the ones worth my time, with dated sources."),
|
||||
PersonalizedStarter(title="Settle a question", prompt="Ask me one question I've been meaning to look into, then research it properly and give me a current, sourced answer."),
|
||||
PersonalizedStarter(title="Plan a weekend trip", prompt="Ask me where I'd like to go, then research a realistic 3-day itinerary with current prices and opening hours, and turn it into a printable page."),
|
||||
],
|
||||
"web": [
|
||||
PersonalizedStarter(title="Find a table tonight", prompt="Open OpenTable and find three well-reviewed restaurants near me with availability tonight, compare them, and report back."),
|
||||
PersonalizedStarter(title="Watch a flight price", prompt="Ask me for a route, then open Google Flights, search it, compare dates and airlines across a few pages, and report the best current options."),
|
||||
PersonalizedStarter(title="Best rated near me", prompt="Open Google Maps, search for the best-rated coffee shops nearby, read through reviews on a few of them, and tell me which one to try and why."),
|
||||
PersonalizedStarter(title="Catch me up on tech", prompt="Open Hacker News, read through today's top discussions, and give me the three most interesting threads with what people are actually saying."),
|
||||
],
|
||||
"build": [
|
||||
PersonalizedStarter(title="Habit tracker", prompt="Build me a simple habit tracker app I can use right now."),
|
||||
PersonalizedStarter(title="Bill splitter", prompt="Build me a tool where I enter a bill total, tip, and the people involved, and it computes exactly who owes what."),
|
||||
PersonalizedStarter(title="CSV instant charts", prompt="Build me a tool where I paste CSV data and it instantly renders clean charts I can screenshot."),
|
||||
PersonalizedStarter(title="Countdown to a date", prompt="Build me a countdown page for a date that matters to me; ask me the date and label, then make it look great."),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_scan_grounded_rows(category: str, scan: Optional[ScanResult]) -> List[PersonalizedStarter]:
|
||||
"""No-LLM personalization: ground a couple of rows per category in the real scan."""
|
||||
if scan is None:
|
||||
return []
|
||||
out: List[PersonalizedStarter] = []
|
||||
if category == "computer":
|
||||
shots = next((f for f in scan.folders if f.screenshot_count > 2), None)
|
||||
if shots:
|
||||
out.append(PersonalizedStarter(
|
||||
title="Frame my screenshots",
|
||||
prompt=f"Find the screenshot images in my {shots.name} folder and build one browsable gallery web page showing them as a neat scrollable grid. Write only the new page; never move or delete the originals.",
|
||||
))
|
||||
if scan.git_repo_count > 0:
|
||||
out.append(PersonalizedStarter(
|
||||
title="Recap my projects",
|
||||
prompt="Look at the code projects on my computer, read each one's README and recent activity, and write me one short page summarizing what each project is and where it stands. Write only the summary; change nothing.",
|
||||
))
|
||||
if category == "research" and scan.signal_apps:
|
||||
out.append(PersonalizedStarter(
|
||||
title=f"{scan.signal_apps[0]} tips",
|
||||
prompt=f"Search the web right now for the most useful current tips, shortcuts, and workflows for {scan.signal_apps[0]}, and give me a tight summary with dated sources.",
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
@typechecked
|
||||
def fill_menu(parsed: Optional[PersonalizedMenu], scan: Optional[ScanResult]) -> PersonalizedMenu:
|
||||
"""Guarantee 4 rows per category: LLM rows first, then scan-grounded, then static."""
|
||||
menu = PersonalizedMenu()
|
||||
for category in MENU_CATEGORIES:
|
||||
rows: List[PersonalizedStarter] = list(getattr(parsed, category)) if parsed is not None else []
|
||||
for extra in [*p_scan_grounded_rows(category, scan), *P_STATIC_MENU[category]]:
|
||||
if len(rows) >= MENU_SIZE:
|
||||
break
|
||||
if all(extra.title != existing.title for existing in rows):
|
||||
rows.append(extra)
|
||||
setattr(menu, category, rows[:MENU_SIZE])
|
||||
return menu
|
||||
|
||||
|
||||
@typechecked
|
||||
def parse_menu(text: str) -> Optional[PersonalizedMenu]:
|
||||
data = load_json_object(normalize_json_text(text))
|
||||
if not data:
|
||||
return None
|
||||
menu = PersonalizedMenu()
|
||||
got_any = False
|
||||
for category in MENU_CATEGORIES:
|
||||
rows = data.get(category)
|
||||
starters = build_starters(rows) if isinstance(rows, list) else []
|
||||
if starters:
|
||||
got_any = True
|
||||
setattr(menu, category, starters[:MENU_SIZE])
|
||||
return menu if got_any else None
|
||||
|
||||
|
||||
@typechecked
|
||||
async def build_menu(settings: AppSettings, facts: dict, scan: Optional[ScanResult]) -> PersonalizedMenu:
|
||||
"""One cheap aux call -> 16 tailored starters; any failure fills from scan + static rows."""
|
||||
parsed: Optional[PersonalizedMenu] = None
|
||||
try:
|
||||
from backend.apps.agents.providers.registry import resolve_aux_model
|
||||
from backend.apps.settings.credentials import get_anthropic_client_for_model
|
||||
|
||||
aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
|
||||
client = get_anthropic_client_for_model(settings, aux_model)
|
||||
resp = await client.messages.create(
|
||||
model=aux_model,
|
||||
max_tokens=aux_max_tokens_for(aux_model, base=1800),
|
||||
system=P_MENU_SYSTEM,
|
||||
messages=[{"role": "user", "content": json.dumps(facts)}],
|
||||
timeout=45.0,
|
||||
)
|
||||
parsed = parse_menu(safe_resp_text(resp))
|
||||
except Exception:
|
||||
parsed = None
|
||||
return fill_menu(parsed, scan)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Shared JSON/text salvage helpers for the onboarding aux-call parsers (prep + menu)."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Dict, List
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.apps.settings.models import PersonalizedStarter
|
||||
|
||||
P_CURLY_QUOTES: Dict[str, str] = {"“": '"', "”": '"', "‘": "'", "’": "'"}
|
||||
|
||||
|
||||
@typechecked
|
||||
def normalize_json_text(text: str) -> str:
|
||||
for bad, good in P_CURLY_QUOTES.items():
|
||||
text = text.replace(bad, good)
|
||||
return text
|
||||
|
||||
|
||||
@typechecked
|
||||
def strip_trailing_commas(s: str) -> str:
|
||||
return re.sub(r",(\s*[}\]])", r"\1", s)
|
||||
|
||||
|
||||
@typechecked
|
||||
def strip_dashes(s: str) -> str:
|
||||
"""The house style bans em/en dashes and the model slips them into the greeting anyway, so
|
||||
guarantee it in code: turn a dash-clause into a comma-clause, then tidy any doubled punctuation."""
|
||||
s = s.replace(" — ", ", ").replace("—", ", ").replace(" – ", ", ").replace("–", ", ")
|
||||
s = re.sub(r"\s+([,.;:])", r"\1", s)
|
||||
s = re.sub(r",\s*,", ", ", s)
|
||||
s = re.sub(r"\s{2,}", " ", s)
|
||||
return s.strip()
|
||||
|
||||
|
||||
@typechecked
|
||||
def load_json_object(text: str) -> dict:
|
||||
"""Best-effort load of the outermost JSON object: strict first, then a
|
||||
trailing-comma repair. Returns {} if neither parses (salvage handles the rest)."""
|
||||
match = re.search(r"\{.*\}", text, re.DOTALL)
|
||||
if not match:
|
||||
return {}
|
||||
for candidate in (match.group(0), strip_trailing_commas(match.group(0))):
|
||||
try:
|
||||
parsed = json.loads(candidate)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except Exception:
|
||||
continue
|
||||
return {}
|
||||
|
||||
|
||||
@typechecked
|
||||
def salvage_flat_objects(text: str) -> List[dict]:
|
||||
"""Pull every complete flat {..} object out of a truncated/malformed blob so a
|
||||
cut-off response still yields the starters it did finish (partial > generic)."""
|
||||
out: List[dict] = []
|
||||
for m in re.finditer(r"\{[^{}]*\}", text):
|
||||
try:
|
||||
obj = json.loads(strip_trailing_commas(m.group(0)))
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(obj, dict):
|
||||
out.append(obj)
|
||||
return out
|
||||
|
||||
|
||||
@typechecked
|
||||
def build_starters(rows: List[dict]) -> List[PersonalizedStarter]:
|
||||
return [
|
||||
PersonalizedStarter(title=strip_dashes(str(s.get("title", ""))), prompt=strip_dashes(str(s.get("prompt", ""))), reason=strip_dashes(str(s.get("reason", ""))))
|
||||
for s in rows
|
||||
if isinstance(s, dict) and str(s.get("title", "")).strip() and str(s.get("prompt", "")).strip() and "cadence" not in s
|
||||
]
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Parse the prep aux response into a PrepResponse, salvaging what it can from malformed JSON."""
|
||||
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.apps.onboarding.models import PrepResponse
|
||||
from backend.apps.onboarding.prep.parse_helpers import build_starters, load_json_object, normalize_json_text, salvage_flat_objects, strip_dashes
|
||||
from backend.apps.settings.models import PersonalizedAutomation
|
||||
|
||||
VALID_CADENCE = {"daily", "weekday", "weekly"}
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_extract_string_field(text: str, name: str) -> str:
|
||||
"""Pull a top-level "name": "value" string straight out of the raw blob, for the fields that
|
||||
aren't objects (greeting, app_*) so they survive when the strict JSON load failed and we salvage."""
|
||||
m = re.search(rf'"{name}"\s*:\s*"((?:[^"\\]|\\.)*)"', text)
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_build_automations(rows: List[dict]) -> List[PersonalizedAutomation]:
|
||||
return [
|
||||
PersonalizedAutomation(
|
||||
title=strip_dashes(str(a.get("title", ""))),
|
||||
prompt=strip_dashes(str(a.get("prompt", ""))),
|
||||
cadence=(str(a.get("cadence", "weekly")).strip().lower() if str(a.get("cadence", "")).strip().lower() in VALID_CADENCE else "weekly"),
|
||||
)
|
||||
for a in rows
|
||||
if isinstance(a, dict) and str(a.get("title", "")).strip() and str(a.get("prompt", "")).strip()
|
||||
]
|
||||
|
||||
|
||||
@typechecked
|
||||
def parse_prep(text: str) -> Optional[PrepResponse]:
|
||||
text = normalize_json_text(text)
|
||||
data = load_json_object(text)
|
||||
starters = build_starters(data.get("starters") if isinstance(data.get("starters"), list) else [])
|
||||
automations = p_build_automations(data.get("automations") if isinstance(data.get("automations"), list) else [])
|
||||
headline = str(data.get("headline", "")).strip()
|
||||
epithets = [strip_dashes(str(x)).strip() for x in (data.get("epithets") or []) if str(x).strip()][:3]
|
||||
greeting = str(data.get("greeting", "")).strip()
|
||||
app_title = str(data.get("app_title", "")).strip()
|
||||
app_prompt = str(data.get("app_prompt", "")).strip()
|
||||
app_reason = str(data.get("app_reason", "")).strip()
|
||||
research_title = str(data.get("research_title", "")).strip()
|
||||
research_prompt = str(data.get("research_prompt", "")).strip()
|
||||
research_reason = str(data.get("research_reason", "")).strip()
|
||||
browser_title = str(data.get("browser_title", "")).strip()
|
||||
browser_prompt = str(data.get("browser_prompt", "")).strip()
|
||||
browser_reason = str(data.get("browser_reason", "")).strip()
|
||||
|
||||
# Truncation / trailing comma / smart quotes broke the strict load: salvage the complete pieces
|
||||
# rather than throwing the whole personalized reveal away for one bad character.
|
||||
if not starters or not automations:
|
||||
objs = salvage_flat_objects(text)
|
||||
if not starters:
|
||||
starters = build_starters([o for o in objs if "cadence" not in o])
|
||||
if not automations:
|
||||
automations = p_build_automations([o for o in objs if "cadence" in o])
|
||||
# Top-level string fields don't live in the flat objects above, so recover them by name when the
|
||||
# strict load dropped them (a malformed response was still yielding starters but a blank app).
|
||||
if not headline:
|
||||
headline = p_extract_string_field(text, "headline")
|
||||
if not greeting:
|
||||
greeting = p_extract_string_field(text, "greeting")
|
||||
if not app_title:
|
||||
app_title = p_extract_string_field(text, "app_title")
|
||||
if not app_prompt:
|
||||
app_prompt = p_extract_string_field(text, "app_prompt")
|
||||
if not app_reason:
|
||||
app_reason = p_extract_string_field(text, "app_reason")
|
||||
if not research_title:
|
||||
research_title = p_extract_string_field(text, "research_title")
|
||||
if not research_prompt:
|
||||
research_prompt = p_extract_string_field(text, "research_prompt")
|
||||
if not research_reason:
|
||||
research_reason = p_extract_string_field(text, "research_reason")
|
||||
if not browser_title:
|
||||
browser_title = p_extract_string_field(text, "browser_title")
|
||||
if not browser_prompt:
|
||||
browser_prompt = p_extract_string_field(text, "browser_prompt")
|
||||
if not browser_reason:
|
||||
browser_reason = p_extract_string_field(text, "browser_reason")
|
||||
|
||||
if not starters:
|
||||
return None
|
||||
return PrepResponse(
|
||||
headline=strip_dashes(headline),
|
||||
epithets=epithets,
|
||||
greeting=strip_dashes(greeting),
|
||||
starters=starters[:4],
|
||||
app_title=strip_dashes(app_title),
|
||||
app_prompt=strip_dashes(app_prompt),
|
||||
app_reason=strip_dashes(app_reason),
|
||||
research_title=strip_dashes(research_title),
|
||||
research_prompt=strip_dashes(research_prompt),
|
||||
research_reason=strip_dashes(research_reason),
|
||||
browser_title=strip_dashes(browser_title),
|
||||
browser_prompt=strip_dashes(browser_prompt),
|
||||
browser_reason=strip_dashes(browser_reason),
|
||||
automations=automations[:3],
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""The two aux prompts behind the personalized reveal: the profile distill and the full prep shape."""
|
||||
|
||||
PREP_SYSTEM = (
|
||||
"Ground every claim in the evidence you were given (scan, picks, usage summary). If the "
|
||||
"evidence is thin or empty, stay warm but GENERIC; NEVER invent specific tools, apps, or "
|
||||
"habits the user did not show you (a fabricated 'you use Ollama and Docker' reads as creepy "
|
||||
"and wrong to someone who does not). "
|
||||
"You write first-run starter tasks for OpenSwarm, a desktop AI agent platform that can "
|
||||
"organize local files, browse the web in a real browser, build small apps, and run agents in parallel. "
|
||||
"Given facts about the user's machine and the apps they picked, respond with STRICT JSON only: "
|
||||
'{"headline": string, "greeting": string, "epithets": [string, string, string], "starters": [{"title": string, "prompt": string, "reason": string}], "app_title": string, "app_prompt": string, "app_reason": string, "research_title": string, "research_prompt": string, "research_reason": string, "browser_title": string, "browser_prompt": string, "browser_reason": string, "automations": [{"title": string, "prompt": string, "cadence": "daily"|"weekday"|"weekly"}]}. '
|
||||
"First, silently infer a short, confident profile of this user: who they are and what they are working on. "
|
||||
"If usage_summary is present it is the STRONGEST signal (a distilled profile of who this person is and what "
|
||||
"they actually work on, read from their real AI conversations); weight it above everything else. Do NOT let a "
|
||||
"single installed app override it: signal_apps (an IDE, a design app, a DAW) is only a WEAK hint about their "
|
||||
"craft (having Xcode installed does not make someone an iOS developer), used only to color what the profile "
|
||||
"already says, then folders, plan tier, email domain. Tune every task and the personal app to that profile; "
|
||||
"do not output the profile. "
|
||||
"THE BAR every single item must clear: it is either (a) SPECIFICALLY useful to THIS person's real work in a way "
|
||||
"they could not quickly get elsewhere (it uses their ACTUAL files, projects, or data to produce a real finished "
|
||||
"thing worth keeping), OR (b) a genuine 'oh, it can do THAT?' that makes them see a hundred uses (a surprising "
|
||||
"capability shown on their real stuff). A generic chore FAILS the bar and must be replaced: a file-cleanup report, "
|
||||
"a folder audit, a read-only summary or mirror dashboard of their data, a research overview they could google, an "
|
||||
"empty log file, or any 'set up / organize / plan' task is BANNED. "
|
||||
"SPAN THE WHOLE PERSON. First, silently list this person's DISTINCT life threads from the profile (for example: "
|
||||
"their main work or product, their sport or fitness, their food or local life, their tech or tooling curiosity, "
|
||||
"their games or hobbies, a recurring practical need). Then assign the four starters to FOUR DIFFERENT threads, "
|
||||
"one each. HARD RULE: treat EVERYTHING about their company, product, startup, pitch, competitive landscape, "
|
||||
"positioning, pricing, fundraising, growth, or the thing they are building as ONE single 'work' bucket. EXACTLY "
|
||||
"ONE of the four starters may come from that work bucket, no more, count them before you answer. The other THREE "
|
||||
"must each come from a clearly DIFFERENT NON-work thread you actually see in the profile (their sport or fitness, "
|
||||
"their food or local life, their games or hobbies, a personal curiosity or practical need). Anything work-adjacent, "
|
||||
"even a browser tool 'for the product' or research 'for the pitch', is WORK and does NOT count as a non-work "
|
||||
"thread. Before you finalize, verify that THREE of the four starters have nothing to do with their work; if they "
|
||||
"do, replace them. The app and the research should also lean to NON-work threads, not pile onto the work one. The "
|
||||
"ONLY exception: if the profile genuinely shows they talk about almost nothing but that one thing, follow the real "
|
||||
"data instead of forcing variety. Every item still clears the bar on its own. "
|
||||
"Exactly 4 starters. Each title is 2-5 words that PLAINLY say what the task does (like 'Frame my screenshots' or "
|
||||
"'Compare headphones'), never clever, punny, or brand-style. Each prompt is a concrete, safe, immediately runnable "
|
||||
"task referencing the user's real folders, files, or picked apps; never invent facts. The FIRST starter is the one "
|
||||
"that RUNS automatically, so it must be safe unattended AND clear the bar: a real, specific DELIVERABLE built from "
|
||||
"the user's ACTUAL files that they'd want and could not quickly make themselves (a designer with many screenshots: "
|
||||
"a browsable gallery page of their app screenshots; someone with many notebooks or PDFs of one kind: an indexed, "
|
||||
"searchable library page of them). It READS their real files and writes ONE new artifact (a page or a file); it "
|
||||
"must NEVER modify or delete an existing file. It must NOT be a cleanup report, a folder audit, or a 'plan'. Every "
|
||||
"starter must produce a tangible result the user can see and want; never propose setup, documentation of "
|
||||
"preferences, or planning-only tasks. "
|
||||
"Each starter's 'reason' is ONE short standalone clause (max 12 words, no leading 'because') naming the SPECIFIC "
|
||||
"real thing you observed (a folder, a file count, a picked app, a usage fact) that makes this task useful for THIS "
|
||||
"user; it must be grounded in the input facts, never invented, and read like a person pointing at what they saw. "
|
||||
"Design ONE small but genuinely useful WORKING TOOL for this person's craft and make it the CENTERPIECE, this is "
|
||||
"the 'oh, it can build me THAT?' moment. It must DO something: take their input and produce useful output, or "
|
||||
"automate a fiddly micro-task they repeat in their ACTUAL work (inferred from usage_summary + signal_apps). It is "
|
||||
"a real interactive tool they would reopen and USE, NOT a read-only dashboard, NOT a mirror of their data, NOT a "
|
||||
"summary, NOT a feed. Examples of the SHAPE only (never copy, always tailor to THEM, and never "
|
||||
"default to an iOS, app, or coding tool just because it is a familiar example): for a writer, a tool that "
|
||||
"rewrites a pasted paragraph across tones; for a data person, a tool that pastes a CSV and instantly charts it; "
|
||||
"for a musician, a tool that transposes a chord progression; for a language learner, a drill built from words "
|
||||
"they paste. Match the shape to THIS person's actual craft from the profile. app_title 2-4 words that plainly name what it DOES "
|
||||
"(like 'Icon Previewer' or 'Screenshot Framer'), never punny. app_prompt starts with 'Build me' and specifies the "
|
||||
"tool's INPUT, what it PRODUCES, and the interaction; fully client-side and self-contained. It MUST run "
|
||||
"on the user's input with DETERMINISTIC logic only (math, parsing, formatting, layout, charts, filtering, "
|
||||
"transforms). It must NEVER call an AI model, an LLM, a chat completion, or any network/remote API, those "
|
||||
"only work inside a published app and will fail in the reveal with 'make sure you're on a published app'. "
|
||||
"If the idea would need AI generation to work, pick a DIFFERENT tool that doesn't (no accounts, no API keys, "
|
||||
"no backend, no fetch, everything computed in the browser). app_reason follows the same one-clause grounded-"
|
||||
"observation rule as a starter reason and says why THIS tool fits their real work. "
|
||||
"Also pick the SINGLE topic this user most repeatedly asks their AI about (from usage_summary; if it is thin, use "
|
||||
"their strongest work signal from signal_apps or folders) and turn it into a live web-research task. research_title "
|
||||
"is 2-4 words plainly naming the topic (like 'App Store Fees' or 'Best Vector DBs'), never clever or punny. "
|
||||
"research_prompt is one instruction telling the agent to search the web RIGHT NOW and produce a tight, useful, "
|
||||
"current answer or comparison of THAT topic for this user (an actual answer, never a plan); it must demand "
|
||||
"THIS-YEAR information with publication dates on sources, so the answer cannot quietly be stale training data. "
|
||||
"research_reason follows the one-clause grounded-observation rule and names the specific recurring question you saw. "
|
||||
"Also design ONE browser task that shows the agent DRIVING a real website live (so the user watches it control a "
|
||||
"browser, not just fetch text). browser_title is 2-4 words plainly naming it (like 'Nearby Michelin' or 'Jump "
|
||||
"Threads'). browser_prompt tells the agent to OPEN a specific real, PUBLIC website by name and do a genuinely "
|
||||
"MULTI-STEP task there: navigate, search, click through, read across a few pages, compare, then report what it "
|
||||
"found. It must be safe read-only browsing on public pages only, NEVER log in, buy, post, submit, or act on the "
|
||||
"user's behalf. Pick a topic from a DIFFERENT thread than the app and the research, ideally a fun or personal "
|
||||
"interest (food, sport, travel, a hobby), not their work. browser_reason follows the one-clause grounded rule. "
|
||||
"THE FOUR THINGS THAT ACTUALLY RUN are the app, the research, the browser task, and the first automation. This "
|
||||
"rule OVERRIDES every 'pick their craft / their top topic' hint above: assign each of these four to a DIFFERENT "
|
||||
"thread of this person's life, and AT MOST ONE of the four may touch their work/company/product/pitch/competitors, "
|
||||
"count them before you answer. FIXED ASSIGNMENT: the APP must be built for a NON-work thread (a hobby, sport, "
|
||||
"food, or personal need), it is a delightful surprise precisely because it is NOT about their job (a jump-log "
|
||||
"tool, a restaurant picker, a practice-drill tool, never a pitch/competitor/metrics dashboard). The BROWSER task "
|
||||
"must also take a clearly personal or fun NON-work thread. Only the RESEARCH may be about their work, and only if "
|
||||
"that is genuinely their burning question; if you use work for the research, then the first automation must be "
|
||||
"NON-work too, so no more than ONE of the four is ever about work. Make all four GENUINELY MULTI-STEP (several "
|
||||
"real actions, never a one-liner). "
|
||||
"Also propose 1-2 automations: recurring routines that genuinely help THIS user and clear the bar (NEVER a folder "
|
||||
"cleanup, NEVER an empty log, NEVER 'keep a dashboard updated'). A good one delivers something the user actually "
|
||||
"wants on a cadence, e.g. a daily digest of what is new in their SPECIFIC niche (named from usage_summary and "
|
||||
"signal_apps) written to a dated file they will read, or a weekly pull of new items relevant to a project they are "
|
||||
"shipping. Each automation title is 2-4 words that plainly name it (like 'iOS Design Digest'), cadence is exactly "
|
||||
"'daily', 'weekday', or 'weekly'. The prompt is the COMPLETE instruction an agent executes alone on each scheduled "
|
||||
"run with NO human present: produce its result in one pass, never ask questions, never wait for input, never set up "
|
||||
"schedules or reminders (the schedule already exists), and write the result to a concrete file (like "
|
||||
"Documents/<name>_<date>.md). 'Search X and write the result to Y' is right; 'remind me' or 'set up a log' is wrong. "
|
||||
"Safe to run unattended (never delete without review). "
|
||||
"The HEADLINE is the single most important line: a punchy, specific, SCANNABLE identity hook of AT MOST 10 "
|
||||
"words that this person reads in one second and thinks 'yes, that's me'. Name their actual work and their one "
|
||||
"defining trait, no filler, no full sentence, no period. It is read at a glance in big type, so it must NOT be "
|
||||
"a paragraph. Example shapes only (never copy, tailor to THEM): 'OpenSwarm founder who measures everything, "
|
||||
"vertical jump to agent latency' or 'Ships iOS apps, obsessed with the last 5% of polish'. Sharp, not wordy. "
|
||||
"epithets: exactly 3 short identity titles for this person, 2-4 plain words each (like 'QUIET POWER USER' but THEIRS, drawn from their real work and interests in the profile), confident and warm, never punny, never generic. "
|
||||
"The greeting is one or two warm, punchy sentences that make this person feel INSTANTLY understood, the "
|
||||
"'wait, it actually gets me' hook. Lead with the single most specific true thing about them from the profile "
|
||||
"(their actual project BY NAME, their real craft, the obsession they keep returning to), then add ONE more "
|
||||
"concrete detail that proves you get them. Ground it in the profile above all; nod to a folder or tool only if "
|
||||
"it sharpens the picture, never lead with a generic installed app, and never name boring system apps. It should "
|
||||
"read like a sharp friend who knows exactly what you're about, not a system reciting what it scanned. Do not be "
|
||||
"creepy: name their work and interests, not private personal numbers. Never use em-dashes or en-dashes anywhere. "
|
||||
"No markdown, no commentary, JSON only."
|
||||
)
|
||||
|
||||
# The clustering pass: one cheap read that turns the raw chat dump into a tight character read, so the
|
||||
# reveal reasons over "who is this person" instead of skimming fragments and latching onto a stray word.
|
||||
PROFILE_SYSTEM = (
|
||||
"You are reading a person's OWN recent AI chat conversations (their messages and the AI's replies, "
|
||||
"most recent first). Write a SHORT, confident, specific profile of who this person actually is and "
|
||||
"what they genuinely work on and care about, grounded ONLY in what you see. 3 to 5 sentences, plain "
|
||||
"prose, no lists, no hedging, no preamble, no 'based on'. Name concrete specifics: the projects they "
|
||||
"are building, the tools and languages they use, the topics they return to again and again, their "
|
||||
"interests and side-obsessions, how they think. Separate a real recurring throughline from a one-off "
|
||||
"tangent, weight what they keep coming back to. If one thing is clearly their main focus right now, "
|
||||
"say so plainly; if their attention is split across a few real threads, name them. Never invent "
|
||||
"anything not present. No markdown. Never use em-dashes or en-dashes."
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""The no-LLM reveal: starters grounded in the real local scan when the aux call can't run."""
|
||||
|
||||
from typing import List
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.apps.onboarding.models import PrepRequest, PrepResponse, ScanResult
|
||||
from backend.apps.settings.models import PersonalizedStarter
|
||||
|
||||
FALLBACK_STARTERS: List[PersonalizedStarter] = [
|
||||
PersonalizedStarter(title="Clean up Downloads", prompt="Sort my Downloads folder into tidy subfolders. Show me the plan before moving anything."),
|
||||
PersonalizedStarter(title="Research something", prompt="Research the best noise-cancelling headphones under $300 and give me a comparison table."),
|
||||
PersonalizedStarter(title="Build a tiny app", prompt="Build me a simple habit tracker app I can use right now."),
|
||||
PersonalizedStarter(title="Plan a trip", prompt="Plan a 3-day weekend trip itinerary and turn it into a printable page."),
|
||||
]
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_scan_grounded_starters(scan: ScanResult) -> List[PersonalizedStarter]:
|
||||
"""Build starters from the REAL scan (no LLM): each references something concrete on this machine, a
|
||||
screenshot pile, a content-heavy folder, the app they lean on, their code projects. Every one produces
|
||||
a keepable artifact and never modifies an existing file, so even the no-LLM path is genuinely tailored."""
|
||||
out: List[PersonalizedStarter] = []
|
||||
apps = scan.signal_apps[:3]
|
||||
# Screenshots -> a browsable gallery page (real deliverable from their real files).
|
||||
shots = next((f for f in scan.folders if f.screenshot_count > 2), None)
|
||||
if shots:
|
||||
out.append(PersonalizedStarter(
|
||||
title="Frame my screenshots",
|
||||
prompt=f"Find the screenshot images in my {shots.name} folder and build one browsable gallery web page showing them as a neat scrollable grid. Write only the new page; never move or delete the originals.",
|
||||
reason=f"{shots.screenshot_count} screenshots sitting in {shots.name}.",
|
||||
))
|
||||
# A content-heavy folder -> a searchable index page of what's in it.
|
||||
docs = next((f for f in scan.folders if f.name in ("Documents", "Downloads", "Desktop") and f.entry_count > 5 and f.top_extensions), None)
|
||||
if docs:
|
||||
ext = docs.top_extensions[0].lstrip(".") or "file"
|
||||
out.append(PersonalizedStarter(
|
||||
title=f"Index my {ext} files",
|
||||
prompt=f"Look through my {docs.name} folder and build one searchable index page listing my {ext} files with their names and dates so I can find things fast. Write only the new page; do not move or delete anything.",
|
||||
reason=f"{docs.entry_count} files in {docs.name}, lots of .{ext}.",
|
||||
))
|
||||
# Top signal app -> live web research for current tips (a real answer, not a plan).
|
||||
if apps:
|
||||
out.append(PersonalizedStarter(
|
||||
title=f"{apps[0]} tips",
|
||||
prompt=f"Search the web right now for the most useful current tips, shortcuts, and workflows for {apps[0]}, and give me a tight summary with dated sources.",
|
||||
reason=f"You lean on {apps[0]} a lot.",
|
||||
))
|
||||
# Code projects -> a plain-English recap of where each stands.
|
||||
if scan.git_repo_count > 0:
|
||||
out.append(PersonalizedStarter(
|
||||
title="Recap my projects",
|
||||
prompt="Look at the code projects on my computer, read each one's README and recent activity, and write me one short page summarizing what each project is and where it stands. Write only the summary; change nothing.",
|
||||
reason=f"{scan.git_repo_count} code projects on your machine.",
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
@typechecked
|
||||
def scan_grounded_fallback(request: PrepRequest) -> PrepResponse:
|
||||
"""When the aux call can't be made (a sole gemini/codex lane returns empty on 0.3.60, provider
|
||||
down, no anthropic-reachable model), still ground the reveal in the REAL scan via a template so a
|
||||
cross-provider user gets their-Mac-specific starters, not generic stubs. No LLM, so it never fails."""
|
||||
scan = request.scan
|
||||
if scan is None:
|
||||
return PrepResponse(greeting="", starters=list(FALLBACK_STARTERS))
|
||||
apps = scan.signal_apps[:3]
|
||||
starters = p_scan_grounded_starters(scan)
|
||||
# Backfill from the generic list ONLY to reach four, and only when the machine was too sparse to
|
||||
# ground three real ones. On a normal Mac all four come from the scan, so 3-of-4 stays tailored.
|
||||
for s in FALLBACK_STARTERS:
|
||||
if len(starters) >= 4:
|
||||
break
|
||||
if all(s.title != existing.title for existing in starters):
|
||||
starters.append(s)
|
||||
downloads = next((f for f in scan.folders if f.name == "Downloads" and f.entry_count > 0), None)
|
||||
bits: List[str] = []
|
||||
if downloads:
|
||||
bits.append(f"{downloads.entry_count} files in Downloads")
|
||||
if apps:
|
||||
bits.append(", ".join(apps))
|
||||
greeting = f"I took a look around your Mac: {'; '.join(bits)}. Here is where I would start." if bits else ""
|
||||
# Ground the research card on their top tool so the "looked into this" card still appears cross-provider.
|
||||
research_title = ""
|
||||
research_prompt = ""
|
||||
research_reason = ""
|
||||
if apps:
|
||||
research_title = f"{apps[0]} Tips"
|
||||
research_prompt = f"Search the web right now for the most useful current tips, shortcuts, and workflows for {apps[0]}, and give me a tight summary with sources."
|
||||
research_reason = f"You have {apps[0]} installed and use it a lot."
|
||||
return PrepResponse(
|
||||
greeting=greeting,
|
||||
starters=starters[:4],
|
||||
research_title=research_title,
|
||||
research_prompt=research_prompt,
|
||||
research_reason=research_reason,
|
||||
)
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Read the user's own logged-in provider cookies from their real browser, so
|
||||
onboarding can harvest their actual chat history at first run without an in-app login.
|
||||
|
||||
Chromium (Chrome/Arc/Brave/Edge) on macOS AND Windows. We first find WHICH store holds the
|
||||
session by counting cookie names in the SQLite (no decryption, no keychain/DPAPI), then decrypt
|
||||
only that one store, so the secret key is fetched at most once per browser (cached for the
|
||||
process). Per-OS decryption:
|
||||
- macOS: "Safe Storage" keychain password -> PBKDF2 -> AES-CBC (v10/v11).
|
||||
- Windows: DPAPI-unwrapped key from Local State -> AES-256-GCM (v10/v11).
|
||||
v20 = app-bound encryption (modern Chrome), out of reach on both without the browser's own
|
||||
elevation service. Fails open to {} on anything (no browser, app-bound cookies, denied
|
||||
keychain/DPAPI, Safari-only user), so prep just falls back to the local scan.
|
||||
|
||||
Only ever reads the specific provider domain asked for; never a general cookie sweep.
|
||||
The values are session secrets: used in-process for the harvest, never logged or stored.
|
||||
|
||||
NOTE: the Windows path is written to the well-documented Chromium/DPAPI scheme but is NOT
|
||||
live-tested from this repo's dev machine (macOS); the macOS path is live-proven (490 real
|
||||
Claude convos). Both fail open, so a Windows decryption miss degrades to the scan, never crashes.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
IS_WIN = sys.platform == "win32"
|
||||
|
||||
# Per-OS "User Data" roots (relative to the home dir), where profiles + Local State live.
|
||||
if IS_WIN:
|
||||
p_local = os.environ.get("LOCALAPPDATA", os.path.expanduser("~/AppData/Local"))
|
||||
CHROMIUM_ROOTS = {
|
||||
"Chrome": os.path.join(p_local, "Google", "Chrome", "User Data"),
|
||||
"Arc": os.path.join(p_local, "Packages"), # Arc/Windows is UWP-packaged + rare; best-effort
|
||||
"Brave": os.path.join(p_local, "BraveSoftware", "Brave-Browser", "User Data"),
|
||||
"Edge": os.path.join(p_local, "Microsoft", "Edge", "User Data"),
|
||||
}
|
||||
else:
|
||||
p_home = os.path.expanduser("~")
|
||||
CHROMIUM_ROOTS = {
|
||||
"Chrome": os.path.join(p_home, "Library/Application Support/Google/Chrome"),
|
||||
"Arc": os.path.join(p_home, "Library/Application Support/Arc/User Data"),
|
||||
"Brave": os.path.join(p_home, "Library/Application Support/BraveSoftware/Brave-Browser"),
|
||||
"Edge": os.path.join(p_home, "Library/Application Support/Microsoft Edge"),
|
||||
}
|
||||
KEYCHAIN_SERVICE = {
|
||||
"Chrome": "Chrome Safe Storage",
|
||||
"Arc": "Arc Safe Storage",
|
||||
"Brave": "Brave Safe Storage",
|
||||
"Edge": "Microsoft Edge Safe Storage",
|
||||
}
|
||||
PROFILES = ["Default"] + [f"Profile {i}" for i in range(1, 12)]
|
||||
|
||||
# One key fetch per browser per process; "Always Allow" (mac) / DPAPI (win) then never re-prompts.
|
||||
p_key_cache: Dict[str, Optional[bytes]] = {}
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_win_dpapi_unprotect(data: bytes) -> Optional[bytes]:
|
||||
"""CryptUnprotectData via crypt32.dll (no pywin32 dependency). None on any failure."""
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
class DATA_BLOB(ctypes.Structure):
|
||||
_fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))]
|
||||
|
||||
buf = ctypes.create_string_buffer(data, len(data))
|
||||
blob_in = DATA_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)))
|
||||
blob_out = DATA_BLOB()
|
||||
ok = ctypes.windll.crypt32.CryptUnprotectData(
|
||||
ctypes.byref(blob_in), None, None, None, None, 0, ctypes.byref(blob_out)
|
||||
)
|
||||
if not ok:
|
||||
return None
|
||||
n = int(blob_out.cbData)
|
||||
out = ctypes.create_string_buffer(n)
|
||||
ctypes.memmove(out, blob_out.pbData, n)
|
||||
ctypes.windll.kernel32.LocalFree(blob_out.pbData)
|
||||
return out.raw
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@typechecked
|
||||
def win_storage_key(browser: str) -> Optional[bytes]:
|
||||
"""The AES key from a Chromium install's Local State: base64 -> strip 'DPAPI' -> CryptUnprotectData."""
|
||||
base = CHROMIUM_ROOTS.get(browser)
|
||||
if not base:
|
||||
return None
|
||||
local_state = os.path.join(base, "Local State")
|
||||
try:
|
||||
with open(local_state, "r", encoding="utf-8") as f:
|
||||
enc_b64 = json.load(f)["os_crypt"]["encrypted_key"]
|
||||
raw = base64.b64decode(enc_b64)
|
||||
if raw[:5] != b"DPAPI":
|
||||
return None
|
||||
return p_win_dpapi_unprotect(raw[5:])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_mac_storage_key(browser: str) -> Optional[bytes]:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["security", "find-generic-password", "-w", "-s", KEYCHAIN_SERVICE[browser]],
|
||||
capture_output=True, text=True, timeout=20,
|
||||
)
|
||||
pw = r.stdout.strip()
|
||||
if pw:
|
||||
return hashlib.pbkdf2_hmac("sha1", pw.encode(), b"saltysalt", 1003, 16)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_safe_storage_key(browser: str) -> Optional[bytes]:
|
||||
if browser in p_key_cache:
|
||||
return p_key_cache[browser]
|
||||
key = win_storage_key(browser) if IS_WIN else p_mac_storage_key(browser)
|
||||
p_key_cache[browser] = key
|
||||
return key
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_count_domain(db_path: str, domain: str) -> int:
|
||||
tmp = tempfile.mktemp()
|
||||
try:
|
||||
shutil.copy2(db_path, tmp)
|
||||
con = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
|
||||
cur = con.cursor()
|
||||
cur.execute("SELECT count(*) FROM cookies WHERE host_key LIKE ?", (f"%{domain}",))
|
||||
n = int(cur.fetchone()[0])
|
||||
con.close()
|
||||
return n
|
||||
except Exception:
|
||||
return 0
|
||||
finally:
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_best_store(domain: str) -> Optional[Tuple[str, str]]:
|
||||
"""The (browser, db_path) holding the most cookies for `domain`, found WITHOUT the keychain."""
|
||||
best: Optional[Tuple[str, str]] = None
|
||||
best_score = (0, -1.0)
|
||||
for browser, base in CHROMIUM_ROOTS.items():
|
||||
if not os.path.isdir(base):
|
||||
continue
|
||||
for prof in PROFILES:
|
||||
for sub in ("Cookies", "Network/Cookies"):
|
||||
path = os.path.join(base, prof, sub)
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
n = p_count_domain(path, domain)
|
||||
if n:
|
||||
score = (n, os.path.getmtime(path))
|
||||
if score > best_score:
|
||||
best, best_score = (browser, path), score
|
||||
return best
|
||||
|
||||
|
||||
@typechecked
|
||||
def decrypt_cookie_value(enc: bytes, key: bytes) -> Optional[str]:
|
||||
if enc[:3] not in (b"v10", b"v11"):
|
||||
return None # v20 = app-bound encryption, out of reach without the browser
|
||||
try:
|
||||
if IS_WIN:
|
||||
# Windows Chromium: v10/v11 = AES-256-GCM, [3:15]=nonce, tail 16 bytes=tag (bundled with ct).
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
dec = AESGCM(key).decrypt(enc[3:15], enc[15:], None)
|
||||
else:
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
c = Cipher(algorithms.AES(key), modes.CBC(b" " * 16), backend=default_backend())
|
||||
d = c.decryptor()
|
||||
dec = d.update(enc[3:]) + d.finalize()
|
||||
dec = dec[: -dec[-1]] # strip PKCS7 padding
|
||||
for cut in (0, 32): # newer Chromium prepends a 32-byte domain hash
|
||||
try:
|
||||
return dec[cut:].decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
@typechecked
|
||||
def read_provider_cookies(domain: str) -> Dict[str, str]:
|
||||
"""Decrypted cookie jar for `domain`, from whichever browser store actually has the session. At most one keychain touch (that store's browser), cached for the process."""
|
||||
store = p_best_store(domain)
|
||||
if store is None:
|
||||
return {}
|
||||
browser, db_path = store
|
||||
key = p_safe_storage_key(browser)
|
||||
if key is None:
|
||||
return {}
|
||||
jar: Dict[str, str] = {}
|
||||
tmp = tempfile.mktemp()
|
||||
try:
|
||||
shutil.copy2(db_path, tmp)
|
||||
con = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
|
||||
cur = con.cursor()
|
||||
cur.execute("SELECT name, encrypted_value FROM cookies WHERE host_key LIKE ?", (f"%{domain}",))
|
||||
for name, enc in cur.fetchall():
|
||||
if not enc:
|
||||
continue
|
||||
val = decrypt_cookie_value(bytes(enc), key)
|
||||
if val:
|
||||
jar[str(name)] = val
|
||||
con.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
return jar
|
||||
|
||||
|
||||
@typechecked
|
||||
def read_provider_cookie_records(domain: str) -> List[Dict[str, Any]]:
|
||||
"""Full cookie records ({name,value,domain,path,secure,httponly}) for `domain`, so Electron's offscreen browser can re-inject the session faithfully and pass Cloudflare with a real Chrome TLS handshake. Same one-store, one-keychain-touch path as read_provider_cookies."""
|
||||
store = p_best_store(domain)
|
||||
if store is None:
|
||||
return []
|
||||
browser, db_path = store
|
||||
key = p_safe_storage_key(browser)
|
||||
if key is None:
|
||||
return []
|
||||
records: List[Dict[str, Any]] = []
|
||||
tmp = tempfile.mktemp()
|
||||
try:
|
||||
shutil.copy2(db_path, tmp)
|
||||
con = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
|
||||
cur = con.cursor()
|
||||
cur.execute(
|
||||
"SELECT name, encrypted_value, host_key, path, is_secure, is_httponly FROM cookies WHERE host_key LIKE ?",
|
||||
(f"%{domain}",),
|
||||
)
|
||||
for name, enc, host_key, path, is_secure, is_httponly in cur.fetchall():
|
||||
if not enc:
|
||||
continue
|
||||
val = decrypt_cookie_value(bytes(enc), key)
|
||||
if val is None:
|
||||
continue
|
||||
records.append({
|
||||
"name": str(name), "value": val, "domain": str(host_key),
|
||||
"path": str(path) or "/", "secure": bool(is_secure), "httponly": bool(is_httponly),
|
||||
})
|
||||
con.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
return records
|
||||
|
||||
|
||||
# Gemini authenticates on the parent .google.com SSO domain, not gemini.google.com, so its
|
||||
# session lives in these named cookies. We read ONLY these (never the whole google cookie
|
||||
# jar) and only to load a Gemini page offscreen, keeping the ChatGPT/Claude trust frame.
|
||||
GOOGLE_AUTH_COOKIE_NAMES = {
|
||||
"SID", "HSID", "SSID", "APISID", "SAPISID", "SIDCC", "NID",
|
||||
"__Secure-1PSID", "__Secure-3PSID", "__Secure-1PSIDTS", "__Secure-3PSIDTS",
|
||||
"__Secure-1PSIDCC", "__Secure-3PSIDCC", "__Secure-1PAPISID", "__Secure-3PAPISID",
|
||||
}
|
||||
|
||||
|
||||
@typechecked
|
||||
def read_google_session_records() -> List[Dict[str, Any]]:
|
||||
"""The named Google SSO cookies from .google.com, so the offscreen browser can load Gemini logged in. Scoped to the auth set by name, never a general google-cookie sweep."""
|
||||
return [r for r in read_provider_cookie_records(".google.com") if r.get("name") in GOOGLE_AUTH_COOKIE_NAMES]
|
||||
|
||||
|
||||
@typechecked
|
||||
def cookie_header(jar: Dict[str, str]) -> str:
|
||||
return "; ".join(f"{k}={v}" for k, v in jar.items())
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Read the user's own ChatGPT conversation titles + Memory straight from ChatGPT's
|
||||
backend using the codex connect token, no website login or browser session needed.
|
||||
|
||||
The token 9Router already holds from "Sign in with ChatGPT" is a valid bearer for
|
||||
chatgpt.com/backend-api (that is how Codex runs); paired with the chatgpt-account-id
|
||||
claim from the id token it reaches /conversations and /memories from the user's own
|
||||
machine. Read-only, capped, and fails open to "" on anything (expired token,
|
||||
Cloudflare, shape drift) so prep just falls back to the local scan.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.apps.nine_router.process import read_persisted_connections
|
||||
from backend.apps.onboarding.identity import decode_jwt_payload
|
||||
|
||||
BASE = "https://chatgpt.com/backend-api"
|
||||
PAGE = 100
|
||||
CAP_PAGES = 40
|
||||
# Prep only reads ~150 titles; this endpoint is ~4s/page, so fetching 1000 cost 38s of onboarding
|
||||
# runway for nothing. Cap the title pull; the real conversation total comes from the API's own count.
|
||||
CAP_TITLES = 200
|
||||
# Depth: full text of the most recent CONVO_N chats. Per-convo cap stops one marathon dominating; the
|
||||
# total cap bounds the block (a clustering pass distills it downstream, so it can be generous).
|
||||
CONVO_N = 10
|
||||
CONVO_CHARS = 30000
|
||||
TOTAL_CONVO_CHARS = 130000
|
||||
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_codex_creds() -> Optional[Tuple[str, Optional[str]]]:
|
||||
for c in read_persisted_connections():
|
||||
if c.get("provider") == "codex" and c.get("isActive") and c.get("accessToken"):
|
||||
claims = decode_jwt_payload(c.get("idToken") or "")
|
||||
auth = claims.get("https://api.openai.com/auth", {}) if isinstance(claims, dict) else {}
|
||||
acct = auth.get("chatgpt_account_id") if isinstance(auth, dict) else None
|
||||
return (str(c["accessToken"]), str(acct) if acct else None)
|
||||
return None
|
||||
|
||||
|
||||
@typechecked
|
||||
def summarize_chatgpt_usage(total: int, memories: List[str], titles: List[str], convos: List[str], capped: bool = False) -> str:
|
||||
parts: List[str] = []
|
||||
if total > 0:
|
||||
parts.append(f"They have {total}{'+' if capped else ''} past AI conversations.")
|
||||
if memories:
|
||||
parts.append("Facts their AI remembers about them: " + "; ".join(memories))
|
||||
if titles:
|
||||
parts.append("Recent conversation titles (breadth): " + "; ".join(titles[:150]))
|
||||
if convos:
|
||||
block: List[str] = []
|
||||
used = 0
|
||||
for cv in convos:
|
||||
if used + len(cv) > TOTAL_CONVO_CHARS:
|
||||
break
|
||||
block.append(cv)
|
||||
used += len(cv)
|
||||
if block:
|
||||
parts.append("Full text of their most recent conversations (their real asks + the exchange):\n\n" + "\n\n---\n\n".join(block))
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@typechecked
|
||||
async def p_fetch_chatgpt_convo(client: httpx.AsyncClient, cid: str) -> str:
|
||||
"""One conversation's full text, both sides, ordered, capped. "" on any failure (convo skipped)."""
|
||||
try:
|
||||
r = await client.get(f"{BASE}/conversation/{cid}")
|
||||
if r.status_code != 200:
|
||||
return ""
|
||||
data = r.json()
|
||||
mapping = data.get("mapping") if isinstance(data, dict) else None
|
||||
if not isinstance(mapping, dict):
|
||||
return ""
|
||||
rows: List[Tuple[float, str]] = []
|
||||
for node in mapping.values():
|
||||
m = node.get("message") if isinstance(node, dict) else None
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
role = (m.get("author") or {}).get("role")
|
||||
if role not in ("user", "assistant"):
|
||||
continue
|
||||
content = m.get("content") or {}
|
||||
if content.get("content_type") != "text":
|
||||
continue
|
||||
text = " ".join(str(p) for p in (content.get("parts") or []) if p).strip()
|
||||
if len(text) > 5:
|
||||
rows.append((float(m.get("create_time") or 0), ("You: " if role == "user" else "AI: ") + text))
|
||||
rows.sort(key=lambda x: x[0])
|
||||
return "\n".join(t for _, t in rows)[:CONVO_CHARS]
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
@typechecked
|
||||
async def harvest_chatgpt_usage() -> str:
|
||||
creds = p_codex_creds()
|
||||
if creds is None:
|
||||
return ""
|
||||
token, acct = creds
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": UA,
|
||||
"Origin": "https://chatgpt.com",
|
||||
"Referer": "https://chatgpt.com/",
|
||||
}
|
||||
if acct:
|
||||
headers["chatgpt-account-id"] = acct
|
||||
titles: List[str] = []
|
||||
conv_ids: List[str] = []
|
||||
seen: set = set()
|
||||
memories: List[str] = []
|
||||
convos: List[str] = []
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20.0, headers=headers) as client:
|
||||
offset = 0
|
||||
for _ in range(CAP_PAGES):
|
||||
if len(titles) >= CAP_TITLES:
|
||||
break
|
||||
r = await client.get(f"{BASE}/conversations", params={"offset": offset, "limit": PAGE, "order": "updated"})
|
||||
if r.status_code != 200:
|
||||
return "" # expired token / Cloudflare / shape drift: fail open, prep uses the scan
|
||||
items = (r.json() or {}).get("items") or []
|
||||
if not items:
|
||||
break
|
||||
fresh = 0
|
||||
for it in items:
|
||||
cid = it.get("id")
|
||||
if cid and cid not in seen:
|
||||
seen.add(cid)
|
||||
conv_ids.append(str(cid))
|
||||
title = it.get("title")
|
||||
if title:
|
||||
titles.append(str(title))
|
||||
fresh += 1
|
||||
if fresh == 0 or len(items) < PAGE:
|
||||
break
|
||||
offset += PAGE
|
||||
try:
|
||||
mr = await client.get(f"{BASE}/memories", params={"include_memory_entries": "true"})
|
||||
if mr.status_code == 200:
|
||||
memories = [str(m.get("content")) for m in (mr.json() or {}).get("memories", []) if m.get("content")][:40]
|
||||
except Exception:
|
||||
pass
|
||||
# Depth pass: full text of the most recent few, fetched in parallel.
|
||||
convos = [c for c in await asyncio.gather(*(p_fetch_chatgpt_convo(client, cid) for cid in conv_ids[:CONVO_N])) if c]
|
||||
except Exception:
|
||||
return ""
|
||||
return summarize_chatgpt_usage(len(seen), memories, titles, convos, capped=len(titles) >= CAP_TITLES)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Read the user's real Claude history from claude.ai using their own logged-in browser
|
||||
cookies (see browser_cookies), no in-app login. Claude's website session is the only way
|
||||
in (its API token is a different realm), and a plain request carries it fine (unlike
|
||||
ChatGPT, claude.ai does not fingerprint-block). We pull the recent conversation titles for
|
||||
breadth AND the FULL text of the most recent few for depth: their actual asks + the exchange
|
||||
are far stronger signal than a vague title. Capped, read-only, fails open to "" on anything.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import List
|
||||
|
||||
import httpx
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.apps.onboarding.usage.browser_cookies import cookie_header, read_provider_cookies
|
||||
|
||||
BASE = "https://claude.ai"
|
||||
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
||||
PAGE = 100
|
||||
CAP_PAGES = 40
|
||||
CAP_TITLES = 1000
|
||||
# Depth: full text of the most recent CONVO_N chats. A per-convo cap keeps one marathon from
|
||||
# dominating; the total cap bounds the whole block (a clustering pass distills it downstream, so
|
||||
# this can be generous). ~130K chars is ~32K tokens, a couple cents for the cheap aux model.
|
||||
CONVO_N = 10
|
||||
CONVO_CHARS = 30000
|
||||
TOTAL_CONVO_CHARS = 130000
|
||||
|
||||
|
||||
@typechecked
|
||||
def summarize_claude_usage(total: int, titles: List[str], convos: List[str]) -> str:
|
||||
parts: List[str] = []
|
||||
if total > 0:
|
||||
parts.append(f"They have {total} past Claude conversations.")
|
||||
if titles:
|
||||
parts.append("Recent conversation titles (breadth): " + "; ".join(titles[:150]))
|
||||
if convos:
|
||||
block: List[str] = []
|
||||
used = 0
|
||||
for cv in convos:
|
||||
if used + len(cv) > TOTAL_CONVO_CHARS:
|
||||
break
|
||||
block.append(cv)
|
||||
used += len(cv)
|
||||
if block:
|
||||
parts.append("Full text of their most recent conversations (their real asks + the exchange):\n\n" + "\n\n---\n\n".join(block))
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@typechecked
|
||||
async def p_fetch_claude_convo(client: httpx.AsyncClient, org: str, cid: str) -> str:
|
||||
"""One conversation's full text, both sides, capped. "" on any failure so a bad convo is skipped."""
|
||||
try:
|
||||
r = await client.get(
|
||||
f"{BASE}/api/organizations/{org}/chat_conversations/{cid}",
|
||||
params={"tree": "True", "rendering_mode": "raw"},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return ""
|
||||
data = r.json()
|
||||
msgs = data.get("chat_messages") if isinstance(data, dict) else None
|
||||
if not isinstance(msgs, list):
|
||||
return ""
|
||||
lines: List[str] = []
|
||||
for m in msgs:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
sender = m.get("sender")
|
||||
if sender not in ("human", "assistant"):
|
||||
continue
|
||||
text = m.get("text") or ""
|
||||
if not text and isinstance(m.get("content"), list):
|
||||
text = " ".join(str(x.get("text", "")) for x in m["content"] if isinstance(x, dict) and x.get("text"))
|
||||
text = str(text).strip()
|
||||
if len(text) > 5:
|
||||
lines.append(("You: " if sender == "human" else "AI: ") + text)
|
||||
return "\n".join(lines)[:CONVO_CHARS]
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
@typechecked
|
||||
async def harvest_claude_usage() -> str:
|
||||
jar = read_provider_cookies("claude.ai")
|
||||
if not jar:
|
||||
return ""
|
||||
headers = {"Cookie": cookie_header(jar), "User-Agent": UA, "Accept": "application/json"}
|
||||
titles: List[str] = []
|
||||
conv_ids: List[str] = []
|
||||
seen: set = set()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20.0, headers=headers) as client:
|
||||
org_res = await client.get(f"{BASE}/api/organizations")
|
||||
if org_res.status_code != 200:
|
||||
return ""
|
||||
orgs = org_res.json()
|
||||
if not isinstance(orgs, list) or not orgs:
|
||||
return ""
|
||||
org = orgs[0].get("uuid")
|
||||
offset = 0
|
||||
for _ in range(CAP_PAGES):
|
||||
if len(titles) >= CAP_TITLES:
|
||||
break
|
||||
cr = await client.get(
|
||||
f"{BASE}/api/organizations/{org}/chat_conversations",
|
||||
params={"limit": PAGE, "offset": offset},
|
||||
)
|
||||
if cr.status_code != 200:
|
||||
break
|
||||
items = cr.json()
|
||||
if not isinstance(items, list) or not items:
|
||||
break
|
||||
fresh = 0
|
||||
for it in items:
|
||||
cid = it.get("uuid")
|
||||
if cid and cid not in seen:
|
||||
seen.add(cid)
|
||||
conv_ids.append(str(cid))
|
||||
name = it.get("name")
|
||||
if name:
|
||||
titles.append(str(name))
|
||||
fresh += 1
|
||||
if fresh == 0 or len(items) < PAGE:
|
||||
break
|
||||
offset += PAGE
|
||||
# Depth pass: full text of the most recent few, fetched in parallel.
|
||||
top = conv_ids[:CONVO_N]
|
||||
convos = [c for c in await asyncio.gather(*(p_fetch_claude_convo(client, org, cid) for cid in top)) if c]
|
||||
except Exception:
|
||||
return ""
|
||||
return summarize_claude_usage(len(seen), titles, convos)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""One-shot CLI: print the user's provider cookie records as JSON for a domain.
|
||||
|
||||
Electron main spawns `python -m backend.apps.onboarding.usage.dump_cookies <domain>`
|
||||
to get the session cookies to inject into its offscreen browser (real Chrome TLS to
|
||||
beat Cloudflare). Kept as a spawned one-shot, not an HTTP endpoint, so a token-holding
|
||||
agent can never reach it: only the trusted app shell can invoke it. Prints [] on
|
||||
anything (bad domain, no session, denied keychain). Never logs the cookie values.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from backend.apps.onboarding.usage.browser_cookies import (
|
||||
read_google_session_records,
|
||||
read_provider_cookie_records,
|
||||
)
|
||||
|
||||
ALLOWED_DOMAINS = {"chatgpt.com", "claude.ai", "gemini.google.com"}
|
||||
|
||||
|
||||
def records_for(domain: str) -> List[Dict[str, Any]]:
|
||||
# Gemini's login lives on the parent .google.com SSO domain, so read the scoped google
|
||||
# auth set for it; every other provider reads only its own domain.
|
||||
if domain == "gemini.google.com":
|
||||
return read_google_session_records()
|
||||
return read_provider_cookie_records(domain)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
domain = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
records = records_for(domain) if domain in ALLOWED_DOMAINS else []
|
||||
sys.stdout.write(json.dumps(records))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -206,6 +206,11 @@ import Typography from '@mui/material/Typography';
|
||||
import { Button, Box, Stack, Typography } from '@mui/material';
|
||||
```
|
||||
|
||||
**This template is MUI v7.** For layout use `import Grid from '@mui/material/Grid'` (the v7 Grid takes
|
||||
`size={{ xs: 12, md: 6 }}`). Do NOT import `@mui/material/Grid2` or `@mui/material/Unstable_Grid2` —
|
||||
those are v5/v6 paths and DO NOT EXIST here, they crash the build with "Failed to resolve import". When
|
||||
unsure a component or path exists, prefer plain `Box` with fl*/grid `sx` instead of guessing a package path.
|
||||
|
||||
Same rule for icons — even more important there because
|
||||
`@mui/icons-material` re-exports thousands of SVG components:
|
||||
|
||||
@@ -617,5 +622,10 @@ When making a new app from scratch:
|
||||
4. Add additional pages under `frontend/src/pages/`.
|
||||
5. If using a sidebar, update its nav entries in
|
||||
`frontend/src/app/components/Layout/Sidebar.tsx`.
|
||||
6. Style with `useClaudeTokens()` and MUI's `sx`.
|
||||
6. Style with `useClaudeTokens()` and MUI's `sx`. **The moment you type
|
||||
`useClaudeTokens()` in a file, add its import to that SAME file:**
|
||||
`import { useClaudeTokens } from '@/shared/styles/ThemeContext';` — you are
|
||||
rewriting `pages/index.tsx` from scratch (step 2), so the import the starter
|
||||
had is GONE. A missing import here is the #1 way a rewritten page throws
|
||||
"useClaudeTokens is not defined" and the whole app fails to boot.
|
||||
7. If you need a backend: `bash backend_init.sh`, then add a SubApp under `backend/apps/<name>/`.
|
||||
|
||||
@@ -78,6 +78,10 @@ export default defineConfig(({ mode }) => {
|
||||
port: Number(process.env.FRONTEND_PORT) || 3000,
|
||||
strictPort: true,
|
||||
open: false,
|
||||
// Never show Vite's full-screen red error overlay: a transient bad import mid-build (the agent is
|
||||
// still writing files) would flash a scary crash screen at the user. Errors still hit the console
|
||||
// + terminal.log, which the agent reads to fix; the card shows a clean "building" state instead.
|
||||
hmr: { overlay: false },
|
||||
proxy: backendEnabled
|
||||
? {
|
||||
'/api': {
|
||||
|
||||
@@ -31,18 +31,27 @@ DEFAULT_SYSTEM_PROMPT = (
|
||||
)
|
||||
|
||||
|
||||
# Fresh-install / unset-fallback model. "opus-5" is the plain row: cc/ sub lane for subscription users, API key otherwise.
|
||||
DEFAULT_MODEL = "opus-5"
|
||||
|
||||
|
||||
class AppSettings(BaseModel):
|
||||
default_system_prompt: Optional[str] = DEFAULT_SYSTEM_PROMPT
|
||||
default_folder: Optional[str] = None
|
||||
default_model: str = "sonnet"
|
||||
default_model: str = DEFAULT_MODEL
|
||||
default_mode: str = "agent"
|
||||
default_max_turns: Optional[int] = None
|
||||
default_thinking_level: Literal["off", "low", "medium", "high", "auto"] = "auto"
|
||||
zoom_sensitivity: float = 50.0
|
||||
# Root font-size multiplier (0.9/1/1.1/1.2 from Settings > Interface); the whole rem type scale rides it.
|
||||
ui_font_scale: float = 1.0
|
||||
theme: str = "light"
|
||||
# Shared across App Builder workspaces (each runs its own vite port / localStorage origin); null = follow system.
|
||||
app_template_theme_override: Optional[Literal["light", "dark"]] = None
|
||||
new_agent_shortcut: str = "Meta+l"
|
||||
# None = platform default (Cmd/Ctrl+Shift+D); parts format matches new_agent_shortcut.
|
||||
dictation_shortcut: Optional[str] = None
|
||||
voice_hold_to_talk: bool = True
|
||||
anthropic_api_key: Optional[str] = None
|
||||
browser_homepage: str = "https://www.google.com"
|
||||
openai_api_key: Optional[str] = None
|
||||
@@ -65,8 +74,17 @@ class AppSettings(BaseModel):
|
||||
onboarding_v3: Optional[str] = None
|
||||
# User-picked accent hex from the onboarding theme pad; None = stock accent.
|
||||
accent_color: Optional[str] = None
|
||||
# Multi-stop gradient from the theme pad (2-3 hexes); washes the canvas.
|
||||
accent_gradient: Optional[list[str]] = None
|
||||
personalized_greeting: Optional[str] = None
|
||||
# Short one-glance identity hook for the reveal's focal beat (greeting is the longer warm read).
|
||||
personalized_headline: Optional[str] = None
|
||||
personalized_starters: list["PersonalizedStarter"] = Field(default_factory=list)
|
||||
personalized_automations: list["PersonalizedAutomation"] = Field(default_factory=list)
|
||||
# The hero's two-level menu: 4 general categories, each holding 4 starters tailored to this user.
|
||||
personalized_menu: Optional["PersonalizedMenu"] = None
|
||||
# Distilled from the user's provider chat history the first time they open ChatGPT/Claude in-app; re-feeds prep to sharpen suggestions.
|
||||
personalized_usage_summary: Optional[str] = None
|
||||
# Suppresses preflight suggestion modal entries the user dismissed; keyed by ToolDefinition.name, value ISO timestamp.
|
||||
dismissed_mcp_suggestions: dict[str, str] = Field(default_factory=dict)
|
||||
analytics_opt_in: bool = True
|
||||
@@ -108,3 +126,19 @@ class CustomProvider(BaseModel):
|
||||
class PersonalizedStarter(BaseModel):
|
||||
title: str
|
||||
prompt: str
|
||||
# One short clause tying this task to something real we saw about the user; the reveal shows it off.
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class PersonalizedAutomation(BaseModel):
|
||||
title: str
|
||||
prompt: str
|
||||
# 'daily' | 'weekday' | 'weekly'; the frontend maps this to a workflow schedule.
|
||||
cadence: str = "weekly"
|
||||
|
||||
|
||||
class PersonalizedMenu(BaseModel):
|
||||
computer: list[PersonalizedStarter] = Field(default_factory=list)
|
||||
research: list[PersonalizedStarter] = Field(default_factory=list)
|
||||
web: list[PersonalizedStarter] = Field(default_factory=list)
|
||||
build: list[PersonalizedStarter] = Field(default_factory=list)
|
||||
|
||||
@@ -136,7 +136,8 @@ async def clear_free_trial(settings_obj) -> None:
|
||||
if getattr(settings_obj, "default_model", None) == "haiku" and (
|
||||
has_own_model(settings_obj) or await p_has_connected_subscription()
|
||||
):
|
||||
settings_obj.default_model = "sonnet"
|
||||
from backend.apps.settings.models import DEFAULT_MODEL
|
||||
settings_obj.default_model = DEFAULT_MODEL
|
||||
settings_obj.free_trial_token = None
|
||||
await save_settings_async(settings_obj)
|
||||
await p_sync_routing(settings_obj)
|
||||
|
||||
@@ -14,6 +14,7 @@ from uuid import uuid4
|
||||
|
||||
from backend.apps.swarm.exportable import DepRef, ExportContext, RemapTable
|
||||
from backend.apps.swarm.models import EntityType, Requirement, RequirementKind
|
||||
from backend.apps.settings.models import DEFAULT_MODEL
|
||||
|
||||
P_BUILTIN_MODES = {"agent", "ask", "plan", "view-builder", "skill-builder"}
|
||||
# Transcript fields ride along so the shared agent keeps its history; ids inside (message ids, branch ids, their parent/fork refs) are self-consistent within the one session file, so they carry verbatim with no remap.
|
||||
@@ -95,7 +96,7 @@ class SessionExportable:
|
||||
"name": payload.get("name") or "Agent",
|
||||
"status": "completed",
|
||||
"provider": payload.get("provider") or "anthropic",
|
||||
"model": payload.get("model") or "sonnet",
|
||||
"model": payload.get("model") or DEFAULT_MODEL,
|
||||
"mode": payload.get("mode") or "agent",
|
||||
"system_prompt": payload.get("system_prompt"),
|
||||
"allowed_tools": payload.get("allowed_tools") or [],
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Dictation cleanup: raw whisper text -> punctuated, filler-free prose via the cheap aux tier.
|
||||
|
||||
One fast call on whatever lane the user has configured (provider-agnostic per the aux registry).
|
||||
Every failure path returns the RAW text so dictation never breaks when the aux is unreachable.
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.config.Apps import SubApp
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def voice_lifespan() -> AsyncIterator[None]:
|
||||
yield
|
||||
|
||||
|
||||
voice = SubApp("voice", voice_lifespan)
|
||||
|
||||
P_POLISH_SYSTEM = (
|
||||
"You clean up raw speech-to-text dictation. Return ONLY the cleaned text, nothing else. "
|
||||
"Fix punctuation, capitalization, and obvious homophone errors. Remove filler words (um, uh, "
|
||||
"like when used as filler, you know) and false starts. Apply spoken formatting commands: "
|
||||
"'new line'/'new paragraph' become real breaks, 'period'/'comma'/'question mark' become the "
|
||||
"mark when clearly dictated as punctuation. NEVER add content, never answer questions in the "
|
||||
"text, never translate, never wrap in quotes, never use em-dashes. Keep the speaker's words "
|
||||
"and tone; this is transcription cleanup, not rewriting."
|
||||
)
|
||||
|
||||
POLISH_INPUT_CAP = 8_000
|
||||
|
||||
|
||||
class PolishRequest(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
text: str
|
||||
# A one-line hint about where the user is dictating (e.g. a page title), so names spell right.
|
||||
context: Optional[str] = None
|
||||
|
||||
|
||||
class PolishResponse(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
text: str
|
||||
polished: bool
|
||||
|
||||
|
||||
@voice.router.post("/polish")
|
||||
@typechecked
|
||||
async def polish(body: PolishRequest) -> dict:
|
||||
raw = (body.text or "").strip()
|
||||
if not raw:
|
||||
return PolishResponse(text="", polished=False).model_dump()
|
||||
try:
|
||||
from backend.apps.agents.core.aux_llm import aux_max_tokens_for, safe_resp_text
|
||||
from backend.apps.agents.providers.registry import resolve_aux_model
|
||||
from backend.apps.settings.credentials import get_anthropic_client_for_model
|
||||
from backend.apps.settings.store import load_settings
|
||||
|
||||
settings = load_settings()
|
||||
aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
|
||||
client = get_anthropic_client_for_model(settings, aux_model)
|
||||
prompt = raw[:POLISH_INPUT_CAP]
|
||||
if body.context:
|
||||
prompt = f"[Dictating into: {body.context[:200]}]\n{prompt}"
|
||||
resp = await client.messages.create(
|
||||
model=aux_model,
|
||||
max_tokens=aux_max_tokens_for(aux_model, base=1000),
|
||||
system=P_POLISH_SYSTEM,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
# Dictation is interactive; a slow aux must never hold the paste hostage.
|
||||
timeout=6.0,
|
||||
)
|
||||
cleaned = safe_resp_text(resp).strip()
|
||||
if cleaned:
|
||||
return PolishResponse(text=cleaned, polished=True).model_dump()
|
||||
except Exception:
|
||||
pass
|
||||
return PolishResponse(text=raw, polished=False).model_dump()
|
||||
@@ -14,6 +14,7 @@ from typing import Optional
|
||||
from backend.apps.agents.core.models import AgentConfig
|
||||
from backend.apps.workflows.models import Workflow, WorkflowRun
|
||||
from backend.apps.workflows import storage
|
||||
from backend.apps.settings.models import DEFAULT_MODEL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -268,7 +269,7 @@ async def execute(
|
||||
resolved_allowed_tools = _resolve_allowed_tools(wf)
|
||||
config = AgentConfig(
|
||||
name=wf.title or "Workflow",
|
||||
model=wf.model or "sonnet",
|
||||
model=wf.model or DEFAULT_MODEL,
|
||||
mode=wf.mode or "agent",
|
||||
provider=wf.provider or "anthropic",
|
||||
system_prompt=_resolve_system_prompt(wf),
|
||||
|
||||
@@ -2,6 +2,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
|
||||
from typing import Optional, Literal, Any
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
from backend.apps.settings.models import DEFAULT_MODEL
|
||||
|
||||
|
||||
# Each "tier" in the permission chain: notify in app, fall through to text after N minutes if no response, then to call after a further N minutes/hours. Matches images 17 to 19 (Schedule edit). Order in the list = escalation order.
|
||||
@@ -97,7 +98,7 @@ class Workflow(BaseModel):
|
||||
# Tool names observed in the source chat when this workflow was generated. This preserves conversion context without pretending those calls map to generated workflow step ids. Explicit approval decisions still live in remembered_approvals and are the only values reused as permissions.
|
||||
source_tools: list[str] = Field(default_factory=list)
|
||||
dashboard_id: Optional[str] = None
|
||||
model: str = "sonnet"
|
||||
model: str = DEFAULT_MODEL
|
||||
mode: str = "agent"
|
||||
provider: str = "anthropic"
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
|
||||
@@ -20,6 +20,7 @@ from backend.apps.workflows.models import (
|
||||
GenerateMetadataResponse,
|
||||
)
|
||||
from backend.apps.workflows import storage, scheduler, executor, audit, escalation
|
||||
from backend.apps.settings.models import DEFAULT_MODEL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -265,7 +266,7 @@ async def create_workflow(body: WorkflowCreate):
|
||||
permissions=body.permissions or [],
|
||||
source_session_id=body.source_session_id,
|
||||
dashboard_id=body.dashboard_id,
|
||||
model=body.model or "sonnet",
|
||||
model=body.model or DEFAULT_MODEL,
|
||||
mode=body.mode or "agent",
|
||||
provider=body.provider or "anthropic",
|
||||
cost_cap_usd_monthly=body.cost_cap_usd_monthly,
|
||||
@@ -313,7 +314,7 @@ async def create_workflow(body: WorkflowCreate):
|
||||
@workflows.router.post("/generate-metadata")
|
||||
async def generate_workflow_metadata(body: GenerateMetadataRequest) -> GenerateMetadataResponse:
|
||||
# Preview-time naming for the convert-to-workflow draft. Generates without persisting so the card can show a real title before the user saves.
|
||||
wf = Workflow(steps=body.steps, model=body.model or "sonnet")
|
||||
wf = Workflow(steps=body.steps, model=body.model or DEFAULT_MODEL)
|
||||
title, description, labels = await _generate_workflow_metadata(wf)
|
||||
return GenerateMetadataResponse(title=title, description=description, step_labels=labels)
|
||||
|
||||
@@ -1017,7 +1018,7 @@ async def edit_agent_session(workflow_id: str):
|
||||
edit_dashboard_id = p_wsm.active_dashboard_id or executor.resolve_workflow_dashboard_id(wf)
|
||||
config = AgentConfig(
|
||||
name=f"Edit Agent: {wf.title}",
|
||||
model=wf.model or "sonnet",
|
||||
model=wf.model or DEFAULT_MODEL,
|
||||
mode=wf.mode or "agent",
|
||||
provider=wf.provider or "anthropic",
|
||||
system_prompt=system_prompt,
|
||||
@@ -1256,7 +1257,7 @@ async def test_run_workflow(workflow_id: str, body: dict):
|
||||
resolved_allowed_tools = executor._resolve_allowed_tools(wf)
|
||||
config = AgentConfig(
|
||||
name=f"{wf.title or 'Workflow'} (test)",
|
||||
model=wf.model or "sonnet",
|
||||
model=wf.model or DEFAULT_MODEL,
|
||||
mode=wf.mode or "agent",
|
||||
provider=wf.provider or "anthropic",
|
||||
system_prompt=executor._resolve_system_prompt(wf),
|
||||
@@ -1395,7 +1396,7 @@ async def schedule_agent_session(workflow_id: str):
|
||||
)
|
||||
config = AgentConfig(
|
||||
name=f"Scheduling: {wf.title}",
|
||||
model=wf.model or "sonnet",
|
||||
model=wf.model or DEFAULT_MODEL,
|
||||
mode=wf.mode or "agent",
|
||||
provider=wf.provider or "anthropic",
|
||||
system_prompt=system_prompt,
|
||||
|
||||
+42
-2
@@ -43,6 +43,8 @@ from backend.apps.subscription.router import subscription
|
||||
from backend.apps.auth.router import auth
|
||||
from backend.apps.web.web import web
|
||||
from backend.apps.onboarding.onboarding import onboarding
|
||||
from backend.apps.voice.polish import voice
|
||||
from backend.apps.help.bundle import help_app
|
||||
from backend.apps.agents.proxy.anthropic_proxy import anthropic_proxy
|
||||
from backend.apps.agents.core.openai_passthrough import openai_passthrough
|
||||
from backend.apps.workflows.workflows import workflows
|
||||
@@ -50,7 +52,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
import json
|
||||
|
||||
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, output_versions, dashboards, swarm, service, subscription, auth, web, onboarding, anthropic_proxy, workflows, openai_passthrough])
|
||||
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, output_versions, dashboards, swarm, service, subscription, auth, web, onboarding, voice, help_app, anthropic_proxy, workflows, openai_passthrough])
|
||||
app = main_app.app
|
||||
|
||||
# Generate per-install auth token BEFORE we bind the HTTP port. By the time any request lands, the token file exists. See backend/auth.py.
|
||||
@@ -501,7 +503,8 @@ async def browser_agent_run(request: Request):
|
||||
|
||||
body = await request.json()
|
||||
tasks = body.get("tasks", [])
|
||||
model = body.get("model", "sonnet")
|
||||
from backend.apps.settings.models import DEFAULT_MODEL
|
||||
model = body.get("model", DEFAULT_MODEL)
|
||||
dashboard_id = body.get("dashboard_id", "")
|
||||
pre_selected_browser_ids = body.get("pre_selected_browser_ids", [])
|
||||
parent_session_id = body.get("parent_session_id", "")
|
||||
@@ -902,6 +905,43 @@ async def spawn_agent_run(request: Request):
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.post("/api/ui-requests/wait")
|
||||
async def ui_request_wait(request: Request):
|
||||
"""AskUI's blocking half: parks until the user answers the interactive component in the
|
||||
transcript. Called by the show_ui_mcp_server stdio subprocess."""
|
||||
body = await request.json()
|
||||
session_id = str(body.get("session_id", ""))
|
||||
component_id = str(body.get("component_id", ""))
|
||||
timeout_s = float(body.get("timeout_s", 600) or 600)
|
||||
if not session_id or not component_id:
|
||||
return JSONResponse({"error": "session_id and component_id are required"}, status_code=400)
|
||||
try:
|
||||
from backend.apps.agents.ui_request_bridge import wait_for_ui_response
|
||||
response = await wait_for_ui_response(session_id, component_id, timeout_s)
|
||||
return JSONResponse({"ok": response is not None, "response": response})
|
||||
except ValueError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=429)
|
||||
except Exception as e:
|
||||
logger.exception("ui_request_wait failed")
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.post("/api/ui-requests/respond")
|
||||
async def ui_request_respond(request: Request):
|
||||
"""The user's answer from the rendered component; releases the matching parked wait."""
|
||||
body = await request.json()
|
||||
session_id = str(body.get("session_id", ""))
|
||||
component_id = str(body.get("component_id", ""))
|
||||
response = body.get("response")
|
||||
if not session_id or not component_id or not isinstance(response, dict):
|
||||
return JSONResponse({"error": "session_id, component_id and response object are required"}, status_code=400)
|
||||
from backend.apps.agents.ui_request_bridge import respond_to_ui_request
|
||||
delivered = respond_to_ui_request(session_id, component_id, response)
|
||||
if not delivered:
|
||||
return JSONResponse({"error": "no pending request for that component"}, status_code=404)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.post("/api/invoke-agent/run")
|
||||
async def invoke_agent_run(request: Request):
|
||||
"""Fork an existing agent session and send it a new message.
|
||||
|
||||
@@ -4,7 +4,6 @@ exists. output.files is frozen at creation (v1); the agent edits land on disk
|
||||
imported app reverted every edited file (new files survived since they were never
|
||||
in the snapshot). With a workspace, disk is the single source of truth; only true
|
||||
flat apps (no workspace) still carry the inline copy."""
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -168,13 +168,13 @@ async def test_clear_keeps_haiku_as_trial_face_unless_a_real_model_is_connected(
|
||||
s2 = AppSettings(connection_mode="free-trial", free_trial_token="ftk",
|
||||
default_model="haiku", anthropic_api_key="sk-ant-real")
|
||||
await clear_free_trial(s2)
|
||||
assert s2.default_model == "sonnet"
|
||||
assert s2.default_model == "opus-5"
|
||||
|
||||
# A connected 9Router subscription (invisible in settings) also frees haiku -> sonnet.
|
||||
monkeypatch.setattr(ft, "p_has_connected_subscription", _true)
|
||||
s3 = AppSettings(connection_mode="free-trial", free_trial_token="ftk", default_model="haiku")
|
||||
await clear_free_trial(s3)
|
||||
assert s3.default_model == "sonnet"
|
||||
assert s3.default_model == "opus-5"
|
||||
|
||||
# A user who deliberately picked haiku OUTSIDE free-trial mode is left alone.
|
||||
s4 = AppSettings(connection_mode="own_key", default_model="haiku")
|
||||
|
||||
@@ -7,9 +7,11 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from backend.apps.onboarding.identity import build_identity, decode_jwt_payload
|
||||
from backend.apps.onboarding.local_scan import run_local_scan
|
||||
from backend.apps.onboarding.local_scan import detect_signal_apps, run_local_scan
|
||||
from backend.apps.onboarding.models import PrepRequest, ScanResult
|
||||
from backend.apps.onboarding.prep import FALLBACK_STARTERS, build_prep, parse_prep
|
||||
from backend.apps.onboarding.prep.build_prep import build_prep
|
||||
from backend.apps.onboarding.prep.parse_prep import parse_prep
|
||||
from backend.apps.onboarding.prep.scan_fallback import FALLBACK_STARTERS
|
||||
from backend.apps.settings.models import AppSettings
|
||||
|
||||
|
||||
@@ -78,16 +80,41 @@ def test_run_local_scan_counts_names_only(tmp_path: Path):
|
||||
assert "[user]" not in serialized
|
||||
|
||||
|
||||
def test_signal_apps_picks_tools_not_noise_and_avoids_substring_traps():
|
||||
apps = ["Calculator", "Xcode", "Visual Studio Code", "Figma", "Search", "System Settings", "Adobe Photoshop 2024", "Arc"]
|
||||
signal = detect_signal_apps(apps)
|
||||
assert "Xcode" in signal and "Visual Studio Code" in signal and "Figma" in signal
|
||||
assert "Adobe Photoshop 2024" in signal # prefix match on a versioned name
|
||||
assert "Arc" in signal
|
||||
# "Search" contains "arc" as a substring but must NOT be treated as the Arc browser.
|
||||
assert "Search" not in signal
|
||||
assert "Calculator" not in signal and "System Settings" not in signal
|
||||
|
||||
|
||||
def test_parse_prep_strict_and_lenient():
|
||||
good = '{"greeting": "Hey!", "starters": [{"title": "A", "prompt": "do a"}, {"title": "B", "prompt": "do b"}]}'
|
||||
parsed = parse_prep(f"Sure! Here you go: {good}")
|
||||
assert parsed is not None
|
||||
assert parsed.greeting == "Hey!"
|
||||
assert [s.title for s in parsed.starters] == ["A", "B"]
|
||||
# Reason is optional; missing reason defaults to empty, never drops the starter.
|
||||
assert parsed.starters[0].reason == ""
|
||||
assert parse_prep("no json here") is None
|
||||
assert parse_prep('{"greeting": "hi", "starters": []}') is None
|
||||
|
||||
|
||||
def test_parse_prep_carries_reasons():
|
||||
rich = (
|
||||
'{"greeting": "Hey!", "app_title": "Lift Log", "app_prompt": "Build me a lifting tracker", '
|
||||
'"app_reason": "you plan lifts with ChatGPT daily", '
|
||||
'"starters": [{"title": "Audit Downloads", "prompt": "audit it", "reason": "1,305 files piling up there"}]}'
|
||||
)
|
||||
parsed = parse_prep(rich)
|
||||
assert parsed is not None
|
||||
assert parsed.starters[0].reason == "1,305 files piling up there"
|
||||
assert parsed.app_reason == "you plan lifts with ChatGPT daily"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_prep_fails_open_without_provider(monkeypatch):
|
||||
async def boom(*args, **kwargs):
|
||||
@@ -97,3 +124,59 @@ async def test_build_prep_fails_open_without_provider(monkeypatch):
|
||||
result = await build_prep(AppSettings(), PrepRequest(scan=ScanResult(), picked_apps=["notion"]))
|
||||
assert result.greeting == ""
|
||||
assert result.starters == FALLBACK_STARTERS
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- hero menu ---------------------------------------------------------------------------
|
||||
|
||||
def test_parse_menu_strict_and_partial():
|
||||
from backend.apps.onboarding.prep.menu import parse_menu
|
||||
|
||||
full = json.dumps({
|
||||
"computer": [{"title": "Frame shots", "prompt": "do it"}],
|
||||
"research": [{"title": "Vector DBs", "prompt": "compare"}],
|
||||
"web": [],
|
||||
"build": [{"title": "Jump log", "prompt": "Build me a jump log"}],
|
||||
})
|
||||
menu = parse_menu(full)
|
||||
assert menu is not None
|
||||
assert menu.computer[0].title == "Frame shots"
|
||||
assert menu.web == []
|
||||
assert parse_menu("not json at all") is None
|
||||
assert parse_menu(json.dumps({"computer": [], "research": [], "web": [], "build": []})) is None
|
||||
|
||||
|
||||
def test_fill_menu_always_serves_four_per_category():
|
||||
from backend.apps.onboarding.prep.menu import MENU_CATEGORIES, fill_menu, parse_menu
|
||||
|
||||
partial = parse_menu(json.dumps({
|
||||
"computer": [{"title": "Frame shots", "prompt": "do it"}],
|
||||
"research": [], "web": [], "build": [],
|
||||
}))
|
||||
scan = ScanResult(signal_apps=["Figma"], folders=[], git_repo_count=2)
|
||||
menu = fill_menu(partial, scan)
|
||||
for category in MENU_CATEGORIES:
|
||||
rows = getattr(menu, category)
|
||||
assert len(rows) == 4, category
|
||||
assert len({r.title for r in rows}) == 4, category
|
||||
# LLM rows lead, scan-grounded rows fill before statics.
|
||||
assert menu.computer[0].title == "Frame shots"
|
||||
assert any(r.title == "Recap my projects" for r in menu.computer)
|
||||
assert menu.research[0].title == "Figma tips"
|
||||
# No scan at all still fills from statics.
|
||||
empty = fill_menu(None, None)
|
||||
for category in MENU_CATEGORIES:
|
||||
assert len(getattr(empty, category)) == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_prep_attaches_menu_even_on_fallback(monkeypatch):
|
||||
|
||||
async def boom(*a, **k):
|
||||
raise RuntimeError("no aux")
|
||||
|
||||
monkeypatch.setattr("backend.apps.agents.providers.registry.resolve_aux_model", boom)
|
||||
out = await build_prep(AppSettings(), PrepRequest(scan=ScanResult()))
|
||||
assert out.menu is not None
|
||||
from backend.apps.onboarding.prep.menu import MENU_CATEGORIES
|
||||
for category in MENU_CATEGORIES:
|
||||
assert len(getattr(out.menu, category)) == 4
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Usage-harvest tests: chatgpt/claude summaries + the browser-cookie read path (all fail-open)."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
def test_summarize_chatgpt_usage_leads_with_memory_and_caps():
|
||||
from backend.apps.onboarding.usage.chatgpt_usage import TOTAL_CONVO_CHARS, summarize_chatgpt_usage
|
||||
|
||||
s = summarize_chatgpt_usage(
|
||||
812,
|
||||
["Has an Akita", "Squats 495"],
|
||||
["Swift concurrency", "Deadlift form"],
|
||||
["User: fix my squat form?\nAssistant: brace harder."],
|
||||
)
|
||||
assert "812 past AI conversations" in s
|
||||
assert "Has an Akita; Squats 495" in s
|
||||
assert "Swift concurrency; Deadlift form" in s
|
||||
assert "fix my squat form?" in s
|
||||
big = summarize_chatgpt_usage(
|
||||
1000,
|
||||
[],
|
||||
[f"t{i}x" for i in range(1000)],
|
||||
["c" * 60000 for _ in range(10)],
|
||||
)
|
||||
assert "t149x" in big and "t150x" not in big
|
||||
convo_block = big.split("real asks + the exchange")[1]
|
||||
assert len(convo_block) <= TOTAL_CONVO_CHARS + 10000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_harvest_chatgpt_usage_fails_open_without_codex(monkeypatch):
|
||||
from backend.apps.onboarding.usage import chatgpt_usage
|
||||
|
||||
monkeypatch.setattr(chatgpt_usage, "read_persisted_connections", lambda: [])
|
||||
assert await chatgpt_usage.harvest_chatgpt_usage() == ""
|
||||
|
||||
|
||||
def test_read_provider_cookies_fails_open_without_a_store(monkeypatch):
|
||||
from backend.apps.onboarding.usage import browser_cookies
|
||||
|
||||
# No browser store has the domain -> empty jar/records, and the keychain is never touched.
|
||||
monkeypatch.setattr(browser_cookies, "p_best_store", lambda domain: None)
|
||||
assert browser_cookies.read_provider_cookies("claude.ai") == {}
|
||||
assert browser_cookies.read_provider_cookie_records("claude.ai") == []
|
||||
|
||||
|
||||
def test_win_storage_key_parses_local_state_and_unwraps(monkeypatch, tmp_path):
|
||||
from backend.apps.onboarding.usage import browser_cookies
|
||||
|
||||
# Local State carries a base64 "DPAPI"-prefixed key; the Windows path strips the prefix and
|
||||
# hands the rest to CryptUnprotectData. Prove the parse + prefix-strip without needing Windows.
|
||||
raw_key = b"DPAPI" + b"wrapped-key-bytes"
|
||||
local_state_dir = tmp_path / "UserData"
|
||||
local_state_dir.mkdir()
|
||||
(local_state_dir / "Local State").write_text(
|
||||
json.dumps({"os_crypt": {"encrypted_key": base64.b64encode(raw_key).decode()}})
|
||||
)
|
||||
monkeypatch.setattr(browser_cookies, "CHROMIUM_ROOTS", {"Chrome": str(local_state_dir)})
|
||||
seen = {}
|
||||
|
||||
def fake_unprotect(data: bytes):
|
||||
seen["passed"] = data
|
||||
return b"unwrapped-aes-key"
|
||||
|
||||
monkeypatch.setattr(browser_cookies, "p_win_dpapi_unprotect", fake_unprotect)
|
||||
key = browser_cookies.win_storage_key("Chrome")
|
||||
assert key == b"unwrapped-aes-key"
|
||||
assert seen["passed"] == b"wrapped-key-bytes" # the 5-byte "DPAPI" prefix was stripped
|
||||
|
||||
|
||||
def test_win_storage_key_fails_open(monkeypatch, tmp_path):
|
||||
from backend.apps.onboarding.usage import browser_cookies
|
||||
|
||||
# Missing Local State, malformed JSON, and DPAPI failure all fail open to None (-> scan fallback).
|
||||
monkeypatch.setattr(browser_cookies, "CHROMIUM_ROOTS", {"Chrome": str(tmp_path)})
|
||||
assert browser_cookies.win_storage_key("Chrome") is None # no Local State file
|
||||
assert browser_cookies.win_storage_key("Nonexistent") is None
|
||||
|
||||
|
||||
def test_decrypt_rejects_app_bound_v20():
|
||||
from backend.apps.onboarding.usage import browser_cookies
|
||||
|
||||
# v20 = app-bound encryption (modern Chrome), out of reach on both OSes -> None, never a crash.
|
||||
assert browser_cookies.decrypt_cookie_value(b"v20" + b"anything", b"\x00" * 32) is None
|
||||
assert browser_cookies.decrypt_cookie_value(b"", b"\x00" * 32) is None
|
||||
|
||||
|
||||
def test_dump_cookies_only_serves_allowlisted_domains(monkeypatch, capsys):
|
||||
from backend.apps.onboarding.usage import dump_cookies
|
||||
|
||||
# Patch the names in dump_cookies' own namespace, so a real read (+ keychain) never fires.
|
||||
monkeypatch.setattr(dump_cookies, "read_provider_cookie_records", lambda domain: [{"name": "x", "value": "y"}])
|
||||
monkeypatch.setattr(dump_cookies, "read_google_session_records", lambda: [{"name": "SID", "value": "g"}])
|
||||
# An off-list domain must never trigger a read, prints [].
|
||||
monkeypatch.setattr("sys.argv", ["dump_cookies", "evil.example.com"])
|
||||
dump_cookies.main()
|
||||
assert capsys.readouterr().out == "[]"
|
||||
# An allowlisted domain passes through to the reader.
|
||||
monkeypatch.setattr("sys.argv", ["dump_cookies", "claude.ai"])
|
||||
dump_cookies.main()
|
||||
assert '"name": "x"' in capsys.readouterr().out
|
||||
# Gemini routes to the SCOPED google reader, not a raw gemini.google.com read.
|
||||
monkeypatch.setattr("sys.argv", ["dump_cookies", "gemini.google.com"])
|
||||
dump_cookies.main()
|
||||
assert '"name": "SID"' in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_read_google_session_records_scopes_to_named_auth_cookies(monkeypatch):
|
||||
from backend.apps.onboarding.usage import browser_cookies
|
||||
|
||||
seen_domain = {}
|
||||
|
||||
def fake_records(domain: str):
|
||||
seen_domain["d"] = domain
|
||||
return [
|
||||
{"name": "SID", "value": "a"},
|
||||
{"name": "__Secure-1PSID", "value": "b"},
|
||||
{"name": "SEARCH_SAMESITE", "value": "c"}, # non-auth google cookie
|
||||
{"name": "OTZ", "value": "d"}, # non-auth google cookie
|
||||
]
|
||||
|
||||
monkeypatch.setattr(browser_cookies, "read_provider_cookie_records", fake_records)
|
||||
recs = browser_cookies.read_google_session_records()
|
||||
# Reads the parent SSO domain, then keeps ONLY the named auth cookies (never a full sweep).
|
||||
assert seen_domain["d"] == ".google.com"
|
||||
assert {r["name"] for r in recs} == {"SID", "__Secure-1PSID"}
|
||||
|
||||
|
||||
def test_summarize_claude_usage_counts_and_caps():
|
||||
from backend.apps.onboarding.usage.claude_usage import TOTAL_CONVO_CHARS, summarize_claude_usage
|
||||
|
||||
s = summarize_claude_usage(
|
||||
490,
|
||||
["Yuji Itadori and Buddhism", "B2B SaaS Startup Ideas"],
|
||||
["User: pitch me a startup\nAssistant: sure."],
|
||||
)
|
||||
assert "490 past Claude conversations" in s
|
||||
assert "Yuji Itadori and Buddhism; B2B SaaS Startup Ideas" in s
|
||||
assert "pitch me a startup" in s
|
||||
big = summarize_claude_usage(1000, [f"t{i}x" for i in range(1000)], ["c" * 60000 for _ in range(10)])
|
||||
assert "t149x" in big and "t150x" not in big
|
||||
convo_block = big.split("real asks + the exchange")[1]
|
||||
assert len(convo_block) <= TOTAL_CONVO_CHARS + 10000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_harvest_claude_usage_fails_open_without_cookies(monkeypatch):
|
||||
from backend.apps.onboarding.usage import claude_usage
|
||||
|
||||
monkeypatch.setattr(claude_usage, "read_provider_cookies", lambda domain: {})
|
||||
assert await claude_usage.harvest_claude_usage() == ""
|
||||
@@ -4,10 +4,8 @@ evidence so a zero-config user never boots a router with nothing to route."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import backend.apps.nine_router.process as proc
|
||||
from backend.apps.settings.models import AppSettings
|
||||
|
||||
@@ -63,7 +63,7 @@ def test_minimal_old_file_fills_missing_with_defaults(settings_file):
|
||||
p_write(settings_file, {"theme": "light"})
|
||||
s = store.load_settings()
|
||||
assert s.theme == "light"
|
||||
assert s.default_model == "sonnet" # filled from default
|
||||
assert s.default_model == "opus-5" # filled from default
|
||||
assert s.auto_reveal_sub_agents is True
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Dict, List
|
||||
|
||||
from pytest import MonkeyPatch, raises
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ subscription lanes, and results are cached so a double boot-fetch can't double-s
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Dict, List
|
||||
|
||||
import backend.apps.nine_router.subscription_health as sh
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ FULL_TOOLS list that prunes dead preset built-ins WITHOUT dropping anything Open
|
||||
manifest MUST keep every built-in in effective_allowed's source set + ToolSearch (so deferred MCP
|
||||
loading survives); MCP tools ride mcp_servers, not this list."""
|
||||
|
||||
import os
|
||||
|
||||
from backend.apps.agents.manager.prompt.tool_catalog import (
|
||||
FULL_TOOLS,
|
||||
|
||||
@@ -50,11 +50,16 @@ async def test_view_builder_dep_install_broadcasts_app_deps_changed():
|
||||
registry: dict = {}
|
||||
ctx = p_ctx(registry)
|
||||
ctx.session.mode = "view-builder"
|
||||
with patch.object(tool_result_hook.ws_manager, "send_to_session", new=AsyncMock()) as send:
|
||||
# The broadcast gates on an attached preview runtime (not the mode), so agent-mode CreateApp builds get it too.
|
||||
fake_runtime_manager = MagicMock()
|
||||
fake_runtime_manager.get.return_value = object()
|
||||
with patch.object(tool_result_hook.ws_manager, "send_to_session", new=AsyncMock()) as send, \
|
||||
patch("backend.apps.outputs.runtime.manager", fake_runtime_manager):
|
||||
await tool_result_hook.post_tool_hook(
|
||||
ctx, {"tool_name": "Bash", "tool_response": "added 3 packages",
|
||||
"tool_input": {"command": "npm install recharts"}}, "tu1", None
|
||||
)
|
||||
view_builder_state.view_builder_dirty_sessions.discard(ctx.session_id)
|
||||
events = [c.args[1] for c in send.await_args_list]
|
||||
assert "agent:app_deps_changed" in events
|
||||
|
||||
|
||||
@@ -665,18 +665,16 @@ def test_dashboard_get_strips_only_orphan_session_cards():
|
||||
def test_banned_models_not_offered():
|
||||
"""Models with no working lane stay pulled from the picker. Guard so a
|
||||
refactor can't silently re-list a model that can't run. Gemini 3.1 Pro: AG
|
||||
can't serve it, AI Studio key 429s pro-preview. gpt-5.5 (subscription):
|
||||
cx/gpt-5.5 404s on the pinned 9Router 0.3.60, so picking it broke codex
|
||||
entirely; gpt-5.5-api stays (that lane works). (Fable 5 was re-listed
|
||||
2026-07-02 after its ban lifted, so it left this list.)"""
|
||||
can't serve it, AI Studio key 429s pro-preview. (gpt-5.5's cx entry left
|
||||
this list 2026-07-26: the pinned 0.3.60's old 404 healed upstream and a
|
||||
live probe returned a real completion, so both its lanes work. Fable 5
|
||||
left 2026-07-02 after its ban lifted.)"""
|
||||
from backend.apps.agents.providers.registry import BUILTIN_MODELS
|
||||
all_values = {m["value"] for models in BUILTIN_MODELS.values() for m in models}
|
||||
for dead in ("gemini-3.1-pro", "gemini-3.1-pro-api", "gpt-5.5"):
|
||||
for dead in ("gemini-3.1-pro", "gemini-3.1-pro-api"):
|
||||
assert dead not in all_values, f"{dead} is back in the picker"
|
||||
assert "gpt-5.5-api" in all_values # the working API-key lane must survive the pull
|
||||
# No dead cx/gpt-5.5 router id survives either (a renamed entry would dodge the value check).
|
||||
all_router_ids = {m.get("router_model_id") for models in BUILTIN_MODELS.values() for m in models}
|
||||
assert "cx/gpt-5.5" not in all_router_ids
|
||||
assert "gpt-5.5-api" in all_values
|
||||
assert "gpt-5.5" in all_values # cx lane restored 2026-07-26 (live-probed)
|
||||
# No '3.1 pro' label survives in any provider group either.
|
||||
all_labels = " | ".join(m["label"].lower() for models in BUILTIN_MODELS.values() for m in models)
|
||||
assert "3.1 pro" not in all_labels
|
||||
|
||||
@@ -6,7 +6,6 @@ clients backwards, and an open session in the history map had its terminal frame
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.apps.agents.core.ws_manager import ws_manager, slim_status_data
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//
|
||||
// Main-process only (offscreen BrowserWindow isn't a renderer webview). Every
|
||||
// path destroys its window in a finally, so a failure can never leak a window.
|
||||
const { BrowserWindow } = require('electron');
|
||||
const { BrowserWindow, session } = require('electron');
|
||||
|
||||
const SCRAPE_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
||||
const SETTLE_MS = 2800;
|
||||
@@ -36,9 +36,9 @@ function makeWindow(partition) {
|
||||
}
|
||||
}
|
||||
|
||||
async function withWindow(partition, fn) {
|
||||
async function withWindow(partition, fn, extraGraceMs = 0) {
|
||||
const win = makeWindow(partition);
|
||||
const killer = setTimeout(() => { try { win.destroy(); } catch (_) {} }, LOAD_TIMEOUT_MS + SETTLE_MS + 8000);
|
||||
const killer = setTimeout(() => { try { win.destroy(); } catch (_) {} }, LOAD_TIMEOUT_MS + SETTLE_MS + 8000 + extraGraceMs);
|
||||
try {
|
||||
return await fn(win);
|
||||
} finally {
|
||||
@@ -47,6 +47,11 @@ async function withWindow(partition, fn) {
|
||||
}
|
||||
}
|
||||
|
||||
// The provider-history harvest paginates a genuinely slow endpoint (ChatGPT's
|
||||
// /backend-api/conversations runs ~4s/page), so its offscreen window must outlive a
|
||||
// plain fetch/search window or it gets killed mid-pagination and the whole read is lost.
|
||||
const HARVEST_GRACE_MS = 20000;
|
||||
|
||||
async function loadAndSettle(win, url) {
|
||||
// loadURL rejects on a sub-resource abort even when the main frame is fine, so a rejection is a warning, not a failure; we still try to read the DOM.
|
||||
const load = win.loadURL(url, { userAgent: SCRAPE_UA }).catch(() => {});
|
||||
@@ -68,6 +73,53 @@ async function hiddenFetch(partition, url) {
|
||||
});
|
||||
}
|
||||
|
||||
// Load a URL offscreen on the given partition (so it inherits that partition's
|
||||
// logged-in session) and run an app-authored script in the page context, returning
|
||||
// whatever it resolves to. Used to read the user's own provider history (chatgpt.com /
|
||||
// claude.ai) with no visible card. The script is caller-owned and must be app code,
|
||||
// never anything a remote page or the renderer can choose; the offscreen window is
|
||||
// destroyed in withWindow's finally regardless of outcome.
|
||||
async function hiddenEval(partition, url, js) {
|
||||
return withWindow(partition, async (win) => {
|
||||
await loadAndSettle(win, url);
|
||||
return win.webContents.executeJavaScript(js, true).catch(() => null);
|
||||
}, HARVEST_GRACE_MS);
|
||||
}
|
||||
|
||||
// Inject the user's own session cookies (read + decrypted by the Python backend) into a
|
||||
// throwaway IN-MEMORY session, then run an app-authored read from a real Chromium context.
|
||||
// This is how we beat provider Cloudflare: a raw HTTP client's TLS handshake gets fingerprint-
|
||||
// blocked, but this IS Chrome, so it passes exactly like the user's browser. The partition has
|
||||
// no "persist:" prefix, so nothing ever hits disk; cookies are cleared before AND after.
|
||||
const HARVEST_PARTITION = 'osw-usage-harvest';
|
||||
|
||||
async function hiddenEvalWithCookies(url, cookieRecords, js) {
|
||||
const ses = session.fromPartition(HARVEST_PARTITION);
|
||||
const wipe = async () => { try { await ses.clearStorageData({ storages: ['cookies'] }); } catch (_) {} };
|
||||
await wipe();
|
||||
for (const c of cookieRecords || []) {
|
||||
try {
|
||||
await ses.cookies.set({
|
||||
url,
|
||||
name: c.name,
|
||||
value: c.value,
|
||||
domain: c.domain || undefined,
|
||||
path: c.path || '/',
|
||||
secure: c.secure !== false,
|
||||
httpOnly: !!c.httponly,
|
||||
});
|
||||
} catch (_) { /* skip a malformed cookie, never abort the set */ }
|
||||
}
|
||||
try {
|
||||
return await withWindow(HARVEST_PARTITION, async (win) => {
|
||||
await loadAndSettle(win, url);
|
||||
return win.webContents.executeJavaScript(js, true).catch(() => null);
|
||||
}, HARVEST_GRACE_MS);
|
||||
} finally {
|
||||
await wipe();
|
||||
}
|
||||
}
|
||||
|
||||
// Google first (direct result URLs, best quality); DuckDuckGo in a real browser
|
||||
// second (immune to the httpx 202 throttle); Bing last (results are redirect-wrapped).
|
||||
const ENGINES = [
|
||||
@@ -101,4 +153,4 @@ async function hiddenSearch(partition, query, numResults) {
|
||||
return { error: 'all browser search engines failed', detail: errors.join('; ') };
|
||||
}
|
||||
|
||||
module.exports = { hiddenFetch, hiddenSearch };
|
||||
module.exports = { hiddenFetch, hiddenSearch, hiddenEval, hiddenEvalWithCookies };
|
||||
|
||||
+246
-3
@@ -1,4 +1,6 @@
|
||||
const { app, components, BrowserWindow, ipcMain, shell, session, dialog, crashReporter, powerMonitor, Menu, clipboard } = require('electron');
|
||||
const { app, components, BrowserWindow, ipcMain, shell, session, dialog, crashReporter, powerMonitor, Menu, clipboard, globalShortcut } = require('electron');
|
||||
const whisperService = require('./voice/whisperService');
|
||||
const { injectText } = require('./voice/textInjector');
|
||||
|
||||
// Browser cards live in their own persistent partition so cookies/localStorage/IndexedDB survive reload + quit (Discord etc. stay logged in) and site data stays isolated from the app's defaultSession. The "clear browsing data" wipe nukes only this partition. MUST match BROWSER_PARTITION in frontend BrowserCard.tsx.
|
||||
const BROWSER_PARTITION = 'persist:openswarm-browser';
|
||||
@@ -54,6 +56,8 @@ const { spawn, execFileSync } = require('child_process');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const hiddenBrowser = require('./hiddenBrowser');
|
||||
const usageHarvest = require('./usageHarvest');
|
||||
const { installVoiceHotkey } = require('./voiceHotkey');
|
||||
const getPort = require('get-port');
|
||||
const http = require('http');
|
||||
const affiliateTracking = require('./affiliateTracking');
|
||||
@@ -766,6 +770,35 @@ function getPythonPath() {
|
||||
return path.join(__dirname, '..', 'backend', '.venv', 'bin', 'python3');
|
||||
}
|
||||
|
||||
// Read the user's provider session cookies via a one-shot bundled-python invocation, so the
|
||||
// offscreen harvest can inject them and pass provider Cloudflare with a real Chrome TLS
|
||||
// handshake. Spawned, NEVER an HTTP endpoint, so a token-holding agent can't reach it: only
|
||||
// the app shell invokes it. Always resolves (to [] on any failure) so the harvest just falls
|
||||
// back to the opportunistic path. mirrors startBackend's python env (projectRoot + site-packages).
|
||||
function p_readProviderCookies(domain) {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
||||
try {
|
||||
const root = isPackaged ? process.resourcesPath : path.join(__dirname, '..');
|
||||
const env = { ...process.env, PYTHONUTF8: '1', PYTHONDONTWRITEBYTECODE: '1' };
|
||||
if (isPackaged) {
|
||||
const sitePackages = process.platform === 'win32'
|
||||
? path.join(process.resourcesPath, 'python-env', 'Lib', 'site-packages')
|
||||
: path.join(process.resourcesPath, 'python-env', 'lib', 'python3.13', 'site-packages');
|
||||
env.PYTHONPATH = [root, sitePackages].join(path.delimiter);
|
||||
}
|
||||
const proc = spawn(getPythonPath(), ['-m', 'backend.apps.onboarding.usage.dump_cookies', String(domain)], { cwd: root, env });
|
||||
let out = '';
|
||||
proc.stdout.on('data', (d) => { out += d.toString(); });
|
||||
proc.on('error', () => finish([]));
|
||||
proc.on('close', () => { try { const j = JSON.parse(out); finish(Array.isArray(j) ? j : []); } catch (_) { finish([]); } });
|
||||
setTimeout(() => { try { proc.kill(); } catch (_) {} finish([]); }, 25000);
|
||||
} catch (_) { finish([]); }
|
||||
});
|
||||
}
|
||||
usageHarvest.configure({ readCookies: p_readProviderCookies });
|
||||
|
||||
// Path to a real Node.js binary bundled in extraResources, or null if not
|
||||
// shipped (dev mode, or build that skipped the node-fetch step). Backend
|
||||
// reads OPENSWARM_NODE_PATH env var to prefer this over both system `node`
|
||||
@@ -1208,9 +1241,14 @@ function createWindow() {
|
||||
},
|
||||
});
|
||||
|
||||
// Arc-style traffic lights: hidden until the renderer's top-edge hover asks for them.
|
||||
if (process.platform === 'darwin') {
|
||||
try { mainWindow.setWindowButtonVisibility(false); } catch (err) { console.warn('[main] setWindowButtonVisibility failed:', err.message); }
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
// Dev only: OPENSWARM_DEV_PORT lets a second worktree's Electron point at its own webpack-dev-server (default 3000) instead of colliding on the shared port. Packaged builds never hit this branch.
|
||||
mainWindow.loadURL(`http://localhost:${process.env.OPENSWARM_DEV_PORT || 3000}`);
|
||||
// Dev only: OPENSWARM_DEV_URL (full override) or OPENSWARM_DEV_PORT lets a second worktree's Electron point at its own webpack-dev-server instead of colliding on the shared :3000. Packaged builds never hit this branch.
|
||||
mainWindow.loadURL(process.env.OPENSWARM_DEV_URL || `http://localhost:${process.env.OPENSWARM_DEV_PORT || 3000}`);
|
||||
} else if (frontendServerPort) {
|
||||
mainWindow.loadURL(`http://127.0.0.1:${frontendServerPort}/index.html`);
|
||||
} else {
|
||||
@@ -1739,6 +1777,35 @@ function installMacMouseClamp() {
|
||||
}
|
||||
}
|
||||
|
||||
// Trackpad haptic taps (macOS, Force Touch only): dictation start/stop feedback. Fail-open like
|
||||
// mouseclamp; a missing addon or non-mac just makes 'haptic:perform' return false.
|
||||
let hapticsAddon = null;
|
||||
function installHaptics() {
|
||||
if (process.platform !== 'darwin') return;
|
||||
try {
|
||||
const nodePath = isPackaged
|
||||
? path.join(process.resourcesPath, 'haptics', 'haptics.node')
|
||||
: path.join(__dirname, 'build-staging', 'haptics', process.arch, 'haptics.node');
|
||||
if (!fs.existsSync(nodePath)) {
|
||||
console.log('[haptics] addon not present, skipping:', nodePath);
|
||||
return;
|
||||
}
|
||||
hapticsAddon = require(nodePath);
|
||||
console.log('[haptics] addon loaded');
|
||||
} catch (e) {
|
||||
console.log('[haptics] load failed (continuing):', e && e.message);
|
||||
}
|
||||
}
|
||||
ipcMain.handle('haptic:perform', (event, pattern) => {
|
||||
try {
|
||||
if (!hapticsAddon) return false;
|
||||
const p = pattern === 'alignment' ? 1 : pattern === 'level' ? 2 : 0;
|
||||
return hapticsAddon.perform(p);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// We made it here, so any prior update swap finished. Drop a stale updating.lock
|
||||
// (the watchdog never deletes it) so a real crash later isn't silently swallowed.
|
||||
@@ -1750,6 +1817,12 @@ app.whenReady().then(async () => {
|
||||
|
||||
// Off-window mouse-release crash dodge (macOS). Safe to call before windows exist.
|
||||
installMacMouseClamp();
|
||||
installHaptics();
|
||||
|
||||
// Voice dictation hotkey (F5 / Cmd-Ctrl+Shift+D). Native uiohook key tap = true keyboard
|
||||
// hold-to-talk on every platform; falls back to the old press-to-toggle when the tap can't run
|
||||
// (module missing, or macOS without the Accessibility grant). Tiers live in voiceHotkey.js.
|
||||
installVoiceHotkey(() => mainWindow);
|
||||
|
||||
// PASSKEY SPIKE (macOS only): turn on the Secure-Enclave/Touch ID WebAuthn authenticator that Electron 42 added. Without this, isUserVerifyingPlatformAuthenticatorAvailable() is hardwired false (why the old reject-shim existed). keychainAccessGroup MUST match the keychain-access-groups entitlement (Y26NUZH4NG.<bundle>.webauthn) or this throws. Windows has no equivalent, so the reject-shim still runs there.
|
||||
if (process.platform === 'darwin' && typeof app.configureWebAuthn === 'function') {
|
||||
@@ -2152,6 +2225,51 @@ function buildBrowserContextMenu(contents, params, webContentsId) {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// The app's OWN renderer (chat, outputs, sidebar) gets no native menu from Electron by default, so
|
||||
// right-clicking text used to do nothing. This is the browser menu minus the nav items that mean
|
||||
// nothing inside a single-page app: spelling, copy-link, and the edit/copy roles.
|
||||
function buildAppContextMenu(contents, params) {
|
||||
const template = [];
|
||||
const sep = () => template.push({ type: 'separator' });
|
||||
|
||||
if (params.misspelledWord) {
|
||||
const suggestions = params.dictionarySuggestions || [];
|
||||
if (suggestions.length) {
|
||||
for (const s of suggestions) template.push({ label: s, click: () => { try { contents.replaceMisspelling(s); } catch (_) {} } });
|
||||
} else {
|
||||
template.push({ label: 'No spelling suggestions', enabled: false });
|
||||
}
|
||||
template.push({ label: 'Add to Dictionary', click: () => { try { contents.session.addWordToSpellCheckerDictionary(params.misspelledWord); } catch (_) {} } });
|
||||
sep();
|
||||
}
|
||||
|
||||
if (params.linkURL) {
|
||||
template.push({ label: 'Copy Link', click: () => clipboard.writeText(params.linkURL) });
|
||||
sep();
|
||||
}
|
||||
|
||||
const flags = params.editFlags || {};
|
||||
if (params.isEditable) {
|
||||
template.push({ role: 'cut', enabled: flags.canCut !== false });
|
||||
template.push({ role: 'copy', enabled: flags.canCopy !== false });
|
||||
template.push({ role: 'paste', enabled: flags.canPaste !== false });
|
||||
template.push({ role: 'selectAll' });
|
||||
} else if (params.selectionText) {
|
||||
template.push({ role: 'copy' });
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
if (template.length) sep();
|
||||
template.push({ label: 'Inspect Element', click: () => { try { contents.inspectElement(params.x, params.y); } catch (_) {} } });
|
||||
}
|
||||
|
||||
// Nothing worth showing (empty right-click on non-dev chrome): let the OS do nothing.
|
||||
if (!template.length) return;
|
||||
try {
|
||||
Menu.buildFromTemplate(template).popup({ window: mainWindow || undefined });
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
app.on('web-contents-created', (_event, contents) => {
|
||||
// Block Cmd+W from closing the main window, whether the window chrome or one of
|
||||
// its embedded webviews has focus. OAuth popups (their own 'window' contents,
|
||||
@@ -2161,6 +2279,11 @@ app.on('web-contents-created', (_event, contents) => {
|
||||
contents.on('before-input-event', swallowCloseWindowShortcut);
|
||||
contents.on('before-input-event', routeReloadShortcut);
|
||||
}
|
||||
// The main app window (created while this flag is set) gets a text-focused native menu; OAuth
|
||||
// popups are 'window' contents created with the flag OFF, so they keep the OS default.
|
||||
if (isCreatingMainWindow) {
|
||||
contents.on('context-menu', (_e, params) => buildAppContextMenu(contents, params));
|
||||
}
|
||||
if (contents.getType() === 'webview') {
|
||||
const wcId = contents.id;
|
||||
contents.on('before-input-event', (event, input) => routeBrowserShortcut(event, input, wcId));
|
||||
@@ -2688,6 +2811,8 @@ app.on('before-quit', async (event) => {
|
||||
|
||||
app.on('will-quit', () => {
|
||||
if (!isDev) killBackend();
|
||||
try { globalShortcut.unregisterAll(); } catch (_) {}
|
||||
try { whisperService.stopServer(); } catch (_) {}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
@@ -2772,6 +2897,36 @@ ipcMain.handle = (channel, handler) => {
|
||||
};
|
||||
|
||||
ipcMain.handle('get-backend-port', () => backendPort);
|
||||
|
||||
// ---- Voice dictation (local whisper.cpp) ----
|
||||
function voiceResourceDir() {
|
||||
return getResourcePath('whisper');
|
||||
}
|
||||
function voiceUserDataDir() {
|
||||
try { return app.getPath('userData'); } catch (_) { return __dirname; }
|
||||
}
|
||||
// Renderer records the mic, encodes a 16kHz-mono WAV, and hands us the bytes; we run them through the
|
||||
// warm whisper server and return text. Fail-soft: any error becomes { ok:false } so the pill can show
|
||||
// a clean "couldn't hear that" instead of the app throwing.
|
||||
ipcMain.handle('voice:transcribe', async (_e, wavArrayBuffer) => {
|
||||
try {
|
||||
const buf = Buffer.from(wavArrayBuffer);
|
||||
const text = await whisperService.transcribe(voiceResourceDir(), voiceUserDataDir(), buf);
|
||||
return { ok: true, text };
|
||||
} catch (err) {
|
||||
return { ok: false, error: String(err && err.message ? err.message : err) };
|
||||
}
|
||||
});
|
||||
// Warm the model ahead of the first phrase so dictation feels instant, not "1s to boot then type".
|
||||
ipcMain.handle('voice:warmup', async () => {
|
||||
try { await whisperService.ensureServer(voiceResourceDir(), voiceUserDataDir()); return { ok: true }; } catch (err) { return { ok: false, error: String(err && err.message ? err.message : err) }; }
|
||||
});
|
||||
// First-run model download progress so the pill can show "Preparing voice N%".
|
||||
ipcMain.handle('voice:status', () => whisperService.modelStatus());
|
||||
// Paste the text into the frontmost app (dictate-anywhere). Returns whether the OS paste actually fired.
|
||||
ipcMain.handle('voice:inject', async (_e, text) => {
|
||||
try { const pasted = await injectText(String(text || '')); return { ok: true, pasted }; } catch (err) { return { ok: false, error: String(err && err.message ? err.message : err) }; }
|
||||
});
|
||||
// Sync mirrors so preload.js can expose window.openswarm synchronously (no await), closing the race where React renders before the async exposure resolves and window.openswarm is briefly undefined. backendPort is assigned in app.whenReady before any BrowserWindow is created, so it is always set by the time preload runs.
|
||||
ipcMain.on('get-backend-port-sync', (event) => { event.returnValue = backendPort; });
|
||||
ipcMain.on('get-webview-preload-path-sync', (event) => {
|
||||
@@ -2798,6 +2953,10 @@ ipcMain.handle('get-auth-token', async () => {
|
||||
ipcMain.on('perf:first-agent-response', () => perfMark('first-agent-response'));
|
||||
|
||||
ipcMain.handle('get-app-version', () => app.getVersion());
|
||||
ipcMain.handle('set-window-buttons-visible', (_e, visible) => {
|
||||
if (process.platform !== 'darwin' || !mainWindow || mainWindow.isDestroyed()) return;
|
||||
try { mainWindow.setWindowButtonVisibility(!!visible); } catch (err) { console.warn('[main] setWindowButtonVisibility failed:', err.message); }
|
||||
});
|
||||
// Phase 2 provenance: the renderer's About panel shows the commit this build
|
||||
// was cut from, so a screenshot is enough to identify the exact code shipped.
|
||||
ipcMain.handle('get-build-info', () => getBuildInfo());
|
||||
@@ -2805,6 +2964,19 @@ ipcMain.handle('get-webview-preload-path', () => {
|
||||
return `file://${path.join(__dirname, 'webview-preload.js')}`;
|
||||
});
|
||||
|
||||
// Reveal a diagnostics bundle in the file manager so the user can drag it into a GitHub issue.
|
||||
// Scoped HARD to the backend's diagnostics dir: this must never become an arbitrary-path opener.
|
||||
ipcMain.handle('help:reveal-bundle', (event, folderPath) => {
|
||||
try {
|
||||
const p = path.resolve(String(folderPath || ''));
|
||||
if (!p.includes(`${path.sep}diagnostics${path.sep}`) || !fs.existsSync(p)) return { ok: false };
|
||||
shell.showItemInFolder(p);
|
||||
return { ok: true };
|
||||
} catch (_) {
|
||||
return { ok: false };
|
||||
}
|
||||
});
|
||||
|
||||
// Wipe ONLY the browser-card partition (cookies/cache/localStorage/IndexedDB), never the app's defaultSession. Surfaced as Settings -> Data & Privacy -> Clear browsing data.
|
||||
ipcMain.handle('browser:clear-data', async () => {
|
||||
const ses = session.fromPartition(BROWSER_PARTITION);
|
||||
@@ -2835,6 +3007,14 @@ async function readPartitionCookies(domain) {
|
||||
}
|
||||
ipcMain.handle('get-partition-cookies', (_e, domain) => readPartitionCookies(domain));
|
||||
|
||||
// Silently read the user's own chatgpt.com / claude.ai history from the browser
|
||||
// partition's logged-in session (offscreen, no card) so onboarding can personalize.
|
||||
// Provider-gated + main-owned script (see usageHarvest.js); fails open to the empty
|
||||
// shape when no session exists in the partition.
|
||||
ipcMain.handle('harvest-usage', (_e, provider) =>
|
||||
usageHarvest.harvest(BROWSER_PARTITION, provider).catch(() => ({ ok: false, total: 0, titles: [], memories: [] })),
|
||||
);
|
||||
|
||||
// Suspend/resume state capsules: the app renderer stages a resumed webview's sessionStorage snapshot here (keyed by that guest's webContents id) right before loadURL; the guest preload sync-takes it at document-start with an origin match, so page scripts see restored state and logins survive suspension. In-memory only, single-shot, short TTL; a guest can only ever take its OWN capsule.
|
||||
const pendingSessionCapsules = new Map();
|
||||
const SESSION_CAPSULE_TTL_MS = 2 * 60 * 1000;
|
||||
@@ -3041,6 +3221,69 @@ ipcMain.handle('open-external', (_event, url) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Applications launcher support. Names are bare .app basenames from the local scan; both
|
||||
// handlers hard-validate the name and resolve strictly inside /Applications so a hostile
|
||||
// renderer string can't traverse anywhere else.
|
||||
const APP_NAME_RE = /^[\w .&'()+-]{1,80}$/;
|
||||
const appIconCache = new Map();
|
||||
function resolveApplicationPath(name) {
|
||||
if (typeof name !== 'string' || !APP_NAME_RE.test(name) || name.includes('..')) return null;
|
||||
const path = require('path');
|
||||
const resolved = path.join('/Applications', `${name}.app`);
|
||||
if (path.dirname(resolved) !== '/Applications') return null;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
ipcMain.handle('get-app-icon', async (_event, name) => {
|
||||
const target = resolveApplicationPath(name);
|
||||
if (!target) return null;
|
||||
if (appIconCache.has(name)) return appIconCache.get(name);
|
||||
try {
|
||||
let dataUrl = null;
|
||||
if (process.platform === 'darwin') {
|
||||
// NEVER app.getFileIcon here: a corrupt .icns raises a native ObjC exception no JS try/catch
|
||||
// can contain and SIGTRAPs the whole app (reproduced 2026-07-20: last IPC get-app-icon,
|
||||
// crashpad in_range_cast warning, death). sips does the decode in a disposable child instead.
|
||||
const { execFile } = require('child_process');
|
||||
const os = require('os');
|
||||
const run = (cmd, args) => new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, { timeout: 5000 }, (err, stdout) => (err ? reject(err) : resolve(String(stdout).trim())));
|
||||
});
|
||||
const resources = path.join(target, 'Contents', 'Resources');
|
||||
let icnsName = await run('/usr/bin/defaults', ['read', path.join(target, 'Contents', 'Info'), 'CFBundleIconFile']).catch(() => '');
|
||||
if (icnsName && !icnsName.endsWith('.icns')) icnsName += '.icns';
|
||||
let icns = icnsName ? path.join(resources, icnsName) : '';
|
||||
if (!icns || !fs.existsSync(icns)) {
|
||||
const alt = fs.existsSync(resources) ? fs.readdirSync(resources).find((f) => f.endsWith('.icns')) : null;
|
||||
icns = alt ? path.join(resources, alt) : '';
|
||||
}
|
||||
if (icns && fs.existsSync(icns)) {
|
||||
const outPng = path.join(os.tmpdir(), `osw-icon-${process.pid}-${Date.now()}.png`);
|
||||
await run('/usr/bin/sips', ['-s', 'format', 'png', '-z', '128', '128', icns, '--out', outPng]).catch(() => '');
|
||||
if (fs.existsSync(outPng)) {
|
||||
dataUrl = `data:image/png;base64,${fs.readFileSync(outPng).toString('base64')}`;
|
||||
fs.rmSync(outPng, { force: true });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const icon = await app.getFileIcon(target, { size: 'large' });
|
||||
dataUrl = icon && !icon.isEmpty() ? icon.toDataURL() : null;
|
||||
}
|
||||
appIconCache.set(name, dataUrl);
|
||||
return dataUrl;
|
||||
} catch (_) {
|
||||
appIconCache.set(name, null);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('open-application', (_event, name) => {
|
||||
const target = resolveApplicationPath(name);
|
||||
if (!target) return false;
|
||||
shell.openPath(target);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Affiliate install state. Returns the persisted install.json contents so
|
||||
// the renderer can attach the referral code to authenticated cloud calls
|
||||
// (Stripe checkout, sign-in events) for downstream attribution.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"targets": [
|
||||
{
|
||||
"target_name": "haptics",
|
||||
"sources": ["haptics.mm"],
|
||||
"xcode_settings": {
|
||||
"CLANG_ENABLE_OBJC_ARC": "NO",
|
||||
"OTHER_CFLAGS": ["-fobjc-exceptions"]
|
||||
},
|
||||
"libraries": ["-framework Cocoa"],
|
||||
"conditions": [["OS!=\"mac\"", {"type": "none"}]]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Trackpad haptic taps for dictation start/stop (and any future micro-feedback), the missing half
|
||||
// of the WhisperFlow feel. NSHapticFeedbackManager only fires on Force Touch trackpads and only
|
||||
// when macOS deems the app active; both are fine, this is garnish, never load-bearing.
|
||||
//
|
||||
// Fail-open everywhere: no trackpad, no permission, wrong thread, we return false and the app
|
||||
// behaves exactly like today. macOS-only by binding.gyp condition.
|
||||
|
||||
#include <node_api.h>
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
static napi_value Perform(napi_env env, napi_callback_info info) {
|
||||
bool ok = false;
|
||||
@try {
|
||||
size_t argc = 1;
|
||||
napi_value argv[1];
|
||||
napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
|
||||
int32_t pattern = 0;
|
||||
if (argc >= 1) napi_get_value_int32(env, argv[0], &pattern);
|
||||
NSHapticFeedbackPattern p = NSHapticFeedbackPatternGeneric;
|
||||
if (pattern == 1) p = NSHapticFeedbackPatternAlignment;
|
||||
else if (pattern == 2) p = NSHapticFeedbackPatternLevelChange;
|
||||
// Main-thread dispatch: AppKit feedback performers are main-thread creatures, and the IPC
|
||||
// handler that calls us already runs there in Electron's browser process; the async hop is
|
||||
// belt-and-suspenders for any future caller.
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
@try {
|
||||
[[NSHapticFeedbackManager defaultPerformer]
|
||||
performFeedbackPattern:p
|
||||
performanceTime:NSHapticFeedbackPerformanceTimeNow];
|
||||
} @catch (NSException *e) { /* garnish only */ }
|
||||
});
|
||||
ok = true;
|
||||
} @catch (NSException *e) {
|
||||
ok = false;
|
||||
}
|
||||
napi_value result;
|
||||
napi_get_boolean(env, ok, &result);
|
||||
return result;
|
||||
}
|
||||
|
||||
static napi_value Init(napi_env env, napi_value exports) {
|
||||
napi_value fn;
|
||||
napi_create_function(env, "perform", NAPI_AUTO_LENGTH, Perform, NULL, &fn);
|
||||
napi_set_named_property(env, exports, "perform", fn);
|
||||
return exports;
|
||||
}
|
||||
|
||||
NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "haptics",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "macOS-only native addon: Force Touch trackpad haptic taps via NSHapticFeedbackManager for dictation start/stop feedback. See haptics.mm.",
|
||||
"gypfile": true
|
||||
}
|
||||
Generated
+29
-3
@@ -1,16 +1,18 @@
|
||||
{
|
||||
"name": "openswarm",
|
||||
"version": "1.5.9",
|
||||
"version": "1.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openswarm",
|
||||
"version": "1.5.9",
|
||||
"version": "1.6.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"electron-updater": "6.8.3",
|
||||
"get-port": "5.1.1"
|
||||
"get-port": "5.1.1",
|
||||
"uiohook-napi": "^1.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/notarize": "3.1.1",
|
||||
@@ -2724,6 +2726,17 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"node-gyp-build": "bin.js",
|
||||
"node-gyp-build-optional": "optional.js",
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/isexe": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
|
||||
@@ -3470,6 +3483,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/uiohook-napi": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/uiohook-napi/-/uiohook-napi-1.5.5.tgz",
|
||||
"integrity": "sha512-oSlTdnECw2GBfsJPTbBQBeE4v/EXP0EZmX6BJq5nzH/JgFaBE8JpFwEA/kLhiEP7HxQw28FViWiYgdIZzWuuJQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-gyp-build": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.26.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz",
|
||||
|
||||
+18
-2
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "openswarm",
|
||||
"version": "1.5.9",
|
||||
"version": "1.6.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"description": "OpenSwarm — AI Agent Orchestrator",
|
||||
"author": "openswarm-ai",
|
||||
"main": "main.js",
|
||||
@@ -19,7 +20,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "6.8.3",
|
||||
"get-port": "5.1.1"
|
||||
"get-port": "5.1.1",
|
||||
"uiohook-napi": "^1.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/notarize": "3.1.1",
|
||||
@@ -73,6 +75,20 @@
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "build-staging/haptics/${arch}",
|
||||
"to": "haptics",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "build-staging/whisper/${arch}",
|
||||
"to": "whisper",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "build-staging/python-env/${arch}",
|
||||
"to": "python-env",
|
||||
|
||||
+33
-2
@@ -1,7 +1,5 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[diag][preload] start, ua=', navigator.userAgent);
|
||||
|
||||
// E2E gate: set the renderer flag BEFORE any page script parses so the
|
||||
// production-build store-on-window expose fires deterministically when
|
||||
@@ -41,6 +39,8 @@ contextBridge.exposeInMainWorld('openswarm', {
|
||||
getAuthToken: () => ipcRenderer.invoke('get-auth-token'),
|
||||
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
// Arc-style chrome: the mac traffic lights hide at rest; the dashboard's top-edge hover reveals them.
|
||||
setWindowButtonsVisible: (visible) => ipcRenderer.invoke('set-window-buttons-visible', visible),
|
||||
|
||||
// Phase 2 provenance: { sha, shortSha, builtAt, channel } for the About panel.
|
||||
getBuildInfo: () => ipcRenderer.invoke('get-build-info'),
|
||||
@@ -60,8 +60,37 @@ contextBridge.exposeInMainWorld('openswarm', {
|
||||
// Clears cookies/cache/localStorage for the browser-card partition only (never the app's defaultSession). Logs you out of sites opened in browser cards.
|
||||
clearBrowserData: () => ipcRenderer.invoke('browser:clear-data'),
|
||||
connectSlack: () => ipcRenderer.invoke('connect-slack'),
|
||||
// Voice dictation (local whisper.cpp). transcribe takes a 16kHz-mono WAV ArrayBuffer; inject pastes
|
||||
// text into the frontmost app; warmup pre-loads the model; onVoiceToggle fires on the global hotkey.
|
||||
voiceWarmup: () => ipcRenderer.invoke('voice:warmup'),
|
||||
voiceStatus: () => ipcRenderer.invoke('voice:status'),
|
||||
voiceTranscribe: (wavArrayBuffer) => ipcRenderer.invoke('voice:transcribe', wavArrayBuffer),
|
||||
voiceInject: (text) => ipcRenderer.invoke('voice:inject', text),
|
||||
onVoiceToggle: (cb) => {
|
||||
const listener = () => cb();
|
||||
ipcRenderer.on('voice:toggle', listener);
|
||||
return () => ipcRenderer.removeListener('voice:toggle', listener);
|
||||
},
|
||||
// Reveal a diagnostics folder in Finder/Explorer (path validated in main; diagnostics dir only).
|
||||
revealBundle: (folderPath) => ipcRenderer.invoke('help:reveal-bundle', folderPath),
|
||||
// True keyboard hold-to-talk needs the native key tap; renderers ask so Settings copy stays honest,
|
||||
// and request triggers the macOS Accessibility prompt when the tap is blocked on permission.
|
||||
setVoiceHotkey: (combo) => ipcRenderer.send('voice:set-hotkey', combo),
|
||||
voiceHoldCapable: () => ipcRenderer.invoke('voice:hold-capable'),
|
||||
voiceRequestHoldPermission: () => ipcRenderer.invoke('voice:request-hold-permission'),
|
||||
haptic: (pattern) => ipcRenderer.invoke('haptic:perform', pattern),
|
||||
// Native-tap hold relay: real global key-down/key-up for the voice combo, focus-independent.
|
||||
onVoiceHold: (onDown, onUp) => {
|
||||
const down = () => onDown();
|
||||
const up = () => onUp();
|
||||
ipcRenderer.on('voice:hold-down', down);
|
||||
ipcRenderer.on('voice:hold-up', up);
|
||||
return () => { ipcRenderer.removeListener('voice:hold-down', down); ipcRenderer.removeListener('voice:hold-up', up); };
|
||||
},
|
||||
// Hands a vetted social platform's partition cookies to its session-backed MCP shim (allowlisted domains only, gated again in the main process).
|
||||
getPartitionCookies: (domain) => ipcRenderer.invoke('get-partition-cookies', domain),
|
||||
// Silently reads the user's own chatgpt.com/claude.ai history offscreen (no card) for onboarding personalization; main owns the injected script + gates the provider.
|
||||
harvestUsage: (provider) => ipcRenderer.invoke('harvest-usage', provider),
|
||||
// Suspend/resume state capsule: stages a resumed webview's sessionStorage snapshot in main (keyed by webContents id, short TTL) so the guest preload can sync-take it at document-start. Fire-and-forget; main validates the sender.
|
||||
setSessionCapsule: (wcId, capsule) => ipcRenderer.send('browser-capsule-set', wcId, capsule),
|
||||
sendCdpCommand: (wcId, method, params, sessionId) => ipcRenderer.invoke('send-cdp-command', wcId, method, params, sessionId),
|
||||
@@ -73,6 +102,8 @@ contextBridge.exposeInMainWorld('openswarm', {
|
||||
cdpRoutesGet: (wcId, originFilter) => ipcRenderer.invoke('cdp-routes-get', wcId, originFilter),
|
||||
getWebviewConsole: (wcId) => ipcRenderer.invoke('get-webview-console', wcId),
|
||||
capturePage: (rect) => ipcRenderer.invoke('capture-page', rect),
|
||||
getAppIcon: (name) => ipcRenderer.invoke('get-app-icon', name),
|
||||
openApplication: (name) => ipcRenderer.invoke('open-application', name),
|
||||
getUpdateStatus: () => ipcRenderer.invoke('get-update-status'),
|
||||
getCrashRecoveryInfo: () => ipcRenderer.invoke('get-crash-recovery-info'),
|
||||
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
|
||||
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
# Compile the macOS mouse-clamp native addon for one arch and stage it where
|
||||
# electron-builder's extraResources picks it up (build-staging/haptics/<arch>).
|
||||
# See electron/native/haptics/haptics.mm for what it fixes.
|
||||
set -euo pipefail
|
||||
|
||||
ARCH="${1:?usage: build-haptics.sh <arm64|x64>}"
|
||||
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)" # electron/
|
||||
# Derive the node-gyp header target from the actually-installed electron so a version bump (e.g. 42.0.0 -> 42.3.3) is auto-tracked instead of silently building against stale headers. Strip any +wvcus suffix; node-gyp wants a plain semver.
|
||||
ELECTRON_TARGET="$(node -p "require('$HERE/node_modules/electron/package.json').version.split('+')[0]" 2>/dev/null || echo '42.3.3')"
|
||||
SRC="$HERE/native/haptics"
|
||||
OUT="$HERE/build-staging/haptics/$ARCH"
|
||||
NODE_GYP="$HERE/node_modules/.bin/node-gyp"
|
||||
[[ -x "$NODE_GYP" ]] || NODE_GYP="npx --yes node-gyp" # transitive dep usually, npx if not
|
||||
|
||||
echo "[haptics] building for arch=$ARCH (electron $ELECTRON_TARGET)"
|
||||
cd "$SRC"
|
||||
rm -rf build
|
||||
$NODE_GYP rebuild \
|
||||
--target="$ELECTRON_TARGET" \
|
||||
--arch="$ARCH" \
|
||||
--dist-url=https://electronjs.org/headers
|
||||
|
||||
mkdir -p "$OUT"
|
||||
cp "build/Release/haptics.node" "$OUT/haptics.node"
|
||||
echo "[haptics] staged -> $OUT/haptics.node"
|
||||
file "$OUT/haptics.node"
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
# Build whisper.cpp's whisper-server for one arch and stage it where electron-builder's
|
||||
# extraResources picks it up (build-staging/whisper/<arch>). The packaged app resolves it at
|
||||
# resources/whisper/whisper-server (electron/voice/whisperService.js resolveBinary); the model
|
||||
# stays a first-run download (ships +0MB, whisperService owns the fetch + progress UI).
|
||||
# The brew binary can't be bundled: it links homebrew dylibs that don't exist on user machines.
|
||||
set -euo pipefail
|
||||
|
||||
ARCH="${1:?usage: build-whisper.sh <arm64|x64>}"
|
||||
|
||||
WHISPER_VERSION="v1.7.6"
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)" # electron/
|
||||
OUT="$HERE/build-staging/whisper/$ARCH"
|
||||
SRC="$HERE/build-staging/whisper-src"
|
||||
|
||||
# Idempotent across publish reruns: a staged binary from the same pinned version is reused.
|
||||
if [[ -f "$OUT/whisper-server" && -f "$OUT/.version" && "$(cat "$OUT/.version")" == "$WHISPER_VERSION" ]]; then
|
||||
echo "[whisper] $ARCH already staged at $WHISPER_VERSION, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -d "$SRC/.git" ]]; then
|
||||
git clone --depth 1 --branch "$WHISPER_VERSION" https://github.com/ggml-org/whisper.cpp "$SRC"
|
||||
else
|
||||
CUR="$(git -C "$SRC" describe --tags --exact-match 2>/dev/null || echo none)"
|
||||
if [[ "$CUR" != "$WHISPER_VERSION" ]]; then
|
||||
git -C "$SRC" fetch --depth 1 origin tag "$WHISPER_VERSION"
|
||||
git -C "$SRC" checkout -f "$WHISPER_VERSION"
|
||||
fi
|
||||
fi
|
||||
|
||||
case "$ARCH" in
|
||||
arm64) OSX_ARCH="arm64"; EXTRA=(-DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON) ;;
|
||||
# Intel slice: CPU + Accelerate only. Metal shaders target Apple Silicon, and GGML_NATIVE
|
||||
# would bake this arm64 build host's flags into a cross build.
|
||||
x64) OSX_ARCH="x86_64"; EXTRA=(-DGGML_METAL=OFF -DGGML_NATIVE=OFF) ;;
|
||||
*) echo "unknown arch: $ARCH"; exit 1 ;;
|
||||
esac
|
||||
|
||||
BUILD_DIR="$SRC/build-$ARCH"
|
||||
echo "[whisper] building whisper-server $WHISPER_VERSION for $ARCH..."
|
||||
cmake -S "$SRC" -B "$BUILD_DIR" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_OSX_ARCHITECTURES="$OSX_ARCH" \
|
||||
-DBUILD_SHARED_LIBS=OFF \
|
||||
-DWHISPER_BUILD_TESTS=OFF \
|
||||
"${EXTRA[@]}" > /dev/null
|
||||
cmake --build "$BUILD_DIR" --target whisper-server -j "$(sysctl -n hw.ncpu)" > /dev/null
|
||||
|
||||
BIN="$BUILD_DIR/bin/whisper-server"
|
||||
[[ -f "$BIN" ]] || { echo "[whisper] build produced no whisper-server"; exit 1; }
|
||||
|
||||
mkdir -p "$OUT"
|
||||
cp "$BIN" "$OUT/whisper-server"
|
||||
echo "$WHISPER_VERSION" > "$OUT/.version"
|
||||
echo "[whisper] staged -> $OUT/whisper-server"
|
||||
file "$OUT/whisper-server"
|
||||
# A binary that links anything outside the OS is a launch crash on user machines; fail loud here.
|
||||
if otool -L "$OUT/whisper-server" | grep -qE "/opt/homebrew|/usr/local"; then
|
||||
echo "[whisper] ERROR: binary links non-system libraries"; otool -L "$OUT/whisper-server"; exit 1
|
||||
fi
|
||||
@@ -0,0 +1,207 @@
|
||||
// Silently read the user's OWN provider chat history (chatgpt.com / claude.ai) from
|
||||
// the browser partition's logged-in session, with no visible card. Onboarding prep
|
||||
// uses the result to profile what the user actually cares about. The injected script
|
||||
// is defined HERE (main-owned), so the offscreen exec can never be pointed at a script
|
||||
// the renderer or a remote page chose. Only reachable when a session already exists in
|
||||
// the partition; otherwise it fails open to {ok:false} and prep falls back to the scan.
|
||||
//
|
||||
// The RAW read never touches disk or redux: it is returned once to the renderer, which
|
||||
// derives a capped summary for prep and drops the rest. See summarizeUsage (frontend).
|
||||
|
||||
const hiddenBrowser = require('./hiddenBrowser');
|
||||
|
||||
const ORIGIN = {
|
||||
codex: 'https://chatgpt.com/',
|
||||
claude: 'https://claude.ai/',
|
||||
gemini: 'https://gemini.google.com/app',
|
||||
};
|
||||
|
||||
const DOMAIN = {
|
||||
codex: 'chatgpt.com',
|
||||
claude: 'claude.ai',
|
||||
gemini: 'gemini.google.com',
|
||||
};
|
||||
|
||||
// Main injects a (domain) => Promise<cookieRecords[]> that spawns the Python cookie reader.
|
||||
// Left null off-Electron / before boot, so harvest silently skips the imported-cookie path.
|
||||
let p_readCookies = null;
|
||||
function configure(opts) {
|
||||
if (opts && typeof opts.readCookies === 'function') p_readCookies = opts.readCookies;
|
||||
}
|
||||
|
||||
// Runs in the page context. Sweeps recent conversation titles (paginated + deduped)
|
||||
// plus ChatGPT Memory, THEN pulls the FULL text of the CONVO_N most recent conversations
|
||||
// (the user's real asks + the exchange, far higher signal than a vague title). Bounded on
|
||||
// EVERY dimension so no provider's endpoint speed can wedge the read: a wall-clock BUDGET_MS,
|
||||
// a per-fetch abort, hard page/title caps, and a per-conversation char cap so one marathon
|
||||
// chat can't dominate. A partial read is a good result; the recent stuff is the strongest signal.
|
||||
const PREAMBLE = `
|
||||
// Prep reads only ~150 titles, so pulling 1000 just burned onboarding runway for signal we throw
|
||||
// away. Cap the pull; the count becomes an honest "N+" floor when we stop early. CONVO_N full convos
|
||||
// (capped per convo) are the real payload; the budget is raised to fit their detail fetches.
|
||||
const BUDGET_MS=20000, PAGE=100, CAP_PAGES=60, CAP_TITLES=200, GAP_MS=120, FETCH_MS=6000, CONVO_N=10, CONVO_CHARS=8000;
|
||||
const startedAt = Date.now();
|
||||
const haveTime = () => Date.now() - startedAt < BUDGET_MS;
|
||||
const jget = async (url, extra) => {
|
||||
const ac = new AbortController(); const t = setTimeout(() => ac.abort(), FETCH_MS);
|
||||
try { const r = await fetch(url, Object.assign({credentials:'include', signal:ac.signal}, extra||{})); return r.ok ? await r.json() : null; }
|
||||
catch (e) { return null; } finally { clearTimeout(t); }
|
||||
};`;
|
||||
const SCRIPT = {
|
||||
codex: `(async () => {${PREAMBLE}
|
||||
try {
|
||||
const sess = await jget('/api/auth/session');
|
||||
if (!sess || !sess.accessToken) return {ok:false, total:0, titles:[], memories:[], convos:[]};
|
||||
const H = {headers:{Authorization:'Bearer '+sess.accessToken, accept:'application/json'}};
|
||||
const seen = new Set(); const titles = []; const convList = [];
|
||||
let offset = 0, page = 0;
|
||||
while (page < CAP_PAGES && titles.length < CAP_TITLES && haveTime()) {
|
||||
const j = await jget('/backend-api/conversations?offset='+offset+'&limit='+PAGE+'&order=updated', H);
|
||||
const items = (j && j.items) || [];
|
||||
if (!items.length) break;
|
||||
let fresh = 0;
|
||||
for (const c of items) { if (c && c.id && !seen.has(c.id)) { seen.add(c.id); if (c.title) titles.push(c.title); convList.push({id:c.id, title:c.title||''}); fresh++; } }
|
||||
if (fresh === 0 || items.length < PAGE) break;
|
||||
offset += PAGE; page++;
|
||||
await new Promise(r=>setTimeout(r, GAP_MS));
|
||||
}
|
||||
const mem = await jget('/backend-api/memories?include_memory_entries=true', H);
|
||||
// The payload: FULL text of the most recent convos, fetched in parallel (bounded by the budget).
|
||||
const top = convList.slice(0, CONVO_N);
|
||||
const details = await Promise.all(top.map(cv => haveTime() ? jget('/backend-api/conversation/'+cv.id, H) : Promise.resolve(null)));
|
||||
const convos = [];
|
||||
for (let i=0;i<top.length;i++) {
|
||||
const d = details[i]; if (!d || !d.mapping) continue;
|
||||
const msgs = Object.keys(d.mapping).map(k=>d.mapping[k] && d.mapping[k].message)
|
||||
.filter(m=>m && m.author && (m.author.role==='user'||m.author.role==='assistant') && m.content && m.content.content_type==='text' && m.content.parts && m.content.parts.length)
|
||||
.sort((a,b)=>(a.create_time||0)-(b.create_time||0))
|
||||
.map(m=>(m.author.role==='user'?'You: ':'AI: ')+String(m.content.parts.join(' ')).trim())
|
||||
.filter(s=>s.length>5);
|
||||
let text = msgs.join('\\n'); if (text.length > CONVO_CHARS) text = text.slice(0, CONVO_CHARS)+' …';
|
||||
if (text) convos.push({title: top[i].title, text});
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
total: seen.size,
|
||||
capped: seen.size >= CAP_TITLES,
|
||||
titles: titles.slice(0, CAP_TITLES),
|
||||
memories: ((mem && mem.memories) || []).map(m=>m.content).filter(Boolean).slice(0, 40),
|
||||
convos,
|
||||
};
|
||||
} catch (e) { return {ok:false, total:0, titles:[], memories:[], convos:[]}; }
|
||||
})()`,
|
||||
claude: `(async () => {${PREAMBLE}
|
||||
try {
|
||||
const orgs = await jget('/api/organizations', {headers:{accept:'application/json'}});
|
||||
if (!Array.isArray(orgs) || !orgs.length) return {ok:false, total:0, titles:[], memories:[], convos:[]};
|
||||
const org = orgs[0].uuid;
|
||||
const seen = new Set(); const titles = []; const convList = [];
|
||||
let offset = 0, page = 0;
|
||||
while (page < CAP_PAGES && titles.length < CAP_TITLES && haveTime()) {
|
||||
const convs = await jget('/api/organizations/'+org+'/chat_conversations?limit='+PAGE+'&offset='+offset, {headers:{accept:'application/json'}});
|
||||
const items = Array.isArray(convs) ? convs : [];
|
||||
if (!items.length) break;
|
||||
let fresh = 0;
|
||||
for (const c of items) { const id = c && c.uuid; if (id && !seen.has(id)) { seen.add(id); if (c.name) titles.push(c.name); convList.push({id:id, title:c.name||''}); fresh++; } }
|
||||
if (fresh === 0 || items.length < PAGE) break;
|
||||
offset += PAGE; page++;
|
||||
await new Promise(r=>setTimeout(r, GAP_MS));
|
||||
}
|
||||
// The payload: FULL text of the most recent convos (both sides), fetched in parallel + capped.
|
||||
const top = convList.slice(0, CONVO_N);
|
||||
const details = await Promise.all(top.map(cv => haveTime() ? jget('/api/organizations/'+org+'/chat_conversations/'+cv.id+'?tree=True&rendering_mode=raw', {headers:{accept:'application/json'}}) : Promise.resolve(null)));
|
||||
const convos = [];
|
||||
for (let i=0;i<top.length;i++) {
|
||||
const d = details[i]; if (!d) continue;
|
||||
const cms = (d && d.chat_messages) || (Array.isArray(d) ? d : []);
|
||||
const msgs = cms.filter(m=>m && (m.sender==='human'||m.sender==='assistant'))
|
||||
.map(m=>{ let t = m.text || ''; if(!t && Array.isArray(m.content)) t = m.content.map(x=>x&&x.text).filter(Boolean).join(' '); return (m.sender==='human'?'You: ':'AI: ')+String(t).trim(); })
|
||||
.filter(s=>s.length>5);
|
||||
let text = msgs.join('\\n'); if (text.length > CONVO_CHARS) text = text.slice(0, CONVO_CHARS)+' …';
|
||||
if (text) convos.push({title: top[i].title, text});
|
||||
}
|
||||
return {ok:true, total:seen.size, capped:seen.size >= CAP_TITLES, titles:titles.slice(0, CAP_TITLES), memories:[], convos};
|
||||
} catch (e) { return {ok:false, total:0, titles:[], memories:[], convos:[]}; }
|
||||
})()`,
|
||||
// Gemini has no clean history JSON (it's the obfuscated batchexecute RPC), so we scrape the
|
||||
// rendered rail. Robust by design, one bounded loop that: gates on real TEXT (empty conversation
|
||||
// shells persist even when the rail is collapsed, so container count lies); RE-tries the expand
|
||||
// every poll (the SPA can mount the toggle after settle, and a one-shot click would miss it),
|
||||
// via a stable test-id / icon handle (not an English label) so a non-English UI works, and a
|
||||
// no-op once open so an open rail is never toggled shut; reads titles through a fallback
|
||||
// selector so a .title-text rename can't zero the harvest; length-caps each; and gives up fast
|
||||
// when no text renders (no history / broken DOM) so an empty account can't burn the budget.
|
||||
gemini: `(async () => {
|
||||
const BUDGET_MS=14000, TEXT_DEADLINE_MS=8000, POLL_MS=500, CAP_TITLES=200, TITLE_MAX=140;
|
||||
const startedAt=Date.now();
|
||||
const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));
|
||||
const titleNodes=()=>{const n=document.querySelectorAll('[data-test-id="conversation"] .title-text');return n.length?n:document.querySelectorAll('[data-test-id="conversation"] a');};
|
||||
const iconIs=(b,name)=>{const i=b.querySelector('mat-icon');return !!i&&(i.getAttribute('data-mat-icon-name')===name||(i.textContent||'').trim()===name);};
|
||||
const findExpand=()=>{const bs=Array.from(document.querySelectorAll('button,[role="button"]'));return document.querySelector('button[data-test-id="side-nav-sparkle-button"]')||bs.find(b=>iconIs(b,'side_nav_expand'))||bs.find(b=>/expand|open.*(sidebar|menu|nav)/i.test(b.getAttribute('aria-label')||''));};
|
||||
try {
|
||||
const seen=new Set(); const titles=[]; let stable=0;
|
||||
while (Date.now()-startedAt < BUDGET_MS && titles.length < CAP_TITLES) {
|
||||
let fresh=0;
|
||||
titleNodes().forEach(e=>{const t=(e.textContent||'').trim().slice(0,TITLE_MAX); if(t&&!seen.has(t)){seen.add(t);titles.push(t);fresh++;}});
|
||||
if (titles.length>0) { if (fresh===0){ if(++stable>=2) break; } else stable=0; }
|
||||
else { const b=findExpand(); if(b){try{b.click();}catch(_){}} if (Date.now()-startedAt > TEXT_DEADLINE_MS) break; }
|
||||
await sleep(POLL_MS);
|
||||
}
|
||||
return { ok: titles.length>0, total: titles.length, titles: titles.slice(0, CAP_TITLES), memories: [] };
|
||||
} catch (e) { return {ok:false, total:0, titles:[], memories:[]}; }
|
||||
})()`,
|
||||
};
|
||||
|
||||
const EMPTY = { ok: false, total: 0, titles: [], memories: [] };
|
||||
|
||||
function p_usable(res) {
|
||||
return res && typeof res === 'object' && res.ok &&
|
||||
(res.total > 0 || (res.memories && res.memories.length) || (res.titles && res.titles.length));
|
||||
}
|
||||
|
||||
async function p_harvestOnce(partition, provider) {
|
||||
// First run: read the user's own browser session cookies, inject into a throwaway real-Chrome
|
||||
// context, and harvest there. This is the only path that beats provider Cloudflare AND works
|
||||
// before the user has opened the site in-app.
|
||||
if (p_readCookies) {
|
||||
try {
|
||||
const records = await p_readCookies(DOMAIN[provider]);
|
||||
if (records && records.length) {
|
||||
const viaCookies = await hiddenBrowser.hiddenEvalWithCookies(ORIGIN[provider], records, SCRIPT[provider]).catch(() => null);
|
||||
if (p_usable(viaCookies)) return viaCookies;
|
||||
}
|
||||
} catch (_) { /* fall through to the opportunistic path */ }
|
||||
}
|
||||
// Opportunistic: the user already logged into the site in an in-app card, so the browser
|
||||
// partition holds the session; read it directly (also a real Chrome context).
|
||||
const res = await hiddenBrowser.hiddenEval(partition, ORIGIN[provider], SCRIPT[provider]).catch(() => null);
|
||||
return p_usable(res) ? res : EMPTY;
|
||||
}
|
||||
|
||||
// Hard ceiling on the WHOLE harvest (both attempts): a stale-session provider does two full
|
||||
// offscreen loads, and a hung redirect could otherwise churn a background window toward the
|
||||
// window-killer. This guarantees harvest() always resolves fast (fail-open); the offscreen
|
||||
// windows still self-destruct via withWindow. Comfortably above a healthy harvest (~6s).
|
||||
const HARVEST_HARD_CAP_MS = 30000;
|
||||
|
||||
// Never re-hit a provider we've already read successfully within this window. Rapid repeated
|
||||
// reads of the SAME session (especially Google's rotating __Secure-1PSIDTS) can trip provider
|
||||
// anti-abuse and log the user OUT of their real account, so every caller funnels through this
|
||||
// one cooldown. Failed reads are NOT cached, so a not-yet-logged-in provider stays retryable;
|
||||
// a cached success also serves an instant re-request with zero extra network.
|
||||
const HARVEST_COOLDOWN_MS = 15 * 60 * 1000;
|
||||
const p_okCache = {};
|
||||
|
||||
async function harvest(partition, provider) {
|
||||
if (provider !== 'codex' && provider !== 'claude' && provider !== 'gemini') return EMPTY;
|
||||
const cached = p_okCache[provider];
|
||||
if (cached && Date.now() - cached.at < HARVEST_COOLDOWN_MS) return cached.result;
|
||||
const result = await Promise.race([
|
||||
p_harvestOnce(partition, provider),
|
||||
new Promise((resolve) => setTimeout(() => resolve(EMPTY), HARVEST_HARD_CAP_MS)),
|
||||
]);
|
||||
if (p_usable(result)) p_okCache[provider] = { at: Date.now(), result };
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { harvest, configure };
|
||||
@@ -0,0 +1,39 @@
|
||||
// Drop transcribed text into whatever app currently has focus, WhisperFlow-style. We can't synthesize
|
||||
// raw keystrokes from a sandboxed renderer, so the durable trick is the clipboard: stash the user's
|
||||
// existing clipboard, write our text, fire the OS paste chord, then restore the clipboard a beat later
|
||||
// so we don't clobber what they had. macOS paste needs Accessibility permission (same wall clicky hits).
|
||||
|
||||
const { clipboard } = require('electron');
|
||||
const { exec } = require('child_process');
|
||||
|
||||
function pasteFrontmost() {
|
||||
return new Promise((resolve) => {
|
||||
if (process.platform === 'darwin') {
|
||||
exec('osascript -e \'tell application "System Events" to keystroke "v" using command down\'', (err) => resolve(!err));
|
||||
} else if (process.platform === 'win32') {
|
||||
// SendWait "^v" = Ctrl+V into the focused control.
|
||||
exec('powershell -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^v\')"', (err) => resolve(!err));
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Write text to the clipboard and paste it into the focused field, then put the old clipboard back so
|
||||
// dictation is non-destructive. Returns false if we couldn't fire the paste (e.g. no Accessibility grant).
|
||||
async function injectText(text) {
|
||||
if (!text) return false;
|
||||
const previous = clipboard.readText();
|
||||
clipboard.writeText(text);
|
||||
const pasted = await pasteFrontmost();
|
||||
// Restore after the paste has had time to read the clipboard. If the paste failed the text stays on
|
||||
// the clipboard so the user can paste it by hand rather than losing the dictation entirely.
|
||||
if (pasted) {
|
||||
setTimeout(() => {
|
||||
try { if (clipboard.readText() === text) clipboard.writeText(previous); } catch (_) {}
|
||||
}, 400);
|
||||
}
|
||||
return pasted;
|
||||
}
|
||||
|
||||
module.exports = { injectText };
|
||||
@@ -0,0 +1,173 @@
|
||||
// Local speech-to-text via whisper.cpp, kept WARM so a phrase transcribes in ~0.2s instead of the
|
||||
// ~16s cold-model-load a fresh CLI pays every time. We spawn `whisper-server` once (model loaded),
|
||||
// then POST audio to it per utterance. Same "bundle a binary + manage its lifecycle" shape as the
|
||||
// 9router subprocess: dev uses the system whisper.cpp, prod uses the per-arch binary + model we ship.
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const https = require('https');
|
||||
|
||||
const MODEL_FILE = 'ggml-base.en.bin';
|
||||
const MODEL_URL = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin';
|
||||
|
||||
// First-run model fetch, so a dev build (or a prod build that shipped without the model) still works
|
||||
// instead of dead-ending on "no model". Progress is exposed so the pill can say "Preparing voice 40%".
|
||||
const download = { active: false, pct: 0, error: null };
|
||||
|
||||
function downloadModel(dest) {
|
||||
if (download.active) return;
|
||||
download.active = true;
|
||||
download.pct = 0;
|
||||
download.error = null;
|
||||
try { fs.mkdirSync(path.dirname(dest), { recursive: true }); } catch (_) {}
|
||||
const tmp = `${dest}.part`;
|
||||
try { fs.unlinkSync(tmp); } catch (_) {}
|
||||
|
||||
const fail = (msg) => { download.active = false; download.error = String(msg); try { fs.unlinkSync(tmp); } catch (_) {} };
|
||||
|
||||
// HuggingFace bounces resolve -> CDN -> signed URL, so follow redirects instead of assuming one hop.
|
||||
const fetchUrl = (url, hops) => {
|
||||
if (hops > 6) { fail('too-many-redirects'); return; }
|
||||
const req = https.get(url, { headers: { 'User-Agent': 'openswarm-voice' } }, (res) => {
|
||||
const code = res.statusCode || 0;
|
||||
if (code >= 300 && code < 400 && res.headers.location) {
|
||||
res.resume(); // drain so the socket frees
|
||||
fetchUrl(new URL(res.headers.location, url).toString(), hops + 1);
|
||||
return;
|
||||
}
|
||||
if (code !== 200) { res.resume(); fail(`http-${code}`); return; }
|
||||
const total = Number(res.headers['content-length'] || 0);
|
||||
let got = 0;
|
||||
const file = fs.createWriteStream(tmp);
|
||||
res.on('data', (c) => { got += c.length; if (total) download.pct = Math.round((got / total) * 100); });
|
||||
res.pipe(file);
|
||||
file.on('finish', () => file.close(() => {
|
||||
// A truncated download is worse than none: only accept a complete file.
|
||||
if (total && got < total) { fail('truncated'); return; }
|
||||
try { fs.renameSync(tmp, dest); download.pct = 100; download.active = false; } catch (e) { fail(e && e.message ? e.message : e); }
|
||||
}));
|
||||
res.on('error', () => fail('stream-error'));
|
||||
file.on('error', () => fail('write-error'));
|
||||
});
|
||||
req.on('error', (e) => fail(e && e.message ? e.message : e));
|
||||
};
|
||||
fetchUrl(MODEL_URL, 0);
|
||||
}
|
||||
|
||||
function modelStatus() {
|
||||
return { downloading: download.active, pct: download.pct, error: download.error };
|
||||
}
|
||||
|
||||
// Resolve the whisper-server binary. Env override wins (dev convenience), then the bundled per-arch
|
||||
// copy, then whatever is on PATH so a dev machine with `brew install whisper-cpp` just works.
|
||||
function resolveBinary(resourceDir) {
|
||||
if (process.env.OPENSWARM_WHISPER_BIN && fs.existsSync(process.env.OPENSWARM_WHISPER_BIN)) {
|
||||
return process.env.OPENSWARM_WHISPER_BIN;
|
||||
}
|
||||
const exe = process.platform === 'win32' ? 'whisper-server.exe' : 'whisper-server';
|
||||
const bundled = path.join(resourceDir, exe);
|
||||
if (fs.existsSync(bundled)) return bundled;
|
||||
const brew = process.platform === 'win32' ? null : '/opt/homebrew/bin/whisper-server';
|
||||
if (brew && fs.existsSync(brew)) return brew;
|
||||
return exe; // last resort: hope it is on PATH
|
||||
}
|
||||
|
||||
// Resolve the model file. Env override, then bundled, then a dev cache under the app's data dir.
|
||||
function resolveModel(resourceDir, userDataDir) {
|
||||
if (process.env.OPENSWARM_WHISPER_MODEL && fs.existsSync(process.env.OPENSWARM_WHISPER_MODEL)) {
|
||||
return process.env.OPENSWARM_WHISPER_MODEL;
|
||||
}
|
||||
const bundled = path.join(resourceDir, MODEL_FILE);
|
||||
if (fs.existsSync(bundled)) return bundled;
|
||||
const cached = path.join(userDataDir, 'whisper', MODEL_FILE);
|
||||
if (fs.existsSync(cached)) return cached;
|
||||
return null;
|
||||
}
|
||||
|
||||
let proc = null;
|
||||
let port = 0;
|
||||
let readyPromise = null;
|
||||
|
||||
function pickPort() {
|
||||
// Fixed-ish high port; whisper-server has no ephemeral-port reporting, so we pick and probe.
|
||||
return 8300 + Math.floor(Math.random() * 400);
|
||||
}
|
||||
|
||||
async function waitForReady(p, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${p}/`, { method: 'GET' });
|
||||
if (res.status) return true; // any HTTP answer means the socket is serving
|
||||
} catch (_) { /* not up yet */ }
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function p_bootServer(resourceDir, userDataDir) {
|
||||
const bin = resolveBinary(resourceDir);
|
||||
const model = resolveModel(resourceDir, userDataDir);
|
||||
if (!model) {
|
||||
// Kick off a one-time background fetch so the NEXT dictation just works.
|
||||
downloadModel(path.join(userDataDir, 'whisper', MODEL_FILE));
|
||||
throw new Error(download.active ? 'model-downloading' : 'no-model');
|
||||
}
|
||||
const p = pickPort();
|
||||
const child = spawn(bin, ['-m', model, '--port', String(p), '-nt', '--convert'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
child.on('error', () => { proc = null; port = 0; });
|
||||
child.on('exit', () => { proc = null; port = 0; readyPromise = null; });
|
||||
const ok = await waitForReady(p, 20000);
|
||||
if (!ok) {
|
||||
try { child.kill(); } catch (_) {}
|
||||
throw new Error('server-timeout');
|
||||
}
|
||||
proc = child;
|
||||
port = p;
|
||||
return p;
|
||||
}
|
||||
|
||||
// Boot the warm server once. resourceDir = where a packaged build put the binary+model; userDataDir
|
||||
// = app.getPath('userData') for the dev cache. Returns the port, or throws with an actionable reason.
|
||||
// The readyPromise is cleared AFTER it settles, never synchronously inside the async body: the old
|
||||
// code reset it inside the IIFE where the outer assignment immediately overwrote the null, pinning a
|
||||
// settled-rejected promise forever so every later call kept throwing "model-downloading" even after
|
||||
// the model finished. Clearing on rejection here lets the next call retry cleanly.
|
||||
async function ensureServer(resourceDir, userDataDir) {
|
||||
if (proc && port) return port;
|
||||
if (readyPromise) return readyPromise;
|
||||
readyPromise = p_bootServer(resourceDir, userDataDir);
|
||||
try {
|
||||
return await readyPromise;
|
||||
} catch (err) {
|
||||
readyPromise = null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Transcribe a 16kHz-mono WAV buffer to text. The renderer records + encodes the WAV so the audio
|
||||
// never crosses a CORS boundary; we POST from the main process where there is none.
|
||||
async function transcribe(resourceDir, userDataDir, wavBuffer) {
|
||||
const p = await ensureServer(resourceDir, userDataDir);
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([wavBuffer], { type: 'audio/wav' }), 'audio.wav');
|
||||
form.append('response_format', 'text');
|
||||
const res = await fetch(`http://127.0.0.1:${p}/inference`, { method: 'POST', body: form });
|
||||
if (!res.ok) throw new Error(`whisper-http-${res.status}`);
|
||||
const text = (await res.text()).trim();
|
||||
return text;
|
||||
}
|
||||
|
||||
function stopServer() {
|
||||
if (proc) {
|
||||
try { proc.kill(); } catch (_) {}
|
||||
}
|
||||
proc = null;
|
||||
port = 0;
|
||||
readyPromise = null;
|
||||
}
|
||||
|
||||
module.exports = { ensureServer, transcribe, stopServer, resolveBinary, resolveModel, modelStatus };
|
||||
@@ -0,0 +1,203 @@
|
||||
const { app, globalShortcut, ipcMain, systemPreferences } = require('electron');
|
||||
|
||||
// Voice dictation hotkey, user-rebindable (Settings > Interface > Dictation shortcut), two tiers:
|
||||
//
|
||||
// NATIVE (uiohook-napi event tap): sees real key-down AND key-up globally, in or out of focus,
|
||||
// immune to macOS's letter-keyup-under-Cmd suppression, so the keyboard gets TRUE hold-to-talk
|
||||
// exactly like the mic buttons. Listen-only (never swallows keys from other apps).
|
||||
//
|
||||
// FALLBACK (globalShortcut while unfocused + before-input relay while focused): press-to-toggle,
|
||||
// key-ups undetectable there.
|
||||
//
|
||||
// THE TRAP THIS FILE IS SHAPED AROUND: on macOS a listen-only keyboard tap needs the Input
|
||||
// Monitoring grant, which is SEPARATE from Accessibility, and a tap without it starts cleanly and
|
||||
// then delivers nothing (caught live on the packaged build). So "tap started" proves nothing; the
|
||||
// fallback stays armed until the tap delivers its first real key event. To keep the two paths from
|
||||
// double-firing on one press, fallback sends are deferred 90ms and skipped when the tap just
|
||||
// handled a key; a deaf tap never updates that timestamp, so the fallback always fires.
|
||||
//
|
||||
// F5 is deliberately NOT a default: macOS's media-key layer routes it to Siri before any app sees
|
||||
// it. It stays bindable for users who have remapped that key at the OS level.
|
||||
|
||||
const DEFAULT_COMBO = process.platform === 'darwin' ? 'Meta+Shift+d' : 'Ctrl+Shift+d';
|
||||
const TAP_FRESH_MS = 200;
|
||||
const FALLBACK_DEFER_MS = 90;
|
||||
|
||||
// "Meta+Shift+d" (renderer parts format, same as new_agent_shortcut) -> matcher pieces.
|
||||
function parseCombo(str) {
|
||||
const parts = String(str || DEFAULT_COMBO).split('+').filter(Boolean);
|
||||
const key = parts[parts.length - 1] || 'd';
|
||||
const mods = {
|
||||
meta: parts.includes('Meta'),
|
||||
ctrl: parts.includes('Ctrl') || parts.includes('Control'),
|
||||
alt: parts.includes('Alt'),
|
||||
shift: parts.includes('Shift'),
|
||||
};
|
||||
const accel = [
|
||||
mods.meta ? 'Meta' : null,
|
||||
mods.ctrl ? 'Control' : null,
|
||||
mods.alt ? 'Alt' : null,
|
||||
mods.shift ? 'Shift' : null,
|
||||
key.length === 1 ? key.toUpperCase() : key,
|
||||
].filter(Boolean).join('+');
|
||||
return { key, mods, accel };
|
||||
}
|
||||
|
||||
function uiohookKeycodeFor(key, UiohookKey) {
|
||||
if (key.length === 1 && /[a-z]/i.test(key)) return UiohookKey[key.toUpperCase()];
|
||||
if (key.length === 1 && /[0-9]/.test(key)) return UiohookKey[key];
|
||||
if (/^F([1-9]|1[0-9]|2[0-4])$/.test(key)) return UiohookKey[key];
|
||||
if (key === ' ' || key === 'Space') return UiohookKey.Space;
|
||||
return undefined; // unmappable for the tap; the fallback tiers still cover it
|
||||
}
|
||||
|
||||
function installVoiceHotkey(getMainWindow) {
|
||||
const send = (channel) => {
|
||||
const win = getMainWindow();
|
||||
if (win && !win.isDestroyed()) win.webContents.send(channel);
|
||||
};
|
||||
|
||||
let combo = parseCombo(DEFAULT_COMBO);
|
||||
let tapProven = false;
|
||||
let lastTapKeyMs = 0;
|
||||
let registeredAccel = null;
|
||||
|
||||
const unregisterFallbackShortcut = () => {
|
||||
if (!registeredAccel) return;
|
||||
try { globalShortcut.unregister(registeredAccel); } catch (_) {}
|
||||
registeredAccel = null;
|
||||
};
|
||||
|
||||
// Fallback toggle, deferred so a live tap's hold-down wins the same press.
|
||||
const sendFallbackToggle = () => {
|
||||
setTimeout(() => {
|
||||
if (Date.now() - lastTapKeyMs < TAP_FRESH_MS) return;
|
||||
send('voice:toggle');
|
||||
}, FALLBACK_DEFER_MS);
|
||||
};
|
||||
|
||||
// Fallback shortcut stays registered while unfocused until the tap proves alive.
|
||||
const registerVoiceShortcut = () => {
|
||||
if (tapProven) return;
|
||||
if (registeredAccel === combo.accel) return;
|
||||
unregisterFallbackShortcut();
|
||||
try {
|
||||
if (globalShortcut.register(combo.accel, sendFallbackToggle)) registeredAccel = combo.accel;
|
||||
} catch (_) { /* a taken shortcut just means no global hotkey; the pill still works */ }
|
||||
};
|
||||
|
||||
let tapKeycode;
|
||||
let UiohookKeyRef = null;
|
||||
|
||||
const tryStartNativeTap = () => {
|
||||
try {
|
||||
if (process.platform === 'darwin' && !systemPreferences.isTrustedAccessibilityClient(false)) {
|
||||
console.log('[voice] no Accessibility grant, keyboard stays press-to-toggle');
|
||||
return false;
|
||||
}
|
||||
const { uIOhook, UiohookKey } = require('uiohook-napi');
|
||||
UiohookKeyRef = UiohookKey;
|
||||
tapKeycode = uiohookKeycodeFor(combo.key, UiohookKey);
|
||||
const MOD_KEYS = new Set([
|
||||
UiohookKey.Ctrl, UiohookKey.CtrlRight, UiohookKey.Shift, UiohookKey.ShiftRight,
|
||||
UiohookKey.Meta, UiohookKey.MetaRight, UiohookKey.Alt, UiohookKey.AltRight,
|
||||
]);
|
||||
let held = false;
|
||||
|
||||
const markAlive = () => {
|
||||
lastTapKeyMs = Date.now();
|
||||
if (!tapProven) {
|
||||
tapProven = true;
|
||||
unregisterFallbackShortcut();
|
||||
console.log('[voice] native key tap PROVEN (events flowing), hold-to-talk enabled');
|
||||
}
|
||||
};
|
||||
|
||||
const modsMatch = (e) =>
|
||||
(!combo.mods.meta || e.metaKey) &&
|
||||
(!combo.mods.ctrl || e.ctrlKey) &&
|
||||
(!combo.mods.alt || e.altKey) &&
|
||||
(!combo.mods.shift || e.shiftKey);
|
||||
|
||||
uIOhook.on('keydown', (e) => {
|
||||
markAlive();
|
||||
if (held || tapKeycode === undefined) return;
|
||||
if (e.keycode === tapKeycode && modsMatch(e)) {
|
||||
held = true;
|
||||
send('voice:hold-down');
|
||||
}
|
||||
});
|
||||
uIOhook.on('keyup', (e) => {
|
||||
markAlive();
|
||||
if (!held) return;
|
||||
if (e.keycode === tapKeycode || MOD_KEYS.has(e.keycode)) {
|
||||
held = false;
|
||||
send('voice:hold-up');
|
||||
}
|
||||
});
|
||||
|
||||
uIOhook.start();
|
||||
app.on('will-quit', () => { try { uIOhook.stop(); } catch (_) {} });
|
||||
console.log('[voice] native key tap armed (awaiting first event to prove Input Monitoring)');
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.log('[voice] native key tap unavailable (continuing with toggle):', e && e.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
tryStartNativeTap();
|
||||
registerVoiceShortcut();
|
||||
app.on('browser-window-focus', unregisterFallbackShortcut);
|
||||
app.on('browser-window-blur', registerVoiceShortcut);
|
||||
|
||||
const inputMatchesCombo = (input) => {
|
||||
const k = combo.key;
|
||||
const keyHit = k.length === 1
|
||||
? (input.code === `Key${k.toUpperCase()}` || (input.key || '').toLowerCase() === k.toLowerCase())
|
||||
: (input.code === k || input.key === k);
|
||||
return keyHit &&
|
||||
(!combo.mods.meta || input.meta) &&
|
||||
(!combo.mods.ctrl || input.control) &&
|
||||
(!combo.mods.alt || input.alt) &&
|
||||
(!combo.mods.shift || input.shift);
|
||||
};
|
||||
|
||||
const installVoiceHoldRelay = (contents) => {
|
||||
contents.on('before-input-event', (event, input) => {
|
||||
if (input.type !== 'keyDown' || input.isAutoRepeat) return;
|
||||
if (inputMatchesCombo(input)) {
|
||||
if (!tapProven) sendFallbackToggle();
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
};
|
||||
// Installed via web-contents-created: the main window is born later in the whenReady sequence, and
|
||||
// webview guests swallow keys when a page has focus, so every window/guest gets the relay.
|
||||
app.on('web-contents-created', (event, contents) => {
|
||||
const t = contents.getType();
|
||||
if (t === 'window' || t === 'webview') installVoiceHoldRelay(contents);
|
||||
});
|
||||
|
||||
// Renderer pushes the user's saved combo on boot and whenever Settings changes it.
|
||||
ipcMain.on('voice:set-hotkey', (_e, comboStr) => {
|
||||
const next = parseCombo(comboStr);
|
||||
if (next.accel === combo.accel) return;
|
||||
combo = next;
|
||||
if (UiohookKeyRef) tapKeycode = uiohookKeycodeFor(combo.key, UiohookKeyRef);
|
||||
unregisterFallbackShortcut();
|
||||
registerVoiceShortcut();
|
||||
console.log('[voice] hotkey set to', combo.accel);
|
||||
});
|
||||
|
||||
ipcMain.handle('voice:hold-capable', () => tapProven);
|
||||
// Settings' "Hold to talk" fires the Accessibility prompt; Input Monitoring has no Electron API,
|
||||
// but a running tap makes macOS list the app in that pane for the user to flip.
|
||||
ipcMain.handle('voice:request-hold-permission', () => {
|
||||
if (process.platform === 'darwin' && !tapProven) {
|
||||
try { systemPreferences.isTrustedAccessibilityClient(true); } catch (_) {}
|
||||
}
|
||||
return tapProven;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { installVoiceHotkey };
|
||||
Generated
+3634
-22
File diff suppressed because it is too large
Load Diff
+23
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "open-swarm",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"description": "Open Swarm — Agent Orchestrator frontend built with React",
|
||||
"scripts": {
|
||||
"build": "webpack --mode=production",
|
||||
@@ -17,38 +18,59 @@
|
||||
"@codemirror/view": "^6.39.16",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@mui/icons-material": "^7.3.9",
|
||||
"@mui/material": "^7.3.9",
|
||||
"@pierre/diffs": "^1.0.11",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"ansi-to-react": "^6.2.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"framer-motion": "^12.35.2",
|
||||
"html-to-image": "^1.11.13",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.17.0",
|
||||
"radix-ui": "^1.6.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"remark-gfm": "^4.0.1"
|
||||
"recharts": "^2.15.4",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^3.23.0",
|
||||
"supercluster": "^8.0.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.0",
|
||||
"@babel/preset-env": "^7.28.0",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/react-redux": "^7.1.34",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"babel-loader": "^9.2.1",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"css-loader": "^6.8.0",
|
||||
"css-modules-types-loader": "^0.6.10",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"postcss": "^8.5.20",
|
||||
"postcss-loader": "^8.2.1",
|
||||
"sass": "^1.89.2",
|
||||
"sass-loader": "^16.0.5",
|
||||
"style-loader": "^3.3.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.0.0",
|
||||
"webpack": "^5.88.0",
|
||||
"webpack-cli": "^5.1.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -41,6 +41,28 @@
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
// Pre-React boot paint: last session's wash gradient (or the stock one) on the root element,
|
||||
// so a reload never flashes a flat white frame while the bundle boots. Stock stops mirror
|
||||
// DEFAULT_WASH_STOPS in shared/styles/washBackground.ts; keep them in sync by hand.
|
||||
(function () {
|
||||
try {
|
||||
var mode = localStorage.getItem('self-swarm-theme-mode') === 'dark' ? 'dark' : 'light';
|
||||
var g = null;
|
||||
try { g = JSON.parse(localStorage.getItem('self-swarm-theme-gradient') || 'null'); } catch (e) {}
|
||||
var accent = localStorage.getItem('self-swarm-theme-accent');
|
||||
var stops = Array.isArray(g) && g.length > 1 ? g : (accent ? [accent, accent] : ['#B7CDEA', '#EFE0D2', '#E7BDD1']);
|
||||
if (stops.length === 1) stops = [stops[0], stops[0]];
|
||||
var alpha = parseFloat(localStorage.getItem('self-swarm-theme-wash-opacity') || '');
|
||||
if (!isFinite(alpha) || alpha < 0 || alpha > 1) alpha = 0.17;
|
||||
var a = Math.round(alpha * 255).toString(16);
|
||||
if (a.length < 2) a = '0' + a;
|
||||
var css = stops.map(function (hex, i) { return hex + a + ' ' + Math.round((i / (stops.length - 1)) * 100) + '%'; }).join(', ');
|
||||
var base = mode === 'dark' ? '#3D3D3A' : '#F5F4ED';
|
||||
document.documentElement.style.background = 'linear-gradient(115deg, ' + css + ') fixed, ' + base;
|
||||
} catch (e) { /* boot paint is cosmetic; never block boot */ }
|
||||
})();
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,72 @@
|
||||
/* Generates terse per-component prop hints for the ShowUI tool description straight from the
|
||||
vendored tool-ui zod contracts, so the server spec can never drift from what validates.
|
||||
Run: npx tsx --tsconfig tsconfig.json scripts/gen-toolui-hints.ts */
|
||||
import { z } from 'zod';
|
||||
|
||||
const TARGETS: Record<string, [string, string]> = {
|
||||
'approval-card': ['../src/toolui/components/approval-card/schema', 'SerializableApprovalCardSchema'],
|
||||
'audio': ['../src/toolui/components/audio/schema', 'SerializableAudioSchema'],
|
||||
'chart': ['../src/toolui/components/chart/schema', 'SerializableChartSchema'],
|
||||
'citation': ['../src/toolui/components/citation/schema', 'SerializableCitationSchema'],
|
||||
'code-block': ['../src/toolui/components/code-block/schema', 'SerializableCodeBlockSchema'],
|
||||
'code-diff': ['../src/toolui/components/code-diff/schema', 'SerializableCodeDiffSchema'],
|
||||
'data-table': ['../src/toolui/components/data-table/schema', 'SerializableDataTableSchema'],
|
||||
'geo-map': ['../src/toolui/components/geo-map/schema', 'SerializableGeoMapSchema'],
|
||||
'image': ['../src/toolui/components/image/schema', 'SerializableImageSchema'],
|
||||
'image-gallery': ['../src/toolui/components/image-gallery/schema', 'SerializableImageGallerySchema'],
|
||||
'instagram-post': ['../src/toolui/components/instagram-post/schema', 'SerializableInstagramPostSchema'],
|
||||
'item-carousel': ['../src/toolui/components/item-carousel/schema', 'SerializableItemCarouselSchema'],
|
||||
'link-preview': ['../src/toolui/components/link-preview/schema', 'SerializableLinkPreviewSchema'],
|
||||
'linkedin-post': ['../src/toolui/components/linkedin-post/schema', 'SerializableLinkedInPostSchema'],
|
||||
'message-draft': ['../src/toolui/components/message-draft/schema', 'SerializableEmailDraftSchema'],
|
||||
'option-list': ['../src/toolui/components/option-list/schema', 'SerializableOptionListSchema'],
|
||||
'order-summary': ['../src/toolui/components/order-summary/schema', 'SerializableOrderSummarySchema'],
|
||||
'parameter-slider': ['../src/toolui/components/parameter-slider/schema', 'SerializableParameterSliderSchema'],
|
||||
'plan': ['../src/toolui/components/plan/schema', 'SerializablePlanSchema'],
|
||||
'preferences-panel': ['../src/toolui/components/preferences-panel/schema', 'SerializablePreferencesPanelSchema'],
|
||||
'progress-tracker': ['../src/toolui/components/progress-tracker/schema', 'SerializableProgressTrackerSchema'],
|
||||
'question-flow': ['../src/toolui/components/question-flow/schema', 'SerializableProgressiveModeSchema'],
|
||||
'stats-display': ['../src/toolui/components/stats-display/schema', 'SerializableStatsDisplaySchema'],
|
||||
'terminal': ['../src/toolui/components/terminal/schema', 'SerializableTerminalSchema'],
|
||||
'video': ['../src/toolui/components/video/schema', 'SerializableVideoSchema'],
|
||||
'x-post': ['../src/toolui/components/x-post/schema', 'SerializableXPostSchema'],
|
||||
};
|
||||
|
||||
function describe(node: any, depth: number): string {
|
||||
if (!node || typeof node !== 'object') return 'any';
|
||||
if (Array.isArray(node.enum)) return node.enum.map((v: unknown) => `'${v}'`).join('|');
|
||||
if (Array.isArray(node.anyOf)) return node.anyOf.map((n: any) => describe(n, depth)).join('|');
|
||||
const t = node.type;
|
||||
if (t === 'array') return `[${describe(node.items, depth)}]`;
|
||||
if (t === 'object') {
|
||||
if (depth >= 2) return 'obj';
|
||||
const req = new Set(node.required || []);
|
||||
const props = node.properties || {};
|
||||
// Required props FIRST so tail truncation can only ever cost optional detail, never a required field.
|
||||
const keys = Object.keys(props).sort((a, b) => Number(req.has(b)) - Number(req.has(a)));
|
||||
const parts = keys.map((k) => `${k}${req.has(k) ? '' : '?'}: ${describe(props[k], depth + 1)}`);
|
||||
return `{${parts.join(', ')}}`;
|
||||
}
|
||||
if (t === 'string') return 'str';
|
||||
if (t === 'number' || t === 'integer') return 'num';
|
||||
if (t === 'boolean') return 'bool';
|
||||
return 'any';
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const fs = await import('fs');
|
||||
const out: Record<string, { hint: string; schema: unknown }> = {};
|
||||
for (const [name, [path, exportName]] of Object.entries(TARGETS)) {
|
||||
const mod = await import(path);
|
||||
const schema = mod[exportName];
|
||||
const js = z.toJSONSchema(schema, { unrepresentable: 'any', io: 'input' } as any) as any;
|
||||
let hint = describe(js, 0);
|
||||
if (hint.length > 420) hint = hint.slice(0, 417) + '...';
|
||||
out[name] = { hint: `props: ${hint.replace(/"/g, "'")}`, schema: js };
|
||||
}
|
||||
const dest = new URL('../../backend/apps/agents/toolui_schemas.json', import.meta.url).pathname;
|
||||
fs.writeFileSync(dest, JSON.stringify(out, null, 1));
|
||||
console.log(`wrote ${Object.keys(out).length} component schemas to ${dest}`);
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -203,9 +203,10 @@ const DeepLinkListener: React.FC<{ children: React.ReactNode }> = ({ children })
|
||||
const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { setMode: setThemeMode } = useThemeMode();
|
||||
const { setAccent } = useThemeAccent();
|
||||
const { setAccent, setGradient } = useThemeAccent();
|
||||
const theme = useAppSelector((s) => s.settings.data.theme);
|
||||
const accentColor = useAppSelector((s) => s.settings.data.accent_color);
|
||||
const accentGradient = useAppSelector((s) => s.settings.data.accent_gradient);
|
||||
const loaded = useAppSelector((s) => s.settings.loaded);
|
||||
const allowExperimentalUpdates = useAppSelector((s) => s.settings.data.allow_experimental_updates);
|
||||
useEffect(() => {
|
||||
@@ -263,6 +264,13 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
if (loaded) setAccent(accentColor ?? null);
|
||||
}, [loaded, accentColor, setAccent]);
|
||||
|
||||
// Object identity churns on every settings fetch, so key the effect on the serialized stops.
|
||||
const gradientKey = JSON.stringify(accentGradient ?? null);
|
||||
useEffect(() => {
|
||||
if (loaded) setGradient(accentGradient ?? null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loaded, gradientKey, setGradient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
(window as any).openswarm?.setAllowPrerelease?.(allowExperimentalUpdates);
|
||||
@@ -281,9 +289,9 @@ const DEFAULT_MODEL_PRIORITY: string[] = [
|
||||
];
|
||||
|
||||
const DEFAULT_MODEL_PICKS: Record<string, string[]> = {
|
||||
Anthropic: ['sonnet-cc', 'sonnet'],
|
||||
OpenAI: ['gpt-5.4-mini', 'gpt-5.4'],
|
||||
Google: ['gemini-2.5-flash', 'gemini-3-flash', 'gemini-2.5-pro'],
|
||||
Anthropic: ['opus-5-cc', 'opus-5', 'opus-5-api', 'sonnet-5-cc', 'sonnet-5'],
|
||||
OpenAI: ['gpt-5.6', 'gpt-5.6-api', 'gpt-5.5', 'gpt-5.5-api'],
|
||||
Google: ['gemini-3.6-flash-api', 'gemini-3.5-flash-api', 'gemini-3.1-flash-lite'],
|
||||
'OpenSwarm Pro': ['sonnet', 'opus'],
|
||||
OpenSwarm: ['gpt-5-mini', 'claude-haiku-4.5', 'gpt-4.1'],
|
||||
};
|
||||
@@ -381,7 +389,7 @@ const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
severity="info"
|
||||
variant="filled"
|
||||
onClose={() => setSessionSwitch(null)}
|
||||
sx={{ fontSize: '0.8rem' }}
|
||||
sx={{ fontSize: '0.8125rem' }}
|
||||
>
|
||||
{sessionSwitch && (sessionSwitch.toFreeTrial ? (
|
||||
<>Your model isn't connected, you're on the free trial now{sessionSwitch.runs != null ? <> ({sessionSwitch.runs} runs left)</> : null}.</>
|
||||
@@ -422,7 +430,7 @@ const CrashRecoveryChip: React.FC = () => {
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px solid', borderColor: 'divider',
|
||||
boxShadow: 3, borderRadius: '10px',
|
||||
px: 1.75, py: 1, fontSize: '0.85rem',
|
||||
px: 1.75, py: 1, fontSize: '0.875rem',
|
||||
maxWidth: 360,
|
||||
}}>
|
||||
<Box component="span" sx={{
|
||||
|
||||
@@ -97,7 +97,10 @@ export default function InlineEditableTitle({ value, onCommit, sx, placeholder,
|
||||
title="Click to rename"
|
||||
sx={{
|
||||
minWidth: 0, cursor: 'text', borderRadius: 0.5, px: 0.25, mx: -0.25,
|
||||
'&:hover': { bgcolor: c.bg.elevated },
|
||||
// Neutral translucent hover instead of a solid surface token: this title also rides dark
|
||||
// glass headers (the chat card) that live outside the theme scope, where bg.elevated resolved
|
||||
// to a light near-white pill. A gray alpha darkens on light and lightens on dark, reading right on both.
|
||||
'&:hover': { bgcolor: 'rgba(136,136,136,0.18)' },
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, startTransition, useMemo } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import React, { useState, useEffect, useCallback, startTransition, useMemo } from 'react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { openSettingsModal } from '@/shared/state/settingsSlice';
|
||||
import { getLastInteractedBrowser, getKeepAliveBrowserIds, setLastInteractedBrowser, clearLastInteractedBrowser } from '@/shared/browserFocus';
|
||||
import { getWebview } from '@/shared/browserRegistry';
|
||||
import { applyBrowserZoom } from '@/shared/browserZoom';
|
||||
import Box from '@mui/material/Box';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import { VoiceDictationProvider } from '@/shared/voice/VoiceDictationContext';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import Button from '@mui/material/Button';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
// One outlined icon language for the sidebar: thin monoline glyphs (not the filled Material clip-art) so the rail reads as designed, not assembled.
|
||||
import { LayoutDashboard } from 'lucide-react';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import { Settings as LucideSettings } from 'lucide-react';
|
||||
import { ArrowLeft, ArrowRight, Plus, Clock } from 'lucide-react';
|
||||
import { AnimatedPanelLeft } from './animatedIcons';
|
||||
import { Clock } from 'lucide-react';
|
||||
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
@@ -37,23 +28,19 @@ import { useLastDashboardId } from '@/shared/hooks/useLastDashboardId';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { hasModelConnected as selectHasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates';
|
||||
import { shallowEqual } from 'react-redux';
|
||||
import { fetchDashboards, createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice';
|
||||
import { Typewriter } from '@/app/components/feedback/Animated';
|
||||
import { fetchDashboards, createDashboard } from '@/shared/state/dashboardsSlice';
|
||||
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
|
||||
import { addBrowserCard, addBrowserTab, cycleBrowserTab, reopenLastClosed, addViewCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { addBrowserCard, addBrowserTab, cycleBrowserTab, reopenLastClosed, addViewCard, selectFullscreenCardId, setTiledCard, clearTiledCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice';
|
||||
import { fetchOutputs } from '@/shared/state/outputsSlice';
|
||||
import { setInstalling } from '@/shared/state/updateSlice';
|
||||
import { findBrowserByWebContentsId } from '@/shared/browserRegistry';
|
||||
import { byPreviewRecency } from '@/shared/previewOrder';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useClaudeTokens, useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext';
|
||||
import SpacesStrip from '@/app/pages/Dashboard/desktop/SpacesStrip';
|
||||
import { washBackgroundUrl, effectiveWashStops } from '@/shared/styles/washBackground';
|
||||
import { ErrorSlime } from '@/app/components/feedback/ErrorSlime';
|
||||
|
||||
const SIDEBAR_MIN = 160;
|
||||
const SIDEBAR_MAX = 400;
|
||||
// 260 matches Claude.ai's nav-sidebar width: roomy enough that names don't truncate.
|
||||
const SIDEBAR_DEFAULT = 260;
|
||||
const SIDEBAR_WIDTH_KEY = 'openswarm-sidebar-width';
|
||||
const UPDATE_DISMISS_KEY = 'openswarm-update-dismissed';
|
||||
|
||||
const AppShell: React.FC = () => {
|
||||
@@ -71,28 +58,9 @@ const AppShell: React.FC = () => {
|
||||
}, [navigateRaw]);
|
||||
const location = useLocation();
|
||||
// React Router (HashRouter) stores a monotonic index in history state. location re-renders on every nav, by which point window.history.state.idx is updated.
|
||||
const historyIdx = (window.history.state?.idx as number | undefined) ?? 0;
|
||||
const maxHistoryIdx = useRef(0);
|
||||
maxHistoryIdx.current = Math.max(maxHistoryIdx.current, historyIdx);
|
||||
const canGoBack = historyIdx > 0;
|
||||
const canGoForward = historyIdx < maxHistoryIdx.current;
|
||||
const [dashboardsExpanded, setDashboardsExpanded] = useState(true);
|
||||
const [appsExpanded, setAppsExpanded] = useState(true);
|
||||
// Starts collapsed so a fresh boot lands on a clean canvas; the toggle brings it back.
|
||||
// Desktop shell: the wallpaper canvas IS the home surface, so the sidebar starts docked away
|
||||
// (left-edge hover peeks it; the pin toggle brings it back full-time).
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
|
||||
const [renamingDashboardId, setRenamingDashboardId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(SIDEBAR_WIDTH_KEY);
|
||||
if (stored) {
|
||||
const w = Number(stored);
|
||||
if (w >= SIDEBAR_MIN && w <= SIDEBAR_MAX) return w;
|
||||
}
|
||||
} catch {}
|
||||
return SIDEBAR_DEFAULT;
|
||||
});
|
||||
const isResizing = useRef(false);
|
||||
|
||||
const updateStatus = useAppSelector((state) => state.update.status);
|
||||
const availableVersion = useAppSelector((state) => state.update.availableVersion);
|
||||
@@ -122,6 +90,13 @@ const AppShell: React.FC = () => {
|
||||
const modelsLoaded = useAppSelector((s) => s.models.loaded);
|
||||
// "Connected" = the user's OWN model (key/sub/pro/custom), NOT a non-empty /models list: the free-trial Haiku is always in that list now, so a byProvider-length check would falsely read as connected and hide the out-of-runs banner.
|
||||
const hasModelConnected = useAppSelector(selectHasModelConnected);
|
||||
// While onboarding owns the window, the floating sidebar (and its hover-peek strip) must not exist; both out-z the overlay.
|
||||
const v3FlowActive = useAppSelector((st) => st.onboardingV3.flowActive);
|
||||
// Arc/Zen fullscreen ground: ONE themed wash across the whole window (sidebar sits on it borderless,
|
||||
// the content floats as a rounded card). Mirrors the DashboardCanvas wash formula.
|
||||
const { accent: themeAccent, gradient: themeGradient } = useThemeAccent();
|
||||
const { washOpacity: themeWashOpacity } = useThemeWash();
|
||||
const fsWashStops = effectiveWashStops(themeGradient, themeAccent);
|
||||
// During an active free trial the user CAN run things, so a red "no model connected" warning is misleading and discouraging (it sits right above the working starter chips). The trial flips connection_mode back to own_key the moment it's spent, so this banner returns then, landing the connect-a-model nudge after the win, not before it.
|
||||
const freeTrialActive = useAppSelector((s) => {
|
||||
const d = s.settings.data as any;
|
||||
@@ -213,15 +188,6 @@ const AppShell: React.FC = () => {
|
||||
[dashboardItems],
|
||||
);
|
||||
|
||||
const outputItems = useAppSelector(
|
||||
(state) => state.outputs.items,
|
||||
shallowEqual,
|
||||
);
|
||||
const appsList = React.useMemo(
|
||||
() => Object.values(outputItems).sort(byPreviewRecency),
|
||||
[outputItems],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchDashboards());
|
||||
dispatch(fetchOutputs());
|
||||
@@ -375,10 +341,6 @@ const AppShell: React.FC = () => {
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(SIDEBAR_WIDTH_KEY, String(sidebarWidth)); } catch {}
|
||||
}, [sidebarWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail || {};
|
||||
@@ -393,205 +355,108 @@ const AppShell: React.FC = () => {
|
||||
return () => window.removeEventListener('openswarm:notification-click', handler as EventListener);
|
||||
}, [navigate, dispatch]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isResizing.current = true;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
if (!isResizing.current) return;
|
||||
setSidebarWidth(Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, ev.clientX)));
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
isResizing.current = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
}, []);
|
||||
|
||||
const handleResizeDoubleClick = useCallback(() => {
|
||||
setSidebarWidth(SIDEBAR_DEFAULT);
|
||||
}, []);
|
||||
|
||||
const isDashboardRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard/');
|
||||
const isDashboardViewActive = location.pathname.startsWith('/dashboard/');
|
||||
// macOS full screen: a fullscreen-tiled card owns the window, so every shell chrome piece hides. Gated on the dashboard view so navigating away restores the chrome even mid-fullscreen.
|
||||
const fullscreenCardId = useAppSelector(selectFullscreenCardId);
|
||||
// Zen compact mode: the sidebar is the only chrome now, so whenever it's "away" (user collapsed it,
|
||||
// OR a fullscreen card hides everything) a left-edge hover floats it back in as an overlay.
|
||||
const fsActive = !!fullscreenCardId && isDashboardViewActive;
|
||||
// Arc: the sidebar toggle PINS the sidebar open inside fullscreen (docked, card shrinks beside it);
|
||||
// unpinned fullscreen keeps the hover-peek overlay.
|
||||
const [fsSidebarPinned, setFsSidebarPinned] = useState(false);
|
||||
const sidebarAway = (sidebarCollapsed || (fsActive && !fsSidebarPinned)) && isDashboardViewActive;
|
||||
// When the sidebar docks away, the canvas runs flush to the window's left edge, so the floating
|
||||
// dashboard header would sit right under the macOS traffic lights. Publish an inset the header reads
|
||||
// (only on macOS, where the lights exist) so it clears them; the sidebar carries its own clearance.
|
||||
useEffect(() => {
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform);
|
||||
const root = document.documentElement;
|
||||
if (sidebarAway && isMac) root.style.setProperty('--osw-header-inset', '80px');
|
||||
else root.style.removeProperty('--osw-header-inset');
|
||||
return () => { root.style.removeProperty('--osw-header-inset'); };
|
||||
}, [sidebarAway]);
|
||||
// Global text-size ratio (Settings > Interface). Scaling the root font-size scales every rem-based
|
||||
// size in one shot, so type grows or shrinks together with no layout breakage. Clamped to a sane band
|
||||
// so a corrupt value can never wreck the whole UI.
|
||||
const uiFontScale = useAppSelector((s) => s.settings.data.ui_font_scale ?? 1);
|
||||
useEffect(() => {
|
||||
const clamped = Math.min(1.4, Math.max(0.8, uiFontScale || 1));
|
||||
document.documentElement.style.fontSize = `${Math.round(clamped * 100)}%`;
|
||||
}, [uiFontScale]);
|
||||
// When the sidebar is docked, the macOS traffic lights sit over its top strip, which is a window
|
||||
// drag region that swallows mousemove, so the canvas hover-reveal can never fire there. Broadcast
|
||||
// "chrome docked" so the canvas keeps the native lights visible while the sidebar is open (they only
|
||||
// hide-until-hover in the immersive collapsed/fullscreen state). detail.docked = sidebar is present.
|
||||
useEffect(() => {
|
||||
window.dispatchEvent(new CustomEvent('openswarm:chrome-docked', { detail: { docked: !sidebarAway } }));
|
||||
}, [sidebarAway]);
|
||||
// Fullscreen still hides the top-center island anchor + banners; the sidebar floats in on peek.
|
||||
const fsHideChrome = fsActive;
|
||||
const isAppsRoute = false; // /apps route removed; app cards live on the dashboard now.
|
||||
const activeDashboardId = location.pathname.startsWith('/dashboard/')
|
||||
? location.pathname.split('/dashboard/')[1]
|
||||
: null;
|
||||
|
||||
// Flip to the previous/next dashboard, clamped at the ends (no surprise wrap). Shared by the sidebar
|
||||
// swipe and the Cmd/Ctrl+Alt+arrow keyboard path.
|
||||
const switchDashboard = useCallback((dir: -1 | 1) => {
|
||||
if (dashboardList.length < 2) return;
|
||||
const idx = dashboardList.findIndex((d) => d.id === activeDashboardId);
|
||||
if (idx < 0) return;
|
||||
const next = Math.min(dashboardList.length - 1, Math.max(0, idx + dir));
|
||||
if (next !== idx) navigate(`/dashboard/${dashboardList[next].id}`);
|
||||
}, [dashboardList, activeDashboardId, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!(e.metaKey || e.ctrlKey) || !e.altKey || e.shiftKey) return;
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); switchDashboard(-1); }
|
||||
else if (e.key === 'ArrowRight') { e.preventDefault(); switchDashboard(1); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [switchDashboard]);
|
||||
|
||||
const [lastDashboardId, setLastDashboardId] = useLastDashboardId();
|
||||
// Apps no longer have a full-page editor; clicking one in the sidebar drops (or focuses) its live card on the current dashboard. Fold-in of the old App Builder.
|
||||
// Apps no longer have a full-page editor; clicking one in the sidebar drops (or focuses) its live card on the current dashboard. Fold-in of the old App Builder. While a card is fullscreen the click SWAPS the pinned card to this app (Arc: the sidebar switches what fills the screen), otherwise the new card would land invisibly behind it.
|
||||
const navigateToApp = useCallback((id: string) => {
|
||||
dispatch(addViewCard({ outputId: id }));
|
||||
if (fullscreenCardId) {
|
||||
if (fullscreenCardId !== id) dispatch(clearTiledCard(fullscreenCardId));
|
||||
dispatch(setTiledCard({ cardId: id, zone: 'fullscreen' }));
|
||||
return;
|
||||
}
|
||||
if (lastDashboardId && location.pathname !== `/dashboard/${lastDashboardId}`) {
|
||||
navigate(`/dashboard/${lastDashboardId}`);
|
||||
}
|
||||
}, [dispatch, navigate, lastDashboardId, location.pathname]);
|
||||
// With the /apps route gone, an app row is "active" when its card is open on the dashboard, not from the URL.
|
||||
const openViewCardOutputIds = useAppSelector((s) =>
|
||||
new Set(Object.values(s.dashboardLayout.viewCards).map((vc) => vc.output_id)),
|
||||
);
|
||||
}, [dispatch, navigate, lastDashboardId, location.pathname, fullscreenCardId]);
|
||||
|
||||
const handleDashboardsClick = () => {
|
||||
if (isDashboardRoute && location.pathname === '/') {
|
||||
setDashboardsExpanded((prev) => !prev);
|
||||
} else {
|
||||
navigate('/');
|
||||
setDashboardsExpanded(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDashboardItemClick = (dashboardId: string) => {
|
||||
if (renamingDashboardId === dashboardId) return;
|
||||
navigate(`/dashboard/${dashboardId}`);
|
||||
};
|
||||
|
||||
const handleStartDashboardRename = (id: string, currentName: string) => {
|
||||
setRenamingDashboardId(id);
|
||||
setRenameValue(currentName);
|
||||
};
|
||||
|
||||
const handleDashboardRenameSubmit = (id: string) => {
|
||||
const trimmed = renameValue.trim();
|
||||
const previousName = dashboardItems[id]?.name;
|
||||
if (trimmed && trimmed !== previousName) {
|
||||
dispatch(renameDashboard({ id, name: trimmed, previousName }));
|
||||
}
|
||||
setRenamingDashboardId(null);
|
||||
};
|
||||
|
||||
const handleCreateDashboard = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const result = await dispatch(createDashboard('Untitled Dashboard'));
|
||||
if (createDashboard.fulfilled.match(result)) {
|
||||
navigate(`/dashboard/${result.payload.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAppsClick = () => {
|
||||
setAppsExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: c.bg.secondary }}>
|
||||
<Box sx={{
|
||||
display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: c.bg.secondary,
|
||||
...(fsWashStops ? { backgroundImage: washBackgroundUrl(fsWashStops, themeWashOpacity), backgroundSize: '100% 100%' } : {}),
|
||||
}}>
|
||||
{/* Sidebar retired: dashboards switch via the macOS-Spaces top strip; a slim band below the
|
||||
spaces hot zone keeps the frameless window draggable (the sidebar's drag strip is gone). */}
|
||||
{isDashboardViewActive && !v3FlowActive && <SpacesStrip />}
|
||||
<Box sx={{ position: 'fixed', top: 3, left: 260, right: 0, height: 22, zIndex: 5, WebkitAppRegion: 'drag' }} />
|
||||
{/* Top bar dropped (Arc/Zen): a zero-height anchor left only to float the agent-activity island at top-center; the island renders nothing when idle. */}
|
||||
<Box
|
||||
sx={{
|
||||
height: 38,
|
||||
height: 0,
|
||||
flexShrink: 0,
|
||||
bgcolor: 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
WebkitAppRegion: 'drag',
|
||||
userSelect: 'none',
|
||||
pl: '78px',
|
||||
gap: 0.25,
|
||||
zIndex: 10,
|
||||
display: fsHideChrome ? 'none' : 'block',
|
||||
}}
|
||||
>
|
||||
<Tooltip title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setSidebarCollapsed((prev) => !prev)}
|
||||
// Onboarding runtime reads aria-expanded to detect a collapsed sidebar.
|
||||
data-onboarding="sidebar-toggle"
|
||||
aria-expanded={!sidebarCollapsed}
|
||||
sx={{
|
||||
WebkitAppRegion: 'no-drag',
|
||||
color: c.text.tertiary,
|
||||
p: 0.5,
|
||||
borderRadius: 1,
|
||||
'&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` },
|
||||
}}
|
||||
>
|
||||
<AnimatedPanelLeft size={18} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Back">
|
||||
{/* span wrapper so a disabled button still shows its Tooltip; lucide
|
||||
glyph + hover-slide kept from the redesign, disabled-state from #68. */}
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => navigate(-1)}
|
||||
disabled={!canGoBack}
|
||||
sx={{
|
||||
WebkitAppRegion: 'no-drag',
|
||||
color: c.text.tertiary,
|
||||
p: 0.5,
|
||||
borderRadius: 1,
|
||||
'& svg': { transition: 'transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)' },
|
||||
'&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` },
|
||||
'&:hover svg': { transform: 'translateX(-2px)' },
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Forward">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => navigate(1)}
|
||||
disabled={!canGoForward}
|
||||
sx={{
|
||||
WebkitAppRegion: 'no-drag',
|
||||
color: c.text.tertiary,
|
||||
p: 0.5,
|
||||
borderRadius: 1,
|
||||
'& svg': { transition: 'transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)' },
|
||||
'&:hover': { color: c.text.secondary, bgcolor: `${c.text.tertiary}14` },
|
||||
'&:hover svg': { transform: 'translateX(2px)' },
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={18} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<DynamicIsland />
|
||||
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
pr: 1.5,
|
||||
WebkitAppRegion: 'no-drag',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src="./logo.png"
|
||||
alt="OpenSwarm"
|
||||
sx={{ width: 20, height: 20, borderRadius: 0.5, opacity: 0.85 }}
|
||||
/>
|
||||
<Typography
|
||||
sx={{
|
||||
color: c.text.secondary,
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 600,
|
||||
letterSpacing: 0.2,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
OpenSwarm
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Collapse in={showWarningBanner} timeout={350} unmountOnExit>
|
||||
<Collapse in={showWarningBanner && !fsHideChrome} timeout={350} unmountOnExit>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -610,7 +475,7 @@ const AppShell: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<ErrorSlime size={22} />
|
||||
<Typography sx={{ fontSize: '0.86rem', color: '#ef4444', flex: 1, fontWeight: 500, letterSpacing: '0.01em' }}>
|
||||
<Typography sx={{ fontSize: '0.875rem', color: '#ef4444', flex: 1, fontWeight: 500, letterSpacing: '0.01em' }}>
|
||||
{!isOnline
|
||||
? 'No internet connection; agents cannot reach AI models or external services'
|
||||
: (
|
||||
@@ -636,9 +501,9 @@ const AppShell: React.FC = () => {
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
<Collapse in={showFreeTrialNudge} timeout={300} unmountOnExit>
|
||||
<Collapse in={showFreeTrialNudge && !fsHideChrome} timeout={300} unmountOnExit>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 2, py: 0.5, flexShrink: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, flex: 1, letterSpacing: '0.01em' }}>
|
||||
<Typography sx={{ fontSize: '0.8125rem', color: c.text.secondary, flex: 1, letterSpacing: '0.01em' }}>
|
||||
{freeTrialSpent
|
||||
? (refillLabel ? `Out of free runs, fresh ones in ${refillLabel}. ` : "You're out of free runs for now. ")
|
||||
: "Nice, you're rolling. "}
|
||||
@@ -656,7 +521,7 @@ const AppShell: React.FC = () => {
|
||||
role="button"
|
||||
aria-label="Dismiss"
|
||||
onClick={() => { try { localStorage.setItem('os_ft_nudge_dismissed', '1'); } catch {} setFtNudgeDismissed(true); }}
|
||||
sx={{ color: c.text.muted, cursor: 'pointer', fontSize: '0.95rem', lineHeight: 1, px: 0.5, '&:hover': { color: c.text.secondary } }}
|
||||
sx={{ color: c.text.muted, cursor: 'pointer', fontSize: '1rem', lineHeight: 1, px: 0.5, '&:hover': { color: c.text.secondary } }}
|
||||
>
|
||||
×
|
||||
</Box>
|
||||
@@ -664,7 +529,7 @@ const AppShell: React.FC = () => {
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
<Collapse in={showUsageNudge} timeout={300} unmountOnExit>
|
||||
<Collapse in={showUsageNudge && !fsHideChrome} timeout={300} unmountOnExit>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 2, py: 0.5, flexShrink: 0 }}>
|
||||
{/* the bar is the message: how full your Pro window is. calm accent, never red. */}
|
||||
<Box sx={{ width: 132, height: 5, borderRadius: 3, bgcolor: c.border.medium, overflow: 'hidden', flexShrink: 0 }}>
|
||||
@@ -673,14 +538,14 @@ const AppShell: React.FC = () => {
|
||||
{usageResetLabel && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, color: c.text.secondary }}>
|
||||
<Clock size={12} style={{ flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: '0.8rem', letterSpacing: '0.01em' }}>{usageResetLabel}</Typography>
|
||||
<Typography sx={{ fontSize: '0.8125rem', letterSpacing: '0.01em' }}>{usageResetLabel}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{proMaxed && (
|
||||
<Box
|
||||
component="span"
|
||||
onClick={() => dispatch(openSettingsModal('models'))}
|
||||
sx={{ color: c.accent.primary, cursor: 'pointer', fontSize: '0.8rem', '&:hover': { textDecoration: 'underline' } }}
|
||||
sx={{ color: c.accent.primary, cursor: 'pointer', fontSize: '0.8125rem', '&:hover': { textDecoration: 'underline' } }}
|
||||
>
|
||||
Upgrade
|
||||
</Box>
|
||||
@@ -688,7 +553,7 @@ const AppShell: React.FC = () => {
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
{showUpdateBanner && (
|
||||
{showUpdateBanner && !fsHideChrome && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -702,7 +567,7 @@ const AppShell: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<SystemUpdateAltIcon sx={{ fontSize: 16, color: c.accent.primary, flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: '0.8rem', color: c.text.secondary, flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
<Typography sx={{ fontSize: '0.8125rem', color: c.text.secondary, flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{updateStatus === 'available' && `OpenSwarm${verSuffix} is available`}
|
||||
{updateStatus === 'downloading' && `Downloading OpenSwarm${verSuffix}…`}
|
||||
{updateStatus === 'downloaded' && `OpenSwarm${verSuffix} is ready to install`}
|
||||
@@ -722,7 +587,7 @@ const AppShell: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
{updateStatus === 'downloading' && (
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.tertiary, flexShrink: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.75rem', color: c.text.tertiary, flexShrink: 0 }}>
|
||||
{Math.round(downloadPercent)}%
|
||||
</Typography>
|
||||
)}
|
||||
@@ -784,408 +649,42 @@ const AppShell: React.FC = () => {
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||
{!sidebarCollapsed && (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
width: sidebarWidth,
|
||||
flexShrink: 0,
|
||||
bgcolor: c.bg.secondary,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Box sx={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
pt: 0.5,
|
||||
'&::-webkit-scrollbar': { width: 0 },
|
||||
// Tactile hover: the leading section icon springs once on row-hover, then settles. Interaction-only, never ambient. Scoped to ListItemIcon so the +/chevron stay put.
|
||||
'& .MuiListItemIcon-root svg': {
|
||||
transition: 'transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
},
|
||||
// Per-glyph hover choreography: each section icon reacts in its own way, springy then settles. Interaction-only, never ambient.
|
||||
'& [data-onboarding="sidebar-dashboards"]:hover .MuiListItemIcon-root svg': {
|
||||
transform: 'scale(1.14)',
|
||||
},
|
||||
'& [data-onboarding="sidebar-apps"]:hover .MuiListItemIcon-root svg': {
|
||||
transform: 'rotate(8deg) scale(1.08)',
|
||||
},
|
||||
}}>
|
||||
<Box sx={{ px: 1, mb: 0.25 }}>
|
||||
<ListItemButton
|
||||
onClick={handleDashboardsClick}
|
||||
data-onboarding="sidebar-dashboards"
|
||||
// Onboarding reads expanded so it skips the click step (re-click would collapse).
|
||||
data-expanded={dashboardsExpanded ? 'true' : 'false'}
|
||||
aria-expanded={dashboardsExpanded}
|
||||
sx={{
|
||||
borderRadius: 1.5,
|
||||
py: 0.6,
|
||||
px: 1.25,
|
||||
bgcolor: isDashboardRoute ? `${c.accent.primary}12` : 'transparent',
|
||||
'&:hover': { bgcolor: isDashboardRoute ? `${c.accent.primary}18` : `${c.text.tertiary}0A` },
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ color: isDashboardRoute ? c.accent.primary : c.text.tertiary, minWidth: 28 }}>
|
||||
<LayoutDashboard size={18} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Dashboards"
|
||||
sx={{
|
||||
'& .MuiListItemText-primary': {
|
||||
color: isDashboardRoute ? c.text.primary : c.text.muted,
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: isDashboardRoute ? 600 : 400,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tooltip title="New dashboard" placement="right">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleCreateDashboard}
|
||||
sx={{
|
||||
color: c.text.ghost,
|
||||
p: 0.25,
|
||||
mr: 0.25,
|
||||
borderRadius: 1,
|
||||
'&:hover': { color: c.accent.primary, bgcolor: `${c.accent.primary}14` },
|
||||
}}
|
||||
>
|
||||
<Plus size={15} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{dashboardList.length > 0 && (
|
||||
<ExpandMoreIcon
|
||||
sx={{
|
||||
color: c.text.ghost,
|
||||
fontSize: 16,
|
||||
transition: 'transform 0.2s',
|
||||
transform: dashboardsExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ListItemButton>
|
||||
|
||||
<Collapse in={dashboardsExpanded && dashboardList.length > 0} timeout={200}>
|
||||
<Box
|
||||
sx={{
|
||||
ml: 2,
|
||||
mt: 0.25,
|
||||
mb: 0.5,
|
||||
maxHeight: 240,
|
||||
overflow: 'auto',
|
||||
'&::-webkit-scrollbar': { width: 3 },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 4 },
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${c.border.medium} transparent`,
|
||||
}}
|
||||
>
|
||||
{dashboardList.map((entry, idx) => {
|
||||
const isActive = activeDashboardId === entry.id;
|
||||
const isRenaming = renamingDashboardId === entry.id;
|
||||
return (
|
||||
<Box
|
||||
key={entry.id}
|
||||
// First row gets generic "first" alias so onboarding can teach "click into a dashboard" without a specific id.
|
||||
data-onboarding={
|
||||
idx === 0 ? 'dashboard-row-first' : `dashboard-row-${entry.id}`
|
||||
}
|
||||
onClick={() => handleDashboardItemClick(entry.id)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
pl: 1.25,
|
||||
pr: 1,
|
||||
py: isRenaming ? 0.25 : 0.5,
|
||||
mx: 0.5,
|
||||
cursor: isRenaming ? 'default' : 'pointer',
|
||||
// Finder-style selection: the rounded fill is the one active cue, no rail marker.
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: isActive ? `${c.accent.primary}40` : 'transparent',
|
||||
'&:hover': { bgcolor: isActive ? `${c.accent.primary}55` : `${c.text.tertiary}0A` },
|
||||
transition: 'background-color 0.12s',
|
||||
}}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<InputBase
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={() => handleDashboardRenameSubmit(entry.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleDashboardRenameSubmit(entry.id);
|
||||
if (e.key === 'Escape') setRenamingDashboardId(null);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onFocus={(e) => e.target.select()}
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: '0.86rem',
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
color: isActive ? c.text.secondary : c.text.ghost,
|
||||
py: 0,
|
||||
px: 0.5,
|
||||
borderRadius: 0.75,
|
||||
border: `1px solid ${c.accent.primary}80`,
|
||||
bgcolor: `${c.bg.page}`,
|
||||
'& input': {
|
||||
padding: '1px 0',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStartDashboardRename(entry.id, entry.name);
|
||||
}}
|
||||
sx={{
|
||||
color: isActive ? c.text.secondary : c.text.ghost,
|
||||
fontSize: '0.86rem',
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{entry.name}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* Sections separate with air, not lines. */}
|
||||
<Box sx={{ my: 0.75 }} />
|
||||
|
||||
<Box sx={{ px: 1, mb: 0.25 }}>
|
||||
<ListItemButton
|
||||
onClick={handleAppsClick}
|
||||
onMouseEnter={() => {
|
||||
const fn = (window as any).__openswarmPrefetchRoute;
|
||||
if (typeof fn === 'function') fn('/apps');
|
||||
}}
|
||||
data-onboarding="sidebar-apps"
|
||||
sx={{
|
||||
borderRadius: 1.5,
|
||||
py: 0.6,
|
||||
px: 1.25,
|
||||
bgcolor: isAppsRoute ? `${c.accent.primary}12` : 'transparent',
|
||||
'&:hover': { bgcolor: isAppsRoute ? `${c.accent.primary}18` : `${c.text.tertiary}0A` },
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ color: isAppsRoute ? c.accent.primary : c.text.tertiary, minWidth: 28 }}>
|
||||
<LayoutGrid size={18} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Apps"
|
||||
sx={{
|
||||
'& .MuiListItemText-primary': {
|
||||
color: isAppsRoute ? c.text.primary : c.text.muted,
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: isAppsRoute ? 600 : 400,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{appsList.length > 0 && (
|
||||
<ExpandMoreIcon
|
||||
sx={{
|
||||
color: c.text.ghost,
|
||||
fontSize: 16,
|
||||
transition: 'transform 0.2s',
|
||||
transform: appsExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ListItemButton>
|
||||
|
||||
<Collapse in={appsExpanded && appsList.length > 0} timeout={200}>
|
||||
<Box
|
||||
sx={{
|
||||
ml: 2,
|
||||
mt: 0.25,
|
||||
mb: 0.5,
|
||||
maxHeight: 240,
|
||||
overflow: 'auto',
|
||||
'&::-webkit-scrollbar': { width: 3 },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 4 },
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${c.border.medium} transparent`,
|
||||
}}
|
||||
>
|
||||
{appsList.map((app) => {
|
||||
const isActive = openViewCardOutputIds.has(app.id);
|
||||
return (
|
||||
<Box
|
||||
key={app.id}
|
||||
onClick={() => navigateToApp(app.id)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
pl: 1.25,
|
||||
pr: 1,
|
||||
py: 0.5,
|
||||
mx: 0.5,
|
||||
cursor: 'pointer',
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
bgcolor: isActive ? `${c.accent.primary}40` : 'transparent',
|
||||
'&:hover': { bgcolor: isActive ? `${c.accent.primary}55` : `${c.text.tertiary}0A` },
|
||||
transition: 'background-color 0.12s',
|
||||
}}
|
||||
>
|
||||
<Typewriter value={app.name || 'Untitled App'} enabled={!!app.name && app.name !== 'Untitled App'}>
|
||||
{(t) => (
|
||||
<Typography
|
||||
sx={{
|
||||
color: isActive ? c.text.secondary : c.text.ghost,
|
||||
fontSize: '0.86rem',
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</Typography>
|
||||
)}
|
||||
</Typewriter>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 1.25,
|
||||
}}
|
||||
>
|
||||
<ListItemButton
|
||||
onClick={() => dispatch(openSettingsModal())}
|
||||
data-onboarding="sidebar-settings-button"
|
||||
sx={{
|
||||
borderRadius: 1.5,
|
||||
py: 0.6,
|
||||
px: 1.25,
|
||||
'& .MuiListItemIcon-root svg': { transition: 'transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)' },
|
||||
'&:hover': { bgcolor: `${c.text.tertiary}0A` },
|
||||
'&:hover .MuiListItemIcon-root svg': { transform: 'rotate(90deg)' },
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ color: c.text.tertiary, minWidth: 28, position: 'relative' }}>
|
||||
<LucideSettings size={18} />
|
||||
{showUpdateDot && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: 10,
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: '50%',
|
||||
bgcolor: c.accent.primary,
|
||||
border: `1.5px solid ${c.bg.secondary}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Settings"
|
||||
sx={{
|
||||
'& .MuiListItemText-primary': {
|
||||
color: c.text.muted,
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 400,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
onMouseDown={handleResizeStart}
|
||||
onDoubleClick={handleResizeDoubleClick}
|
||||
sx={{
|
||||
// 6px hit-target at -3px margin overlaps the seam so the drag region doesn't read as a visible empty strip.
|
||||
width: 6,
|
||||
marginLeft: '-3px',
|
||||
marginRight: '-3px',
|
||||
flexShrink: 0,
|
||||
cursor: 'col-resize',
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 2,
|
||||
bgcolor: 'transparent',
|
||||
transition: 'background-color 0.2s',
|
||||
},
|
||||
'&:hover::after': {
|
||||
bgcolor: c.border.strong,
|
||||
},
|
||||
'&:active::after': {
|
||||
bgcolor: `${c.accent.primary}40`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Sidebar excised: dashboards live in the Spaces strip (hover the top edge; right-click a tile for rename/duplicate/delete). */}
|
||||
|
||||
<Box sx={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
bgcolor: c.bg.page,
|
||||
position: 'relative',
|
||||
// Float the content as a rounded inset panel ("column pill"): the chrome (bg.secondary) frames it, so there are no divider lines, just air + radius.
|
||||
mt: '6px',
|
||||
mr: '6px',
|
||||
mb: '6px',
|
||||
ml: '6px',
|
||||
borderRadius: '14px',
|
||||
// Float the content as a rounded inset panel ("column pill"): the chrome (bg.secondary) frames it, so there are no divider lines, just air + radius. Fullscreen drops the frame entirely.
|
||||
mt: fsHideChrome ? 0 : '6px',
|
||||
mr: fsHideChrome ? 0 : '6px',
|
||||
mb: fsHideChrome ? 0 : '6px',
|
||||
ml: fsHideChrome ? 0 : '6px',
|
||||
borderRadius: fsHideChrome ? 0 : '14px',
|
||||
}}>
|
||||
{/* Hidden (not unmounted) when the dashboard view is active so the persistent Dashboard layered above can take over. */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
visibility: isDashboardViewActive ? 'hidden' : 'visible',
|
||||
pointerEvents: isDashboardViewActive ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Box>
|
||||
{/* One voice controller wraps BOTH the routed content and the persistent Dashboard host, so
|
||||
the spawn-pill mic (which lives in the persistent host, not the Outlet) shares the recorder. */}
|
||||
<VoiceDictationProvider>
|
||||
{/* Hidden (not unmounted) when the dashboard view is active so the persistent Dashboard layered above can take over. */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
visibility: isDashboardViewActive ? 'hidden' : 'visible',
|
||||
pointerEvents: isDashboardViewActive ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Box>
|
||||
|
||||
{/* CSS-hidden on other routes so webviews + state survive nav. */}
|
||||
{lastDashboardId && (
|
||||
<DashboardHost visible={isDashboardViewActive}>
|
||||
<Dashboard dashboardId={lastDashboardId} isActive={isDashboardViewActive} />
|
||||
</DashboardHost>
|
||||
)}
|
||||
{/* CSS-hidden on other routes so webviews + state survive nav. */}
|
||||
{lastDashboardId && (
|
||||
<DashboardHost visible={isDashboardViewActive}>
|
||||
<Dashboard dashboardId={lastDashboardId} isActive={isDashboardViewActive} />
|
||||
</DashboardHost>
|
||||
)}
|
||||
</VoiceDictationProvider>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1210,7 +709,7 @@ const AppShell: React.FC = () => {
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setSnackbarDismissed(true)}
|
||||
sx={{ color: c.text.muted, textTransform: 'none', fontSize: '0.8rem', minWidth: 'auto' }}
|
||||
sx={{ color: c.text.muted, textTransform: 'none', fontSize: '0.8125rem', minWidth: 'auto' }}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
@@ -1223,7 +722,7 @@ const AppShell: React.FC = () => {
|
||||
bgcolor: c.accent.primary,
|
||||
'&:hover': { bgcolor: c.accent.pressed },
|
||||
textTransform: 'none',
|
||||
fontSize: '0.8rem',
|
||||
fontSize: '0.8125rem',
|
||||
borderRadius: 1.5,
|
||||
minWidth: 'auto',
|
||||
}}
|
||||
@@ -1243,7 +742,7 @@ const AppShell: React.FC = () => {
|
||||
'&:hover': { bgcolor: c.accent.pressed },
|
||||
'&.Mui-disabled': { bgcolor: c.accent.primary, color: '#fff', opacity: 0.7 },
|
||||
textTransform: 'none',
|
||||
fontSize: '0.8rem',
|
||||
fontSize: '0.8125rem',
|
||||
borderRadius: 1.5,
|
||||
minWidth: 'auto',
|
||||
}}
|
||||
@@ -1265,6 +764,8 @@ const AppShell: React.FC = () => {
|
||||
{updateStatus === 'downloaded' && `OpenSwarm${verSuffix} downloaded; restart to update`}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -65,8 +65,8 @@ const OnboardingRoot: React.FC = () => {
|
||||
!(s.onboardingProgress.completedSteps ?? []).includes('launch_agent') &&
|
||||
Object.keys(s.agents?.sessions ?? {}).length === 0,
|
||||
);
|
||||
// Onboarding v3 owns the window on a fresh install; the v2 cursor tour stays dormant until the flow resolves.
|
||||
const v3Owns = useAppSelector((s) => s.onboardingV3.flowActive);
|
||||
// Onboarding v3 owns the window on a fresh install; the v2 cursor tour + panel stay dormant while the flow runs AND forever after it resolves (a v3'd user has already onboarded, so the v2 "connect your model / Show me" tour on top of the v3 reveal is contradictory clutter).
|
||||
const v3Owns = useAppSelector((s) => s.onboardingV3.flowActive || !!s.settings.data.onboarding_v3);
|
||||
const welcomeFiredRef = useRef(false);
|
||||
const welcomeTimerRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -89,7 +89,7 @@ const ACMultiChoice: React.FC<Props> = ({
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.84rem',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 600,
|
||||
color: c.text.primary,
|
||||
lineHeight: 1.4,
|
||||
@@ -118,7 +118,7 @@ const ACMultiChoice: React.FC<Props> = ({
|
||||
borderRadius: '10px',
|
||||
px: 1.1,
|
||||
py: 0.7,
|
||||
fontSize: '0.78rem',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
fontFamily: c.font.sans,
|
||||
transition: 'all 0.12s',
|
||||
|
||||
@@ -164,7 +164,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
|
||||
<Typography
|
||||
sx={{
|
||||
// 0.85rem with bold weight reads cleanly without dominating.
|
||||
fontSize: '0.85rem',
|
||||
fontSize: '0.875rem',
|
||||
color: c.text.primary,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Check } from 'lucide-react';
|
||||
import { INTEGRATIONS } from '@/app/pages/Tools/integrations';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import BeatShell from './BeatShell';
|
||||
|
||||
// Order matters: the first row should read as "your work lives here" for the widest audience.
|
||||
const PICKER_IDS = ['google-workspace', 'notion', 'slack', 'github', 'discord', 'microsoft-365', 'reddit', 'youtube', 'x', 'airtable', 'hubspot', 'tiktok'];
|
||||
|
||||
// Picks do double duty: they brief the prep call on what this person's work looks like, and they seed which integrations we suggest connecting later. Nothing installs here; the MCP gate stays untouched.
|
||||
const BeatApps: React.FC<{
|
||||
c: ClaudeTokens;
|
||||
picks: string[];
|
||||
setPicks: (ids: string[]) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}> = ({ c, picks, setPicks, onNext, onBack }) => {
|
||||
const entries = PICKER_IDS
|
||||
.map((id) => INTEGRATIONS.find((i) => i.id === id))
|
||||
.filter((i): i is NonNullable<typeof i> => !!i);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setPicks(picks.includes(id) ? picks.filter((p) => p !== id) : [...picks, id]);
|
||||
};
|
||||
|
||||
return (
|
||||
<BeatShell
|
||||
c={c}
|
||||
title="Choose the apps you live in."
|
||||
body="I'll shape your starting canvas around them, and I can connect to them later so your agents work where you already do."
|
||||
nextLabel={picks.length > 0 ? 'Continue' : 'Skip for now'}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
>
|
||||
<div style={{ width: 'min(520px, 100%)', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(112px, 1fr))', gap: 12 }}>
|
||||
{entries.map((entry, i) => {
|
||||
const picked = picks.includes(entry.id);
|
||||
return (
|
||||
<motion.button
|
||||
key={entry.id}
|
||||
onClick={() => toggle(entry.id)}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 360, damping: 24, delay: 0.06 + i * 0.04 }}
|
||||
style={{
|
||||
position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10,
|
||||
padding: '18px 10px 14px', borderRadius: c.radius.md,
|
||||
border: `1.5px solid ${picked ? c.accent.primary : c.border.medium}`,
|
||||
background: c.bg.surface, cursor: 'pointer', fontFamily: 'inherit',
|
||||
boxShadow: picked ? `0 0 0 3px ${c.accent.primary}22` : c.shadow.sm,
|
||||
transition: 'border-color 140ms ease, box-shadow 140ms ease',
|
||||
}}
|
||||
>
|
||||
{picked && (
|
||||
<span style={{ position: 'absolute', top: 7, right: 7, width: 18, height: 18, borderRadius: 999, background: c.accent.primary, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Check size={12} color="#fff" />
|
||||
</span>
|
||||
)}
|
||||
<span style={{ width: 34, height: 34, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{entry.icon}</span>
|
||||
<span style={{ fontSize: '0.8rem', fontWeight: 500, color: c.text.secondary, textAlign: 'center' }}>{entry.name}</span>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</BeatShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeatApps;
|
||||
@@ -1,170 +0,0 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Check } from 'lucide-react';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import { fetchModels } from '@/shared/state/modelsSlice';
|
||||
import { fetchSubscriptionStatus, markSubscriptionConnected } from '@/shared/state/subscriptionsSlice';
|
||||
import { updateSettingsPatch } from '@/shared/state/settingsSlice';
|
||||
import { hasFreeTrialActive, hasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates';
|
||||
import { SUBSCRIPTION_PROVIDERS } from '@/app/pages/Settings/sections/subscription/subscriptionProviders';
|
||||
import { runConnectFlow } from '@/app/pages/Settings/sections/subscription/subscriptionConnect';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import type { ProviderIdentity } from './onboardingV3Api';
|
||||
import BeatShell from './BeatShell';
|
||||
|
||||
// The single ask of the whole flow. Reuses the proven Settings connect flow verbatim; the scan disclosure lives here as one honest line with an opt-out, and the scan runs during the OAuth wait.
|
||||
const BeatConnect: React.FC<{
|
||||
c: ClaudeTokens;
|
||||
identity: ProviderIdentity[];
|
||||
scanConsent: boolean;
|
||||
setScanConsent: (v: boolean) => void;
|
||||
onConnected: () => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}> = ({ c, identity, scanConsent, setScanConsent, onConnected, onNext, onBack }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const connected = useAppSelector((s) => hasModelConnected(s));
|
||||
const freeTrial = useAppSelector((s) => hasFreeTrialActive(s));
|
||||
const [connecting, setConnecting] = useState<string | null>(null);
|
||||
const [userCode, setUserCode] = useState('');
|
||||
const [showKeys, setShowKeys] = useState(false);
|
||||
const [keyDraft, setKeyDraft] = useState('');
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const connectedOnce = useRef(false);
|
||||
|
||||
useEffect(() => () => { if (pollTimerRef.current) clearInterval(pollTimerRef.current); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (connected && !connectedOnce.current) {
|
||||
connectedOnce.current = true;
|
||||
onConnected();
|
||||
}
|
||||
}, [connected, onConnected]);
|
||||
|
||||
const handleConnect = useCallback(async (providerId: string) => {
|
||||
setConnecting(providerId);
|
||||
setUserCode('');
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/agents/subscriptions/connect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: providerId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(String(data?.detail ?? 'connect failed'));
|
||||
runConnectFlow({
|
||||
providerId,
|
||||
data,
|
||||
setConnecting,
|
||||
setUserCode,
|
||||
setPollTimer: (t) => { pollTimerRef.current = t; },
|
||||
fetchStatus: (opts) => dispatch(fetchSubscriptionStatus(opts)).unwrap(),
|
||||
refreshPickerModels: () => { dispatch(fetchModels()); },
|
||||
markConnected: (provider) => { dispatch(markSubscriptionConnected({ provider })); },
|
||||
});
|
||||
} catch {
|
||||
setConnecting(null);
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
const saveKey = useCallback(() => {
|
||||
const v = keyDraft.trim();
|
||||
if (!v) return;
|
||||
const field = v.startsWith('sk-ant-') ? 'anthropic_api_key' : v.startsWith('sk-or-') ? 'openrouter_api_key' : v.startsWith('AIza') ? 'google_api_key' : 'openai_api_key';
|
||||
dispatch(updateSettingsPatch({ [field]: v }));
|
||||
setKeyDraft('');
|
||||
}, [keyDraft, dispatch]);
|
||||
|
||||
const connectedIdentity = identity.length > 0 ? identity[0] : null;
|
||||
|
||||
return (
|
||||
<BeatShell
|
||||
c={c}
|
||||
title="Connect your AI."
|
||||
body="Use the subscription you already pay for. OpenSwarm runs on your own Claude, ChatGPT, or Gemini account, right from this Mac."
|
||||
nextLabel={connected ? 'Continue' : 'Continue without connecting'}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
>
|
||||
<div style={{ width: 'min(460px, 100%)', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{SUBSCRIPTION_PROVIDERS.map((p, i) => (
|
||||
<motion.button
|
||||
key={p.id}
|
||||
onClick={() => !connected && handleConnect(p.id)}
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 320, damping: 26, delay: 0.1 + i * 0.08 }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14, padding: '16px 18px', textAlign: 'left',
|
||||
borderRadius: c.radius.md, border: `1px solid ${c.border.medium}`, background: c.bg.surface,
|
||||
cursor: connected ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
boxShadow: c.shadow.sm,
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 12, height: 12, borderRadius: 999, background: p.color, flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ display: 'block', fontSize: '1rem', fontWeight: 600, color: c.text.primary }}>{p.name}</span>
|
||||
<span style={{ display: 'block', fontSize: '0.82rem', color: c.text.tertiary }}>{p.desc}</span>
|
||||
</span>
|
||||
{connecting === p.id && !connected && <span style={{ fontSize: '0.8rem', color: c.text.tertiary }}>waiting for sign-in...</span>}
|
||||
{connected && connecting === p.id && <Check size={18} color={c.status.success} />}
|
||||
</motion.button>
|
||||
))}
|
||||
{userCode && !connected && (
|
||||
<div style={{ textAlign: 'center', padding: '10px 0', fontSize: '0.9rem', color: c.text.secondary }}>
|
||||
Your code: <strong style={{ fontFamily: c.font.mono, letterSpacing: '0.08em' }}>{userCode}</strong>
|
||||
</div>
|
||||
)}
|
||||
{connected && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
style={{
|
||||
padding: '12px 16px', borderRadius: c.radius.md, background: c.status.successBg,
|
||||
color: c.status.success, fontSize: '0.9rem', fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Connected{connectedIdentity?.email ? ` as ${connectedIdentity.email}` : ''}
|
||||
{connectedIdentity?.plan ? ` · ${connectedIdentity.label} ${connectedIdentity.plan}` : ''}
|
||||
</motion.div>
|
||||
)}
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 9, marginTop: 6, cursor: 'pointer', fontSize: '0.8rem', color: c.text.tertiary, lineHeight: 1.5 }}>
|
||||
<input type="checkbox" checked={scanConsent} onChange={(e) => setScanConsent(e.target.checked)} style={{ marginTop: 2 }} />
|
||||
<span>
|
||||
While you sign in, take a quick local look around (app names and folder counts only) to personalize my suggestions.
|
||||
Nothing is stored or sent anywhere except to your own AI.
|
||||
</span>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 18, marginTop: 4 }}>
|
||||
<button onClick={() => setShowKeys(!showKeys)} style={{ border: 'none', background: 'transparent', padding: 0, color: c.text.ghost, fontSize: '0.82rem', cursor: 'pointer', fontFamily: 'inherit', textDecoration: 'underline' }}>
|
||||
Use an API key instead
|
||||
</button>
|
||||
{freeTrial && !connected && (
|
||||
<button onClick={onNext} style={{ border: 'none', background: 'transparent', padding: 0, color: c.text.ghost, fontSize: '0.82rem', cursor: 'pointer', fontFamily: 'inherit', textDecoration: 'underline' }}>
|
||||
Start free, connect later
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showKeys && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
value={keyDraft}
|
||||
onChange={(e) => setKeyDraft(e.target.value)}
|
||||
placeholder="Paste an Anthropic, OpenAI, Google, or OpenRouter key"
|
||||
style={{
|
||||
flex: 1, padding: '10px 12px', borderRadius: c.radius.sm, border: `1px solid ${c.border.medium}`,
|
||||
background: c.bg.surface, color: c.text.primary, fontSize: '0.85rem', fontFamily: c.font.mono,
|
||||
}}
|
||||
/>
|
||||
<button onClick={saveKey} style={{ padding: '10px 16px', borderRadius: c.radius.sm, border: 'none', background: c.accent.primary, color: '#fff', fontSize: '0.85rem', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BeatShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeatConnect;
|
||||
@@ -1,85 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
|
||||
// Split-stage layout for the interactive beats: dark copy panel left, live artifact right. One loud button, a whisper Back, no progress bar; each beat is a room, not step 3 of 9.
|
||||
const BeatShell: React.FC<{
|
||||
c: ClaudeTokens;
|
||||
title: string;
|
||||
body: string;
|
||||
nextLabel: string;
|
||||
nextDisabled?: boolean;
|
||||
onNext: () => void;
|
||||
onBack?: () => void;
|
||||
children: React.ReactNode;
|
||||
}> = ({ c, title, body, nextLabel, nextDisabled, onNext, onBack, children }) => {
|
||||
// Zen steal: controls stay inert until the entrance animation lands so a double-click from the prior beat can't fire them.
|
||||
const [armed, setArmed] = useState(false);
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setArmed(true), 450);
|
||||
return () => window.clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%' }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{
|
||||
width: 'min(400px, 36%)', flexShrink: 0, display: 'flex', flexDirection: 'column',
|
||||
justifyContent: 'center', padding: '48px 44px', boxSizing: 'border-box',
|
||||
background: c.bg.inverse, color: c.text.inverse,
|
||||
}}
|
||||
>
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={() => armed && onBack()}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, alignSelf: 'flex-start',
|
||||
marginBottom: 18, padding: 0, border: 'none', background: 'transparent',
|
||||
color: c.text.inverse + '77', fontSize: '0.85rem', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={14} /> Back
|
||||
</button>
|
||||
)}
|
||||
<h1 style={{ margin: 0, fontSize: 'clamp(1.9rem, 3.2vw, 2.6rem)', lineHeight: 1.12, fontWeight: 700, letterSpacing: '-0.01em' }}>
|
||||
{title}
|
||||
</h1>
|
||||
<p style={{ margin: '16px 0 0', fontSize: '0.98rem', lineHeight: 1.55, color: c.text.inverse + '99', maxWidth: '34ch' }}>
|
||||
{body}
|
||||
</p>
|
||||
<div style={{ marginTop: 40 }}>
|
||||
<button
|
||||
onClick={() => armed && !nextDisabled && onNext()}
|
||||
disabled={!!nextDisabled}
|
||||
style={{
|
||||
width: '100%', padding: '13px 18px', borderRadius: c.radius.md,
|
||||
border: 'none', background: c.accent.primary, color: '#fff',
|
||||
fontSize: '0.98rem', fontWeight: 600, cursor: nextDisabled ? 'default' : 'pointer',
|
||||
opacity: nextDisabled ? 0.45 : 1, fontFamily: 'inherit',
|
||||
transition: 'background 150ms ease, opacity 150ms ease',
|
||||
}}
|
||||
>
|
||||
{nextLabel}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.55, delay: 0.12 }}
|
||||
style={{
|
||||
flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: c.bg.page, padding: 36, boxSizing: 'border-box', overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeatShell;
|
||||
@@ -1,131 +0,0 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useThemeAccent, useThemeMode } from '@/shared/styles/ThemeContext';
|
||||
import { hexToHsl, hslToHex } from '@/shared/styles/claudeTokens';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import BeatShell from './BeatShell';
|
||||
|
||||
const PRESETS = ['#ae5630', '#b0453c', '#8e5cb8', '#3a6fc4', '#2e8f6f', '#b08b2e', '#c2588f', '#5c6470'];
|
||||
|
||||
// The IKEA-effect beat: dragging on the pad drives the REAL app theme live through ThemeContext, so the product becomes theirs before they've entered it. Persistence happens at finish(), not here.
|
||||
const BeatTheme: React.FC<{
|
||||
c: ClaudeTokens;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}> = ({ c, onNext, onBack }) => {
|
||||
const { accent, setAccent } = useThemeAccent();
|
||||
const { mode, setMode } = useThemeMode();
|
||||
const padRef = useRef<HTMLDivElement | null>(null);
|
||||
const draggingRef = useRef(false);
|
||||
const lastApplyRef = useRef(0);
|
||||
|
||||
const applyFromEvent = useCallback((clientX: number, clientY: number) => {
|
||||
const pad = padRef.current;
|
||||
if (!pad) return;
|
||||
// ~30ms throttle: every apply re-derives tokens and re-renders the tree, and pointermove fires far faster than paint needs.
|
||||
const now = performance.now();
|
||||
if (now - lastApplyRef.current < 30) return;
|
||||
lastApplyRef.current = now;
|
||||
const rect = pad.getBoundingClientRect();
|
||||
const fx = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
|
||||
const fy = Math.min(1, Math.max(0, (clientY - rect.top) / rect.height));
|
||||
setAccent(hslToHex({ h: fx, s: 0.72, l: 0.62 - fy * 0.34 }));
|
||||
}, [setAccent]);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
draggingRef.current = true;
|
||||
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
lastApplyRef.current = 0;
|
||||
applyFromEvent(e.clientX, e.clientY);
|
||||
}, [applyFromEvent]);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (draggingRef.current) applyFromEvent(e.clientX, e.clientY);
|
||||
}, [applyFromEvent]);
|
||||
|
||||
const onPointerUp = useCallback(() => { draggingRef.current = false; }, []);
|
||||
|
||||
const dot = accent ? hexToHsl(accent) : null;
|
||||
|
||||
return (
|
||||
<BeatShell
|
||||
c={c}
|
||||
title="Make it yours."
|
||||
body="Pick a color, any color. The whole app repaints as you drag; this is your home now."
|
||||
nextLabel="Continue"
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
>
|
||||
<div style={{ width: 'min(420px, 100%)', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<motion.div
|
||||
ref={padRef}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
style={{
|
||||
position: 'relative', height: 240, borderRadius: c.radius.lg, cursor: 'crosshair',
|
||||
border: `1px solid ${c.border.medium}`, touchAction: 'none',
|
||||
background: 'linear-gradient(to bottom, rgba(255,255,255,0.55), rgba(0,0,0,0.45)), linear-gradient(to right, hsl(0,72%,55%), hsl(60,72%,55%), hsl(120,72%,55%), hsl(180,72%,55%), hsl(240,72%,55%), hsl(300,72%,55%), hsl(360,72%,55%))',
|
||||
}}
|
||||
>
|
||||
{dot && (
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
left: `${dot.h * 100}%`,
|
||||
top: `${((0.62 - dot.l) / 0.34) * 100}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 26, height: 26, borderRadius: 999, background: accent ?? 'transparent',
|
||||
border: '3px solid #fff', boxShadow: '0 2px 8px rgba(0,0,0,0.35)', pointerEvents: 'none',
|
||||
}} />
|
||||
)}
|
||||
</motion.div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
|
||||
{PRESETS.map((hex) => (
|
||||
<button
|
||||
key={hex}
|
||||
onClick={() => setAccent(hex)}
|
||||
style={{
|
||||
width: 26, height: 26, borderRadius: 999, background: hex, cursor: 'pointer',
|
||||
border: accent === hex ? '2.5px solid #fff' : '2.5px solid transparent',
|
||||
boxShadow: accent === hex ? `0 0 0 2px ${hex}` : 'none', padding: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setAccent(null)}
|
||||
style={{
|
||||
marginLeft: 'auto', border: 'none', background: 'transparent', padding: 0,
|
||||
color: c.text.ghost, fontSize: '0.8rem', cursor: 'pointer', fontFamily: 'inherit', textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{(['light', 'dark'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
padding: '11px 0', borderRadius: c.radius.md, fontFamily: 'inherit', fontSize: '0.88rem', fontWeight: 500,
|
||||
border: `1.5px solid ${mode === m ? c.accent.primary : c.border.medium}`,
|
||||
background: c.bg.surface, color: c.text.secondary, cursor: 'pointer',
|
||||
transition: 'border-color 140ms ease',
|
||||
}}
|
||||
>
|
||||
{m === 'light' ? <Sun size={15} /> : <Moon size={15} />}
|
||||
{m === 'light' ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</BeatShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeatTheme;
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
// The OpenSwarm octopus, our actual brand mark. A raster (its coral already sits right next to the
|
||||
// default accent), so unlike the old asterisk it wears its own color rather than the picked gradient.
|
||||
const OnboardingLogo: React.FC<{ size: number; style?: React.CSSProperties }> = ({ size, style }) => (
|
||||
<img
|
||||
src="./logo.png"
|
||||
width={size}
|
||||
height={size}
|
||||
alt="OpenSwarm"
|
||||
draggable={false}
|
||||
style={{ display: 'block', objectFit: 'contain', ...style }}
|
||||
/>
|
||||
);
|
||||
|
||||
export default OnboardingLogo;
|
||||
@@ -1,19 +1,25 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { updateSettingsPatch } from '@/shared/state/settingsSlice';
|
||||
import { setFlowActive } from '@/shared/state/onboardingV3Slice';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { selectSubscriptionConnections } from '@/shared/state/subscriptionsSlice';
|
||||
import { useClaudeTokens, useThemeAccent } from '@/shared/styles/ThemeContext';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import { useOnboardingV3Pipeline } from './useOnboardingV3Pipeline';
|
||||
import BeatConnect from './BeatConnect';
|
||||
import BeatApps from './BeatApps';
|
||||
import BeatTheme from './BeatTheme';
|
||||
import { GRAIN_URL } from '@/shared/styles/grainTexture';
|
||||
import { ARC_BLUE_BG, ONBOARDING_SANS } from './beats/BeatShell';
|
||||
import BeatSignIn from './beats/BeatSignIn';
|
||||
import BeatConnect from './beats/BeatConnect';
|
||||
import BeatApps from './beats/BeatApps';
|
||||
import BeatTheme from './beats/BeatTheme';
|
||||
import BeatCard from './beats/BeatCard';
|
||||
|
||||
type Beat = 'welcome' | 'newos' | 'connect' | 'apps' | 'theme';
|
||||
type Beat = 'welcome' | 'newos' | 'signin' | 'connect' | 'apps' | 'theme' | 'card';
|
||||
|
||||
const V2_STORAGE_KEY = 'openswarm.onboarding.v2';
|
||||
const WINDOWED_BEATS: Beat[] = ['welcome', 'newos'];
|
||||
|
||||
// Decides whether the v3 full-screen flow owns this launch. Only genuinely fresh installs see it: anyone with the v2 tour key or existing sessions is auto-marked skipped so an update never re-onboards a veteran.
|
||||
function useOnboardingV3Gate(): boolean {
|
||||
@@ -27,43 +33,51 @@ function useOnboardingV3Gate(): boolean {
|
||||
try { return localStorage.getItem(V2_STORAGE_KEY) !== null; } catch { return false; }
|
||||
}, []);
|
||||
|
||||
// Dev/QA only (stripped from production builds): localStorage 'osw_force_onboarding'='1' replays the
|
||||
// v3 flow on a non-fresh install, since the gate is otherwise first-install-only. Lets us showcase
|
||||
// + live-test the whole flow without a truly clean profile.
|
||||
const forceShow = useMemo(() => {
|
||||
if (process.env.NODE_ENV === 'production') return false;
|
||||
try { return localStorage.getItem('osw_force_onboarding') === '1'; } catch { return false; }
|
||||
}, []);
|
||||
|
||||
// Under the replay flag we open the flow exactly ONCE (a ref, not on every v3State change) so that
|
||||
// finish() setting flowActive=false actually closes the curtain instead of the effect re-opening it.
|
||||
const forcedOpenRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (forceShow) {
|
||||
if (!forcedOpenRef.current) { forcedOpenRef.current = true; dispatch(setFlowActive(true)); }
|
||||
return;
|
||||
}
|
||||
if (!settingsLoaded || v3State) return;
|
||||
if (hasV2History) {
|
||||
dispatch(updateSettingsPatch({ onboarding_v3: 'skipped' }));
|
||||
return;
|
||||
}
|
||||
dispatch(setFlowActive(true));
|
||||
}, [settingsLoaded, v3State, hasV2History, dispatch]);
|
||||
}, [settingsLoaded, v3State, hasV2History, dispatch, forceShow]);
|
||||
|
||||
// Backstop for a veteran who cleared localStorage: real sessions arriving mid-flow means this is not a fresh install.
|
||||
// Backstop for a veteran who cleared localStorage: real sessions arriving mid-flow means this is not a fresh install. (Skipped under the dev replay flag, whose demo dashboard legitimately has sessions.)
|
||||
useEffect(() => {
|
||||
if (!flowActive || sessionCount === 0) return;
|
||||
if (forceShow || !flowActive || sessionCount === 0) return;
|
||||
dispatch(setFlowActive(false));
|
||||
dispatch(updateSettingsPatch({ onboarding_v3: 'skipped' }));
|
||||
}, [flowActive, sessionCount, dispatch]);
|
||||
}, [flowActive, sessionCount, dispatch, forceShow]);
|
||||
|
||||
return flowActive && settingsLoaded && !v3State;
|
||||
// forceShow bypasses the v2/v3 ENTRY block but still respects flowActive, so finish() closes it.
|
||||
return forceShow ? (flowActive && settingsLoaded) : (flowActive && settingsLoaded && !v3State);
|
||||
}
|
||||
|
||||
// Full-bleed intro rooms: a soft accent blob blooms behind giant type, one arrow, nothing else.
|
||||
// Full-bleed intro room, Arc's "A browser for you.": giant heavy white type centered on the grained
|
||||
// electric-blue gradient, one arrow, nothing else.
|
||||
const IntroBeat: React.FC<{ c: ClaudeTokens; line: string; sub?: string; onNext: () => void }> = ({ c, line, sub, onNext }) => (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: c.bg.inverse, overflow: 'hidden' }}>
|
||||
<motion.div
|
||||
initial={{ scale: 0.35, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 0.55 }}
|
||||
transition={{ duration: 1.4, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{
|
||||
position: 'absolute', width: 560, height: 560, borderRadius: 999,
|
||||
background: `radial-gradient(circle at 42% 38%, ${c.accent.hover}, ${c.accent.primary} 55%, transparent 75%)`,
|
||||
filter: 'blur(70px)', pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: ARC_BLUE_BG, overflow: 'hidden', fontFamily: ONBOARDING_SANS }}>
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: GRAIN_URL, opacity: 0.32, pointerEvents: 'none' }} />
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 16, filter: 'blur(8px)' }}
|
||||
animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
|
||||
transition={{ duration: 0.7, delay: 0.35, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ position: 'relative', margin: 0, fontSize: 'clamp(2.6rem, 6vw, 4.4rem)', fontWeight: 700, color: c.text.inverse, letterSpacing: '-0.02em', textAlign: 'center', padding: '0 24px' }}
|
||||
transition={{ duration: 0.7, delay: 0.45, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ position: 'relative', margin: 0, fontSize: 'clamp(2.8rem, 5.4vw, 4.4rem)', fontWeight: 800, color: '#fff', letterSpacing: '-0.02em', textAlign: 'center', padding: '0 24px', fontFamily: 'inherit' }}
|
||||
>
|
||||
{line}
|
||||
</motion.h1>
|
||||
@@ -72,7 +86,7 @@ const IntroBeat: React.FC<{ c: ClaudeTokens; line: string; sub?: string; onNext:
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.8 }}
|
||||
style={{ position: 'relative', margin: '14px 0 0', fontSize: '1.05rem', color: c.text.inverse + '99' }}
|
||||
style={{ position: 'relative', margin: '14px 0 0', fontSize: '1rem', color: 'rgba(255,255,255,0.75)' }}
|
||||
>
|
||||
{sub}
|
||||
</motion.p>
|
||||
@@ -84,7 +98,7 @@ const IntroBeat: React.FC<{ c: ClaudeTokens; line: string; sub?: string; onNext:
|
||||
transition={{ duration: 0.5, delay: 1.05 }}
|
||||
whileHover={{ scale: 1.06 }}
|
||||
style={{
|
||||
position: 'relative', marginTop: 44, width: 54, height: 40, borderRadius: 12, border: 'none',
|
||||
position: 'relative', marginTop: 40, width: 54, height: 40, borderRadius: 12, border: 'none',
|
||||
background: 'rgba(255,255,255,0.92)', color: '#1a1a18', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
@@ -94,39 +108,58 @@ const IntroBeat: React.FC<{ c: ClaudeTokens; line: string; sub?: string; onNext:
|
||||
</div>
|
||||
);
|
||||
|
||||
// Onboarding v3: connect-first, Arc/Zen-style staged rooms over the live app. Each beat commits its side effect on exit; the overlay dissolving IS the reveal (the seeder has already dressed the canvas behind it).
|
||||
// Onboarding v3, staged like Arc: a floating window births over the dimmed canvas, expands to own the screen on the first commitment, then each beat is a room. Side effects commit on beat exit; the overlay dissolving IS the reveal.
|
||||
const OnboardingV3Root: React.FC = () => {
|
||||
const active = useOnboardingV3Gate();
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const pipeline = useOnboardingV3Pipeline();
|
||||
const [beat, setBeat] = useState<Beat>('welcome');
|
||||
const [scanConsent, setScanConsent] = useState(true);
|
||||
const [picks, setPicks] = useState<string[]>([]);
|
||||
const [finishing, setFinishing] = useState(false);
|
||||
const connectedProvider = useAppSelector((s) => selectSubscriptionConnections(s).find((cx) => cx.isActive !== false)?.provider ?? null);
|
||||
const { accent, gradient } = useThemeAccent();
|
||||
|
||||
const { kickIdentity, kickScan, kickPrep, finish } = pipeline;
|
||||
const { kickIdentity, kickScan, kickUsageRead, kickPrep, finish } = pipeline;
|
||||
|
||||
// Start ALL the background work the instant they connect: identity + scan + usage + prep + the
|
||||
// audit/app jobs. They then run through the entire rest of the flow (connect -> apps -> theme ->
|
||||
// card), so the reveal lands on work already well underway. Personalization is default-on (no
|
||||
// opt-in checkbox), so scan + chat-read always fire here. kickPrep is idempotent and awaits the
|
||||
// scan/usage promises just kicked here; picks aren't in yet and prep doesn't gate on them.
|
||||
const onConnected = useCallback(() => {
|
||||
kickIdentity();
|
||||
kickScan(scanConsent);
|
||||
}, [kickIdentity, kickScan, scanConsent]);
|
||||
kickScan(true);
|
||||
if (connectedProvider) kickUsageRead(connectedProvider, true);
|
||||
kickPrep(picks, true);
|
||||
}, [kickIdentity, kickScan, kickUsageRead, kickPrep, connectedProvider, picks]);
|
||||
|
||||
// Backstop: onConnected fires prep for subscription/api-key connects; this covers any path where it
|
||||
// didn't (e.g. free trial). Idempotent, so it's a no-op when prep already started at connect.
|
||||
const leaveConnect = useCallback(() => {
|
||||
kickScan(scanConsent);
|
||||
kickScan(true);
|
||||
kickPrep(picks);
|
||||
setBeat('apps');
|
||||
}, [kickScan, scanConsent]);
|
||||
}, [kickScan, kickPrep, picks]);
|
||||
|
||||
const leaveApps = useCallback(() => {
|
||||
kickPrep(picks);
|
||||
setBeat('theme');
|
||||
}, [kickPrep, picks]);
|
||||
|
||||
const leaveTheme = useCallback(async () => {
|
||||
setFinishing(true);
|
||||
await finish('done');
|
||||
}, [finish]);
|
||||
const leaveCard = useCallback((name: string | null) => {
|
||||
if (name) dispatch(updateSettingsPatch({ user_name: name }));
|
||||
// finish() is now non-blocking: it stages the reveal + drops flowActive immediately, so the overlay
|
||||
// fades straight onto the live canvas (jobs already in motion). No "Setting up your canvas" spinner.
|
||||
void finish('done');
|
||||
}, [dispatch, finish]);
|
||||
|
||||
const skipAll = useCallback(() => { void finish('skipped'); }, [finish]);
|
||||
|
||||
const windowed = WINDOWED_BEATS.includes(beat);
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
// Expanded beats cap at a large centered card (not edge-to-edge) so on very wide displays the stage never balloons into a beige void around the sparse content.
|
||||
const stageW = windowed ? Math.min(900, Math.round(vw * 0.72)) : Math.min(1680, Math.round(vw * 0.94));
|
||||
const stageH = windowed ? Math.min(560, Math.round(vh * 0.74)) : Math.min(1000, Math.round(vh * 0.92));
|
||||
|
||||
// AnimatePresence stays mounted so the overlay's exit fade (the curtain lift) actually plays when active flips false.
|
||||
return (
|
||||
@@ -136,56 +169,49 @@ const OnboardingV3Root: React.FC = () => {
|
||||
key="onboarding-v3"
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 100000, background: c.bg.page }}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 100000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(10, 10, 9, 0.42)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)',
|
||||
}}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={beat}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.32 }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
{beat === 'welcome' && <IntroBeat c={c} line="Welcome." onNext={() => setBeat('newos')} />}
|
||||
{beat === 'newos' && <IntroBeat c={c} line="This is your new OS." sub="A canvas where AI agents do real work for you." onNext={() => setBeat('connect')} />}
|
||||
{beat === 'connect' && (
|
||||
<BeatConnect
|
||||
c={c}
|
||||
identity={pipeline.identity}
|
||||
scanConsent={scanConsent}
|
||||
setScanConsent={setScanConsent}
|
||||
onConnected={onConnected}
|
||||
onNext={leaveConnect}
|
||||
onBack={() => setBeat('newos')}
|
||||
/>
|
||||
)}
|
||||
{beat === 'apps' && <BeatApps c={c} picks={picks} setPicks={setPicks} onNext={leaveApps} onBack={() => setBeat('connect')} />}
|
||||
{beat === 'theme' && <BeatTheme c={c} onNext={() => { void leaveTheme(); }} onBack={() => setBeat('apps')} />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
{finishing && (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: c.bg.page }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.72, filter: 'blur(18px)' }}
|
||||
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)', width: stageW, height: stageH, borderRadius: 16 }}
|
||||
transition={{ type: 'spring', stiffness: 170, damping: 24, mass: 0.9 }}
|
||||
style={{
|
||||
position: 'relative', overflow: 'hidden',
|
||||
boxShadow: '0 30px 90px rgba(0,0,0,0.5)',
|
||||
background: c.bg.page,
|
||||
}}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.div
|
||||
key={beat}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
style={{ fontSize: '1.05rem', color: c.text.tertiary }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.35, ease: 'easeInOut' }}
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
>
|
||||
Setting up your canvas...
|
||||
{beat === 'welcome' && <IntroBeat c={c} line="Welcome." onNext={() => setBeat('newos')} />}
|
||||
{beat === 'newos' && <IntroBeat c={c} line="This is your new OS." onNext={() => setBeat('signin')} />}
|
||||
{beat === 'signin' && <BeatSignIn c={c} onNext={() => setBeat('connect')} onBack={() => setBeat('newos')} />}
|
||||
{beat === 'connect' && (
|
||||
<BeatConnect
|
||||
c={c}
|
||||
identity={pipeline.identity}
|
||||
onConnected={onConnected}
|
||||
onNext={leaveConnect}
|
||||
onBack={() => setBeat('signin')}
|
||||
/>
|
||||
)}
|
||||
{beat === 'apps' && <BeatApps c={c} picks={picks} setPicks={setPicks} onNext={leaveApps} onBack={() => setBeat('connect')} />}
|
||||
{beat === 'theme' && <BeatTheme c={c} onNext={() => setBeat('card')} onBack={() => setBeat('apps')} />}
|
||||
{beat === 'card' && <BeatCard c={c} identity={pipeline.identity} personalizedEpithets={pipeline.getPrepEpithets()} onFinish={(name) => { void leaveCard(name); }} onBack={() => setBeat('theme')} />}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
{!finishing && (
|
||||
<button
|
||||
onClick={skipAll}
|
||||
style={{
|
||||
position: 'absolute', bottom: 18, left: 20, border: 'none', background: 'transparent',
|
||||
color: c.text.tertiary, fontSize: '0.8rem', cursor: 'pointer', fontFamily: 'inherit', padding: 4,
|
||||
}}
|
||||
>
|
||||
Skip setup
|
||||
</button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { INTEGRATIONS } from '@/app/pages/Tools/integrations';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import BeatShell from './BeatShell';
|
||||
|
||||
// Order matters: the first row should read as "your work lives here" for the widest audience.
|
||||
const PICKER_IDS = ['google-workspace', 'notion', 'slack', 'github', 'discord', 'microsoft-365', 'reddit', 'youtube', 'x', 'airtable', 'hubspot', 'tiktok'];
|
||||
|
||||
// Picks do double duty: they brief the prep call on what this person's work looks like, and they seed which integrations we suggest connecting later. Nothing installs here; the MCP gate stays untouched. Staged inside a miniature OpenSwarm window (Zen's diegetic picker) so the canvas metaphor lands before the canvas exists.
|
||||
const BeatApps: React.FC<{
|
||||
c: ClaudeTokens;
|
||||
picks: string[];
|
||||
setPicks: (ids: string[]) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}> = ({ c, picks, setPicks, onNext, onBack }) => {
|
||||
const entries = PICKER_IDS
|
||||
.map((id) => INTEGRATIONS.find((i) => i.id === id))
|
||||
.filter((i): i is NonNullable<typeof i> => !!i);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setPicks(picks.includes(id) ? picks.filter((p) => p !== id) : [...picks, id]);
|
||||
};
|
||||
|
||||
return (
|
||||
<BeatShell
|
||||
c={c}
|
||||
title="Choose the apps you live in."
|
||||
body="I'll shape your starting canvas around them."
|
||||
nextLabel="Next"
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
secondaryLabel="Skip for now"
|
||||
onSecondary={onNext}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 220, damping: 24, delay: 0.2 }}
|
||||
style={{
|
||||
width: 'min(600px, 100%)', borderRadius: 14, background: c.bg.surface,
|
||||
border: `1px solid ${c.border.medium}`, boxShadow: '0 18px 50px rgba(0,0,0,0.22)', overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '9px 14px', borderBottom: `1px solid ${c.border.subtle}`, background: c.bg.elevated }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: c.text.tertiary, letterSpacing: '0.04em' }}>OpenSwarm</span>
|
||||
</div>
|
||||
<div style={{ padding: 18, display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(118px, 1fr))', gap: 12 }}>
|
||||
{entries.map((entry, i) => {
|
||||
const picked = picks.includes(entry.id);
|
||||
// Arc's picked tile lights up in the APP's own brand color, not one shared accent.
|
||||
return (
|
||||
<motion.button
|
||||
key={entry.id}
|
||||
onClick={() => toggle(entry.id)}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 360, damping: 24, delay: 0.28 + i * 0.035 }}
|
||||
style={{
|
||||
position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 9,
|
||||
padding: '18px 8px', borderRadius: 16,
|
||||
border: `2px solid ${picked ? entry.color : 'transparent'}`,
|
||||
background: picked ? `${entry.color}14` : c.bg.secondary,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
boxShadow: picked ? `0 0 0 4px ${entry.color}22` : 'none',
|
||||
transition: 'border-color 150ms ease, box-shadow 150ms ease, background 150ms ease',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 38, height: 38, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{entry.icon}</span>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 500, color: picked ? c.text.primary : c.text.secondary, textAlign: 'center' }}>{entry.name}</span>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
</BeatShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeatApps;
|
||||
@@ -0,0 +1,291 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Check, Copy, Dices, Download, Share } from 'lucide-react';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import { hexToHsl, hslToHex } from '@/shared/styles/claudeTokens';
|
||||
import { useThemeAccent } from '@/shared/styles/ThemeContext';
|
||||
import type { ProviderIdentity } from '../onboardingV3Api';
|
||||
import BeatShell, { ONBOARDING_SANS } from './BeatShell';
|
||||
|
||||
const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace';
|
||||
|
||||
const EPITHETS = [
|
||||
'METHODICAL PERFECTIONIST', 'SAVORY ARCHIVIST', 'MIDNIGHT ORCHESTRATOR', 'GENTLE MAXIMALIST',
|
||||
'PRACTICAL DREAMER', 'QUIET POWER USER', 'CURIOUS CARTOGRAPHER', 'SWARM WHISPERER',
|
||||
'DELIBERATE TINKERER', 'WARM SYSTEMATIZER', 'PATIENT ACCELERATIONIST', 'ANALOG FUTURIST',
|
||||
];
|
||||
|
||||
function nameFromIdentity(identity: ProviderIdentity[]): string {
|
||||
const email = identity.find((p) => p.email)?.email ?? '';
|
||||
const local = email.split('@')[0] ?? '';
|
||||
const letters = local.replace(/[^a-zA-Z]/g, '');
|
||||
if (!letters) return '';
|
||||
return letters.charAt(0).toUpperCase() + letters.slice(1, 12);
|
||||
}
|
||||
|
||||
// The card's leaf takes the two ends of the user's picked theme gradient (or a dark->light pair
|
||||
// derived from the single accent), so the artifact literally wears the theme they just chose.
|
||||
function leafStops(gradient: string[] | null, base: string, c: ClaudeTokens): [string, string] {
|
||||
if (gradient && gradient.length >= 2) return [gradient[0], gradient[gradient.length - 1]];
|
||||
const hsl = hexToHsl(base);
|
||||
if (!hsl) return [c.accent.pressed, c.accent.primary];
|
||||
const dark = hslToHex({ h: hsl.h, s: Math.min(1, hsl.s * 1.02), l: Math.max(0.34, hsl.l - 0.1) });
|
||||
const light = hslToHex({ h: (hsl.h + 0.015) % 1, s: Math.max(0.55, hsl.s * 0.92), l: Math.min(0.74, hsl.l + 0.18) });
|
||||
return [dark, light];
|
||||
}
|
||||
|
||||
// One accent-hued ink dark enough to read on the cream card, for every bit of card type.
|
||||
function readableInk(base: string, c: ClaudeTokens): string {
|
||||
const hsl = hexToHsl(base);
|
||||
if (!hsl) return c.accent.pressed;
|
||||
return hslToHex({ h: hsl.h, s: Math.max(0.5, hsl.s), l: Math.min(0.42, hsl.l) });
|
||||
}
|
||||
|
||||
// The Arc Card moment: onboarding ends with an identity artifact, not a settings screen. A gradient
|
||||
// leaf wearing the picked theme, the name + a re-rollable epithet, and a real PNG you can save,
|
||||
// copy, or share, an artifact you show off has to be takeable.
|
||||
const BeatCard: React.FC<{
|
||||
c: ClaudeTokens;
|
||||
identity: ProviderIdentity[];
|
||||
// Prep-written identity titles for THIS user; the dice leads with these, statics are the floor.
|
||||
personalizedEpithets?: string[];
|
||||
onFinish: (name: string | null) => void;
|
||||
onBack: () => void;
|
||||
}> = ({ c, identity, personalizedEpithets, onFinish, onBack }) => {
|
||||
const { accent, gradient } = useThemeAccent();
|
||||
const [name, setName] = useState(() => nameFromIdentity(identity));
|
||||
// finish() can legitimately wait up to PREP_WAIT_CAP_MS on prep; the button must say so, once.
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const pool = useMemo(() => {
|
||||
const personal = (personalizedEpithets ?? []).map((e) => e.toUpperCase()).filter(Boolean);
|
||||
return personal.length > 0 ? [...personal, ...EPITHETS] : EPITHETS;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
// Personalized titles lead; only a pure-static pool starts on a random one.
|
||||
const seed = useMemo(() => ((personalizedEpithets ?? []).length > 0 ? 0 : Math.floor(Math.random() * pool.length)), [pool]);
|
||||
const [roll, setRoll] = useState(0);
|
||||
const epithet = pool[(seed + roll) % pool.length];
|
||||
const today = useMemo(() => new Date().toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }), []);
|
||||
const [tilt, setTilt] = useState<{ rx: number; ry: number; mx: number; my: number } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const baseHex = (gradient && gradient[0]) || accent || c.accent.primary;
|
||||
const [dark, light] = leafStops(gradient, baseHex, c);
|
||||
const ink = readableInk(baseHex, c);
|
||||
|
||||
const drawCard = useCallback(async (): Promise<HTMLCanvasElement> => {
|
||||
const W = 580;
|
||||
const H = 800;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = W;
|
||||
cv.height = H;
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) return cv;
|
||||
ctx.fillStyle = '#FCFBF5';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, 0, W, H, 36);
|
||||
ctx.fill();
|
||||
// The leaf: three round corners + one soft point at bottom-right, wearing the theme gradient.
|
||||
const grad = ctx.createLinearGradient(48, 44, 532, 400);
|
||||
grad.addColorStop(0, dark);
|
||||
grad.addColorStop(1, light);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(48, 44, W - 96, 340, [150, 150, 12, 150]);
|
||||
ctx.fill();
|
||||
// Small brand mark in the corner, tinted with the same gradient (source-in keeps the octopus alpha).
|
||||
const logo = new Image();
|
||||
logo.src = './logo.png';
|
||||
await new Promise<void>((res) => { logo.onload = () => res(); logo.onerror = () => res(); });
|
||||
if (logo.naturalWidth > 0) {
|
||||
const lc = document.createElement('canvas');
|
||||
lc.width = 72;
|
||||
lc.height = 72;
|
||||
const lctx = lc.getContext('2d');
|
||||
if (lctx) {
|
||||
lctx.drawImage(logo, 0, 0, 72, 72);
|
||||
lctx.globalCompositeOperation = 'source-in';
|
||||
const lg = lctx.createLinearGradient(0, 0, 72, 72);
|
||||
lg.addColorStop(0, dark);
|
||||
lg.addColorStop(1, light);
|
||||
lctx.fillStyle = lg;
|
||||
lctx.fillRect(0, 0, 72, 72);
|
||||
ctx.drawImage(lc, 34, 30, 42, 42);
|
||||
}
|
||||
}
|
||||
ctx.fillStyle = ink;
|
||||
ctx.font = `800 58px ${ONBOARDING_SANS}`;
|
||||
ctx.fillText(name.trim() || 'Your name', 52, 476);
|
||||
ctx.font = `600 21px ${MONO}`;
|
||||
ctx.fillText(epithet.split('').join(' '), 54, 520);
|
||||
// Bottom-left stamp: OPENSWARM | hatch | date, outlined; bottom-right OPEN / SWARM lockup.
|
||||
const stampText = `OPENSWARM ${today.toUpperCase()}`;
|
||||
ctx.font = `600 18px ${MONO}`;
|
||||
const stampW = ctx.measureText(stampText).width + 30;
|
||||
ctx.strokeStyle = `${ink}88`;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(48, H - 96, stampW, 42, 8);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = ink;
|
||||
ctx.fillText(stampText, 63, H - 68);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.font = `800 19px ${ONBOARDING_SANS}`;
|
||||
ctx.fillText('OPEN', W - 48, H - 84);
|
||||
ctx.fillText('SWARM', W - 48, H - 62);
|
||||
ctx.textAlign = 'left';
|
||||
return cv;
|
||||
}, [name, epithet, today, dark, light, ink]);
|
||||
|
||||
const saveCard = useCallback(() => {
|
||||
void drawCard().then((cv) => {
|
||||
const a = document.createElement('a');
|
||||
a.download = 'swarm-card.png';
|
||||
a.href = cv.toDataURL('image/png');
|
||||
a.click();
|
||||
});
|
||||
}, [drawCard]);
|
||||
|
||||
const copyCard = useCallback(() => {
|
||||
void drawCard().then((cv) => {
|
||||
cv.toBlob((blob) => {
|
||||
if (!blob || !navigator.clipboard || typeof ClipboardItem === 'undefined') return;
|
||||
void navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(() => {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
});
|
||||
});
|
||||
});
|
||||
}, [drawCard]);
|
||||
|
||||
// Native share sheet when the platform offers one (Arc's first card action); quietly absent otherwise.
|
||||
const canShare = typeof navigator.canShare === 'function' && typeof File !== 'undefined'
|
||||
&& navigator.canShare({ files: [new File([''], 'swarm-card.png', { type: 'image/png' })] });
|
||||
const shareCard = useCallback(() => {
|
||||
void drawCard().then((cv) => {
|
||||
cv.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const file = new File([blob], 'swarm-card.png', { type: 'image/png' });
|
||||
void navigator.share({ files: [file], title: 'My Swarm Card' }).catch(() => {});
|
||||
});
|
||||
});
|
||||
}, [drawCard]);
|
||||
|
||||
// Arc's card actions: quiet icon-only buttons under the card on the dark stage.
|
||||
const chip = (label: string, Icon: typeof Dices, onClick: () => void): React.ReactElement => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', width: 34, height: 34,
|
||||
border: 'none', background: 'transparent', color: 'rgba(255,255,255,0.62)',
|
||||
cursor: 'pointer', borderRadius: 8, transition: 'color 140ms ease',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'rgba(255,255,255,0.95)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255,255,255,0.62)'; }}
|
||||
>
|
||||
<Icon size={17} />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<BeatShell
|
||||
c={c}
|
||||
title={name ? `Welcome to OpenSwarm, ${name}` : 'Welcome to OpenSwarm'}
|
||||
body={"Here's your Swarm Card. Show it off to the world or keep it to yourself.\n\nAnd with that, you're ready to run your new OS."}
|
||||
nextLabel={submitting ? 'Setting up...' : 'Get started'}
|
||||
nextDisabled={submitting}
|
||||
onNext={() => { if (submitting) return; setSubmitting(true); onFinish(name.trim() || null); }}
|
||||
onBack={onBack}
|
||||
stageDark
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 18, perspective: 900 }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 22, rotate: -3, scale: 0.94 }}
|
||||
animate={{ opacity: 1, y: 0, rotate: 0, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200, damping: 22, delay: 0.25 }}
|
||||
>
|
||||
<div
|
||||
onMouseMove={(e) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
const px = (e.clientX - r.left) / r.width;
|
||||
const py = (e.clientY - r.top) / r.height;
|
||||
setTilt({ rx: -(py - 0.5) * 13, ry: (px - 0.5) * 13, mx: px * 100, my: py * 100 });
|
||||
}}
|
||||
onMouseLeave={() => setTilt(null)}
|
||||
style={{
|
||||
width: 300, height: 414, borderRadius: 18, background: '#FCFBF5',
|
||||
border: '1px solid rgba(0,0,0,0.05)',
|
||||
boxShadow: tilt ? '0 30px 70px rgba(0,0,0,0.34)' : '0 24px 60px rgba(0,0,0,0.28)',
|
||||
padding: '22px 22px 20px', boxSizing: 'border-box',
|
||||
display: 'flex', flexDirection: 'column', position: 'relative', overflow: 'hidden',
|
||||
transform: tilt ? `rotateX(${tilt.rx}deg) rotateY(${tilt.ry}deg)` : 'rotateX(0deg) rotateY(0deg)',
|
||||
transition: tilt ? 'box-shadow 200ms ease' : 'transform 320ms ease, box-shadow 200ms ease',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
{/* Cursor-following shine, the ProfileCard glare. */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, pointerEvents: 'none', opacity: tilt ? 1 : 0, transition: 'opacity 250ms ease',
|
||||
background: tilt ? `radial-gradient(280px circle at ${tilt.mx}% ${tilt.my}%, rgba(255,255,255,0.45), transparent 62%)` : undefined,
|
||||
}} />
|
||||
{/* Small brand mark in the corner, wearing the same theme gradient (masked octopus). */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 14, left: 16, width: 22, height: 22, zIndex: 3,
|
||||
WebkitMaskImage: 'url(./logo.png)', maskImage: 'url(./logo.png)',
|
||||
WebkitMaskSize: 'contain', maskSize: 'contain',
|
||||
WebkitMaskRepeat: 'no-repeat', maskRepeat: 'no-repeat',
|
||||
WebkitMaskPosition: 'center', maskPosition: 'center',
|
||||
background: `linear-gradient(138deg, ${dark}, ${light})`,
|
||||
}} />
|
||||
{/* The leaf: three round corners + one soft point, bottom-right, wearing the theme gradient. */}
|
||||
<div style={{
|
||||
width: '100%', height: 152, flexShrink: 0,
|
||||
borderRadius: '47% 47% 8px 47%',
|
||||
background: `linear-gradient(138deg, ${dark} 0%, ${light} 100%)`,
|
||||
}} />
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value.slice(0, 18))}
|
||||
placeholder="Your name"
|
||||
style={{
|
||||
marginTop: 20, border: 'none', outline: 'none', background: 'transparent',
|
||||
fontSize: '1.75rem', fontWeight: 800, color: ink, fontFamily: 'inherit',
|
||||
width: '100%', padding: 0,
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 5, fontFamily: MONO, fontSize: '0.6875rem', letterSpacing: '0.14em', color: ink }}>
|
||||
{epithet}
|
||||
</div>
|
||||
<div style={{ marginTop: 'auto', display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between' }}>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 7,
|
||||
fontFamily: MONO, fontSize: '0.625rem', letterSpacing: '0.08em',
|
||||
color: ink, border: `1px solid ${ink}55`, borderRadius: 5, padding: '3px 7px',
|
||||
}}>
|
||||
OPENSWARM
|
||||
<span style={{
|
||||
width: 11, height: 13, borderRadius: 1,
|
||||
background: `repeating-linear-gradient(45deg, ${ink} 0 1.6px, transparent 1.6px 3.6px)`,
|
||||
}} />
|
||||
{today.toUpperCase()}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.625rem', letterSpacing: '0.06em', color: ink, fontWeight: 800, textAlign: 'right', lineHeight: 1.35 }}>
|
||||
OPEN<br />SWARM
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.7 }} style={{ display: 'flex', gap: 6 }}>
|
||||
{chip('Re-roll the title', Dices, () => setRoll((r) => r + 1))}
|
||||
{canShare && chip('Share', Share, shareCard)}
|
||||
{chip('Save as image', Download, saveCard)}
|
||||
{chip(copied ? 'Copied' : 'Copy to clipboard', copied ? Check : Copy, copyCard)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</BeatShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeatCard;
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import { fetchModels } from '@/shared/state/modelsSlice';
|
||||
import { fetchSubscriptionStatus, markSubscriptionConnected, selectSubscriptionConnections } from '@/shared/state/subscriptionsSlice';
|
||||
import { hasFreeTrialActive, hasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates';
|
||||
import { SUBSCRIPTION_PROVIDERS } from '@/app/pages/Settings/sections/subscription/subscriptionProviders';
|
||||
import { runConnectFlow } from '@/app/pages/Settings/sections/subscription/subscriptionConnect';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import type { ProviderIdentity } from '../onboardingV3Api';
|
||||
import OnboardingLogo from '../OnboardingLogo';
|
||||
import { providerLogo } from '../providerLogos';
|
||||
import BeatShell from './BeatShell';
|
||||
|
||||
// The single ask of the whole flow, staged as Arc's import list: radio rows, no filler copy. Reuses the proven Settings connect flow verbatim. Personalization (local scan + one-time chat read) just happens the moment they connect; it's not an opt-in checkbox anymore.
|
||||
const BeatConnect: React.FC<{
|
||||
c: ClaudeTokens;
|
||||
identity: ProviderIdentity[];
|
||||
onConnected: () => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}> = ({ c, identity, onConnected, onNext, onBack }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const connected = useAppSelector((s) => hasModelConnected(s));
|
||||
const freeTrial = useAppSelector((s) => hasFreeTrialActive(s));
|
||||
const [connecting, setConnecting] = useState<string | null>(null);
|
||||
const [userCode, setUserCode] = useState('');
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const connectedOnce = useRef(false);
|
||||
|
||||
useEffect(() => () => { if (pollTimerRef.current) clearInterval(pollTimerRef.current); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (connected && !connectedOnce.current) {
|
||||
connectedOnce.current = true;
|
||||
onConnected();
|
||||
}
|
||||
}, [connected, onConnected]);
|
||||
|
||||
const handleConnect = useCallback(async (providerId: string) => {
|
||||
setConnecting(providerId);
|
||||
setUserCode('');
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/agents/subscriptions/connect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: providerId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(String(data?.detail ?? 'connect failed'));
|
||||
runConnectFlow({
|
||||
providerId,
|
||||
data,
|
||||
setConnecting,
|
||||
setUserCode,
|
||||
setPollTimer: (t) => { pollTimerRef.current = t; },
|
||||
fetchStatus: (opts) => dispatch(fetchSubscriptionStatus(opts)).unwrap(),
|
||||
refreshPickerModels: () => { dispatch(fetchModels()); },
|
||||
markConnected: (provider) => { dispatch(markSubscriptionConnected({ provider })); },
|
||||
});
|
||||
} catch {
|
||||
setConnecting(null);
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
// Which provider rows are live, so the tab itself shows "Connected", not a floating label below.
|
||||
const connections = useAppSelector(selectSubscriptionConnections);
|
||||
const connectedIds = new Set(connections.filter((cx) => cx.isActive !== false).map((cx) => cx.provider));
|
||||
// The account gate lives in the prior sign-in beat, so here the free trial is a legitimate model
|
||||
// source again: Continue unlocks on a provider connection OR the armed trial (both mean "can run").
|
||||
const canContinue = connected || freeTrial;
|
||||
|
||||
return (
|
||||
<BeatShell
|
||||
c={c}
|
||||
title="Connect your AI."
|
||||
body="Use the subscription you already pay for."
|
||||
nextLabel="Continue"
|
||||
onNext={onNext}
|
||||
nextDisabled={!canContinue}
|
||||
onBack={onBack}
|
||||
wide
|
||||
logo={<OnboardingLogo size={52} />}
|
||||
>
|
||||
<div style={{ width: 'min(440px, 100%)', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{SUBSCRIPTION_PROVIDERS.map((p, i) => {
|
||||
const isThis = connecting === p.id;
|
||||
const isConnected = connectedIds.has(p.id);
|
||||
return (
|
||||
<motion.button
|
||||
key={p.id}
|
||||
onClick={() => !isConnected && handleConnect(p.id)}
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 320, damping: 26, delay: 0.1 + i * 0.08 }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14, padding: '0 0 0 18px', textAlign: 'left', overflow: 'hidden',
|
||||
borderRadius: 14, border: 'none', boxShadow: isConnected ? '0 0 0 2px #1a9e6a, 0 10px 26px rgba(20,16,80,0.22)' : '0 10px 26px rgba(20,16,80,0.22)',
|
||||
background: '#ffffff', cursor: isConnected ? 'default' : 'pointer', fontFamily: 'inherit', minHeight: 64,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: 19, height: 19, borderRadius: 999, flexShrink: 0, boxSizing: 'border-box',
|
||||
border: `1.5px solid ${isConnected ? '#1a9e6a' : '#c9c7c2'}`,
|
||||
background: isConnected ? '#1a9e6a' : 'transparent',
|
||||
boxShadow: isConnected ? 'inset 0 0 0 3px #ffffff' : 'none',
|
||||
}} />
|
||||
<span style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 2, padding: '11px 0' }}>
|
||||
<span style={{ fontSize: '1rem', fontWeight: 600, color: '#3d3d3a' }}>{p.name}</span>
|
||||
{/* The green ring + filled radio already signal connected, so no redundant "Connected" text. */}
|
||||
{!isConnected && isThis
|
||||
? <span style={{ fontSize: '0.75rem', fontWeight: 400, color: '#8a8a86' }}>waiting for sign-in...</span>
|
||||
: null}
|
||||
</span>
|
||||
{/* Arc's icon tile: a full-height soft-tinted zone on the row's right edge, real brand mark inside. */}
|
||||
<span style={{
|
||||
alignSelf: 'stretch', width: 78, flexShrink: 0, background: `linear-gradient(135deg, ${p.color}30, ${p.color}14)`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{providerLogo(p.id, 28)}
|
||||
</span>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
{userCode && (
|
||||
<div style={{ textAlign: 'center', padding: '6px 0', fontSize: '0.875rem', color: 'rgba(255,255,255,0.92)' }}>
|
||||
Your code: <strong style={{ fontFamily: c.font.mono, letterSpacing: '0.08em' }}>{userCode}</strong>
|
||||
</div>
|
||||
)}
|
||||
{!canContinue && (
|
||||
<div style={{ fontSize: '0.8125rem', color: 'rgba(255,255,255,0.6)', marginTop: 6 }}>
|
||||
Pick a subscription above to continue.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BeatShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeatConnect;
|
||||
@@ -0,0 +1,203 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { effectiveWashStops } from '@/shared/styles/washBackground';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import { GRAIN_URL } from '@/shared/styles/grainTexture';
|
||||
|
||||
// Arc's torn seam as a SMOOTH wave: a sine sampled densely enough that the polygon reads as a
|
||||
// soft ripple (no sharp points), ~18px wavelength, 6px swell.
|
||||
const WAVE_PERIODS = 52;
|
||||
const WAVE_SAMPLES = WAVE_PERIODS * 8;
|
||||
const ZIGZAG_CLIP = `polygon(0 0, ${Array.from({ length: WAVE_SAMPLES + 1 }, (unused, i) => {
|
||||
const inset = 3 + 3 * Math.sin((i / WAVE_SAMPLES) * WAVE_PERIODS * 2 * Math.PI);
|
||||
return `calc(100% - ${inset.toFixed(2)}px) ${((i / WAVE_SAMPLES) * 100).toFixed(3)}%`;
|
||||
}).join(', ')}, 0 100%)`;
|
||||
|
||||
// Arc's electric indigo CTA: onboarding buttons are brand-colored, not user-accent (the accent doesn't exist until the theme beat).
|
||||
export const CTA_BLUE = '#4b48f8';
|
||||
// Arc sets its onboarding in a heavy grotesque; the app's token "sans" actually falls back to a
|
||||
// serif (Anthropic Sans isn't bundled), so the flow pins a real sans stack.
|
||||
export const ONBOARDING_SANS = '-apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Helvetica, Arial, sans-serif';
|
||||
// Arc's onboarding-window backdrop: cold azure light from the top-left, deep indigo mid, a WARM
|
||||
// violet-magenta glow rising from the bottom-right corner, always grained.
|
||||
export const ARC_BLUE_BG = [
|
||||
'radial-gradient(110% 95% at 92% 100%, rgba(186, 70, 235, 0.55) 0%, rgba(146, 60, 244, 0.28) 34%, transparent 62%)',
|
||||
'radial-gradient(120% 100% at 6% 4%, rgba(148, 178, 255, 0.85) 0%, rgba(110, 135, 255, 0.35) 38%, transparent 66%)',
|
||||
'linear-gradient(152deg, #5f7bff 0%, #4a4ff6 44%, #4338ef 68%, #6f3af3 100%)',
|
||||
].join(', ');
|
||||
// Arc's neutral stage: warm mauve-gray under heavy grain (their import beat), until the user picks a color.
|
||||
const STAGE_MAUVE = '#a8a5b3';
|
||||
|
||||
const SPRING = { type: 'spring' as const, stiffness: 260, damping: 26 };
|
||||
|
||||
// One idea per room, staged EXACTLY like Arc: the whole window is grained electric blue, a rounded
|
||||
// split card floats centered in it, dark copy panel (torn right edge, grain, staggered spring+blur
|
||||
// copy, bottom-pinned CTA) beside a heavily grained stage. `wide` = Arc's account layout instead:
|
||||
// edge-to-edge 50/50 split, centered copy, no floating card.
|
||||
const BeatShell: React.FC<{
|
||||
c: ClaudeTokens;
|
||||
title: string;
|
||||
body: string;
|
||||
nextLabel: string;
|
||||
nextDisabled?: boolean;
|
||||
onNext: () => void;
|
||||
onBack?: () => void;
|
||||
children: React.ReactNode;
|
||||
wide?: boolean;
|
||||
logo?: React.ReactNode;
|
||||
stageDark?: boolean;
|
||||
secondaryLabel?: string;
|
||||
onSecondary?: () => void;
|
||||
}> = ({ c, title, body, nextLabel, nextDisabled, onNext, onBack, children, wide, logo, stageDark, secondaryLabel, onSecondary }) => {
|
||||
// Once the user has picked stops the stage wears them (our theme beat repaints live); before that it stays Arc-mauve.
|
||||
const { accent, gradient } = useThemeAccent();
|
||||
const { washOpacity, grain } = useThemeWash();
|
||||
const stops = effectiveWashStops(gradient, accent);
|
||||
// Picked color reads VIVID on the stage (alpha floor over near-white), not muddied into the mauve.
|
||||
const washAlpha = Math.round(Math.max(0.5, washOpacity) * 255).toString(16).padStart(2, '0');
|
||||
const stageBg = stageDark
|
||||
? '#262320'
|
||||
: stops
|
||||
? `linear-gradient(115deg, ${stops.map((hex, i) => `${hex}${washAlpha} ${stops.length > 1 ? (i / (stops.length - 1)) * 100 : 100}%`).join(', ')}), #edebe7`
|
||||
: STAGE_MAUVE;
|
||||
// Arc post-theme: the CTA flips to cream (dark label) and the window backdrop wears the user's
|
||||
// stops under a soft white veil; before any pick both stay brand blue.
|
||||
const themed = !!stops;
|
||||
const ctaBg = themed ? '#F5EFDF' : CTA_BLUE;
|
||||
const ctaFg = themed ? '#232320' : '#fff';
|
||||
const backdrop = stops
|
||||
? `linear-gradient(rgba(255,255,255,0.16), rgba(255,255,255,0.16)), linear-gradient(160deg, ${stops.map((hex, i) => `${hex} ${stops.length > 1 ? (i / (stops.length - 1)) * 100 : 100}%`).join(', ')})`
|
||||
: ARC_BLUE_BG;
|
||||
// Zen steal: controls stay inert until the entrance lands so a double-click from the prior beat can't fire them.
|
||||
const [armed, setArmed] = useState(false);
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setArmed(true), 600);
|
||||
return () => window.clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
const enter = (delay: number) => ({
|
||||
initial: { opacity: 0, y: 14, filter: 'blur(6px)' },
|
||||
animate: { opacity: 1, y: 0, filter: 'blur(0px)' },
|
||||
transition: { ...SPRING, delay },
|
||||
});
|
||||
|
||||
const panel = (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative', flexShrink: 0, display: 'flex', flexDirection: 'column',
|
||||
width: wide ? '50%' : 'clamp(300px, 36%, 460px)',
|
||||
justifyContent: wide ? 'center' : 'flex-start',
|
||||
alignItems: wide ? 'center' : 'stretch',
|
||||
textAlign: wide ? 'center' : 'left',
|
||||
padding: wide ? '48px 64px' : '56px 40px 36px 44px', boxSizing: 'border-box',
|
||||
// Fixed white-on-near-black: the panel is ALWAYS dark, so it never reads theme tokens (dark mode used to turn the copy invisible).
|
||||
background: '#1e1e1d', color: '#ffffff', clipPath: ZIGZAG_CLIP,
|
||||
fontFamily: ONBOARDING_SANS,
|
||||
// Overlap the stage by one tooth-depth (+ sit above it) so the torn edge bites INTO the stage color instead of leaving a dead paper gap.
|
||||
marginRight: -6, zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: GRAIN_URL, opacity: 0.07, pointerEvents: 'none', mixBlendMode: 'overlay' }} />
|
||||
{onBack && (
|
||||
<motion.button
|
||||
{...enter(0.05)}
|
||||
onClick={() => armed && onBack()}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, alignSelf: 'flex-start',
|
||||
marginBottom: 18, padding: 0, border: 'none', background: 'transparent',
|
||||
color: 'rgba(255,255,255,0.47)', fontSize: '0.875rem', cursor: 'pointer', fontFamily: 'inherit',
|
||||
position: wide ? 'absolute' : 'relative', top: wide ? 24 : undefined, left: wide ? 28 : undefined,
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={14} /> Back
|
||||
</motion.button>
|
||||
)}
|
||||
{logo && <motion.div {...enter(0.08)} style={{ marginBottom: 18 }}>{logo}</motion.div>}
|
||||
<motion.h1 {...enter(0.12)} style={{ margin: 0, fontSize: wide ? 'clamp(2.1rem, 2.8vw, 2.7rem)' : 'clamp(2.1rem, 3.2vw, 3rem)', lineHeight: 1.06, fontWeight: 800, letterSpacing: '-0.02em', fontFamily: 'inherit' }}>
|
||||
{title}
|
||||
</motion.h1>
|
||||
<motion.p {...enter(0.26)} style={{ margin: '18px 0 0', fontSize: '1rem', lineHeight: 1.6, color: 'rgba(255,255,255,0.72)', maxWidth: '36ch', whiteSpace: 'pre-line' }}>
|
||||
{body}
|
||||
</motion.p>
|
||||
{/* Arc pins the CTA to the panel's bottom on card beats; on the wide account beat it follows the content. */}
|
||||
<motion.div {...enter(0.42)} style={{ marginTop: wide ? 34 : 'auto', paddingTop: wide ? 0 : 28, width: wide ? 'min(340px, 100%)' : '100%' }}>
|
||||
<button
|
||||
onClick={() => armed && !nextDisabled && onNext()}
|
||||
disabled={!!nextDisabled}
|
||||
style={{
|
||||
width: '100%', padding: '15px 18px', borderRadius: 10,
|
||||
border: 'none', background: ctaBg, color: ctaFg,
|
||||
fontSize: '1rem', fontWeight: 700, cursor: nextDisabled ? 'default' : 'pointer',
|
||||
opacity: nextDisabled ? 0.45 : 1, fontFamily: 'inherit',
|
||||
transition: 'background 150ms ease, opacity 150ms ease',
|
||||
}}
|
||||
>
|
||||
{nextLabel}
|
||||
</button>
|
||||
{/* Arc's quiet escape hatch under the primary CTA. */}
|
||||
{secondaryLabel && onSecondary && (
|
||||
<button
|
||||
onClick={() => armed && onSecondary()}
|
||||
style={{
|
||||
marginTop: 12, width: '100%', border: 'none', background: 'transparent',
|
||||
color: 'rgba(255,255,255,0.55)', fontSize: '0.875rem', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit', padding: 4,
|
||||
}}
|
||||
>
|
||||
{secondaryLabel}
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const stage = (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.55, delay: 0.15 }}
|
||||
style={{
|
||||
position: 'relative', flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 32, boxSizing: 'border-box', overflow: 'auto',
|
||||
background: wide ? ARC_BLUE_BG : stageBg,
|
||||
}}
|
||||
>
|
||||
{/* Arc's stage always wears texture; the slider can add more but never strips it during onboarding. */}
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: GRAIN_URL, opacity: Math.max(0.22, grain), pointerEvents: 'none' }} />
|
||||
<div style={{ position: 'relative', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
if (wide) {
|
||||
return (
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', fontFamily: ONBOARDING_SANS }}>
|
||||
{panel}
|
||||
{stage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: backdrop, fontFamily: ONBOARDING_SANS }}>
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: GRAIN_URL, opacity: 0.3, pointerEvents: 'none' }} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 18, scale: 0.985 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ ...SPRING, delay: 0.05 }}
|
||||
style={{
|
||||
position: 'relative', display: 'flex', width: 'min(1240px, 82%)', height: 'min(880px, 82%)',
|
||||
borderRadius: 24, overflow: 'hidden', boxShadow: '0 30px 80px rgba(20, 16, 80, 0.35)',
|
||||
}}
|
||||
>
|
||||
{panel}
|
||||
{stage}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeatShell;
|
||||
@@ -0,0 +1,128 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { fetchSettings } from '@/shared/state/settingsSlice';
|
||||
import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config';
|
||||
import { report } from '@/shared/serviceClient';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import SignInDialog from '@/app/components/overlays/SignInDialog';
|
||||
import OnboardingLogo from '../OnboardingLogo';
|
||||
import BeatShell from './BeatShell';
|
||||
|
||||
// The account gate: users sign in (Google or email) before anything else, so the free trial and their
|
||||
// setup are tied to a real account. Google hands off through the external browser and lands out-of-band
|
||||
// (the cloud page POSTs the bearer to the local backend), so we poll settings until user_id appears.
|
||||
// Email reuses the proven SignInDialog (magic-link 6-digit code) on top of the beat.
|
||||
const BeatSignIn: React.FC<{
|
||||
c: ClaudeTokens;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}> = ({ c, onNext, onBack }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const userId = useAppSelector((s) => s.settings.data.user_id ?? null);
|
||||
const userEmail = useAppSelector((s) => s.settings.data.user_email ?? null);
|
||||
const proxyUrl = useAppSelector((s) => s.settings.data.openswarm_proxy_url || OPENSWARM_DEFAULT_PROXY_URL);
|
||||
const installId = useAppSelector((s) => s.settings.data.installation_id ?? '');
|
||||
const [waitingGoogle, setWaitingGoogle] = useState(false);
|
||||
const [emailOpen, setEmailOpen] = useState(false);
|
||||
const signedIn = !!userId;
|
||||
|
||||
useEffect(() => {
|
||||
if (signedIn || !waitingGoogle) return undefined;
|
||||
const id = window.setInterval(() => { void dispatch(fetchSettings()); }, 2000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [signedIn, waitingGoogle, dispatch]);
|
||||
|
||||
const onGoogle = (): void => {
|
||||
if (signedIn) return;
|
||||
report('signin', 'google_clicked');
|
||||
const localPort = (window as unknown as { __OPENSWARM_PORT__?: number }).__OPENSWARM_PORT__ || 8324;
|
||||
const params = new URLSearchParams({ install_id: installId, local_port: String(localPort) });
|
||||
const startUrl = `${proxyUrl.replace(/\/$/, '')}/api/auth/google/start?${params.toString()}`;
|
||||
const api = (window as unknown as { openswarm?: { openExternal?: (u: string) => void } }).openswarm;
|
||||
if (api?.openExternal) api.openExternal(startUrl);
|
||||
else window.open(startUrl, '_blank');
|
||||
setWaitingGoogle(true);
|
||||
};
|
||||
|
||||
const rows: Array<{ id: string; name: string; icon: React.ReactNode; onClick: () => void; hint?: string }> = [
|
||||
{ id: 'google', name: 'Continue with Google', icon: <GoogleIcon sx={{ fontSize: 20, color: '#4285F4' }} />, onClick: onGoogle, hint: waitingGoogle && !signedIn ? 'Waiting for your browser...' : undefined },
|
||||
{ id: 'email', name: 'Continue with email', icon: <EmailIcon sx={{ fontSize: 20, color: '#6f6e6a' }} />, onClick: () => { if (!signedIn) setEmailOpen(true); } },
|
||||
];
|
||||
|
||||
return (
|
||||
<BeatShell
|
||||
c={c}
|
||||
title="Sign in."
|
||||
body="Your account keeps your setup, and your free trial, tied to you."
|
||||
nextLabel="Continue"
|
||||
onNext={onNext}
|
||||
nextDisabled={!signedIn}
|
||||
onBack={onBack}
|
||||
wide
|
||||
logo={<OnboardingLogo size={52} />}
|
||||
>
|
||||
<div style={{ width: 'min(380px, 100%)', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{signedIn ? (
|
||||
// Claude/ChatGPT-style done state: one quiet confirmation card, the buttons step aside.
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 320, damping: 26 }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, padding: '16px 18px',
|
||||
borderRadius: 14, background: '#ffffff', boxShadow: '0 10px 26px rgba(20,16,80,0.22)',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: 34, height: 34, borderRadius: '50%', background: '#e7f6ee', flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<CheckRoundedIcon sx={{ fontSize: 20, color: '#1a9e6a' }} />
|
||||
</span>
|
||||
<span style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<span style={{ fontSize: '1rem', fontWeight: 600, color: '#2a2a27' }}>You're signed in</span>
|
||||
{userEmail && (
|
||||
<span style={{ fontSize: '0.8125rem', color: '#8a8a86', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{userEmail}</span>
|
||||
)}
|
||||
</span>
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
{rows.map((row, i) => (
|
||||
// The Claude/ChatGPT auth grammar: full-width white button, brand mark on the left,
|
||||
// label centered, thin border, generous height. No decoration doing the talking.
|
||||
<motion.button
|
||||
key={row.id}
|
||||
onClick={row.onClick}
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 320, damping: 26, delay: 0.1 + i * 0.08 }}
|
||||
style={{
|
||||
position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: 52, borderRadius: 12, border: '1px solid rgba(0,0,0,0.08)',
|
||||
background: '#ffffff', boxShadow: '0 8px 22px rgba(20,16,80,0.18)',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<span style={{ position: 'absolute', left: 18, display: 'flex', alignItems: 'center' }}>{row.icon}</span>
|
||||
<span style={{ fontSize: '1rem', fontWeight: 600, color: '#2a2a27' }}>
|
||||
{row.hint ? row.hint : row.name}
|
||||
</span>
|
||||
</motion.button>
|
||||
))}
|
||||
<div style={{ fontSize: '0.8125rem', color: 'rgba(255,255,255,0.65)', textAlign: 'center', marginTop: 2 }}>
|
||||
Sign in to continue.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{emailOpen && !signedIn && <SignInDialog initialStage="email_form" onClose={() => setEmailOpen(false)} />}
|
||||
</BeatShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeatSignIn;
|
||||
@@ -0,0 +1,97 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { useThemeAccent, useThemeMode, useThemeWash } from '@/shared/styles/ThemeContext';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import AccentColorPad from '@/app/components/theme/AccentColorPad';
|
||||
import BeatShell from './BeatShell';
|
||||
|
||||
// The IKEA-effect beat, staged as a physical picker device (Arc/Zen theme gadget): light/dark/system on the bezel, the shared pad (color-theory stops + intensity + grain) as the screen. Every touch drives the REAL app theme live; persistence happens at finish().
|
||||
const BeatTheme: React.FC<{
|
||||
c: ClaudeTokens;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}> = ({ c, onNext, onBack }) => {
|
||||
const { accent, setAccent, gradient, setGradient } = useThemeAccent();
|
||||
const { mode, setMode } = useThemeMode();
|
||||
const { washOpacity, grain, setWashOpacity, setGrain } = useThemeWash();
|
||||
const stops = gradient ?? (accent ? [accent] : []);
|
||||
const onStops = (next: string[] | null) => {
|
||||
setAccent(next?.[0] ?? null);
|
||||
setGradient(next && next.length > 1 ? next : null);
|
||||
};
|
||||
|
||||
// 'system' isn't a persisted mode; it applies the OS preference now and follows it while this beat is mounted.
|
||||
const [choice, setChoice] = React.useState<'light' | 'dark' | 'system'>(mode);
|
||||
const followSystem = useRef(false);
|
||||
const pickSystem = useCallback(() => {
|
||||
setChoice('system');
|
||||
followSystem.current = true;
|
||||
setMode(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
}, [setMode]);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const onChange = () => { if (followSystem.current) setMode(mq.matches ? 'dark' : 'light'); };
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
}, [setMode]);
|
||||
const pickMode = useCallback((m: 'light' | 'dark') => { followSystem.current = false; setChoice(m); setMode(m); }, [setMode]);
|
||||
|
||||
const MODES = [
|
||||
{ key: 'light' as const, Icon: Sun, onPick: () => pickMode('light') },
|
||||
{ key: 'dark' as const, Icon: Moon, onPick: () => pickMode('dark') },
|
||||
{ key: 'system' as const, Icon: Monitor, onPick: pickSystem },
|
||||
];
|
||||
|
||||
return (
|
||||
<BeatShell
|
||||
c={c}
|
||||
title="Make it yours."
|
||||
body="The whole app repaints as you drag. Add a second dot for a gradient."
|
||||
nextLabel="Next"
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
stageDark
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.94, y: 14 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 220, damping: 24, delay: 0.2 }}
|
||||
style={{
|
||||
// Fixed dark: the picker is a physical device, it doesn't repaint with the theme it edits.
|
||||
width: 'min(430px, 100%)', borderRadius: 20, background: '#141413',
|
||||
boxShadow: '0 18px 50px rgba(0,0,0,0.3)', padding: '14px 16px 18px', boxSizing: 'border-box',
|
||||
display: 'flex', flexDirection: 'column', gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 10 }}>
|
||||
{MODES.map(({ key, Icon, onPick }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={onPick}
|
||||
title={key.charAt(0).toUpperCase() + key.slice(1)}
|
||||
style={{
|
||||
width: 34, height: 28, borderRadius: 8, border: 'none', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: choice === key ? c.accent.primary : 'transparent',
|
||||
color: choice === key ? '#fff' : 'rgba(255,255,255,0.55)',
|
||||
transition: 'background 140ms ease, color 140ms ease',
|
||||
}}
|
||||
>
|
||||
<Icon size={15} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<AccentColorPad
|
||||
c={c}
|
||||
stops={stops}
|
||||
onChange={onStops}
|
||||
height={210}
|
||||
wash={{ opacity: washOpacity, grain, onOpacity: setWashOpacity, onGrain: setGrain }}
|
||||
/>
|
||||
</motion.div>
|
||||
</BeatShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeatTheme;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user