feat(desktop): polish desktop shell and shared WebUI surfaces (#4195)

* feat(desktop): add native host scaffold

* feat(webui): track turns and usage in gateway

* feat(webui): polish desktop chat experience

* feat(apps): add ArcGIS and Joplin logos

* feat(desktop): polish shell and shared surfaces

* fix(webui): avoid preview chips for glob references

* test: align CI expectations for token fallback

* feat(webui): preview prompt rail entries

* feat(webui): add prompt navigator drawer

* style(webui): refine prompt navigator placement

* style(webui): align prompt navigator with header actions

* style(webui): simplify prompt navigator header

* refactor(webui): clean thread resource refresh

* feat(desktop): add native reply notifications

* fix(webui): preserve desktop restart and replay state

* fix(desktop): harden gateway proxy startup

* fix(web): fall back when readability is unavailable

* fix(desktop): hide window instead of closing on macos

* fix(webui): unify desktop header actions

* fix(webui): simplify prompt history rows

* fix(desktop): log notification delivery failures

* chore(desktop): clean source package artifacts

* fix(cron): support one-time relative reminders

* fix(webui): reveal scroll button in place

* Revert "fix(cron): support one-time relative reminders"

This reverts commit 4c4661da120a3c7283e0768412bae48604e7390b.

* refactor(webui): extract token usage heatmap

* docs(desktop): clarify contributor guides

---------

Co-authored-by: chengyongru <2755839590@qq.com>
This commit is contained in:
Xubin Ren 2026-06-06 19:49:33 +08:00 committed by GitHub
parent a1b9577224
commit ab9f49970d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
103 changed files with 10483 additions and 1003 deletions

9
.gitignore vendored
View File

@ -6,8 +6,13 @@
.env
.web
.orion
nanobot-desktop/
desktop/
# Desktop app generated artifacts
desktop/build/
desktop/dist/
desktop/node_modules/
desktop/package-lock.json
desktop/resources/nanobot-engine/
# Claude / AI assistant artifacts
docs/superpowers/

View File

@ -31,7 +31,7 @@
</p>
</div>
🐈 **nanobot** is an open-source, ultra-lightweight agent runtime for people who want to own their AI agent stack. It gives you a small, readable core plus the practical pieces for real long-running agents: WebUI, chat channels, tools, memory, MCP, model routing, and deployment.
🐈 **nanobot** is an open-source, ultra-lightweight personal AI agent you can truly own. It keeps the agent core small and readable while giving you the practical pieces for real long-running work: WebUI, chat channels, tools, memory, MCP, model routing, automation, and deployment.
## 📢 News

129
desktop/README.md Normal file
View File

@ -0,0 +1,129 @@
# nanobot Desktop
Mac-first desktop app for running nanobot locally with the same product UI as
the browser WebUI.
For users, the desktop app is a local wrapper around nanobot: it starts the
engine for you, keeps config and chat state in the platform app data directory,
and uses the shared WebUI for chat, settings, apps, skills, and workspace
selection.
For contributors, this folder is a native host shell. It reuses the root WebUI
build at `nanobot/web/dist`; it does not copy or fork `webui/src`. Electron owns
the local engine lifecycle, exposes `window.nanobotHost` to the renderer, serves
the `nanobot-app://` app protocol, and proxies `/api/*` plus `/webui/bootstrap`
to a private Unix socket `nanobot desktop-gateway` process.
## What To Read
- Using or trying the app from source: start with the development commands below.
- Changing desktop behavior: read [`docs/development.md`](docs/development.md).
- Adding native host capabilities: read [`docs/host-contract.md`](docs/host-contract.md).
- Keeping browser WebUI and desktop aligned: read [`docs/webui-sync.md`](docs/webui-sync.md).
## Development
This section is for contributors working from a source checkout.
```sh
cd desktop
bun run dev:webui
```
In another terminal:
```sh
cd desktop
bun run dev:app
```
`dev:app` points Electron at the Vite dev server so WebUI changes hot reload.
For source checkouts, the app uses `python3` by default and injects the repo
root into `PYTHONPATH`. Packaged builds look for a bundled interpreter at
`Resources/nanobot-engine/bin/python3`.
## Engine Bundle
Release builds prepare `resources/nanobot-engine/` from a macOS
`python-build-standalone` archive before running `electron-builder`.
By default the script discovers the latest `astral-sh/python-build-standalone`
CPython 3.12 `install_only` asset for the requested architecture.
```sh
cd desktop
bun run make:mac:arm64
bun run make:mac:x64
```
Useful overrides:
- `NANOBOT_DESKTOP_ARCH=arm64|x64`
- `NANOBOT_DESKTOP_PYTHON_VERSION=3.12`
- `PYTHON_STANDALONE_RELEASE=20260510`
- `PYTHON_STANDALONE_TARBALL=/path/to/archive.tar.gz`
- `PYTHON_STANDALONE_URL=https://.../cpython-...tar.gz`
- `NANOBOT_WHEELHOUSE=/path/to/wheels` to install from a locked wheelhouse
The script installs the current checkout's `nanobot-ai[api]` into the bundled
runtime and writes `nanobot-engine.json` for diagnostics.
## Updating Builds
The native host does not copy the WebUI source or fork the Python agent code. A
release bundle is assembled from the current repository state:
1. Build the shared WebUI:
```sh
bun run build --prefix webui
```
`electron-builder` packages the resulting `nanobot/web/dist` directory as
`Resources/nanobot-webui`.
2. Prepare the bundled Python engine:
```sh
cd desktop
NANOBOT_DESKTOP_ARCH=arm64 bun run prepare-engine
```
The script installs the current checkout's `nanobot-ai[api]` package into
`resources/nanobot-engine/`, so agent, provider, tool, WebSocket, and config
changes flow into the next desktop build automatically.
3. Build the desktop app and DMG:
```sh
bun run make:mac:arm64
bun run make:mac:x64
```
User data is not stored in the app bundle. Config, sessions, logs, workspace
state, and the default workspace remain under the platform app data directory,
so updating the app replaces code without overwriting local user state.
## Runtime Contract
- User data lives under Electron's platform app data directory. In development
this is usually `~/Library/Application Support/@nanobot/desktop/` on macOS;
packaged builds use the packaged app name.
- Fresh installs start the private engine directly. The Python desktop gateway
creates the first `config.json` with defaults, then shared WebUI settings own
provider, model, and credential setup.
- The gateway listens on a per-user Unix socket in the app data directory and uses a transient secret.
- The gateway starts with only the WebSocket local channel enabled and does not serve the WebUI static bundle.
- The renderer loads assets through `nanobot-app://app/...`; browser users cannot open the native UI from a localhost port.
- WebSocket traffic uses the generic `nanobot-host://engine/...` URL, is bridged over Electron IPC, and still uses the short-lived token minted by `/webui/bootstrap`.
- Host IPC only accepts the trusted app origin, and socket bridging only accepts the `nanobot-host://engine/...` scheme.
- Native WebUI responses include a restrictive Content Security Policy.
- WebUI talks only to the generic `window.nanobotHost` contract. Product-specific native behavior stays in this folder.
Generated release artifacts, node modules, and bundled runtimes remain ignored
so the tracked desktop package stays source-only.
See also:
- [`docs/development.md`](docs/development.md)
- [`docs/host-contract.md`](docs/host-contract.md)
- [`docs/webui-sync.md`](docs/webui-sync.md)

594
desktop/bun.lock Normal file
View File

@ -0,0 +1,594 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "@nanobot/desktop",
"devDependencies": {
"@types/node": "^22.10.5",
"electron": "^42.3.0",
"electron-builder": "^26.8.1",
"typescript": "^5.7.2",
},
},
},
"packages": {
"7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="],
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
"@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
"@electron/get": ["@electron/get@5.0.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA=="],
"@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="],
"@electron/osx-sign": ["@electron/osx-sign@1.3.3", "", { "dependencies": { "compare-version": "^0.1.2", "debug": "^4.3.4", "fs-extra": "^10.0.0", "isbinaryfile": "^4.0.8", "minimist": "^1.2.6", "plist": "^3.0.5" }, "bin": { "electron-osx-flat": "bin/electron-osx-flat.js", "electron-osx-sign": "bin/electron-osx-sign.js" } }, "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg=="],
"@electron/rebuild": ["@electron/rebuild@4.0.4", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^12.2.0", "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg=="],
"@electron/universal": ["@electron/universal@2.0.3", "", { "dependencies": { "@electron/asar": "^3.3.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", "fs-extra": "^11.1.1", "minimatch": "^9.0.3", "plist": "^3.1.0" } }, "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g=="],
"@electron/windows-sign": ["@electron/windows-sign@1.2.2", "", { "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", "fs-extra": "^11.1.1", "minimist": "^1.2.8", "postject": "^1.0.0-alpha.6" }, "bin": { "electron-windows-sign": "bin/electron-windows-sign.js" } }, "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
"@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="],
"@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="],
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
"@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],
"@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="],
"@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="],
"@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="],
"@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="],
"@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
"@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="],
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
"abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"app-builder-bin": ["app-builder-bin@5.0.0-alpha.12", "", {}, "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w=="],
"app-builder-lib": ["app-builder-lib@26.8.1", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
"astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
"brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"builder-util": ["builder-util@26.8.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="],
"builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
"cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="],
"cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="],
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
"compare-version": ["compare-version@0.1.2", "", {}, "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
"crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="],
"cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
"defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
"dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="],
"dmg-builder": ["dmg-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg=="],
"dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": "bin/dmg-license.js" }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": "bin/cli.js" }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron": ["electron@42.3.0", "", { "dependencies": { "@electron/get": "^5.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-9ZiLdRXk+WDxW1OgIUz8J2rIQ5TYU9o629gCOjU48Q3dQiOmym7osWsH5Ubs/Jh4uuFLn6m6SBD2rmRXLAPz9g=="],
"electron-builder": ["electron-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw=="],
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA=="],
"electron-publish": ["electron-publish@26.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="],
"electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": "cli.js" }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
"extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"got": ["got@11.8.6", "", { "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", "p-cancelable": "^2.0.0", "responselike": "^2.0.0" } }, "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"iconv-corefoundation": ["iconv-corefoundation@1.1.7", "", { "dependencies": { "cli-truncate": "^2.1.0", "node-addon-api": "^1.6.3" }, "os": "darwin" }, "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": "bin/cli.js" }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
"jiti": ["jiti@2.7.0", "", { "bin": "lib/jiti-cli.mjs" }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
"json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="],
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime": ["mime@2.6.0", "", { "bin": "cli.js" }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": "bin/cmd.js" }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"node-abi": ["node-abi@4.31.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw=="],
"node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
"node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="],
"node-gyp": ["node-gyp@12.3.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": "bin/node-gyp.js" }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="],
"nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": "bin/nopt.js" }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="],
"normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="],
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="],
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
"postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": "dist/cli.js" }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="],
"proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="],
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
"promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="],
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
"read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": "cli.js" }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="],
"resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="],
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": "bin.js" }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sanitize-filename": ["sanitize-filename@1.6.4", "", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg=="],
"sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
"semver": ["semver@7.8.1", "", { "bin": "bin/semver.js" }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
"serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="],
"slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tar": ["tar@7.5.15", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ=="],
"temp": ["temp@0.9.4", "", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="],
"temp-file": ["temp-file@3.4.0", "", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="],
"tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="],
"tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="],
"truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="],
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@7.27.0", "", {}, "sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="],
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@electron/asar/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="],
"@electron/universal/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
"@electron/universal/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"@electron/windows-sign/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
"app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
"app-builder-lib/semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"electron/@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
"electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
"filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
"glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"node-gyp/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
"node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
"postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": "bin/semver" }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="],
"@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
"app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="],
"electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"electron/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"filelist/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
"glob/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="],
"node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
"@electron/asar/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"@electron/universal/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"app-builder-lib/@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"dir-compare/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
}
}

116
desktop/docs/development.md Normal file
View File

@ -0,0 +1,116 @@
# Desktop Development Guide
This guide is for GitHub contributors who want to change the desktop app. If
you are using nanobot rather than developing it, the important bit is simpler:
desktop runs the local engine for you and shows the same chat, settings, apps,
skills, and workspace UI as the browser WebUI.
`desktop` is the native host for the shared nanobot WebUI. It is not a fork of
the WebUI, and it should not grow a second copy of product UI.
The healthy mental model is:
```text
nanobot core -> agent runtime, gateway, providers, tools, memory
webui -> shared product UI and runtime-aware UI
desktop -> native host, engine lifecycle, secure host capabilities
```
## Development Workflow
Use this when developing from a source checkout.
Run the shared WebUI dev server:
```sh
cd desktop
bun run dev:webui
```
Run the Electron host in another terminal:
```sh
cd desktop
bun run dev:app
```
In development, Electron loads `http://127.0.0.1:5173`, so changes under
`webui/src` hot reload. Changes under `desktop/src` require restarting
`dev:app`.
For source checkouts, the host starts the engine with local `python3` and
injects the repository root into `PYTHONPATH`. This means Python changes under
`nanobot/` are picked up from the current checkout.
## Where Code Goes
Use this table before adding a desktop feature:
| Change | Location |
| --- | --- |
| Agent behavior, tools, providers, memory, config schema | `nanobot/` |
| Shared chat UI, settings UI, reusable product UI | `webui/` |
| Runtime-aware UI rows, such as native engine status or open logs buttons | `webui/` |
| The implementation behind native capabilities | `desktop/src/main.ts` |
| The trusted renderer bridge contract | `desktop/src/preload.cts` and `desktop/docs/host-contract.md` |
| Electron window, app protocol, native menus, lifecycle, packaging | `desktop/src` and `desktop/package.json` |
| WebSocket-over-Unix-socket bridge | `desktop/src/unixWebSocket.ts` |
| Bundled Python runtime preparation | `desktop/scripts/prepare-engine.mjs` |
For example, if desktop Settings needs an "Open logs" button, the button belongs
in the shared WebUI settings page because it is product UI. The actual filesystem
operation belongs in the desktop host and is exposed through `window.nanobotHost`.
## Host Contract
The shared WebUI talks to desktop through `window.nanobotHost`. WebUI code may
check for host capabilities, but it must not import Electron, Node.js modules,
or desktop source files.
Prefer capability-driven UI:
```text
if host can open logs -> show Open logs
if host can restart engine -> show Restart engine
```
Avoid platform-driven UI:
```text
if desktop -> run Electron-specific logic in WebUI
```
This keeps the WebUI usable in browsers and leaves room for future native hosts
without rewriting product screens.
## Adding A Desktop Feature
Before implementing, answer these questions:
1. Is this product UI or a native capability?
2. Can the WebUI express it through a generic capability instead of a desktop flag?
3. Does the host API validate trusted origins and accepted URL schemes?
4. Does browser WebUI still work when `window.nanobotHost` is missing?
5. Does the engine behavior belong in nanobot core instead of Electron?
6. Does packaged mode use app data for user state instead of app resources?
## Anti-Patterns
- Do not copy or fork `webui/src` into `desktop/`.
- Do not import Electron or Node.js modules from `webui/src`.
- Do not add provider-specific onboarding screens to `desktop/`.
- Do not duplicate WebUI settings or login flows in Electron-owned HTML.
- Do not make `desktop/src/main.ts` own agent behavior.
- Do not commit `desktop/node_modules`, `desktop/build`, `desktop/dist`, DMGs,
or `desktop/resources/nanobot-engine`.
## Release Shape
Release builds assemble three existing parts:
1. the shared WebUI build from `nanobot/web/dist`,
2. the Python engine prepared under `desktop/resources/nanobot-engine`,
3. the Electron host compiled from `desktop/src`.
User config, logs, sessions, workspace state, and the default workspace live in
the platform app data directory, not inside the app bundle.

View File

@ -0,0 +1,94 @@
# Native Host Contract
This is a contributor reference for the boundary between the shared WebUI and
the native desktop host. Users should not need this contract to run the app, but
it explains why the desktop app can use native capabilities without turning the
WebUI into Electron-specific code.
`desktop` is a native host shell around the shared WebUI build. The renderer
must not import Electron directly. It receives a minimal bridge at
`window.nanobotHost`.
## Runtime API
```ts
type HostRuntimeInfo = {
surface: "native";
app_version: string;
engine_status: "starting" | "ready" | "restarting" | "stopped" | "crashed";
data_dir: string;
logs_dir: string;
config_path: string;
workspace_path: string;
python: string;
engine_transport?: "unix_socket";
};
type HostSocketEvent =
| { id: string; type: "open" }
| { id: string; type: "message"; data: string }
| { id: string; type: "error"; message: string }
| { id: string; type: "close"; code?: number; reason?: string };
type NanobotHost = {
getRuntimeInfo(): Promise<HostRuntimeInfo>;
restartEngine(): Promise<void>;
pickFolder(): Promise<string | null>;
openLogs(): Promise<void>;
exportDiagnostics(): Promise<string>;
checkForUpdates(): Promise<{ supported: boolean; message?: string }>;
openSocket(url: string): Promise<string>;
sendSocket(id: string, data: string): Promise<void>;
closeSocket(id: string): Promise<void>;
onSocketEvent(listener: (event: HostSocketEvent) => void): () => void;
onRuntimeStatus(listener: (status: HostRuntimeInfo["engine_status"]) => void): () => void;
};
```
## First Run
The desktop host starts the private engine immediately. If the native data
directory has no `config.json`, `nanobot desktop-gateway` creates one with
defaults before serving the shared WebUI. Provider, model, credential, and login
setup stay in WebUI settings instead of Electron-owned HTML.
## Socket Bridge
The engine listens on a per-user Unix socket under the app data directory.
`/webui/bootstrap` returns `runtime_surface: "native"` and a WebSocket URL in
the `nanobot-host://engine/...` scheme. WebUI never opens that URL directly in
the browser runtime; it hands the URL to `window.nanobotHost.openSocket`.
The native host then performs the WebSocket handshake against the Unix socket
and forwards events over Electron IPC.
## Host Security Boundary
The host bridge is intentionally narrower than a general Electron preload:
- IPC calls are accepted only from renderer frames loaded from `nanobot-app://app/...`.
- `openSocket` accepts only `nanobot-host://engine/...` URLs.
- External navigation is denied in the app window; safe web links are opened by
the operating system.
- Native WebUI responses carry a restrictive Content Security Policy and
`X-Content-Type-Options: nosniff`.
- The renderer runs with `nodeIntegration: false`, `contextIsolation: true`,
`sandbox: true`, and `webSecurity: true`.
Security-sensitive tool behavior still belongs in nanobot core. The host
protects the native app boundary; the engine protects file, network, and tool
permissions.
## Data Directory
The host stores config, workspace, sessions, logs, and transient socket files
under Electron's platform app data directory. In development on macOS this is
usually:
```text
~/Library/Application Support/@nanobot/desktop/
```
Packaged builds use the packaged app name.
The app bundle is replaceable. User data is not stored in the bundle.

View File

@ -0,0 +1,71 @@
# WebUI Sync Workflow
This workflow is for contributors keeping the desktop app and browser WebUI in
sync. Users should experience them as one product surface: desktop adds a native
host and local engine lifecycle, while chat, settings, apps, skills, and
workspace UI still come from the shared WebUI.
`desktop` consumes the shared WebUI build output. It must not copy, fork, or
vendor `webui/src`.
## Development
Run the WebUI dev server:
```sh
cd desktop
bun run dev:webui
```
Run the native host in another terminal:
```sh
cd desktop
bun run dev:app
```
The host loads `http://127.0.0.1:5173` in development, so React changes hot
reload. Main/preload changes still require restarting `dev:app`.
## Release Build
1. Build the shared WebUI:
```sh
bun run build --prefix webui
```
2. Prepare the bundled Python engine:
```sh
cd desktop
NANOBOT_DESKTOP_ARCH=arm64 bun run prepare-engine
```
3. Build the app:
```sh
bun run make:mac:arm64
bun run make:mac:x64
```
`electron-builder` packages `nanobot/web/dist` as `Resources/nanobot-webui`.
## Checklist
- WebUI source remains host-neutral: it may branch on generic runtime
capabilities, but it must not import Electron or desktop source files.
```sh
rg -n "from ['\\\"]electron|desktop/src|nanobotDesktop" webui/src
```
This command should print nothing.
- Native host behavior is implemented in `desktop/src`.
- Provider, model, credential, and login setup stay in shared WebUI settings.
Do not duplicate those flows in Electron-owned HTML.
- Shared UI behavior is implemented in `webui/src` through `window.nanobotHost`
and generic runtime capability checks.
- Do not copy React components from `webui/src` into this folder.
- Do not commit bundled runtimes, DMGs, or `node_modules`.

55
desktop/package.json Normal file
View File

@ -0,0 +1,55 @@
{
"name": "@nanobot/desktop",
"version": "0.2.1",
"private": true,
"type": "module",
"main": "build/main.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"build:webui": "cd ../webui && bun run build",
"prepare-engine": "node scripts/prepare-engine.mjs",
"print-engine-url": "node scripts/prepare-engine.mjs --print-runtime-url",
"dev": "bun run dev:app",
"dev:app": "NANOBOT_DESKTOP_WEB_DEV_URL=http://127.0.0.1:5173 bun run build && electron .",
"dev:webui": "cd ../webui && bun run dev",
"start": "electron .",
"make:mac:arm64": "bun run build:webui && NANOBOT_DESKTOP_ARCH=arm64 bun run prepare-engine && bun run build && electron-builder --mac dmg --arm64",
"make:mac:x64": "bun run build:webui && NANOBOT_DESKTOP_ARCH=x64 bun run prepare-engine && bun run build && electron-builder --mac dmg --x64",
"dist:mac:arm64": "electron-builder --mac dmg --arm64",
"dist:mac:x64": "electron-builder --mac dmg --x64"
},
"devDependencies": {
"@types/node": "^22.10.5",
"electron": "^42.3.0",
"electron-builder": "^26.8.1",
"typescript": "^5.7.2"
},
"build": {
"appId": "wiki.nanobot.desktop",
"productName": "nanobot",
"asar": true,
"files": [
"build/**/*",
"package.json"
],
"extraResources": [
{
"from": "../nanobot/web/dist",
"to": "nanobot-webui"
},
{
"from": "resources/nanobot-engine",
"to": "nanobot-engine",
"filter": [
"**/*"
]
}
],
"mac": {
"category": "public.app-category.developer-tools",
"target": [
"dmg"
]
}
}
}

View File

@ -0,0 +1,265 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import {
cp,
lstat,
mkdir,
readdir,
readFile,
readlink,
rm,
stat,
symlink,
unlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..", "..");
const desktopRoot = path.resolve(__dirname, "..");
const engineDest = path.resolve(
process.env.NANOBOT_ENGINE_DEST ?? path.join(desktopRoot, "resources", "nanobot-engine"),
);
const pythonVersion = process.env.NANOBOT_DESKTOP_PYTHON_VERSION ?? "3.12";
const githubBase = "https://github.com/astral-sh/python-build-standalone";
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
stdio: "inherit",
...options,
});
if (result.status !== 0) {
throw new Error(`${command} ${args.join(" ")} failed`);
}
}
async function download(url, destination) {
const response = await fetch(url, {
headers: {
"User-Agent": "nanobot/desktop-build",
},
});
if (!response.ok) {
throw new Error(`failed to download ${url}: HTTP ${response.status}`);
}
await writeFile(destination, Buffer.from(await response.arrayBuffer()));
}
async function fetchText(url) {
const response = await fetch(url, {
headers: {
"User-Agent": "nanobot/desktop-build",
"Accept": "text/html",
},
});
if (!response.ok) {
throw new Error(`failed to fetch ${url}: HTTP ${response.status}`);
}
return await response.text();
}
function targetTriple() {
const requested = process.env.NANOBOT_DESKTOP_ARCH ?? process.arch;
if (requested === "arm64" || requested === "aarch64") return "aarch64-apple-darwin";
if (requested === "x64" || requested === "x86_64") return "x86_64-apple-darwin";
throw new Error(`unsupported desktop engine arch: ${requested}`);
}
function latestReleaseTag(html) {
const match = html.match(/\/astral-sh\/python-build-standalone\/releases\/tag\/(\d{8})/);
if (!match) {
throw new Error("could not find latest python-build-standalone release tag");
}
return match[1];
}
async function defaultStandaloneUrl() {
const release =
process.env.PYTHON_STANDALONE_RELEASE
?? latestReleaseTag(await fetchText(`${githubBase}/releases`));
const triple = targetTriple();
const assetHtml = await fetchText(`${githubBase}/releases/expanded_assets/${release}`);
const escapedVersion = pythonVersion.replace(".", "\\.");
const assetPattern = new RegExp(
`cpython-${escapedVersion}\\.\\d+\\+${release}-${triple}-install_only\\.tar\\.gz`,
);
const asset = assetHtml.match(assetPattern)?.[0];
if (!asset) {
throw new Error(
`could not find a CPython ${pythonVersion} install_only asset for ${triple} in ${release}`,
);
}
return `${githubBase}/releases/download/${release}/${asset}`;
}
async function walk(dir, matches = []) {
for (const entry of await readdir(dir)) {
const fullPath = path.join(dir, entry);
const info = await stat(fullPath);
if (info.isDirectory()) {
await walk(fullPath, matches);
} else if (entry === "python3" || entry === "python") {
matches.push(fullPath);
}
}
return matches;
}
async function findStandaloneRoot(extractDir) {
const candidates = await walk(extractDir);
for (const candidate of candidates) {
const normalized = candidate.split(path.sep).join("/");
if (normalized.endsWith("/install/bin/python3")) {
return path.dirname(path.dirname(candidate));
}
}
for (const candidate of candidates) {
const parent = path.dirname(candidate);
if (path.basename(parent) === "bin") {
return path.dirname(parent);
}
}
throw new Error("could not find python-build-standalone bin/python3 in extracted archive");
}
function isInside(parent, child) {
const relative = path.relative(parent, child);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
async function rewriteInternalSymlinks(root, sourceRoot) {
const entries = await readdir(root, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(root, entry.name);
const info = await lstat(fullPath);
if (info.isSymbolicLink()) {
const target = await readlink(fullPath);
if (!path.isAbsolute(target) || !isInside(sourceRoot, target)) {
continue;
}
const targetInBundle = path.join(engineDest, path.relative(sourceRoot, target));
const relativeTarget = path.relative(path.dirname(fullPath), targetInBundle);
await unlink(fullPath);
await symlink(relativeTarget, fullPath);
} else if (entry.isDirectory()) {
await rewriteInternalSymlinks(fullPath, sourceRoot);
}
}
}
async function assertNoExternalSymlinks(root) {
const entries = await readdir(root, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(root, entry.name);
const info = await lstat(fullPath);
if (info.isSymbolicLink()) {
const target = await readlink(fullPath);
if (path.isAbsolute(target) && !isInside(engineDest, target)) {
throw new Error(`external symlink left in engine bundle: ${fullPath} -> ${target}`);
}
} else if (entry.isDirectory()) {
await assertNoExternalSymlinks(fullPath);
}
}
}
async function tarSupportsZstd() {
const result = spawnSync("tar", ["--help"], { encoding: "utf8" });
return `${result.stdout}\n${result.stderr}`.includes("zstd");
}
async function extractArchive(archive, destination) {
await mkdir(destination, { recursive: true });
if (archive.endsWith(".tar.zst") && !(await tarSupportsZstd())) {
throw new Error("tar.zst archives require a tar build with zstd support");
}
run("tar", ["-xf", archive, "-C", destination]);
}
async function resolveArchive() {
const localArchive = process.env.PYTHON_STANDALONE_TARBALL;
if (localArchive) {
return { archive: path.resolve(localArchive), cleanupArchive: false };
}
const url = process.env.PYTHON_STANDALONE_URL ?? await defaultStandaloneUrl();
const suffix = url.endsWith(".tar.gz") ? ".tar.gz" : path.extname(url);
const downloadPath = path.join(tmpdir(), `nanobot-python-${Date.now()}${suffix}`);
console.log(`Downloading Python runtime from ${url}`);
await download(url, downloadPath);
return { archive: downloadPath, cleanupArchive: true };
}
async function installNanobot(pythonPath) {
run(pythonPath, ["-m", "ensurepip", "--upgrade"]);
run(pythonPath, ["-m", "pip", "install", "--upgrade", "pip"]);
const installArgs = ["-m", "pip", "install", "--upgrade"];
const wheelhouse = process.env.NANOBOT_WHEELHOUSE;
if (wheelhouse) {
installArgs.push("--no-index", "--find-links", path.resolve(wheelhouse));
}
installArgs.push(`${repoRoot}[api]`);
run(pythonPath, installArgs);
}
async function writeManifest(pythonPath) {
const version = spawnSync(pythonPath, ["--version"], { encoding: "utf8" });
const pyproject = await readFile(path.join(repoRoot, "pyproject.toml"), "utf8");
const match = pyproject.match(/^version\s*=\s*"([^"]+)"/m);
await writeFile(
path.join(engineDest, "nanobot-engine.json"),
JSON.stringify(
{
python: version.stdout.trim() || version.stderr.trim(),
nanobot_version: match?.[1] ?? "unknown",
prepared_at: new Date().toISOString(),
source: "python-build-standalone",
},
null,
2,
),
"utf8",
);
}
async function main() {
if (process.argv.includes("--print-runtime-url")) {
console.log(await defaultStandaloneUrl());
return;
}
const { archive, cleanupArchive } = await resolveArchive();
const extractDir = path.join(tmpdir(), `nanobot-engine-${Date.now()}`);
try {
await rm(extractDir, { recursive: true, force: true });
await extractArchive(archive, extractDir);
const standaloneRoot = await findStandaloneRoot(extractDir);
await rm(engineDest, { recursive: true, force: true });
await mkdir(path.dirname(engineDest), { recursive: true });
await cp(standaloneRoot, engineDest, { recursive: true });
await rewriteInternalSymlinks(engineDest, standaloneRoot);
await assertNoExternalSymlinks(engineDest);
const pythonPath = path.join(engineDest, "bin", "python3");
await installNanobot(pythonPath);
await writeManifest(pythonPath);
await writeFile(path.join(engineDest, ".gitkeep"), "", "utf8");
console.log(`Prepared nanobot desktop engine at ${engineDest}`);
} finally {
await rm(extractDir, { recursive: true, force: true });
if (cleanupArchive) {
await rm(archive, { force: true });
}
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

783
desktop/src/main.ts Normal file
View File

@ -0,0 +1,783 @@
import { spawn, type ChildProcess } from "node:child_process";
import { randomBytes } from "node:crypto";
import { createWriteStream, existsSync } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
app,
BrowserWindow,
dialog,
ipcMain,
net as electronNet,
protocol,
session,
shell,
} from "electron";
import type { IpcMainInvokeEvent, WebContents } from "electron";
import { UnixWebSocketClient } from "./unixWebSocket.js";
import {
clearDesktopNotificationBadge,
handleDesktopNotificationFrame,
} from "./notifications.js";
type EngineStatus = "starting" | "ready" | "restarting" | "stopped" | "crashed";
type HostRuntime = {
configPath: string;
gateway: ChildProcess;
logsDir: string;
python: string;
secret: string;
socketPath: string;
status: EngineStatus;
workspacePath: string;
};
let runtime: HostRuntime | null = null;
let mainWindow: BrowserWindow | null = null;
let crashRestartAttempts = 0;
let isQuitting = false;
const hostSockets = new Map<string, UnixWebSocketClient>();
const APP_PROTOCOL = "nanobot-app:";
const APP_HOST = "app";
const HOST_SOCKET_PROTOCOL = "nanobot-host:";
const HOST_SOCKET_HOST = "engine";
const SAFE_EXTERNAL_PROTOCOLS = new Set(["https:", "http:", "mailto:"]);
const GATEWAY_REQUEST_TIMEOUT_MS = 12_000;
const GATEWAY_REQUEST_RETRIES = 2;
const GATEWAY_RETRY_DELAY_MS = 80;
protocol.registerSchemesAsPrivileged([
{
scheme: "nanobot-app",
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: false,
},
},
]);
function repoRoot(): string {
return process.env.NANOBOT_DESKTOP_REPO_ROOT
? path.resolve(process.env.NANOBOT_DESKTOP_REPO_ROOT)
: path.resolve(app.getAppPath(), "..");
}
function bundledResourcePath(name: string): string {
return app.isPackaged
? path.join(process.resourcesPath, name)
: path.join(repoRoot(), "desktop", "resources", name);
}
function webDistPath(root: string): string {
if (process.env.NANOBOT_DESKTOP_WEB_DIST) {
return path.resolve(process.env.NANOBOT_DESKTOP_WEB_DIST);
}
const bundled = path.join(process.resourcesPath, "nanobot-webui");
if (app.isPackaged && existsSync(path.join(bundled, "index.html"))) {
return bundled;
}
return path.join(root, "nanobot", "web", "dist");
}
function webDevUrl(): string | null {
const value = process.env.NANOBOT_DESKTOP_WEB_DEV_URL?.trim();
return value ? value.replace(/\/+$/, "") : null;
}
function isTrustedAppUrl(rawUrl: string): boolean {
try {
const url = new URL(rawUrl);
return url.protocol === APP_PROTOCOL && url.host === APP_HOST;
} catch {
return false;
}
}
function assertTrustedIpc(event: IpcMainInvokeEvent): void {
const frameUrl = event.senderFrame?.url || event.sender.getURL();
if (!isTrustedAppUrl(frameUrl)) {
throw new Error("Blocked host API call from an untrusted renderer");
}
}
function parseHostSocketUrl(rawUrl: unknown): string {
if (typeof rawUrl !== "string") {
throw new Error("Host socket URL must be a string");
}
const url = new URL(rawUrl);
if (url.protocol !== HOST_SOCKET_PROTOCOL || url.host !== HOST_SOCKET_HOST) {
throw new Error("Host socket URL is not allowed");
}
if (url.username || url.password) {
throw new Error("Host socket URL credentials are not allowed");
}
return url.toString();
}
function openExternalIfSafe(rawUrl: string): void {
try {
const url = new URL(rawUrl);
if (SAFE_EXTERNAL_PROTOCOLS.has(url.protocol)) {
void shell.openExternal(url.toString());
}
} catch {
// Ignore malformed or unsupported external URLs.
}
}
function desktopContentSecurityPolicy(devUrl: string | null): string {
const connectSrc = ["'self'", "nanobot-host:"];
if (devUrl) {
const url = new URL(devUrl);
connectSrc.push(url.origin, url.origin.replace(/^http/, "ws"));
}
return [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'none'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https: nanobot-app:",
"font-src 'self' data:",
"media-src 'self' data: blob:",
"worker-src 'self' blob:",
`connect-src ${connectSrc.join(" ")}`,
].join("; ");
}
function withSecurityHeaders(response: Response, devUrl: string | null): Response {
const headers = new Headers(response.headers);
headers.set("Content-Security-Policy", desktopContentSecurityPolicy(devUrl));
headers.set("X-Content-Type-Options", "nosniff");
if (devUrl) {
headers.set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
headers.set("Pragma", "no-cache");
headers.set("Expires", "0");
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
function handleHostIpc(
channel: string,
handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown | Promise<unknown>,
): void {
ipcMain.handle(channel, async (event, ...args) => {
assertTrustedIpc(event);
return await handler(event, ...args);
});
}
function userDataPath(name: string): string {
return path.join(app.getPath("userData"), name);
}
function engineSocketPath(): string {
return userDataPath("engine.sock");
}
function pythonExecutable(): string {
if (process.env.NANOBOT_DESKTOP_PYTHON) {
return path.resolve(process.env.NANOBOT_DESKTOP_PYTHON);
}
const bundled = path.join(bundledResourcePath("nanobot-engine"), "bin", "python3");
if (existsSync(bundled)) return bundled;
return "python3";
}
function engineCwd(root: string): string {
return app.isPackaged ? app.getPath("userData") : root;
}
function engineEnv(root: string): NodeJS.ProcessEnv {
if (app.isPackaged) {
return { ...process.env };
}
return {
...process.env,
PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter),
};
}
async function ensureAppDirs(): Promise<{
configPath: string;
logsDir: string;
workspacePath: string;
}> {
const dataDir = app.getPath("userData");
const logsDir = userDataPath("logs");
const workspacePath = userDataPath("workspace");
await Promise.all([
mkdir(dataDir, { recursive: true }),
mkdir(logsDir, { recursive: true }),
mkdir(workspacePath, { recursive: true }),
]);
return {
configPath: userDataPath("config.json"),
logsDir,
workspacePath,
};
}
function appendGatewayLogs(gateway: ChildProcess, logsDir: string): void {
const logPath = path.join(logsDir, "engine.log");
const stream = createWriteStream(logPath, { flags: "a" });
gateway.stdout?.on("data", (chunk) => {
stream.write(chunk);
process.stdout.write(`[nanobot] ${chunk}`);
});
gateway.stderr?.on("data", (chunk) => {
stream.write(chunk);
process.stderr.write(`[nanobot] ${chunk}`);
});
gateway.once("exit", (code, signal) => {
stream.write(`\n[nanobot] engine exited code=${code ?? ""} signal=${signal ?? ""}\n`);
stream.end();
});
}
function notifyRuntimeStatus(status: EngineStatus): void {
if (runtime) runtime.status = status;
sendToRenderer(mainWindow?.webContents, "nanobot:runtime-status", status);
}
function sendToRenderer(
sender: WebContents | null | undefined,
channel: string,
payload: unknown,
): void {
if (!sender || sender.isDestroyed()) return;
sender.send(channel, payload);
}
function closeHostSockets(): void {
for (const [id, socket] of hostSockets) {
socket.close();
hostSockets.delete(id);
}
}
async function fetchGateway(
current: HostRuntime,
requestPath: string,
init: {
body?: ArrayBuffer;
headers?: Headers | Record<string, string>;
method: string;
},
): Promise<Response> {
let lastError: unknown;
for (let attempt = 0; attempt <= GATEWAY_REQUEST_RETRIES; attempt += 1) {
try {
return await fetchGatewayOnce(current, requestPath, init);
} catch (error) {
lastError = error;
if (!isTransientGatewayError(error) || attempt >= GATEWAY_REQUEST_RETRIES) {
break;
}
await new Promise((resolve) => setTimeout(resolve, GATEWAY_RETRY_DELAY_MS));
}
}
throw lastError instanceof Error ? lastError : new Error("gateway request failed");
}
async function fetchGatewayOnce(
current: HostRuntime,
requestPath: string,
init: {
body?: ArrayBuffer;
headers?: Headers | Record<string, string>;
method: string;
},
): Promise<Response> {
const body = init.body ? Buffer.from(init.body) : undefined;
const headers: http.OutgoingHttpHeaders = {};
if (init.headers instanceof Headers) {
init.headers.forEach((value, key) => {
headers[key] = value;
});
} else {
for (const [key, value] of Object.entries(init.headers ?? {})) {
headers[key] = value;
}
}
if (body) headers["content-length"] = String(body.length);
return await new Promise<Response>((resolve, reject) => {
let settled = false;
const fail = (error: Error) => {
if (settled) return;
settled = true;
reject(error);
};
const req = http.request(
{
socketPath: current.socketPath,
path: requestPath,
method: init.method,
headers,
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", () => {
if (settled) return;
settled = true;
const responseHeaders = new Headers();
for (const [key, value] of Object.entries(res.headers)) {
if (Array.isArray(value)) {
for (const item of value) responseHeaders.append(key, item);
} else if (value !== undefined) {
responseHeaders.set(key, String(value));
}
}
resolve(
new Response(Buffer.concat(chunks), {
status: res.statusCode ?? 500,
statusText: res.statusMessage,
headers: responseHeaders,
}),
);
});
},
);
req.setTimeout(GATEWAY_REQUEST_TIMEOUT_MS, () => {
req.destroy(new Error(`gateway request timed out after ${GATEWAY_REQUEST_TIMEOUT_MS}ms`));
});
req.on("error", fail);
if (body) req.write(body);
req.end();
});
}
function isTransientGatewayError(error: unknown): boolean {
const code = typeof error === "object" && error !== null
? (error as { code?: unknown }).code
: undefined;
if (
code === "ECONNRESET" ||
code === "ECONNREFUSED" ||
code === "EPIPE" ||
code === "ETIMEDOUT"
) {
return true;
}
const message = error instanceof Error ? error.message : "";
return message.includes("socket hang up") || message.includes("timed out");
}
async function startGateway(): Promise<HostRuntime> {
const root = repoRoot();
const dirs = await ensureAppDirs();
const socketPath = engineSocketPath();
await rm(socketPath, { force: true });
const secret = randomBytes(32).toString("base64url");
const python = pythonExecutable();
const args = [
"-m",
"nanobot",
"desktop-gateway",
"--config",
dirs.configPath,
"--workspace",
dirs.workspacePath,
"--webui-socket",
socketPath,
"--token-issue-secret",
secret,
];
const gateway = spawn(python, args, {
cwd: engineCwd(root),
env: engineEnv(root),
stdio: ["ignore", "pipe", "pipe"],
});
appendGatewayLogs(gateway, dirs.logsDir);
gateway.once("exit", () => scheduleCrashRestart(gateway));
return {
configPath: dirs.configPath,
gateway,
logsDir: dirs.logsDir,
python,
secret,
socketPath,
status: "starting",
workspacePath: dirs.workspacePath,
};
}
async function bootstrapFromGateway(current: HostRuntime): Promise<Record<string, unknown>> {
const response = await fetchGateway(current, "/webui/bootstrap", {
method: "GET",
headers: {
"X-Nanobot-Auth": current.secret,
},
});
if (!response.ok) {
throw new Error(`engine bootstrap failed: HTTP ${response.status}`);
}
return await response.json() as Record<string, unknown>;
}
async function waitForGateway(current: HostRuntime): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt < 160; attempt += 1) {
if (current.gateway.exitCode !== null) {
throw new Error(`engine gateway exited with code ${current.gateway.exitCode}`);
}
try {
await bootstrapFromGateway(current);
crashRestartAttempts = 0;
notifyRuntimeStatus("ready");
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
throw lastError instanceof Error ? lastError : new Error("engine gateway did not become ready");
}
async function stopGateway(current: HostRuntime | null): Promise<void> {
if (!current || current.gateway.exitCode !== null) return;
current.status = "stopped";
closeHostSockets();
current.gateway.kill("SIGTERM");
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
if (current.gateway.exitCode === null) current.gateway.kill("SIGKILL");
resolve();
}, 2500);
current.gateway.once("exit", () => {
clearTimeout(timer);
resolve();
});
});
}
async function startRuntime(): Promise<void> {
notifyRuntimeStatus("starting");
runtime = await startGateway();
await waitForGateway(runtime);
}
async function restartRuntime(): Promise<void> {
const previous = runtime;
notifyRuntimeStatus("restarting");
await stopGateway(previous);
await startRuntime();
}
function scheduleCrashRestart(gateway: ChildProcess): void {
if (runtime?.gateway !== gateway || runtime.status === "restarting" || runtime.status === "stopped") {
return;
}
notifyRuntimeStatus("crashed");
if (crashRestartAttempts >= 3) return;
crashRestartAttempts += 1;
setTimeout(() => {
if (runtime?.gateway !== gateway || runtime.status === "stopped") return;
void startRuntime().catch((error) => {
console.error("failed to restart nanobot engine", error);
notifyRuntimeStatus("crashed");
});
}, 1000);
}
async function proxyToGateway(request: Request): Promise<Response> {
if (!runtime) {
return new Response("Engine unavailable", { status: 503 });
}
const requestUrl = new URL(request.url);
const headers = new Headers(request.headers);
headers.delete("host");
if (requestUrl.pathname === "/webui/bootstrap") {
headers.set("X-Nanobot-Auth", runtime.secret);
}
const init: {
body?: ArrayBuffer;
headers: Headers;
method: string;
} = {
method: request.method,
headers,
};
if (request.method !== "GET" && request.method !== "HEAD") {
init.body = await request.arrayBuffer();
}
let response: Response;
try {
response = await fetchGateway(
runtime,
`${requestUrl.pathname}${requestUrl.search}`,
init,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`gateway proxy request failed: ${message}`);
return new Response("Engine unavailable", { status: 503 });
}
if (requestUrl.pathname !== "/webui/bootstrap" || !response.ok) {
return response;
}
const body = await response.json() as Record<string, unknown>;
const wsPath = typeof body.ws_path === "string" ? body.ws_path : "/";
const normalizedWsPath = wsPath.startsWith("/") ? wsPath : `/${wsPath}`;
return Response.json({
...body,
ws_url: `nanobot-host://engine${normalizedWsPath}`,
runtime_surface: "native",
});
}
function resolveStaticAsset(webDist: string, requestUrl: string): string | null {
const url = new URL(requestUrl);
const rawPath = decodeURIComponent(url.pathname);
const relativePath = rawPath === "/" ? "index.html" : rawPath.replace(/^\/+/, "");
const resolved = path.resolve(webDist, relativePath);
if (resolved !== webDist && !resolved.startsWith(`${webDist}${path.sep}`)) {
return null;
}
if (existsSync(resolved)) return resolved;
if (!path.extname(relativePath)) return path.join(webDist, "index.html");
return null;
}
function registerAppProtocol(webDist: string, devUrl: string | null): void {
protocol.handle("nanobot-app", async (request) => {
if (!isTrustedAppUrl(request.url)) {
return new Response("Forbidden", { status: 403 });
}
const requestUrl = new URL(request.url);
if (
requestUrl.pathname === "/webui/bootstrap"
|| requestUrl.pathname.startsWith("/api/")
) {
return proxyToGateway(request);
}
if (devUrl) {
const upstream = new URL(
`${requestUrl.pathname}${requestUrl.search}`,
devUrl,
);
const response = await electronNet.fetch(upstream.toString());
return withSecurityHeaders(response, devUrl);
}
const assetPath = resolveStaticAsset(webDist, request.url);
if (!assetPath) {
return new Response("Not Found", { status: 404 });
}
const response = await electronNet.fetch(pathToFileURL(assetPath).toString());
return withSecurityHeaders(response, devUrl);
});
}
function createWindow(): BrowserWindow {
const preload = path.join(app.getAppPath(), "build", "preload.cjs");
const win = new BrowserWindow({
width: 1180,
height: 820,
minWidth: 920,
minHeight: 640,
title: "nanobot",
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 14, y: 16 },
backgroundColor: process.platform === "darwin" ? "#00000000" : "#ffffff",
transparent: process.platform === "darwin",
...(process.platform === "darwin"
? {
vibrancy: "sidebar" as const,
visualEffectState: "active" as const,
}
: {}),
show: false,
webPreferences: {
preload,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true,
},
});
win.once("ready-to-show", () => win.show());
win.on("focus", clearDesktopNotificationBadge);
win.on("close", (event) => {
if (process.platform !== "darwin" || isQuitting) return;
event.preventDefault();
win.hide();
});
win.webContents.setWindowOpenHandler(({ url }) => {
openExternalIfSafe(url);
return { action: "deny" };
});
win.webContents.on("will-navigate", (event, url) => {
if (!isTrustedAppUrl(url)) {
event.preventDefault();
openExternalIfSafe(url);
}
});
win.webContents.on("preload-error", (_event, preloadPath, error) => {
console.error(`Preload failed: ${preloadPath}`, error);
});
win.on("closed", () => {
if (mainWindow === win) mainWindow = null;
closeHostSockets();
});
return win;
}
function runtimeInfo() {
return {
surface: "native" as const,
app_version: app.getVersion(),
engine_status: runtime?.status ?? "stopped",
data_dir: app.getPath("userData"),
logs_dir: runtime?.logsDir ?? userDataPath("logs"),
config_path: runtime?.configPath ?? userDataPath("config.json"),
workspace_path: runtime?.workspacePath ?? userDataPath("workspace"),
python: runtime?.python ?? pythonExecutable(),
engine_transport: "unix_socket",
};
}
function registerIpcHandlers(): void {
handleHostIpc("nanobot:get-runtime-info", () => runtimeInfo());
handleHostIpc("nanobot:restart-engine", async () => {
await restartRuntime();
});
handleHostIpc("nanobot:pick-folder", async () => {
const result = await dialog.showOpenDialog({
properties: ["openDirectory", "createDirectory"],
});
if (result.canceled || !result.filePaths[0]) return null;
return path.resolve(result.filePaths[0]);
});
handleHostIpc("nanobot:open-logs", async () => {
const logsDir = runtime?.logsDir ?? userDataPath("logs");
await mkdir(logsDir, { recursive: true });
const error = await shell.openPath(logsDir);
if (error) throw new Error(error);
});
handleHostIpc("nanobot:export-diagnostics", async () => {
const diagnosticsPath = path.join(
app.getPath("temp"),
`nanobot-diagnostics-${Date.now()}.json`,
);
await writeFile(
diagnosticsPath,
JSON.stringify(runtimeInfo(), null, 2),
"utf8",
);
shell.showItemInFolder(diagnosticsPath);
return diagnosticsPath;
});
handleHostIpc("nanobot:check-for-updates", () => ({
supported: false,
message: "Auto update is not configured for this build.",
}));
handleHostIpc("nanobot:ws-connect", (event, rawUrl) => {
if (!runtime) throw new Error("nanobot engine is not running");
const url = parseHostSocketUrl(rawUrl);
const id = randomBytes(12).toString("hex");
const client = new UnixWebSocketClient(runtime.socketPath, url, {
onOpen: () => sendToRenderer(event.sender, "nanobot:ws-event", { id, type: "open" }),
onMessage: (data) => {
handleDesktopNotificationFrame(data, { getWindow: () => mainWindow });
sendToRenderer(event.sender, "nanobot:ws-event", { id, type: "message", data });
},
onError: (message) => sendToRenderer(event.sender, "nanobot:ws-event", { id, type: "error", message }),
onClose: (code, reason) => {
hostSockets.delete(id);
sendToRenderer(event.sender, "nanobot:ws-event", { id, type: "close", code, reason });
},
});
hostSockets.set(id, client);
client.connect();
event.sender.once("destroyed", () => {
client.close();
hostSockets.delete(id);
});
return id;
});
handleHostIpc("nanobot:ws-send", (_event, id, data) => {
if (typeof id !== "string" || typeof data !== "string") {
throw new Error("Invalid host socket send arguments");
}
const socket = hostSockets.get(id);
if (!socket) throw new Error("Host socket not found");
socket.send(data);
});
handleHostIpc("nanobot:ws-close", (_event, id) => {
if (typeof id !== "string") {
throw new Error("Invalid host socket close argument");
}
hostSockets.get(id)?.close();
hostSockets.delete(id);
});
}
async function loadAppWindow(win: BrowserWindow): Promise<void> {
if (!runtime || runtime.status === "stopped" || runtime.status === "crashed") {
await startRuntime();
}
await win.loadURL("nanobot-app://app/index.html");
}
app.whenReady().then(async () => {
const root = repoRoot();
const webDist = webDistPath(root);
const devUrl = webDevUrl();
if (!devUrl && !existsSync(path.join(webDist, "index.html"))) {
throw new Error(`WebUI dist not found at ${webDist}. Run npm run build:webui first.`);
}
if (devUrl) {
await session.defaultSession.clearCache();
}
registerIpcHandlers();
registerAppProtocol(webDist, devUrl);
mainWindow = createWindow();
await loadAppWindow(mainWindow);
app.on("activate", () => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (!mainWindow.isVisible()) mainWindow.show();
mainWindow.focus();
return;
}
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow();
void loadAppWindow(mainWindow);
}
});
}).catch((error) => {
console.error(error);
app.quit();
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
app.on("before-quit", () => {
isQuitting = true;
if (runtime) runtime.status = "stopped";
if (runtime?.gateway.exitCode === null) {
runtime.gateway.kill("SIGTERM");
}
});

View File

@ -0,0 +1,159 @@
import {
app,
BrowserWindow,
Notification,
} from "electron";
type NotificationSource = {
kind?: unknown;
label?: unknown;
};
type WsMessageFrame = {
chat_id?: unknown;
event?: unknown;
kind?: unknown;
source?: NotificationSource;
stream_id?: unknown;
text?: unknown;
};
interface DesktopNotifierOptions {
getWindow: () => BrowserWindow | null;
}
const MAX_NOTIFICATION_BODY_LENGTH = 180;
const MAX_NOTIFICATION_TITLE_LENGTH = 80;
let unreadNotificationCount = 0;
const streamTextBuffers = new Map<string, string>();
export function handleDesktopNotificationFrame(
data: string,
options: DesktopNotifierOptions,
): void {
const frame = parseWsMessageFrame(data);
const notificationFrame = frame ? notificationFrameFromWsFrame(frame) : null;
if (!notificationFrame) return;
if (!shouldNotify(options.getWindow())) return;
showDesktopNotification(notificationFrame, options);
}
export function clearDesktopNotificationBadge(): void {
unreadNotificationCount = 0;
app.setBadgeCount(0);
}
function parseWsMessageFrame(data: string): WsMessageFrame | null {
try {
const parsed = JSON.parse(data) as unknown;
return parsed && typeof parsed === "object"
? parsed as WsMessageFrame
: null;
} catch {
return null;
}
}
function isAssistantNotificationFrame(frame: WsMessageFrame): frame is WsMessageFrame & {
chat_id: string;
text: string;
} {
return (
frame.event === "message" &&
typeof frame.chat_id === "string" &&
typeof frame.text === "string" &&
frame.text.trim().length > 0 &&
frame.kind !== "tool_hint" &&
frame.kind !== "progress" &&
frame.kind !== "reasoning"
);
}
function notificationFrameFromWsFrame(frame: WsMessageFrame): WsMessageFrame & {
chat_id: string;
text: string;
} | null {
if (isAssistantNotificationFrame(frame)) return frame;
if (frame.event === "delta") {
if (typeof frame.chat_id === "string" && typeof frame.text === "string") {
const key = streamNotificationKey(frame);
streamTextBuffers.set(key, `${streamTextBuffers.get(key) ?? ""}${frame.text}`);
}
return null;
}
if (frame.event === "stream_end" && typeof frame.chat_id === "string") {
const key = streamNotificationKey(frame);
const text = typeof frame.text === "string"
? frame.text
: streamTextBuffers.get(key) ?? "";
streamTextBuffers.delete(key);
return text.trim().length > 0
? { ...frame, chat_id: frame.chat_id, text }
: null;
}
return null;
}
function streamNotificationKey(frame: WsMessageFrame): string {
const streamId = typeof frame.stream_id === "string" ? frame.stream_id : "";
return `${frame.chat_id ?? ""}\u0000${streamId}`;
}
function shouldNotify(win: BrowserWindow | null): boolean {
if (!Notification.isSupported()) return false;
if (!win || win.isDestroyed()) return false;
return !win.isFocused();
}
function showDesktopNotification(
frame: WsMessageFrame & { chat_id: string; text: string },
options: DesktopNotifierOptions,
): void {
const notification = new Notification({
title: notificationTitle(frame.source),
body: notificationBody(frame.text),
subtitle: "nanobot",
});
notification.on("failed", (_event, error) => {
console.warn(`[nanobot] Desktop notification failed: ${error}`);
});
notification.on("click", () => openChatFromNotification(frame.chat_id, options));
notification.show();
unreadNotificationCount += 1;
app.setBadgeCount(unreadNotificationCount);
}
function notificationTitle(source: NotificationSource | undefined): string {
if (source?.kind === "cron" && typeof source.label === "string") {
const label = source.label.trim();
if (label) return truncateText(label, MAX_NOTIFICATION_TITLE_LENGTH);
}
return "nanobot";
}
function notificationBody(text: string): string {
const compact = text.replace(/\s+/g, " ").trim();
return truncateText(compact, MAX_NOTIFICATION_BODY_LENGTH);
}
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength - 3)}...`;
}
function openChatFromNotification(chatId: string, options: DesktopNotifierOptions): void {
const win = options.getWindow();
if (!win || win.isDestroyed()) return;
if (win.isMinimized()) win.restore();
if (!win.isVisible()) win.show();
win.focus();
clearDesktopNotificationBadge();
const sessionKey = `websocket:${chatId}`;
const hash = `#/chat/${encodeURIComponent(sessionKey)}`;
void win.webContents.executeJavaScript(
`window.location.hash = ${JSON.stringify(hash)}`,
true,
).catch(() => {});
}

55
desktop/src/preload.cts Normal file
View File

@ -0,0 +1,55 @@
import { contextBridge, ipcRenderer } from "electron";
type HostRuntimeInfo = {
surface: "native";
app_version: string;
engine_status: "starting" | "ready" | "restarting" | "stopped" | "crashed";
data_dir: string;
logs_dir: string;
config_path: string;
workspace_path: string;
python: string;
engine_transport?: "unix_socket";
};
type HostSocketEvent =
| { id: string; type: "open" }
| { data: string; id: string; type: "message" }
| { id: string; message: string; type: "error" }
| { code?: number; id: string; reason?: string; type: "close" };
contextBridge.exposeInMainWorld("nanobotHost", {
getRuntimeInfo: (): Promise<HostRuntimeInfo> =>
ipcRenderer.invoke("nanobot:get-runtime-info"),
restartEngine: (): Promise<void> => ipcRenderer.invoke("nanobot:restart-engine"),
pickFolder: (): Promise<string | null> => ipcRenderer.invoke("nanobot:pick-folder"),
openLogs: (): Promise<void> => ipcRenderer.invoke("nanobot:open-logs"),
exportDiagnostics: (): Promise<string> =>
ipcRenderer.invoke("nanobot:export-diagnostics"),
checkForUpdates: (): Promise<{ supported: boolean; message?: string }> =>
ipcRenderer.invoke("nanobot:check-for-updates"),
openSocket: (url: string): Promise<string> =>
ipcRenderer.invoke("nanobot:ws-connect", url),
sendSocket: (id: string, data: string): Promise<void> =>
ipcRenderer.invoke("nanobot:ws-send", id, data),
closeSocket: (id: string): Promise<void> =>
ipcRenderer.invoke("nanobot:ws-close", id),
onSocketEvent: (
listener: (event: HostSocketEvent) => void,
): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, payload: HostSocketEvent) => {
listener(payload);
};
ipcRenderer.on("nanobot:ws-event", handler);
return () => ipcRenderer.removeListener("nanobot:ws-event", handler);
},
onRuntimeStatus: (
listener: (status: HostRuntimeInfo["engine_status"]) => void,
): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, status: HostRuntimeInfo["engine_status"]) => {
listener(status);
};
ipcRenderer.on("nanobot:runtime-status", handler);
return () => ipcRenderer.removeListener("nanobot:runtime-status", handler);
},
});

View File

@ -0,0 +1,208 @@
import net from "node:net";
import { randomBytes } from "node:crypto";
type UnixWebSocketHandlers = {
onClose: (code?: number, reason?: string) => void;
onError: (message: string) => void;
onMessage: (data: string) => void;
onOpen: () => void;
};
const OPCODE_CONTINUATION = 0x0;
const OPCODE_TEXT = 0x1;
const OPCODE_CLOSE = 0x8;
const OPCODE_PING = 0x9;
const OPCODE_PONG = 0xa;
export class UnixWebSocketClient {
private frameBuffer = Buffer.alloc(0);
private handshakeBuffer = Buffer.alloc(0);
private open = false;
private socket: net.Socket | null = null;
private fragmentedText: Buffer[] = [];
constructor(
private readonly socketPath: string,
private readonly url: string,
private readonly handlers: UnixWebSocketHandlers,
) {}
connect(): void {
const socket = net.createConnection(this.socketPath);
this.socket = socket;
socket.once("connect", () => this.writeHandshake());
socket.on("data", (chunk) => this.handleData(chunk));
socket.on("error", (error) => this.fail(error.message));
socket.on("close", () => {
if (this.open) {
this.open = false;
this.handlers.onClose();
}
});
}
send(data: string): void {
if (!this.open || !this.socket || this.socket.destroyed) {
throw new Error("host socket is not open");
}
this.socket.write(encodeFrame(OPCODE_TEXT, Buffer.from(data, "utf8")));
}
close(code = 1000, reason = ""): void {
const socket = this.socket;
if (!socket || socket.destroyed) return;
const reasonBuffer = Buffer.from(reason, "utf8");
const payload = Buffer.alloc(2 + reasonBuffer.length);
payload.writeUInt16BE(code, 0);
reasonBuffer.copy(payload, 2);
socket.write(encodeFrame(OPCODE_CLOSE, payload));
socket.end();
}
private writeHandshake(): void {
const socket = this.socket;
if (!socket) return;
const requestUrl = new URL(this.url);
const path = `${requestUrl.pathname || "/"}${requestUrl.search}`;
const key = randomBytes(16).toString("base64");
socket.write(
[
`GET ${path} HTTP/1.1`,
"Host: nanobot.host",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Key: ${key}`,
"Sec-WebSocket-Version: 13",
"\r\n",
].join("\r\n"),
);
}
private handleData(chunk: Buffer): void {
if (!this.open) {
this.handshakeBuffer = Buffer.concat([this.handshakeBuffer, chunk]);
const headerEnd = this.handshakeBuffer.indexOf("\r\n\r\n");
if (headerEnd === -1) return;
const header = this.handshakeBuffer.subarray(0, headerEnd).toString("utf8");
const remainder = this.handshakeBuffer.subarray(headerEnd + 4);
this.handshakeBuffer = Buffer.alloc(0);
if (!header.startsWith("HTTP/1.1 101")) {
this.fail(`host socket upgrade failed: ${header.split("\r\n")[0]}`);
return;
}
this.open = true;
this.handlers.onOpen();
if (remainder.length > 0) this.handleFrames(remainder);
return;
}
this.handleFrames(chunk);
}
private handleFrames(chunk: Buffer): void {
this.frameBuffer = Buffer.concat([this.frameBuffer, chunk]);
while (this.frameBuffer.length >= 2) {
const first = this.frameBuffer[0];
const second = this.frameBuffer[1];
const fin = (first & 0x80) !== 0;
const opcode = first & 0x0f;
const masked = (second & 0x80) !== 0;
let length = second & 0x7f;
let offset = 2;
if (length === 126) {
if (this.frameBuffer.length < offset + 2) return;
length = this.frameBuffer.readUInt16BE(offset);
offset += 2;
} else if (length === 127) {
if (this.frameBuffer.length < offset + 8) return;
const bigLength = this.frameBuffer.readBigUInt64BE(offset);
if (bigLength > BigInt(Number.MAX_SAFE_INTEGER)) {
this.fail("host socket frame is too large");
return;
}
length = Number(bigLength);
offset += 8;
}
let mask: Buffer | null = null;
if (masked) {
if (this.frameBuffer.length < offset + 4) return;
mask = this.frameBuffer.subarray(offset, offset + 4);
offset += 4;
}
if (this.frameBuffer.length < offset + length) return;
const rawPayload = Buffer.from(this.frameBuffer.subarray(offset, offset + length));
this.frameBuffer = this.frameBuffer.subarray(offset + length);
const payload = mask ? unmask(rawPayload, mask) : rawPayload;
if (opcode === OPCODE_TEXT || opcode === OPCODE_CONTINUATION) {
this.handleTextFrame(opcode, payload, fin);
} else if (opcode === OPCODE_PING) {
this.socket?.write(encodeFrame(OPCODE_PONG, payload));
} else if (opcode === OPCODE_CLOSE) {
const code = payload.length >= 2 ? payload.readUInt16BE(0) : undefined;
const reason = payload.length > 2 ? payload.subarray(2).toString("utf8") : undefined;
this.open = false;
this.socket?.end();
this.handlers.onClose(code, reason);
return;
}
}
}
private handleTextFrame(opcode: number, payload: Buffer, fin: boolean): void {
if (opcode === OPCODE_TEXT && fin) {
this.handlers.onMessage(payload.toString("utf8"));
return;
}
if (opcode === OPCODE_TEXT) {
this.fragmentedText = [payload];
return;
}
if (this.fragmentedText.length === 0) return;
this.fragmentedText.push(payload);
if (fin) {
const data = Buffer.concat(this.fragmentedText).toString("utf8");
this.fragmentedText = [];
this.handlers.onMessage(data);
}
}
private fail(message: string): void {
this.handlers.onError(message);
this.socket?.destroy();
}
}
function encodeFrame(opcode: number, payload: Buffer): Buffer {
const length = payload.length;
const headerLength = length < 126 ? 2 : length <= 0xffff ? 4 : 10;
const header = Buffer.alloc(headerLength + 4);
header[0] = 0x80 | opcode;
if (length < 126) {
header[1] = 0x80 | length;
} else if (length <= 0xffff) {
header[1] = 0x80 | 126;
header.writeUInt16BE(length, 2);
} else {
header[1] = 0x80 | 127;
header.writeBigUInt64BE(BigInt(length), 2);
}
const maskOffset = headerLength;
const mask = randomBytes(4);
mask.copy(header, maskOffset);
const masked = Buffer.alloc(payload.length);
for (let i = 0; i < payload.length; i += 1) {
masked[i] = payload[i] ^ mask[i % 4];
}
return Buffer.concat([header, masked]);
}
function unmask(payload: Buffer, mask: Buffer): Buffer {
const out = Buffer.alloc(payload.length);
for (let i = 0; i < payload.length; i += 1) {
out[i] = payload[i] ^ mask[i % 4];
}
return out;
}

16
desktop/tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM"],
"types": ["node", "electron"],
"strict": true,
"skipLibCheck": true,
"noEmitOnError": true,
"outDir": "build",
"rootDir": "src",
"sourceMap": true
},
"include": ["src/**/*"]
}

View File

@ -26,6 +26,7 @@ class AgentHookContext:
final_content: str | None = None
stop_reason: str | None = None
error: str | None = None
session_key: str | None = None
@dataclass(slots=True)

View File

@ -363,14 +363,15 @@ class AgentRunner:
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
except Exception:
messages_for_model = messages
context = AgentHookContext(iteration=iteration, messages=messages)
context = AgentHookContext(
iteration=iteration,
messages=messages,
session_key=spec.session_key,
)
await hook.before_iteration(context)
response = await self._request_model(spec, messages_for_model, hook, context)
raw_usage = self._usage_dict(response.usage)
context.response = response
context.usage = dict(raw_usage)
context.tool_calls = list(response.tool_calls)
self._accumulate_usage(usage, raw_usage)
reasoning_text, cleaned_content = extract_reasoning(
response.reasoning_content,
@ -378,6 +379,9 @@ class AgentRunner:
response.content,
)
response.content = cleaned_content
raw_usage = self._usage_or_estimate(spec, messages_for_model, response)
context.usage = dict(raw_usage)
self._accumulate_usage(usage, raw_usage)
if reasoning_text and not context.streamed_reasoning:
await hook.emit_reasoning(reasoning_text)
await hook.emit_reasoning_end()
@ -504,8 +508,9 @@ class AgentRunner:
)
if hook.wants_streaming():
await hook.on_stream_end(context, resuming=False)
retry_messages = self._finalization_retry_messages(messages_for_model)
response = await self._request_finalization_retry(spec, messages_for_model)
retry_usage = self._usage_dict(response.usage)
retry_usage = self._usage_or_estimate(spec, retry_messages, response)
self._accumulate_usage(usage, retry_usage)
raw_usage = self._merge_usage(raw_usage, retry_usage)
context.response = response
@ -821,11 +826,60 @@ class AgentRunner:
spec: AgentRunSpec,
messages: list[dict[str, Any]],
):
retry_messages = list(messages)
retry_messages.append(build_finalization_retry_message())
retry_messages = self._finalization_retry_messages(messages)
kwargs = self._build_request_kwargs(spec, retry_messages, tools=None)
return await self.provider.chat_with_retry(**kwargs)
@staticmethod
def _finalization_retry_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
retry_messages = list(messages)
retry_messages.append(build_finalization_retry_message())
return retry_messages
def _usage_or_estimate(
self,
spec: AgentRunSpec,
messages: list[dict[str, Any]],
response: LLMResponse,
) -> dict[str, int]:
usage = self._usage_dict(response.usage)
total = self._usage_total(usage)
if total > 0:
usage["total_tokens"] = total
usage.setdefault("provider_tokens", total)
return usage
if response.finish_reason == "error":
return {}
return self._estimate_response_usage(spec, messages, response)
def _estimate_response_usage(
self,
spec: AgentRunSpec,
messages: list[dict[str, Any]],
response: LLMResponse,
) -> dict[str, int]:
try:
tools = spec.tools.get_definitions()
except Exception:
tools = None
prompt_tokens, _ = estimate_prompt_tokens_chain(self.provider, spec.model, messages, tools)
assistant_message = build_assistant_message(
response.content or "",
tool_calls=[tc.to_openai_tool_call() for tc in response.tool_calls],
reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks,
)
completion_tokens = estimate_message_tokens(assistant_message)
total_tokens = max(0, prompt_tokens) + max(0, completion_tokens)
if total_tokens <= 0:
return {}
return {
"prompt_tokens": max(0, prompt_tokens),
"completion_tokens": max(0, completion_tokens),
"total_tokens": total_tokens,
"estimated_tokens": total_tokens,
}
@staticmethod
def _usage_dict(usage: dict[str, Any] | None) -> dict[str, int]:
if not usage:
@ -838,6 +892,12 @@ class AgentRunner:
continue
return result
@staticmethod
def _usage_total(usage: dict[str, int]) -> int:
return max(0, usage.get("total_tokens", 0) or (
usage.get("prompt_tokens", 0) + usage.get("completion_tokens", 0)
))
@staticmethod
def _accumulate_usage(target: dict[str, int], addition: dict[str, int]) -> None:
for key, value in addition.items():

View File

@ -151,6 +151,24 @@ class SkillsLoader:
+ [f"ENV: {env_name}" for env_name in required_env_vars if not os.environ.get(env_name)]
)
def get_skill_availability(self, name: str) -> tuple[bool, str]:
"""Return whether a skill can run and why not when it cannot."""
meta = self._get_skill_meta(name)
available = self._check_requirements(meta)
return available, "" if available else self._get_missing_requirements(meta)
def get_skill_requirements(self, name: str) -> dict[str, list[str]]:
"""Return explicit command/env requirements and currently missing entries."""
requires = self._get_skill_meta(name).get("requires", {})
bins = [str(value) for value in requires.get("bins", [])]
env = [str(value) for value in requires.get("env", [])]
return {
"bins": bins,
"env": env,
"missing_bins": [value for value in bins if not shutil.which(value)],
"missing_env": [value for value in env if not os.environ.get(value)],
}
def _get_skill_description(self, name: str) -> str:
"""Get the description of a skill from its frontmatter."""
meta = self.get_skill_metadata(name)

View File

@ -826,12 +826,12 @@ class WebFetchTool(Tool):
if "application/json" in ctype:
text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json"
elif "text/html" in ctype or r.text[:256].lower().startswith(("<!doctype", "<html")):
from readability import Document
doc = Document(r.text)
content = self._to_markdown(doc.summary()) if extract_mode == "markdown" else _strip_tags(doc.summary())
text = f"# {doc.title()}\n\n{content}" if doc.title() else content
extractor = "readability"
try:
text = self._extract_readable_html(r.text, extract_mode)
extractor = "readability"
except Exception as e:
logger.warning("Readability failed for {}, using raw HTML fallback: {}", url, e)
text, extractor = _normalize(_strip_tags(r.text)), "html"
else:
text, extractor = r.text, "raw"
@ -852,6 +852,14 @@ class WebFetchTool(Tool):
logger.exception("WebFetch error for {}", url)
return json.dumps({"error": str(e), "url": url}, ensure_ascii=False)
def _extract_readable_html(self, html_content: str, extract_mode: str) -> str:
from readability import Document
doc = Document(html_content)
summary = doc.summary()
content = self._to_markdown(summary) if extract_mode == "markdown" else _strip_tags(summary)
return f"# {doc.title()}\n\n{content}" if doc.title() else content
def _to_markdown(self, html_content: str) -> str:
"""Convert HTML to markdown."""
text = re.sub(r'<a\s+[^>]*href=["\']([^"\']+)["\'][^>]*>([\s\S]*?)</a>',

View File

@ -95,6 +95,8 @@ class CliAppsRuntimeConfig:
_BRANDS: dict[str, tuple[str, str]] = {
"1password-cli": ("1password", "#3B66BC"),
"arcgis": ("arcgis", "#2C7AC3"),
"arcgis-pro": ("arcgis", "#2C7AC3"),
"audacity": ("audacity", "#0000CC"),
"blender": ("blender", "#E87D0D"),
"browser": ("googlechrome", "#4285F4"),
@ -116,6 +118,7 @@ _BRANDS: dict[str, tuple[str, str]] = {
"intelwatch": ("intel", "#0071C5"),
"iterm2": ("iterm2", "#000000"),
"jimeng": ("bytedance", "#3C8CFF"),
"joplin": ("joplin", "#1071D3"),
"kdenlive": ("kdenlive", "#527EB2"),
"krita": ("krita", "#3BABFF"),
"libreoffice": ("libreoffice", "#18A303"),

View File

@ -56,6 +56,7 @@ class ChannelManager:
bus: MessageBus,
*,
session_manager: "SessionManager | None" = None,
cron_service: Any | None = None,
webui_runtime_model_name: Callable[[], str | None] | None = None,
webui_static_dist: bool = True,
webui_runtime_surface: str = "browser",
@ -64,6 +65,7 @@ class ChannelManager:
self.config = config
self.bus = bus
self._session_manager = session_manager
self._cron_service = cron_service
self._webui_runtime_model_name = webui_runtime_model_name
self._webui_static_dist = webui_static_dist
self._webui_runtime_surface = webui_runtime_surface
@ -124,9 +126,11 @@ class ChannelManager:
static_dist_path=static_path,
workspace_path=workspace,
default_restrict_to_workspace=self.config.tools.restrict_to_workspace,
disabled_skills=set(self.config.agents.defaults.disabled_skills),
runtime_model_name=self._webui_runtime_model_name,
runtime_surface=self._webui_runtime_surface,
runtime_capabilities_overrides=self._webui_runtime_capabilities,
cron_service=self._cron_service,
logger=logger,
)
kwargs["gateway"] = gateway

View File

@ -35,9 +35,6 @@ from nanobot.utils.media_decode import (
)
from nanobot.webui.cli_apps_api import normalize_cli_app_mentions
from nanobot.webui.gateway_services import GatewayServices
from nanobot.webui.http_utils import (
is_localhost as _is_localhost,
)
from nanobot.webui.http_utils import (
normalize_config_path as _normalize_config_path,
)
@ -48,7 +45,6 @@ from nanobot.webui.http_utils import (
query_first as _query_first,
)
from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions
from nanobot.webui.transcript import append_transcript_object, build_user_transcript_event
from nanobot.webui.websocket_logging import websockets_server_logger
@ -293,12 +289,16 @@ class WebSocketChannel(BaseChannel):
self._http_router = gateway.http
self._tokens = gateway.tokens
self._media = gateway.media
self._transcripts = gateway.transcripts
self._workspaces = gateway.workspaces
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
# -- Subscription bookkeeping -------------------------------------------
def _workspace_controls_available(self, connection: Any) -> bool:
return self._http_router.workspace_controls_available(connection)
def _attach(self, connection: Any, chat_id: str) -> None:
"""Idempotently subscribe *connection* to *chat_id*."""
self._subs.setdefault(chat_id, set()).add(connection)
@ -651,7 +651,7 @@ class WebSocketChannel(BaseChannel):
connection,
lambda: self._workspaces.scope_for_new_chat(
envelope,
controls_available=_is_localhost(connection),
controls_available=self._workspace_controls_available(connection),
),
)
if scope is None:
@ -688,7 +688,7 @@ class WebSocketChannel(BaseChannel):
envelope,
chat_id=cid,
chat_running=websocket_turn_wall_started_at(cid) is not None,
controls_available=_is_localhost(connection),
controls_available=self._workspace_controls_available(connection),
),
chat_id=cid,
)
@ -740,7 +740,7 @@ class WebSocketChannel(BaseChannel):
envelope,
chat_id=cid,
chat_running=websocket_turn_wall_started_at(cid) is not None,
controls_available=_is_localhost(connection),
controls_available=self._workspace_controls_available(connection),
),
chat_id=cid,
)
@ -753,6 +753,7 @@ class WebSocketChannel(BaseChannel):
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
if envelope.get("webui") is True:
metadata["webui"] = True
metadata.update(self._transcripts.client_turn_metadata(envelope.get("turn_id")))
cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps"))
if cli_apps:
metadata["cli_apps"] = cli_apps
@ -768,13 +769,14 @@ class WebSocketChannel(BaseChannel):
"enabled": True,
"aspect_ratio": aspect_ratio if isinstance(aspect_ratio, str) else None,
}
if envelope.get("webui") is True and self.is_allowed(client_id):
self._try_append_webui_user_transcript(
if metadata.get("webui") is True and self.is_allowed(client_id):
self._transcripts.append_user_message(
cid,
content,
media_paths=media_paths,
cli_apps=cli_apps,
mcp_presets=mcp_presets,
metadata=metadata,
media_paths=media_paths or None,
cli_apps=cli_apps or None,
mcp_presets=mcp_presets or None,
)
await self._handle_message(
sender_id=client_id,
@ -836,36 +838,6 @@ class WebSocketChannel(BaseChannel):
self.logger.exception("send failed{}", label)
raise
def _try_append_webui_transcript(self, chat_id: str, wire: dict[str, Any]) -> None:
sk = f"websocket:{chat_id}"
try:
dup = json.loads(json.dumps(wire, ensure_ascii=False))
append_transcript_object(sk, dup)
except (OSError, ValueError, TypeError) as e:
self.logger.warning("webui transcript append failed: {}", e)
def _try_append_webui_user_transcript(
self,
chat_id: str,
content: str,
*,
media_paths: list[str],
cli_apps: list[dict[str, Any]],
mcp_presets: list[dict[str, Any]],
) -> None:
if content.strip() == "/stop" and not media_paths:
return
payload = build_user_transcript_event(
chat_id,
content,
media_paths=media_paths,
cli_apps=cli_apps,
mcp_presets=mcp_presets,
)
if payload is None:
return
self._try_append_webui_transcript(chat_id, payload)
async def send(self, msg: OutboundMessage) -> None:
if msg.metadata.get("_runtime_model_updated"):
await self.send_runtime_model_updated(
@ -888,20 +860,21 @@ class WebSocketChannel(BaseChannel):
self.logger.debug("no active subscribers for chat_id={}", msg.chat_id)
else:
self.logger.warning("no active subscribers for chat_id={}", msg.chat_id)
return
if msg.metadata.get("_goal_state_sync"):
blob = msg.metadata.get("goal_state")
await self.send_goal_state(msg.chat_id, blob if isinstance(blob, dict) else {"active": False})
if conns:
blob = msg.metadata.get("goal_state")
await self.send_goal_state(msg.chat_id, blob if isinstance(blob, dict) else {"active": False})
return
if msg.metadata.get("_goal_status"):
status = msg.metadata.get("goal_status")
if status in ("running", "idle"):
started_raw = msg.metadata.get("started_at", msg.metadata.get("goal_started_at"))
await self.send_goal_status(
msg.chat_id,
status,
started_at=float(started_raw) if isinstance(started_raw, int | float) else None,
)
if conns:
status = msg.metadata.get("goal_status")
if status in ("running", "idle"):
started_raw = msg.metadata.get("started_at", msg.metadata.get("goal_started_at"))
await self.send_goal_status(
msg.chat_id,
status,
started_at=float(started_raw) if isinstance(started_raw, int | float) else None,
)
return
# Signal that the agent has fully finished processing the current turn.
if msg.metadata.get("_turn_end"):
@ -909,14 +882,20 @@ class WebSocketChannel(BaseChannel):
lat_i = int(lat) if isinstance(lat, (int, float)) else None
gs = msg.metadata.get("goal_state")
gs_blob = gs if isinstance(gs, dict) else None
await self.send_turn_end(msg.chat_id, latency_ms=lat_i, goal_state=gs_blob)
await self.send_turn_end(
msg.chat_id,
latency_ms=lat_i,
goal_state=gs_blob,
metadata=msg.metadata,
)
return
if msg.metadata.get("_session_updated"):
scope = msg.metadata.get("_session_update_scope")
await self.send_session_updated(
msg.chat_id,
scope=scope if isinstance(scope, str) else None,
)
if conns:
scope = msg.metadata.get("_session_update_scope")
await self.send_session_updated(
msg.chat_id,
scope=scope if isinstance(scope, str) else None,
)
return
if msg.metadata.get("_file_edit_events"):
edits = msg.metadata.get("_file_edit_events")
@ -959,10 +938,18 @@ class WebSocketChannel(BaseChannel):
payload["kind"] = "tool_hint"
elif msg.metadata.get("_progress"):
payload["kind"] = "progress"
transcript_payload = dict(payload)
transcript_payload["text"] = text
self._try_append_webui_transcript(msg.chat_id, transcript_payload)
phase = "activity" if payload.get("kind") in ("tool_hint", "progress") else "answer"
self._transcripts.prepare_and_append(
msg.chat_id,
payload,
metadata=msg.metadata,
phase=phase,
include_source=True,
transcript_overrides={"text": text},
)
raw = json.dumps(payload, ensure_ascii=False)
if not conns:
return
for connection in conns:
await self._safe_send_to(connection, raw, label=" ")
@ -978,7 +965,7 @@ class WebSocketChannel(BaseChannel):
until the matching ``reasoning_end`` arrives.
"""
conns = list(self._subs.get(chat_id, ()))
if not conns or not delta:
if not delta:
return
meta = metadata or {}
body: dict[str, Any] = {
@ -989,8 +976,15 @@ class WebSocketChannel(BaseChannel):
stream_id = meta.get("_stream_id")
if stream_id is not None:
body["stream_id"] = stream_id
self._try_append_webui_transcript(chat_id, body)
self._transcripts.prepare_and_append(
chat_id,
body,
metadata=meta,
phase="reasoning",
)
raw = json.dumps(body, ensure_ascii=False)
if not conns:
return
for connection in conns:
await self._safe_send_to(connection, raw, label=" reasoning ")
@ -1001,8 +995,6 @@ class WebSocketChannel(BaseChannel):
) -> None:
"""Close the current reasoning stream segment for in-place renderers."""
conns = list(self._subs.get(chat_id, ()))
if not conns:
return
meta = metadata or {}
body: dict[str, Any] = {
"event": "reasoning_end",
@ -1011,8 +1003,15 @@ class WebSocketChannel(BaseChannel):
stream_id = meta.get("_stream_id")
if stream_id is not None:
body["stream_id"] = stream_id
self._try_append_webui_transcript(chat_id, body)
self._transcripts.prepare_and_append(
chat_id,
body,
metadata=meta,
phase="reasoning",
)
raw = json.dumps(body, ensure_ascii=False)
if not conns:
return
for connection in conns:
await self._safe_send_to(connection, raw, label=" reasoning_end ")
@ -1023,15 +1022,20 @@ class WebSocketChannel(BaseChannel):
metadata: dict[str, Any] | None = None,
) -> None:
conns = list(self._subs.get(chat_id, ()))
if not conns:
return
payload: dict[str, Any] = {
"event": "file_edit",
"chat_id": chat_id,
"edits": edits,
}
self._try_append_webui_transcript(chat_id, payload)
self._transcripts.prepare_and_append(
chat_id,
payload,
metadata=metadata,
phase="activity",
)
raw = json.dumps(payload, ensure_ascii=False)
if not conns:
return
for connection in conns:
await self._safe_send_to(connection, raw, label=" file_edit ")
@ -1042,8 +1046,6 @@ class WebSocketChannel(BaseChannel):
metadata: dict[str, Any] | None = None,
) -> None:
conns = list(self._subs.get(chat_id, ()))
if not conns:
return
meta = metadata or {}
stream_key = (chat_id, str(meta.get("_stream_id") or ""))
if meta.get("_stream_end"):
@ -1064,8 +1066,15 @@ class WebSocketChannel(BaseChannel):
self._stream_text_buffers.setdefault(stream_key, []).append(delta)
if meta.get("_stream_id") is not None:
body["stream_id"] = meta["_stream_id"]
self._try_append_webui_transcript(chat_id, body)
self._transcripts.prepare_and_append(
chat_id,
body,
metadata=meta,
phase="answer",
)
raw = json.dumps(body, ensure_ascii=False)
if not conns:
return
for connection in conns:
await self._safe_send_to(connection, raw, label=" stream ")
@ -1075,18 +1084,24 @@ class WebSocketChannel(BaseChannel):
latency_ms: int | None = None,
*,
goal_state: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
"""Signal that the agent has fully finished processing the current turn."""
conns = list(self._subs.get(chat_id, ()))
if not conns:
return
body: dict[str, Any] = {"event": "turn_end", "chat_id": chat_id}
if latency_ms is not None:
body["latency_ms"] = int(latency_ms)
if goal_state is not None:
body["goal_state"] = goal_state
self._try_append_webui_transcript(chat_id, body)
self._transcripts.prepare_and_append(
chat_id,
body,
metadata=metadata,
phase="complete",
)
raw = json.dumps(body, ensure_ascii=False)
if not conns:
return
for connection in conns:
await self._safe_send_to(connection, raw, label=" turn_end ")

View File

@ -5,8 +5,10 @@ import os
import select
import signal
import sys
import uuid
from collections.abc import Callable
from contextlib import nullcontext, suppress
from contextvars import ContextVar
from pathlib import Path
from typing import Any
@ -83,6 +85,34 @@ class SafeFileHistory(FileHistory):
def store_string(self, string: str) -> None:
super().store_string(_sanitize_surrogates(string))
_WEBUI_TURN_META_KEY = "webui_turn_id"
_WEBUI_MESSAGE_SOURCE_META_KEY = "_webui_message_source"
_PROACTIVE_WEBUI_METADATA: ContextVar[dict[str, Any] | None] = ContextVar(
"proactive_webui_metadata",
default=None,
)
def _proactive_delivery_metadata(
channel: str,
metadata: dict[str, Any] | None,
*,
turn_seed: str,
source_label: str | None = None,
) -> dict[str, Any]:
"""Return channel metadata for a fresh proactive delivery turn."""
out = dict(metadata or {})
out.pop(_WEBUI_TURN_META_KEY, None)
if channel == "websocket":
out[_WEBUI_TURN_META_KEY] = f"{turn_seed}:{uuid.uuid4().hex}"
source: dict[str, str] = {"kind": "cron"}
if source_label:
source["label"] = source_label
out[_WEBUI_MESSAGE_SOURCE_META_KEY] = source
return out
app = typer.Typer(
name="nanobot",
context_settings={"help_option_names": ["-h", "--help"]},
@ -738,6 +768,59 @@ def gateway(
_run_gateway(cfg, port=port)
DESKTOP_BOOTSTRAP_PROVIDER = "openai_codex"
DESKTOP_BOOTSTRAP_MODEL = "openai-codex/gpt-5.1-codex"
def _desktop_provider_error_is_recoverable(error: ValueError) -> bool:
message = str(error)
return "No API key configured" in message or "requires api_key and api_base" in message
def _desktop_provider_needs_bootstrap(config: Config) -> bool:
from nanobot.providers.factory import make_provider
try:
make_provider(config)
return False
except ValueError as e:
if not _desktop_provider_error_is_recoverable(e):
raise
return True
def _reset_desktop_config_to_unconfigured(config: Config) -> bool:
defaults = config.agents.defaults
changed = False
if defaults.model_preset is not None:
defaults.model_preset = None
changed = True
if defaults.provider:
defaults.provider = ""
changed = True
if defaults.model:
defaults.model = ""
changed = True
return changed
def _is_persisted_desktop_bootstrap(config: Config) -> bool:
defaults = config.agents.defaults
return (
defaults.model_preset is None
and defaults.provider == DESKTOP_BOOTSTRAP_PROVIDER
and defaults.model == DESKTOP_BOOTSTRAP_MODEL
and not config.model_presets
)
def _apply_desktop_runtime_bootstrap(config: Config) -> None:
defaults = config.agents.defaults
config.agents.defaults.model_preset = None
defaults.provider = DESKTOP_BOOTSTRAP_PROVIDER
defaults.model = DESKTOP_BOOTSTRAP_MODEL
def _load_or_create_desktop_config(config: str | None, workspace: str | None) -> Config:
"""Load the desktop-owned config, creating it on first launch."""
from nanobot.config.loader import (
@ -751,7 +834,7 @@ def _load_or_create_desktop_config(config: str | None, workspace: str | None) ->
config_path = Path(config).expanduser().resolve() if config else get_config_path()
set_config_path(config_path)
created = False
changed = False
if config_path.exists():
try:
loaded = resolve_config_env_vars(load_config(config_path))
@ -760,16 +843,25 @@ def _load_or_create_desktop_config(config: str | None, workspace: str | None) ->
raise typer.Exit(1)
else:
loaded = NanobotConfig()
created = True
changed = True
if workspace:
workspace_path = Path(workspace).expanduser()
loaded.agents.defaults.workspace = str(workspace_path)
created = True
changed = True
if created:
if _is_persisted_desktop_bootstrap(loaded):
changed = _reset_desktop_config_to_unconfigured(loaded) or changed
elif _desktop_provider_needs_bootstrap(loaded):
changed = _reset_desktop_config_to_unconfigured(loaded) or changed
if changed:
save_config(loaded, config_path)
return loaded
runtime_config = loaded.model_copy(deep=True)
if _desktop_provider_needs_bootstrap(runtime_config):
_apply_desktop_runtime_bootstrap(runtime_config)
return runtime_config
def _configure_desktop_gateway(
@ -889,6 +981,7 @@ def _run_gateway(
from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.session.manager import SessionManager
from nanobot.session.webui_turns import WebuiTurnCoordinator
from nanobot.webui.token_usage import TokenUsageHook
port = port if port is not None else config.gateway.port
@ -923,6 +1016,7 @@ def _run_gateway(
provider_snapshot_loader=load_provider_snapshot,
runtime_events=runtime_events,
provider_signature=provider_snapshot.signature,
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
)
WebuiTurnCoordinator(
bus=bus,
@ -946,6 +1040,9 @@ def _run_gateway(
"""Publish a user-visible message and mirror it into that channel's session."""
metadata = dict(msg.metadata or {})
record = record or bool(metadata.pop("_record_channel_delivery", False))
proactive_webui_metadata = _PROACTIVE_WEBUI_METADATA.get()
if record and msg.channel == "websocket" and proactive_webui_metadata:
metadata = {**metadata, **proactive_webui_metadata}
if metadata != (msg.metadata or {}):
msg = OutboundMessage(
channel=msg.channel,
@ -1017,6 +1114,13 @@ def _run_gateway(
except Exception:
logger.exception("Dream cron job failed")
finally:
from nanobot.webui.token_usage import record_response_token_usage
record_response_token_usage(
resp,
source="dream",
timezone_name=config.agents.defaults.timezone,
)
if store.git.is_initialized():
msg = build_dream_commit_message(
"dream: periodic memory consolidation", resp,
@ -1107,6 +1211,14 @@ def _run_gateway(
if isinstance(message_tool, MessageTool):
message_record_token = message_tool.set_record_channel_delivery(True)
proactive_webui_metadata = _proactive_delivery_metadata(
"websocket",
None,
turn_seed=f"cron:{job.id}",
source_label=job.name,
)
proactive_token = _PROACTIVE_WEBUI_METADATA.set(proactive_webui_metadata)
try:
resp = await agent.process_direct(
reminder_note,
@ -1116,6 +1228,7 @@ def _run_gateway(
on_progress=_silent,
)
finally:
_PROACTIVE_WEBUI_METADATA.reset(proactive_token)
if isinstance(cron_tool, CronTool) and cron_token is not None:
cron_tool.reset_cron_context(cron_token)
if isinstance(message_tool, MessageTool) and message_record_token is not None:
@ -1131,12 +1244,18 @@ def _run_gateway(
response, reminder_note, agent.provider, agent.model,
)
if should_notify:
proactive_metadata = _proactive_delivery_metadata(
job.payload.channel or "cli",
job.payload.channel_meta,
turn_seed=f"cron:{job.id}",
source_label=job.name,
)
await _deliver_to_channel(
OutboundMessage(
channel=job.payload.channel or "cli",
chat_id=job.payload.to,
content=response,
metadata=dict(job.payload.channel_meta),
metadata=proactive_metadata,
),
record=True,
session_key=job.payload.session_key,
@ -1158,6 +1277,7 @@ def _run_gateway(
config,
bus,
session_manager=session_manager,
cron_service=cron,
webui_runtime_model_name=_webui_runtime_model_name,
webui_static_dist=webui_static_dist,
webui_runtime_surface=webui_runtime_surface,

View File

@ -350,6 +350,13 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
elapsed = time.monotonic() - t0
content = f"Dream failed after {elapsed:.1f}s: {e}"
finally:
from nanobot.webui.token_usage import record_response_token_usage
record_response_token_usage(
resp,
source="dream",
timezone_name=getattr(loop.context, "timezone", None),
)
if store.git.is_initialized():
commit_msg = build_dream_commit_message("dream: manual run", resp)
sha = store.git.auto_commit(commit_msg)

View File

@ -71,7 +71,7 @@ class OpenAICodexProvider(LLMProvider):
try:
try:
content, tool_calls, finish_reason, reasoning_content = await _request_codex(
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
DEFAULT_CODEX_URL, headers, body, verify=True,
on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
@ -81,7 +81,7 @@ class OpenAICodexProvider(LLMProvider):
if "CERTIFICATE_VERIFY_FAILED" not in str(e):
raise
logger.warning("SSL verification failed for Codex API; retrying with verify=False")
content, tool_calls, finish_reason, reasoning_content = await _request_codex(
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
DEFAULT_CODEX_URL, headers, body, verify=False,
on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
@ -91,6 +91,7 @@ class OpenAICodexProvider(LLMProvider):
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
usage=usage,
reasoning_content=reasoning_content,
)
except Exception as e:
@ -197,7 +198,7 @@ async def _request_codex(
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str, str | None]:
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
async with httpx.AsyncClient(timeout=idle_timeout_s, verify=verify) as client:
async with client.stream("POST", url, headers=headers, json=body) as response:

View File

@ -25,12 +25,31 @@ def map_finish_reason(status: str | None) -> str:
return FINISH_REASON_MAP.get(status or "completed", "stop")
def _usage_from_response_obj(response: Any) -> dict[str, int]:
usage_raw = response.get("usage") if isinstance(response, dict) else getattr(response, "usage", None)
if not usage_raw:
return {}
if not isinstance(usage_raw, dict):
dump = getattr(usage_raw, "model_dump", None)
usage_raw = dump() if callable(dump) else vars(usage_raw)
prompt_tokens = int(usage_raw.get("input_tokens") or usage_raw.get("prompt_tokens") or 0)
completion_tokens = int(
usage_raw.get("output_tokens") or usage_raw.get("completion_tokens") or 0
)
total_tokens = int(usage_raw.get("total_tokens") or prompt_tokens + completion_tokens)
return {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
async def iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]:
"""Yield parsed JSON events from a Responses API SSE stream."""
buffer: list[str] = []
def _flush() -> dict[str, Any] | None:
data_lines = [l[5:].strip() for l in buffer if l.startswith("data:")]
data_lines = [line[5:].strip() for line in buffer if line.startswith("data:")]
buffer.clear()
if not data_lines:
return None
@ -65,7 +84,7 @@ async def consume_sse(
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str]:
"""Consume a Responses API SSE stream into ``(content, tool_calls, finish_reason)``."""
content, tool_calls, finish_reason, _ = await consume_sse_with_reasoning(
content, tool_calls, finish_reason, _, _ = await consume_sse_with_reasoning(
response,
on_content_delta=on_content_delta,
on_tool_call_delta=on_tool_call_delta,
@ -78,13 +97,14 @@ async def consume_sse_with_reasoning(
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str, str | None]:
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
"""Consume a Responses API SSE stream, including visible reasoning summaries."""
content = ""
tool_calls: list[ToolCallRequest] = []
tool_call_buffers: dict[str, dict[str, Any]] = {}
tool_call_args_emitted: set[str] = set()
finish_reason = "stop"
usage: dict[str, int] = {}
reasoning_content: str | None = None
streamed_reasoning = False
@ -198,6 +218,7 @@ async def consume_sse_with_reasoning(
response_obj = event.get("response") or {}
status = response_obj.get("status")
finish_reason = map_finish_reason(status)
usage = _usage_from_response_obj(response_obj) or usage
if not reasoning_content:
summary = _extract_reasoning_summary_from_output(response_obj.get("output") or [])
if summary:
@ -208,7 +229,7 @@ async def consume_sse_with_reasoning(
detail = event.get("error") or event.get("message") or event
raise RuntimeError(f"Response failed: {str(detail)[:500]}")
return content, tool_calls, finish_reason, reasoning_content
return content, tool_calls, finish_reason, usage, reasoning_content
def _extract_reasoning_summary_from_output(output: Any) -> str | None:
@ -280,17 +301,7 @@ def parse_response_output(response: Any) -> LLMResponse:
arguments=args if isinstance(args, dict) else {},
))
usage_raw = response.get("usage") or {}
if not isinstance(usage_raw, dict):
dump = getattr(usage_raw, "model_dump", None)
usage_raw = dump() if callable(dump) else vars(usage_raw)
usage = {}
if usage_raw:
usage = {
"prompt_tokens": int(usage_raw.get("input_tokens") or 0),
"completion_tokens": int(usage_raw.get("output_tokens") or 0),
"total_tokens": int(usage_raw.get("total_tokens") or 0),
}
usage = _usage_from_response_obj(response)
status = response.get("status")
finish_reason = map_finish_reason(status)

View File

@ -0,0 +1,137 @@
"""Workspace-scoped source preview payloads for the WebUI."""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlparse
from nanobot.security.workspace_access import WorkspaceScope
from nanobot.security.workspace_policy import WorkspaceBoundaryError, resolve_allowed_path
MAX_FILE_PREVIEW_BYTES = 384 * 1024
class WebUIFilePreviewError(ValueError):
"""Raised when a file cannot be previewed through the WebUI."""
def __init__(self, status: int, message: str) -> None:
super().__init__(message)
self.status = status
self.message = message
def file_preview_payload(
raw_path: str | None,
*,
scope: WorkspaceScope,
max_bytes: int = MAX_FILE_PREVIEW_BYTES,
) -> dict[str, Any]:
"""Return a text preview for a file inside the session workspace."""
path = _clean_preview_path(raw_path)
if not path:
raise WebUIFilePreviewError(400, "missing path")
if len(path) > 4096:
raise WebUIFilePreviewError(400, "path is too long")
try:
resolved = resolve_allowed_path(
path,
workspace=scope.project_path,
allowed_root=scope.project_path,
strict=True,
)
except FileNotFoundError as e:
raise WebUIFilePreviewError(404, "file not found") from e
except WorkspaceBoundaryError as e:
raise WebUIFilePreviewError(403, "file is outside the current workspace") from e
except OSError as e:
raise WebUIFilePreviewError(400, "invalid path") from e
if not resolved.is_file():
raise WebUIFilePreviewError(404, "file not found")
try:
with open(resolved, "rb") as f:
raw = f.read(max_bytes + 1)
except OSError as e:
raise WebUIFilePreviewError(500, "failed to read file") from e
if b"\0" in raw[:4096]:
raise WebUIFilePreviewError(415, "binary files cannot be previewed")
truncated = len(raw) > max_bytes
preview_bytes = raw[:max_bytes]
try:
content = preview_bytes.decode("utf-8")
except UnicodeDecodeError:
content = preview_bytes.decode("utf-8", errors="replace")
display_path = _display_path(resolved, scope.project_path)
return {
"path": str(resolved),
"display_path": display_path,
"project_path": str(scope.project_path),
"language": _language_for_path(resolved),
"content": content,
"size": resolved.stat().st_size,
"truncated": truncated,
}
def _clean_preview_path(raw_path: str | None) -> str:
if raw_path is None:
return ""
value = raw_path.strip()
if not value:
return ""
if value.startswith("file://"):
parsed = urlparse(value)
value = unquote(parsed.path)
if re.match(r"^/[A-Za-z]:[\\/]", value):
value = value[1:]
else:
value = unquote(value)
value = value.split("?", 1)[0].split("#", 1)[0].strip()
if not re.match(r"^[A-Za-z]:[\\/]", value):
value = re.sub(r":\d+(?::\d+)?$", "", value)
return value
def _display_path(path: Path, root: Path) -> str:
try:
return path.relative_to(root).as_posix()
except ValueError:
return path.as_posix()
def _language_for_path(path: Path) -> str:
name = path.name.lower()
ext = path.suffix.lower().lstrip(".")
if name == "dockerfile":
return "dockerfile"
return {
"cjs": "javascript",
"css": "css",
"cts": "typescript",
"html": "html",
"js": "javascript",
"json": "json",
"jsonl": "json",
"jsx": "jsx",
"md": "markdown",
"mdx": "markdown",
"mjs": "javascript",
"mts": "typescript",
"py": "python",
"pyi": "python",
"scss": "scss",
"sh": "bash",
"toml": "toml",
"ts": "typescript",
"tsx": "tsx",
"yaml": "yaml",
"yml": "yaml",
}.get(ext, ext or "text")

View File

@ -10,6 +10,7 @@ from loguru import logger as default_logger
from nanobot.webui.gateway_tokens import GatewayTokenStore
from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.transcript import WebUITranscriptRecorder
from nanobot.webui.workspaces import WebUIWorkspaceController
from nanobot.webui.ws_http import GatewayHTTPHandler
@ -21,8 +22,10 @@ class GatewayServices:
http: GatewayHTTPHandler
tokens: GatewayTokenStore
media: WebUIMediaGateway
transcripts: WebUITranscriptRecorder
workspaces: WebUIWorkspaceController
session_manager: Any | None
cron_service: Any | None
def build_gateway_services(
@ -36,6 +39,8 @@ def build_gateway_services(
runtime_model_name: Any | None,
runtime_surface: str,
runtime_capabilities_overrides: dict[str, Any] | None,
disabled_skills: set[str] | None = None,
cron_service: Any | None = None,
logger: Any = default_logger,
) -> GatewayServices:
tokens = GatewayTokenStore()
@ -43,6 +48,7 @@ def build_gateway_services(
workspace_path=workspace_path,
logger=logger,
)
transcripts = WebUITranscriptRecorder(log=logger)
workspaces = WebUIWorkspaceController(
session_manager=session_manager,
default_workspace=workspace_path,
@ -59,12 +65,17 @@ def build_gateway_services(
tokens=tokens,
media=media,
workspaces=workspaces,
skills_workspace_path=workspace_path,
disabled_skills=disabled_skills,
cron_service=cron_service,
log=logger,
)
return GatewayServices(
http=http,
tokens=tokens,
media=media,
transcripts=transcripts,
workspaces=workspaces,
session_manager=session_manager,
cron_service=cron_service,
)

View File

@ -16,8 +16,8 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Literal, Mapping
from nanobot.apps.protocol import app_manifest, compact_dict
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.apps.protocol import app_manifest, compact_dict
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
from nanobot.config.paths import get_runtime_subdir
from nanobot.config.schema import MCPServerConfig

View File

@ -0,0 +1,56 @@
"""Session-scoped automation payloads for the embedded WebUI."""
from __future__ import annotations
from typing import Any, Protocol
from nanobot.cron.types import CronJob
class _CronServiceLike(Protocol):
def list_jobs(self, *, include_disabled: bool = False) -> list[CronJob]: ...
def session_automations_payload(
cron_service: _CronServiceLike | None,
session_key: str,
) -> dict[str, Any]:
"""Return user-created automation jobs attached to a WebUI session."""
jobs: list[CronJob] = []
if cron_service is not None:
all_jobs = cron_service.list_jobs(include_disabled=True)
jobs = [job for job in all_jobs if _job_matches_session(job, session_key)]
return {"jobs": [_serialize_job(job) for job in jobs]}
def _job_matches_session(job: CronJob, session_key: str) -> bool:
payload = job.payload
if payload.kind != "agent_turn":
return False
if payload.session_key:
return payload.session_key == session_key
if payload.channel and payload.to:
return f"{payload.channel}:{payload.to}" == session_key
return False
def _serialize_job(job: CronJob) -> dict[str, Any]:
return {
"id": job.id,
"name": job.name,
"enabled": job.enabled,
"schedule": {
"kind": job.schedule.kind,
"at_ms": job.schedule.at_ms,
"every_ms": job.schedule.every_ms,
"expr": job.schedule.expr,
"tz": job.schedule.tz,
},
"payload": {
"message": job.payload.message,
},
"state": {
"next_run_at_ms": job.state.next_run_at_ms,
"last_status": job.state.last_status,
},
}

View File

@ -23,6 +23,7 @@ from nanobot.providers.image_generation import (
)
from nanobot.providers.registry import PROVIDERS, find_by_name
from nanobot.security.workspace_access import workspace_sandbox_status
from nanobot.webui.token_usage import token_usage_payload
from nanobot.webui.workspaces import (
read_webui_default_access_mode,
write_webui_default_access_mode,
@ -747,6 +748,7 @@ def settings_payload(
},
"unified_session": defaults.unified_session,
},
"usage": token_usage_payload(timezone_name=defaults.timezone),
"advanced": {
"restrict_to_workspace": config.tools.restrict_to_workspace,
"workspace_sandbox": sandbox_status.as_dict(),
@ -771,6 +773,12 @@ def settings_payload(
)
def settings_usage_payload() -> dict[str, Any]:
"""Return the lightweight token usage slice for Overview refreshes."""
config = load_config()
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
def update_agent_settings(query: QueryParams) -> dict[str, Any]:
config = load_config()
defaults = config.agents.defaults

View File

@ -27,6 +27,7 @@ from nanobot.webui.settings_api import (
logout_oauth_provider,
provider_models_payload,
settings_payload,
settings_usage_payload,
update_agent_settings,
update_image_generation_settings,
update_model_configuration,
@ -79,6 +80,8 @@ class WebUISettingsRouter:
async def dispatch(self, request: WsRequest, path: str) -> Response | None:
if path == "/api/settings":
return self._handle_settings(request)
if path == "/api/settings/usage":
return self._handle_settings_usage(request)
if path == "/api/settings/update":
return self._handle_settings_update(request)
if path == "/api/settings/model-configurations/create":
@ -184,6 +187,11 @@ class WebUISettingsRouter:
)
)
def _handle_settings_usage(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
return self._json_response(settings_usage_payload())
def _handle_settings_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()

View File

@ -0,0 +1,61 @@
"""Lightweight skill summaries for the WebUI."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from nanobot.agent.skills import SkillsLoader
def webui_skills_payload(
workspace_path: Path,
*,
disabled_skills: set[str] | None = None,
) -> dict[str, Any]:
"""Return agent skills without leaking local filesystem paths."""
loader = SkillsLoader(workspace_path, disabled_skills=disabled_skills)
entries = sorted(
loader.list_skills(filter_unavailable=False),
key=lambda entry: (entry.get("source") != "workspace", entry["name"]),
)
return {"skills": [_skill_payload(loader, entry) for entry in entries]}
def webui_skill_detail_payload(
workspace_path: Path,
name: str,
*,
disabled_skills: set[str] | None = None,
) -> dict[str, Any] | None:
"""Return a single skill's safe detail payload."""
loader = SkillsLoader(workspace_path, disabled_skills=disabled_skills)
entries = loader.list_skills(filter_unavailable=False)
entry = next((item for item in entries if item["name"] == name), None)
if entry is None:
return None
return {
**_skill_payload(loader, entry),
"requirements": loader.get_skill_requirements(name),
"raw_markdown": loader.load_skill(name) or "",
}
def _skill_payload(loader: SkillsLoader, entry: dict[str, str]) -> dict[str, Any]:
name = entry["name"]
metadata = loader.get_skill_metadata(name)
available, unavailable_reason = loader.get_skill_availability(name)
return {
"name": name,
"description": _description(metadata, name),
"source": entry.get("source", "unknown"),
"available": available,
"unavailable_reason": unavailable_reason,
}
def _description(metadata: dict[str, Any] | None, fallback: str) -> str:
if metadata is None:
return fallback
value = metadata.get("description")
return value.strip() if isinstance(value, str) and value.strip() else fallback

View File

@ -0,0 +1,357 @@
"""Workspace-scoped token usage telemetry for WebUI overview surfaces."""
from __future__ import annotations
import json
import os
import threading
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from loguru import logger
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.config.paths import get_webui_dir
TOKEN_USAGE_SCHEMA_VERSION = 1
_MAX_STATE_FILE_BYTES = 512 * 1024
_MAX_DAYS_RETAINED = 400
_USAGE_KEYS = (
"prompt_tokens",
"completion_tokens",
"cached_tokens",
"total_tokens",
"provider_tokens",
"estimated_tokens",
)
_REQUEST_KEYS = ("requests", "provider_requests", "estimated_requests")
_SOURCE_KEYS = ("user", "api", "cron", "dream", "system")
_WRITE_LOCK = threading.Lock()
def token_usage_state_path() -> Path:
return get_webui_dir() / "token-usage.json"
def default_token_usage_state() -> dict[str, Any]:
return {
"schema_version": TOKEN_USAGE_SCHEMA_VERSION,
"days": {},
"updated_at": None,
}
def _utc_now_iso() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def _zone(timezone_name: str | None) -> timezone | ZoneInfo:
if not timezone_name:
return timezone.utc
try:
return ZoneInfo(timezone_name)
except ZoneInfoNotFoundError:
return timezone.utc
def _local_day(now: datetime | None = None, *, timezone_name: str | None = None) -> str:
dt = now or datetime.now(timezone.utc)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(_zone(timezone_name)).date().isoformat()
def _clean_int(value: Any) -> int:
try:
return max(0, int(value or 0))
except (TypeError, ValueError):
return 0
def _clean_source(value: str | None) -> str:
return value if value in _SOURCE_KEYS else "system"
def _source_from_session_key(session_key: str | None) -> str:
key = session_key or ""
if key.startswith("dream:"):
return "dream"
if key == "heartbeat" or key.startswith("cron:"):
return "cron"
if key.startswith("api:"):
return "api"
if key.startswith("system:"):
return "system"
return "user"
def _normalize_usage(raw: dict[str, Any] | None) -> dict[str, int]:
if not isinstance(raw, dict):
return {}
usage = {key: _clean_int(raw.get(key)) for key in _USAGE_KEYS}
fallback_total = usage["prompt_tokens"] + usage["completion_tokens"]
if usage["total_tokens"] <= 0:
usage["total_tokens"] = fallback_total
if usage["estimated_tokens"] <= 0 and usage["provider_tokens"] <= 0:
usage["provider_tokens"] = usage["total_tokens"]
elif usage["estimated_tokens"] > 0 and usage["provider_tokens"] <= 0:
usage["estimated_tokens"] = min(usage["estimated_tokens"], usage["total_tokens"])
elif usage["provider_tokens"] > 0 and usage["estimated_tokens"] <= 0:
usage["provider_tokens"] = min(usage["provider_tokens"], usage["total_tokens"])
return usage if usage["total_tokens"] > 0 else {}
def _normalize_usage_row(row: dict[str, Any]) -> dict[str, int]:
cleaned = {key: _clean_int(row.get(key)) for key in _USAGE_KEYS}
if cleaned["total_tokens"] <= 0:
cleaned["total_tokens"] = cleaned["prompt_tokens"] + cleaned["completion_tokens"]
if cleaned["provider_tokens"] <= 0 and cleaned["estimated_tokens"] <= 0:
cleaned["provider_tokens"] = cleaned["total_tokens"]
requests = {key: _clean_int(row.get(key)) for key in _REQUEST_KEYS}
if (
requests["requests"] > 0
and requests["provider_requests"] <= 0
and requests["estimated_requests"] <= 0
):
if cleaned["estimated_tokens"] > 0 and cleaned["provider_tokens"] <= 0:
requests["estimated_requests"] = requests["requests"]
else:
requests["provider_requests"] = requests["requests"]
return {**cleaned, **requests}
def _normalize_sources(raw: Any, fallback: dict[str, int]) -> dict[str, dict[str, int]]:
sources: dict[str, dict[str, int]] = {}
if isinstance(raw, dict):
for source, row in raw.items():
if not isinstance(row, dict):
continue
normalized = _normalize_usage_row(row)
if normalized["total_tokens"] <= 0 and normalized["requests"] <= 0:
continue
source_key = _clean_source(str(source))
current = sources.get(source_key)
if current is None:
sources[source_key] = normalized
else:
for key in (*_USAGE_KEYS, *_REQUEST_KEYS):
current[key] = _clean_int(current.get(key)) + normalized[key]
if not sources and (fallback["total_tokens"] > 0 or fallback["requests"] > 0):
sources["user"] = {key: fallback[key] for key in (*_USAGE_KEYS, *_REQUEST_KEYS)}
return sources
def normalize_token_usage_state(raw: Any) -> dict[str, Any]:
state = default_token_usage_state()
if not isinstance(raw, dict):
return state
days_raw = raw.get("days")
if not isinstance(days_raw, dict):
return state
days: dict[str, dict[str, Any]] = {}
for date, row in sorted(days_raw.items())[-_MAX_DAYS_RETAINED:]:
if not isinstance(date, str) or len(date) != 10 or not isinstance(row, dict):
continue
normalized = _normalize_usage_row(row)
if normalized["total_tokens"] <= 0 and normalized["requests"] <= 0:
continue
days[date] = {
"date": date,
**normalized,
"sources": _normalize_sources(row.get("sources"), normalized),
}
state["days"] = days
updated_at = raw.get("updated_at")
state["updated_at"] = updated_at if isinstance(updated_at, str) else None
return state
def read_token_usage_state() -> dict[str, Any]:
path = token_usage_state_path()
if not path.is_file():
return default_token_usage_state()
try:
if path.stat().st_size > _MAX_STATE_FILE_BYTES:
logger.warning("token usage state too large, ignoring: {}", path)
return default_token_usage_state()
with open(path, encoding="utf-8") as f:
raw = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.warning("read token usage state failed {}: {}", path, e)
return default_token_usage_state()
return normalize_token_usage_state(raw)
def write_token_usage_state(raw: dict[str, Any]) -> dict[str, Any]:
state = normalize_token_usage_state(raw)
state["updated_at"] = _utc_now_iso()
encoded = json.dumps(
state,
ensure_ascii=False,
indent=2,
sort_keys=True,
).encode("utf-8")
if len(encoded) > _MAX_STATE_FILE_BYTES:
raise ValueError("token usage state is too large")
path = token_usage_state_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
with open(tmp, "wb") as f:
f.write(encoded)
f.write(b"\n")
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
try:
dir_fd = os.open(path.parent, os.O_RDONLY)
except OSError:
return state
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
return state
def record_token_usage(
usage: dict[str, Any] | None,
*,
source: str = "user",
timezone_name: str | None = None,
now: datetime | None = None,
) -> dict[str, Any]:
normalized = _normalize_usage(usage)
if not normalized:
return read_token_usage_state()
with _WRITE_LOCK:
state = read_token_usage_state()
day = _local_day(now, timezone_name=timezone_name)
row = dict(state["days"].get(day) or {"date": day, "requests": 0})
for key in _USAGE_KEYS:
row[key] = _clean_int(row.get(key)) + normalized.get(key, 0)
row["requests"] = _clean_int(row.get("requests")) + 1
if normalized.get("estimated_tokens", 0) > 0 and normalized.get("provider_tokens", 0) <= 0:
row["estimated_requests"] = _clean_int(row.get("estimated_requests")) + 1
else:
row["provider_requests"] = _clean_int(row.get("provider_requests")) + 1
source_key = _clean_source(source)
sources = dict(row.get("sources") or {})
source_row = dict(sources.get(source_key) or {"requests": 0})
for key in _USAGE_KEYS:
source_row[key] = _clean_int(source_row.get(key)) + normalized.get(key, 0)
source_row["requests"] = _clean_int(source_row.get("requests")) + 1
if normalized.get("estimated_tokens", 0) > 0 and normalized.get("provider_tokens", 0) <= 0:
source_row["estimated_requests"] = _clean_int(source_row.get("estimated_requests")) + 1
else:
source_row["provider_requests"] = _clean_int(source_row.get("provider_requests")) + 1
sources[source_key] = source_row
row["sources"] = sources
state["days"][day] = row
if len(state["days"]) > _MAX_DAYS_RETAINED:
kept = dict(sorted(state["days"].items())[-_MAX_DAYS_RETAINED:])
state["days"] = kept
return write_token_usage_state(state)
def record_response_token_usage(
response: Any,
*,
source: str,
timezone_name: str | None = None,
) -> None:
try:
record_token_usage(
getattr(response, "usage", None),
source=source,
timezone_name=timezone_name,
)
except Exception:
logger.exception("failed to record {} token usage", source)
def token_usage_payload(
*,
days: int = 371,
timezone_name: str | None = None,
now: datetime | None = None,
) -> dict[str, Any]:
state = read_token_usage_state()
today = datetime.fromisoformat(_local_day(now, timezone_name=timezone_name)).date()
start = today - timedelta(days=max(1, days) - 1)
day_rows = [
row
for date, row in sorted(state["days"].items())
if start.isoformat() <= date <= today.isoformat()
]
last_30_start = today - timedelta(days=29)
last_30 = [
row
for date, row in state["days"].items()
if last_30_start.isoformat() <= date <= today.isoformat()
]
last_365_start = today - timedelta(days=364)
last_365 = [
row
for date, row in state["days"].items()
if last_365_start.isoformat() <= date <= today.isoformat()
]
active_dates = {
datetime.fromisoformat(date).date()
for date, row in state["days"].items()
if _clean_int(row.get("total_tokens")) > 0
}
current_streak = 0
cursor = today
while cursor in active_dates:
current_streak += 1
cursor -= timedelta(days=1)
longest_streak = 0
running_streak = 0
for cursor in sorted(active_dates):
if cursor - timedelta(days=1) in active_dates:
running_streak += 1
else:
running_streak = 1
longest_streak = max(longest_streak, running_streak)
all_rows = list(state["days"].values())
return {
"days": day_rows,
"total_tokens": sum(_clean_int(row.get("total_tokens")) for row in all_rows),
"total_tokens_30d": sum(_clean_int(row.get("total_tokens")) for row in last_30),
"total_tokens_365d": sum(_clean_int(row.get("total_tokens")) for row in last_365),
"peak_day_tokens": max([_clean_int(row.get("total_tokens")) for row in all_rows] or [0]),
"current_streak_days": current_streak,
"longest_streak_days": longest_streak,
"active_days_30d": sum(1 for row in last_30 if _clean_int(row.get("total_tokens")) > 0),
"requests_30d": sum(_clean_int(row.get("requests")) for row in last_30),
"updated_at": state.get("updated_at"),
}
class TokenUsageHook(AgentHook):
"""Persist provider-reported token usage without coupling it to chat messages."""
def __init__(self, *, timezone_name: str | None = None) -> None:
super().__init__()
self._timezone_name = timezone_name
async def after_iteration(self, context: AgentHookContext) -> None:
try:
record_token_usage(
context.usage,
source=_source_from_session_key(context.session_key),
timezone_name=self._timezone_name,
)
except Exception:
logger.exception("failed to record token usage")

View File

@ -18,6 +18,9 @@ from nanobot.session.manager import SessionManager
WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3
_MAX_TRANSCRIPT_FILE_BYTES = 8 * 1024 * 1024
_WEBUI_TURN_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
WEBUI_TURN_METADATA_KEY = "webui_turn_id"
WEBUI_MESSAGE_SOURCE_METADATA_KEY = "_webui_message_source"
_MARKDOWN_LOCAL_IMAGE_RE = re.compile(
r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
)
@ -152,6 +155,125 @@ def append_transcript_object(session_key: str, obj: dict[str, Any]) -> None:
os.fsync(f.fileno())
def normalize_webui_turn_id(value: Any) -> str:
if isinstance(value, str):
candidate = value.strip()
if _WEBUI_TURN_ID_RE.fullmatch(candidate):
return candidate
return str(uuid.uuid4())
def webui_message_source(metadata: dict[str, Any] | None) -> dict[str, str] | None:
raw = (metadata or {}).get(WEBUI_MESSAGE_SOURCE_METADATA_KEY)
if not isinstance(raw, dict) or raw.get("kind") != "cron":
return None
source: dict[str, str] = {"kind": "cron"}
label = raw.get("label")
if isinstance(label, str) and label.strip():
source["label"] = label.strip()
return source
class WebUITranscriptRecorder:
"""Prepare and persist WebUI wire events without leaking UI rules into channels."""
def __init__(self, log: Any = logger) -> None:
self._log = log
self._turn_sequences: dict[tuple[str, str], int] = {}
def client_turn_metadata(self, value: Any) -> dict[str, str]:
return {WEBUI_TURN_METADATA_KEY: normalize_webui_turn_id(value)}
def prepare_event(
self,
chat_id: str,
event: dict[str, Any],
*,
metadata: dict[str, Any] | None = None,
phase: str | None = None,
include_source: bool = False,
) -> None:
if include_source and (source := webui_message_source(metadata)):
event["source"] = source
self._annotate_turn(chat_id, event, metadata, phase)
def prepare_and_append(
self,
chat_id: str,
event: dict[str, Any],
*,
metadata: dict[str, Any] | None = None,
phase: str | None = None,
include_source: bool = False,
transcript_overrides: dict[str, Any] | None = None,
) -> None:
self.prepare_event(
chat_id,
event,
metadata=metadata,
phase=phase,
include_source=include_source,
)
record = dict(event)
if transcript_overrides:
record.update(transcript_overrides)
self.append(chat_id, record)
def append_user_message(
self,
chat_id: str,
text: str,
*,
metadata: dict[str, Any],
media_paths: list[str] | None = None,
cli_apps: list[dict[str, Any]] | None = None,
mcp_presets: list[dict[str, Any]] | None = None,
) -> None:
if text.strip() == "/stop" and not media_paths:
return
payload = build_user_transcript_event(
chat_id,
text,
media_paths=media_paths,
cli_apps=cli_apps,
mcp_presets=mcp_presets,
)
if payload is None:
return
self.prepare_and_append(chat_id, payload, metadata=metadata, phase="user")
def append(self, chat_id: str, event: dict[str, Any]) -> None:
try:
dup = json.loads(json.dumps(event, ensure_ascii=False))
append_transcript_object(f"websocket:{chat_id}", dup)
except (OSError, ValueError, TypeError) as e:
self._log.warning("webui transcript append failed: {}", e)
def _next_turn_seq(self, chat_id: str, turn_id: str) -> int:
key = (chat_id, turn_id)
seq = self._turn_sequences.get(key, 0) + 1
self._turn_sequences[key] = seq
return seq
def _annotate_turn(
self,
chat_id: str,
event: dict[str, Any],
metadata: dict[str, Any] | None,
phase: str | None,
) -> None:
if phase is None:
return
turn_id = (metadata or {}).get(WEBUI_TURN_METADATA_KEY)
if not isinstance(turn_id, str) or not turn_id:
return
event["turn_id"] = turn_id
event["turn_phase"] = phase
event["turn_seq"] = self._next_turn_seq(chat_id, turn_id)
if phase == "complete":
self._turn_sequences.pop((chat_id, turn_id), None)
def delete_webui_transcript(session_key: str) -> bool:
path = webui_transcript_path(session_key)
if not path.is_file():
@ -560,6 +682,8 @@ def replay_transcript_to_ui_messages(
active_file_edit_segment_id: str | None = None
activity_segment_counter = 0
_ts_base = int(time.time() * 1000)
closed_turn_ids: set[str] = set()
replay_turn_aliases: dict[str, str] = {}
def _new_id(prefix: str, idx: int) -> str:
return f"{prefix}-{idx}-{uuid.uuid4().hex[:8]}"
@ -572,6 +696,42 @@ def replay_transcript_to_ui_messages(
active_activity_segment_id = segment_id
return segment_id
def _turn_fields(rec: dict[str, Any], fallback_phase: str | None = None) -> dict[str, Any]:
fields: dict[str, Any] = {}
turn_id = rec.get("turn_id")
if isinstance(turn_id, str) and turn_id:
if turn_id in closed_turn_ids:
fields["turnId"] = replay_turn_aliases.setdefault(
turn_id,
f"{turn_id}:replay:{idx}",
)
else:
fields["turnId"] = turn_id
phase = rec.get("turn_phase")
if isinstance(phase, str) and phase:
fields["turnPhase"] = phase
elif fallback_phase:
fields["turnPhase"] = fallback_phase
seq = rec.get("turn_seq")
if isinstance(seq, (int, float)):
fields["turnSeq"] = int(seq)
return fields
def _source_fields(rec: dict[str, Any]) -> dict[str, Any]:
source = rec.get("source")
if not isinstance(source, dict) or source.get("kind") != "cron":
return {}
out: dict[str, Any] = {"source": {"kind": "cron"}}
label = source.get("label")
if isinstance(label, str) and label.strip():
out["source"]["label"] = label.strip()
return out
def _same_turn(message: dict[str, Any], turn_fields: dict[str, Any]) -> bool:
turn_id = turn_fields.get("turnId")
message_turn_id = message.get("turnId")
return not turn_id or not message_turn_id or turn_id == message_turn_id
def _ensure_activity_segment() -> str:
return active_activity_segment_id or _new_activity_segment()
@ -586,7 +746,13 @@ def replay_transcript_to_ui_messages(
active_activity_segment_id = None
active_file_edit_segment_id = None
def attach_reasoning_chunk(prev: list[dict[str, Any]], chunk: str, idx: int) -> None:
def attach_reasoning_chunk(
prev: list[dict[str, Any]],
chunk: str,
idx: int,
turn_fields: dict[str, Any] | None = None,
) -> None:
turn_fields = turn_fields or {}
for i in range(len(prev) - 1, -1, -1):
candidate = prev[i]
if candidate.get("role") == "user":
@ -595,6 +761,8 @@ def replay_transcript_to_ui_messages(
break
if candidate.get("role") != "assistant":
continue
if not _same_turn(candidate, turn_fields):
break
content = str(candidate.get("content") or "")
has_answer = len(content) > 0
if (
@ -608,6 +776,7 @@ def replay_transcript_to_ui_messages(
"reasoning": (str(candidate.get("reasoning") or "")) + chunk,
"reasoningStreaming": True,
"activitySegmentId": candidate.get("activitySegmentId") or _ensure_activity_segment(),
**turn_fields,
}
return
if not has_answer and candidate.get("isStreaming"):
@ -616,6 +785,7 @@ def replay_transcript_to_ui_messages(
"reasoning": chunk,
"reasoningStreaming": True,
"activitySegmentId": candidate.get("activitySegmentId") or _ensure_activity_segment(),
**turn_fields,
}
return
break
@ -629,11 +799,16 @@ def replay_transcript_to_ui_messages(
"reasoning": chunk,
"reasoningStreaming": True,
"activitySegmentId": segment,
**turn_fields,
"createdAt": _ts_base + idx,
},
)
def find_active_placeholder(prev: list[dict[str, Any]]) -> str | None:
def find_active_placeholder(
prev: list[dict[str, Any]],
turn_fields: dict[str, Any] | None = None,
) -> str | None:
turn_fields = turn_fields or {}
last = prev[-1] if prev else None
if not last:
return None
@ -643,6 +818,8 @@ def replay_transcript_to_ui_messages(
return None
if not last.get("isStreaming"):
return None
if not _same_turn(last, turn_fields):
return None
return str(last.get("id"))
def demote_interrupted_assistant(segment: str) -> None:
@ -721,7 +898,7 @@ def replay_transcript_to_ui_messages(
def absorb_complete(extra: dict[str, Any], idx: int) -> None:
nonlocal active_activity_segment_id, active_file_edit_segment_id
last = messages[-1] if messages else None
if last and is_reasoning_only_placeholder(last):
if last and is_reasoning_only_placeholder(last) and _same_turn(last, extra):
messages[-1] = {
**last,
**extra,
@ -768,8 +945,13 @@ def replay_transcript_to_ui_messages(
return i
return None
def upsert_file_edits(edits: list[dict[str, Any]], idx: int) -> None:
def upsert_file_edits(
edits: list[dict[str, Any]],
idx: int,
turn_fields: dict[str, Any] | None = None,
) -> None:
nonlocal active_file_edit_segment_id
turn_fields = turn_fields or {}
if not edits:
return
segment = active_file_edit_segment_id
@ -796,6 +978,7 @@ def replay_transcript_to_ui_messages(
"traces": [],
"fileEdits": [],
"activitySegmentId": segment,
**turn_fields,
"createdAt": _ts_base + idx,
},
)
@ -827,6 +1010,7 @@ def replay_transcript_to_ui_messages(
**last,
"fileEdits": existing,
"activitySegmentId": last.get("activitySegmentId") or segment,
**turn_fields,
}
for idx, rec in enumerate(lines):
@ -847,6 +1031,7 @@ def replay_transcript_to_ui_messages(
"id": _new_id("u", idx),
"role": "user",
"content": text_s,
**_turn_fields(rec, "user"),
"createdAt": _ts_base + idx,
}
if media_att:
@ -867,7 +1052,11 @@ def replay_transcript_to_ui_messages(
if ev == "file_edit":
raw_edits = rec.get("edits")
if isinstance(raw_edits, list):
upsert_file_edits([e for e in raw_edits if isinstance(e, dict)], idx)
upsert_file_edits(
[e for e in raw_edits if isinstance(e, dict)],
idx,
_turn_fields(rec, "activity"),
)
continue
if ev == "delta":
@ -877,7 +1066,8 @@ def replay_transcript_to_ui_messages(
if not isinstance(chunk, str):
continue
close_activity_for_answer()
adopted = find_active_placeholder(messages) if buffer_message_id is None else None
turn_fields = _turn_fields(rec, "answer")
adopted = find_active_placeholder(messages, turn_fields) if buffer_message_id is None else None
if buffer_message_id is None:
if adopted:
buffer_message_id = adopted
@ -889,6 +1079,7 @@ def replay_transcript_to_ui_messages(
"role": "assistant",
"content": "",
"isStreaming": True,
**_turn_fields(rec, "answer"),
"createdAt": _ts_base + idx,
},
)
@ -896,7 +1087,12 @@ def replay_transcript_to_ui_messages(
combined = "".join(buffer_parts)
for i, m in enumerate(messages):
if m.get("id") == buffer_message_id:
messages[i] = {**m, "content": combined, "isStreaming": True}
messages[i] = {
**m,
"content": combined,
"isStreaming": True,
**_turn_fields(rec, "answer"),
}
break
continue
@ -915,13 +1111,19 @@ def replay_transcript_to_ui_messages(
"role": "assistant",
"content": final_text,
"isStreaming": True,
**_turn_fields(rec, "answer"),
"createdAt": _ts_base + idx,
},
)
else:
for i, m in enumerate(messages):
if m.get("id") == buffer_message_id:
messages[i] = {**m, "content": final_text, "isStreaming": True}
messages[i] = {
**m,
"content": final_text,
"isStreaming": True,
**_turn_fields(rec, "answer"),
}
break
buffer_message_id = None
buffer_parts = []
@ -934,7 +1136,7 @@ def replay_transcript_to_ui_messages(
if not isinstance(chunk, str) or not chunk:
continue
close_file_edit_phase_before_activity()
attach_reasoning_chunk(messages, chunk, idx)
attach_reasoning_chunk(messages, chunk, idx, _turn_fields(rec, "reasoning"))
continue
if ev == "reasoning_end":
@ -956,7 +1158,7 @@ def replay_transcript_to_ui_messages(
if not isinstance(line, str) or not line:
continue
close_file_edit_phase_before_activity()
attach_reasoning_chunk(messages, line, idx)
attach_reasoning_chunk(messages, line, idx, _turn_fields(rec, "reasoning"))
close_reasoning(messages)
continue
if kind in ("tool_hint", "progress"):
@ -998,6 +1200,7 @@ def replay_transcript_to_ui_messages(
if visible_structured_events
else last.get("toolEvents"),
"activitySegmentId": last.get("activitySegmentId") or segment,
**_turn_fields(rec, "activity"),
}
messages[-1] = merged
else:
@ -1010,6 +1213,7 @@ def replay_transcript_to_ui_messages(
"traces": trace_lines,
**({"toolEvents": visible_structured_events} if visible_structured_events else {}),
"activitySegmentId": segment,
**_turn_fields(rec, "activity"),
"createdAt": _ts_base + idx,
},
)
@ -1033,6 +1237,8 @@ def replay_transcript_to_ui_messages(
lat = rec.get("latency_ms")
if isinstance(lat, (int, float)) and lat >= 0:
extra["latencyMs"] = int(lat)
extra.update(_turn_fields(rec, "answer"))
extra.update(_source_fields(rec))
absorb_complete(extra, idx)
if media:
suppress_until_turn_end = True
@ -1042,6 +1248,12 @@ def replay_transcript_to_ui_messages(
suppress_until_turn_end = False
active_activity_segment_id = None
active_file_edit_segment_id = None
turn_id = rec.get("turn_id")
if isinstance(turn_id, str) and turn_id:
if turn_id in replay_turn_aliases:
replay_turn_aliases.pop(turn_id, None)
else:
closed_turn_ids.add(turn_id)
for i, m in enumerate(messages):
if m.get("isStreaming"):
messages[i] = {**m, "isStreaming": False}

View File

@ -22,6 +22,7 @@ from websockets.http11 import Response
from nanobot.command.builtin import builtin_command_palette
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload
from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload
from nanobot.webui.http_utils import (
case_insensitive_header as _case_insensitive_header,
@ -60,16 +61,19 @@ from nanobot.webui.http_utils import (
safe_host_header as _safe_host_header,
)
from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.session_automations import session_automations_payload
from nanobot.webui.sidebar_state import (
read_webui_sidebar_state,
write_webui_sidebar_state,
)
from nanobot.webui.skills_api import webui_skill_detail_payload, webui_skills_payload
from nanobot.webui.thread_disk import delete_webui_thread
from nanobot.webui.transcript import build_webui_thread_response
from nanobot.webui.workspaces import WebUIWorkspaceController
if TYPE_CHECKING:
from nanobot.bus.queue import MessageBus
from nanobot.cron.service import CronService
from nanobot.session.manager import SessionManager
@ -95,7 +99,7 @@ def _default_model_name_from_config() -> str | None:
def _resolve_bootstrap_model_name(
runtime_name: Callable[[], str | None] | None,
) -> str | None:
) -> str:
if runtime_name is not None:
try:
raw = runtime_name()
@ -106,7 +110,7 @@ def _resolve_bootstrap_model_name(
stripped = raw.strip()
if stripped:
return stripped
return _default_model_name_from_config()
return _default_model_name_from_config() or ""
# ---------------------------------------------------------------------------
@ -134,6 +138,9 @@ class GatewayHTTPHandler:
tokens: GatewayTokenStore,
media: WebUIMediaGateway,
workspaces: WebUIWorkspaceController,
skills_workspace_path: Path,
disabled_skills: set[str] | None = None,
cron_service: CronService | None = None,
log: Any = logger,
) -> None:
self.config = config
@ -144,6 +151,9 @@ class GatewayHTTPHandler:
self.tokens = tokens
self.media = media
self.workspaces = workspaces
self.skills_workspace_path = skills_workspace_path
self.disabled_skills = disabled_skills or set()
self.cron_service = cron_service
self._log = log
self._runtime_surface = runtime_surface
@ -162,6 +172,9 @@ class GatewayHTTPHandler:
runtime_capabilities=self._capabilities,
)
def workspace_controls_available(self, connection: Any) -> bool:
return self._runtime_surface == "native" or _is_localhost(connection)
# -- Token management ---------------------------------------------------
def check_api_token(self, request: WsRequest) -> bool:
@ -291,6 +304,14 @@ class GatewayHTTPHandler:
if m:
return self._handle_webui_thread_get(request, m.group(1))
m = re.match(r"^/api/sessions/([^/]+)/file-preview$", got)
if m:
return self._handle_file_preview(request, m.group(1))
m = re.match(r"^/api/sessions/([^/]+)/automations$", got)
if m:
return self._handle_session_automations(request, m.group(1))
m = re.match(r"^/api/sessions/([^/]+)/delete$", got)
if m:
return self._handle_session_delete(request, m.group(1))
@ -369,6 +390,36 @@ class GatewayHTTPHandler:
data["workspace_scope"] = scope.payload()
return _http_json_response(data)
def _handle_file_preview(self, request: WsRequest, key: str) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
decoded_key = _decode_api_key(key)
if decoded_key is None:
return _http_error(400, "invalid session key")
if not _is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
path = _query_first(_parse_query(request.path), "path")
try:
payload = file_preview_payload(
path,
scope=self.workspaces.scope_for_session_key(decoded_key),
)
except WebUIFilePreviewError as e:
return _http_error(e.status, e.message)
return _http_json_response(payload)
def _handle_session_automations(self, request: WsRequest, key: str) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
decoded_key = _decode_api_key(key)
if decoded_key is None:
return _http_error(400, "invalid session key")
if not _is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
return _http_json_response(
session_automations_payload(self.cron_service, decoded_key)
)
def _handle_session_delete(self, request: WsRequest, key: str) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
@ -411,6 +462,11 @@ class GatewayHTTPHandler:
return self._handle_commands(request)
if got == "/api/workspaces":
return self._handle_workspaces(connection, request)
if got == "/api/webui/skills":
return self._handle_webui_skills(request)
m = re.match(r"^/api/webui/skills/([^/]+)$", got)
if m:
return self._handle_webui_skill_detail(request, m.group(1))
if got == "/api/webui/sidebar-state":
return self._handle_webui_sidebar_state(request)
if got == "/api/webui/sidebar-state/update":
@ -426,9 +482,38 @@ class GatewayHTTPHandler:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response(
self.workspaces.payload(controls_available=_is_localhost(connection))
self.workspaces.payload(
controls_available=self.workspace_controls_available(connection)
)
)
def _handle_webui_skills(self, request: WsRequest) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response(
webui_skills_payload(
self.skills_workspace_path,
disabled_skills=self.disabled_skills,
)
)
def _handle_webui_skill_detail(self, request: WsRequest, raw_name: str) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
from urllib.parse import unquote
name = unquote(raw_name)
if not name or "/" in name or "\\" in name:
return _http_error(400, "invalid skill name")
payload = webui_skill_detail_payload(
self.skills_workspace_path,
name,
disabled_skills=self.disabled_skills,
)
if payload is None:
return _http_error(404, "skill not found")
return _http_json_response(payload)
def _handle_webui_sidebar_state(self, request: WsRequest) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")

View File

@ -34,6 +34,7 @@ dependencies = [
"oauth-cli-kit>=0.1.3,<1.0.0",
"loguru>=0.7.3,<1.0.0",
"readability-lxml>=0.8.4,<1.0.0",
"lxml-html-clean>=0.4.0,<1.0.0",
"rich>=14.0.0,<15.0.0",
"croniter>=6.0.0,<7.0.0",
"dingtalk-stream>=0.24.0,<1.0.0",

View File

@ -356,7 +356,6 @@ class TestEphemeralHooks:
await loop.process_direct("test", session_key="cli:normal")
spy.before_iteration.assert_called()
class TestDreamCommitMessage:
async def test_commit_includes_response_summary(self, tmp_path):
"""Git auto-commit after Dream should include the LLM response in the body."""

View File

@ -592,16 +592,16 @@ async def test_internal_continuation_queues_turn_without_fake_user_history(
"paused",
[],
[*initial_messages, {"role": "assistant", "content": "paused"}],
"max_iterations",
False,
)
"max_iterations",
False,
)
return (
"done",
[],
[*initial_messages, {"role": "assistant", "content": "done"}],
"completed",
False,
)
"completed",
False,
)
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
pending: asyncio.Queue[InboundMessage] = asyncio.Queue()
@ -665,9 +665,9 @@ async def test_internal_continuation_preserves_streaming_route_metadata(
"paused",
[],
[*initial_messages, {"role": "assistant", "content": "paused"}],
"max_iterations",
False,
)
"max_iterations",
False,
)
assert on_stream is not None
assert on_stream_end is not None
await on_stream("done")
@ -744,9 +744,9 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
"paused",
[],
[*initial_messages, {"role": "assistant", "content": "paused"}],
"max_iterations",
False,
)
"max_iterations",
False,
)
return (
"done",
[],

View File

@ -170,6 +170,48 @@ async def test_runner_passes_cached_tokens_to_hook_context():
assert len(captured_usage) == 1
assert captured_usage[0]["cached_tokens"] == 150
assert captured_usage[0]["provider_tokens"] == 220
@pytest.mark.asyncio
async def test_runner_estimates_usage_when_provider_omits_usage(monkeypatch):
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunner, AgentRunSpec
provider = MagicMock(spec=LLMProvider)
captured_usage: list[dict] = []
class UsageHook(AgentHook):
async def after_iteration(self, context: AgentHookContext) -> None:
captured_usage.append(dict(context.usage))
async def chat_with_retry(**kwargs):
return LLMResponse(content="done", tool_calls=[], usage={})
provider.chat_with_retry = chat_with_retry
tools = MagicMock()
tools.get_definitions.return_value = [{"type": "function", "function": {"name": "lookup"}}]
monkeypatch.setattr(
"nanobot.agent.runner.estimate_prompt_tokens_chain",
lambda provider, model, messages, tools: (123, "test"),
)
monkeypatch.setattr("nanobot.agent.runner.estimate_message_tokens", lambda message: 7)
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "hi"}],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
hook=UsageHook(),
))
assert result.usage["prompt_tokens"] == 123
assert result.usage["completion_tokens"] == 7
assert result.usage["total_tokens"] == 130
assert result.usage["estimated_tokens"] == 130
assert captured_usage[0]["estimated_tokens"] == 130
@pytest.mark.asyncio
@ -232,7 +274,12 @@ async def test_runner_calls_run_level_hooks_on_success():
"done",
"completed",
None,
{"prompt_tokens": 3, "completion_tokens": 2},
{
"prompt_tokens": 3,
"completion_tokens": 2,
"total_tokens": 5,
"provider_tokens": 5,
},
["user", "assistant"],
),
("on_finally", "completed", None),

View File

@ -104,6 +104,7 @@ def bus() -> MagicMock:
@pytest.fixture(autouse=True)
def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
monkeypatch.setattr(
"nanobot.webui.workspaces.get_webui_dir",
lambda: tmp_path / "webui",
@ -277,6 +278,8 @@ async def test_token_issue_route_requires_secret_when_static_token_configured(bu
@pytest.mark.asyncio
async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) -> None:
from nanobot.webui.transcript import read_transcript_lines
channel = _ch(bus)
conn = MagicMock()
conn.remote_address = ("127.0.0.1", 50123)
@ -284,14 +287,30 @@ async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) ->
await channel._dispatch_envelope(
conn,
"webui-client",
{"type": "message", "chat_id": "chat-1", "content": "hello", "webui": True},
{
"type": "message",
"chat_id": "chat-1",
"content": "hello",
"webui": True,
"turn_id": "turn-1",
},
)
msg = bus.publish_inbound.await_args.args[0]
assert msg.channel == "websocket"
assert msg.chat_id == "chat-1"
assert msg.metadata["webui"] is True
assert msg.metadata["webui_turn_id"] == "turn-1"
assert msg.metadata["_wants_stream"] is True
lines = read_transcript_lines("websocket:chat-1")
assert lines == [{
"event": "user",
"chat_id": "chat-1",
"text": "hello",
"turn_id": "turn-1",
"turn_phase": "user",
"turn_seq": 1,
}]
@pytest.mark.asyncio
@ -359,7 +378,7 @@ async def test_webui_user_transcript_append_failure_does_not_block_inbound(
def fail_append(_session_key: str, _obj: dict[str, Any]) -> None:
raise OSError("disk full")
monkeypatch.setattr("nanobot.channels.websocket.append_transcript_object", fail_append)
monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", fail_append)
channel = _ch(bus)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
@ -664,6 +683,58 @@ async def test_webui_scope_rejects_non_loopback_custom_scope(bus: MagicMock, tmp
assert sessions.read_session_file("websocket:chat-remote") is None
@pytest.mark.asyncio
async def test_native_webui_scope_allows_custom_scope_without_loopback(
bus: MagicMock,
tmp_path,
) -> None:
default_workspace = tmp_path / "default"
project = tmp_path / "project"
default_workspace.mkdir()
project.mkdir()
sessions = SessionManager(tmp_path / "sessions")
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
gateway=_basic_handler(
bus,
session_manager=sessions,
workspace_path=default_workspace,
runtime_surface="native",
),
)
conn = AsyncMock()
conn.remote_address = None
await channel._dispatch_envelope(
conn,
"native-client",
{
"type": "set_workspace_scope",
"chat_id": "chat-native",
"workspace_scope": {
"project_path": str(project),
"access_mode": "full",
},
},
)
payload = json.loads(conn.send.await_args.args[0])
assert payload["event"] == "session_updated"
assert payload["chat_id"] == "chat-native"
assert payload["workspace_scope"]["project_path"] == str(project.resolve())
assert payload["workspace_scope"]["project_name"] == "project"
assert payload["workspace_scope"]["access_mode"] == "full"
assert payload["workspace_scope"]["restrict_to_workspace"] is False
assert payload["workspace_scope"]["sandbox_status"]["restrict_to_workspace"] is False
assert payload["workspace_scope"]["sandbox_status"]["workspace_root"] == str(project.resolve())
saved = sessions.read_session_file("websocket:chat-native")
assert saved["metadata"]["workspace_scope"] == {
"project_path": str(project.resolve()),
"access_mode": "full",
}
@pytest.mark.asyncio
async def test_send_delivers_json_message_with_media_and_reply() -> None:
bus = MagicMock()
@ -799,6 +870,7 @@ async def test_send_progress_includes_structured_tool_events() -> None:
metadata={
"_progress": True,
"_tool_hint": True,
"webui_turn_id": "turn-1",
"_tool_events": [
{
"version": 1,
@ -818,6 +890,9 @@ async def test_send_progress_includes_structured_tool_events() -> None:
payload = json.loads(mock_ws.send.await_args.args[0])
assert payload["event"] == "message"
assert payload["kind"] == "tool_hint"
assert payload["turn_id"] == "turn-1"
assert payload["turn_phase"] == "activity"
assert payload["turn_seq"] == 1
assert payload["tool_events"] == [
{
"version": 1,
@ -1091,6 +1166,37 @@ async def test_send_reasoning_without_subscribers_is_noop() -> None:
assert channel._subs == {}
@pytest.mark.asyncio
async def test_stream_transcript_persists_without_subscribers() -> None:
from nanobot.webui.transcript import build_webui_thread_response, read_transcript_lines
bus = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True},
bus,
gateway=_basic_handler(bus),
)
await channel.send_delta("chat-1", "hello", {"_stream_delta": True, "_stream_id": "s1"})
await channel.send_delta("chat-1", " world", {"_stream_delta": True, "_stream_id": "s1"})
await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "s1"})
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_turn_end": True, "latency_ms": 42},
))
assert channel._subs == {}
lines = read_transcript_lines("websocket:chat-1")
assert [line["event"] for line in lines] == ["delta", "delta", "stream_end", "turn_end"]
body = build_webui_thread_response("websocket:chat-1")
assert body is not None
assert body["messages"][-1]["role"] == "assistant"
assert body["messages"][-1]["content"] == "hello world"
assert body["messages"][-1]["latencyMs"] == 42
@pytest.mark.asyncio
async def test_send_turn_end_emits_turn_end_event() -> None:
bus = MagicMock()
@ -2494,6 +2600,71 @@ def test_handle_webui_thread_get_returns_json(tmp_path, monkeypatch) -> None:
assert body["messages"][0]["content"] == "hi"
def test_handle_file_preview_returns_workspace_file(tmp_path) -> None:
from urllib.parse import quote
from websockets.datastructures import Headers
from websockets.http11 import Request
workspace = tmp_path / "workspace"
source = workspace / "nanobot" / "agent" / "hook.py"
source.parent.mkdir(parents=True)
source.write_text("print('hello')\n", encoding="utf-8")
gateway = _basic_handler(MagicMock(), workspace_path=workspace)
gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
key = "websocket:file-preview"
enc = quote(key, safe="")
path = quote("nanobot/agent/hook.py:12", safe="")
req = Request(
f"/api/sessions/{enc}/file-preview?path={path}",
Headers([("Authorization", "Bearer tok")]),
)
resp = gateway.http._handle_file_preview(req, enc)
assert resp.status_code == 200
body = json.loads(resp.body.decode())
assert body["display_path"] == "nanobot/agent/hook.py"
assert body["language"] == "python"
assert body["content"].splitlines() == ["print('hello')"]
assert body["truncated"] is False
def test_file_preview_normalizes_windows_file_url() -> None:
from nanobot.webui.file_preview import _clean_preview_path
assert _clean_preview_path("file:///C:/Users/me/project/app.py") == (
"C:/Users/me/project/app.py"
)
assert _clean_preview_path("file:///tmp/project/app.py") == "/tmp/project/app.py"
def test_handle_file_preview_rejects_paths_outside_workspace(tmp_path) -> None:
from urllib.parse import quote
from websockets.datastructures import Headers
from websockets.http11 import Request
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "secret.py"
outside.write_text("secret = True\n", encoding="utf-8")
gateway = _basic_handler(MagicMock(), workspace_path=workspace)
gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
key = "websocket:file-preview"
enc = quote(key, safe="")
req = Request(
f"/api/sessions/{enc}/file-preview?path={quote(str(outside), safe='')}",
Headers([("Authorization", "Bearer tok")]),
)
resp = gateway.http._handle_file_preview(req, enc)
assert resp.status_code == 403
def test_handle_webui_thread_get_backfills_legacy_missing_user_rows(
tmp_path,
monkeypatch,

View File

@ -12,6 +12,8 @@ import httpx
import pytest
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
from nanobot.session.manager import Session, SessionManager
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
@ -24,10 +26,12 @@ def _make_handler(
*,
session_manager: SessionManager | None = None,
static_dist_path: Path | None = None,
workspace_path: Path | None = None,
runtime_model_name: Any | None = None,
cron_service: CronService | None = None,
) -> GatewayServices:
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
workspace = Path.cwd()
workspace = workspace_path or Path.cwd()
return build_gateway_services(
config=config,
bus=bus,
@ -38,6 +42,7 @@ def _make_handler(
runtime_model_name=runtime_model_name,
runtime_surface="browser",
runtime_capabilities_overrides=None,
cron_service=cron_service,
)
@ -46,8 +51,10 @@ def _ch(
*,
session_manager: SessionManager | None = None,
static_dist_path: Path | None = None,
workspace_path: Path | None = None,
port: int = _PORT,
runtime_model_name: Any | None = None,
cron_service: CronService | None = None,
**extra: Any,
) -> WebSocketChannel:
cfg: dict[str, Any] = {
@ -63,7 +70,9 @@ def _ch(
cfg, bus,
session_manager=session_manager,
static_dist_path=static_dist_path,
workspace_path=workspace_path,
runtime_model_name=runtime_model_name,
cron_service=cron_service,
)
return WebSocketChannel(cfg, bus, gateway=gateway)
@ -161,6 +170,156 @@ async def test_sessions_routes_require_bearer_token(
await server_task
@pytest.mark.asyncio
async def test_session_automations_route_filters_by_webui_session(
bus: MagicMock, tmp_path: Path
) -> None:
cron = CronService(tmp_path / "cron" / "jobs.json")
hourly = CronSchedule(kind="every", every_ms=3_600_000)
for name, message, to in (
("Morning check", "Check the project status", "abc"),
("Other session", "Do not show", "other"),
):
cron.add_job(
name=name,
schedule=hourly,
message=message,
channel="websocket",
to=to,
session_key=f"websocket:{to}",
)
cron.register_system_job(
CronJob(
id="heartbeat",
name="heartbeat",
schedule=CronSchedule(kind="every", every_ms=60_000),
payload=CronPayload(kind="system_event"),
)
)
channel = _ch(
bus,
session_manager=_seed_session(tmp_path, key="websocket:abc"),
cron_service=cron,
port=29914,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
deny = await _http_get(
"http://127.0.0.1:29914/api/sessions/websocket:abc/automations"
)
assert deny.status_code == 401
boot = await _http_get("http://127.0.0.1:29914/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
resp = await _http_get(
"http://127.0.0.1:29914/api/sessions/websocket%3Aabc/automations",
headers=auth,
)
assert resp.status_code == 200
body = resp.json()
assert [job["name"] for job in body["jobs"]] == ["Morning check"]
job = body["jobs"][0]
assert job["schedule"]["kind"] == "every"
assert job["schedule"]["every_ms"] == 3_600_000
assert job["payload"]["message"] == "Check the project status"
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_webui_skills_route_requires_token_and_hides_paths(
bus: MagicMock, tmp_path: Path
) -> None:
workspace_skill = tmp_path / "skills" / "workspace-skill"
workspace_skill.mkdir(parents=True)
(workspace_skill / "SKILL.md").write_text(
"---\nname: workspace-skill\ndescription: Workspace skill.\n---\n",
encoding="utf-8",
)
unavailable_skill = tmp_path / "skills" / "zz-unavailable-skill"
unavailable_skill.mkdir(parents=True)
(unavailable_skill / "SKILL.md").write_text(
"\n".join([
"---",
"name: zz-unavailable-skill",
"description: Missing CLI skill.",
"metadata:",
" nanobot:",
" requires:",
" bins:",
" - definitely-missing-nanobot-skill-cli",
" env:",
" - DEFINITELY_MISSING_NANOBOT_SKILL_ENV",
"---",
"Use the missing CLI and env var.",
]),
encoding="utf-8",
)
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
workspace_path=tmp_path,
port=29920,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
deny = await _http_get("http://127.0.0.1:29920/api/webui/skills")
assert deny.status_code == 401
deny_detail = await _http_get("http://127.0.0.1:29920/api/webui/skills/workspace-skill")
assert deny_detail.status_code == 401
boot = await _http_get("http://127.0.0.1:29920/webui/bootstrap")
token = boot.json()["token"]
resp = await _http_get(
"http://127.0.0.1:29920/api/webui/skills",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
body = resp.json()
names = [skill["name"] for skill in body["skills"]]
assert names[0] == "workspace-skill"
assert "cron" in names
assert all("path" not in skill for skill in body["skills"])
workspace = body["skills"][0]
assert workspace == {
"name": "workspace-skill",
"description": "Workspace skill.",
"source": "workspace",
"available": True,
"unavailable_reason": "",
}
unavailable = next(skill for skill in body["skills"] if skill["name"] == "zz-unavailable-skill")
assert unavailable["available"] is False
assert unavailable["unavailable_reason"] == (
"CLI: definitely-missing-nanobot-skill-cli, "
"ENV: DEFINITELY_MISSING_NANOBOT_SKILL_ENV"
)
detail = await _http_get(
"http://127.0.0.1:29920/api/webui/skills/zz-unavailable-skill",
headers={"Authorization": f"Bearer {token}"},
)
assert detail.status_code == 200
detail_body = detail.json()
assert "path" not in detail_body
assert detail_body["requirements"] == {
"bins": ["definitely-missing-nanobot-skill-cli"],
"env": ["DEFINITELY_MISSING_NANOBOT_SKILL_ENV"],
"missing_bins": ["definitely-missing-nanobot-skill-cli"],
"missing_env": ["DEFINITELY_MISSING_NANOBOT_SKILL_ENV"],
}
assert "Use the missing CLI and env var." in detail_body["raw_markdown"]
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_cli_apps_routes_require_token_and_return_payload(
bus: MagicMock,

View File

@ -9,17 +9,37 @@ import pytest
from typer.testing import CliRunner
from nanobot.bus.events import OutboundMessage
from nanobot.cli.commands import app
from nanobot.providers.factory import make_provider
from nanobot.cli.commands import _proactive_delivery_metadata, app
from nanobot.config.schema import Config
from nanobot.cron.types import CronJob, CronPayload
from nanobot.providers.factory import ProviderSnapshot
from nanobot.providers.factory import ProviderSnapshot, make_provider
from nanobot.providers.openai_codex_provider import _strip_model_prefix
from nanobot.providers.registry import find_by_name
runner = CliRunner()
def test_proactive_websocket_delivery_gets_fresh_turn_id() -> None:
metadata = {
"webui": True,
"webui_turn_id": "turn-that-created-the-reminder",
"workspace_scope": {"mode": "default"},
}
out = _proactive_delivery_metadata(
"websocket",
metadata,
turn_seed="cron:drink-water",
source_label="drink water",
)
assert out["webui"] is True
assert out["workspace_scope"] == {"mode": "default"}
assert out["webui_turn_id"].startswith("cron:drink-water:")
assert out["webui_turn_id"] != metadata["webui_turn_id"]
assert out["_webui_message_source"] == {"kind": "cron", "label": "drink water"}
def _fake_provider():
"""Return a minimal fake provider that satisfies AgentLoop.__init__."""
p = MagicMock()
@ -542,8 +562,8 @@ def test_openai_compat_provider_passes_model_through():
def test_make_provider_uses_github_copilot_backend():
from nanobot.providers.factory import make_provider
from nanobot.config.schema import Config
from nanobot.providers.factory import make_provider
config = Config.model_validate(
{
@ -1317,6 +1337,41 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
}
]
bus.publish_outbound.reset_mock()
old_turn_id = "turn-that-created-the-reminder"
websocket_job = CronJob(
id="drink-water",
name="drink water",
payload=CronPayload(
message="Remind me to drink water.",
deliver=True,
channel="websocket",
to="chat-1",
channel_meta={
"webui": True,
"webui_turn_id": old_turn_id,
"workspace_scope": {"mode": "default"},
},
session_key="websocket:chat-1",
),
)
response = asyncio.run(cron.on_job(websocket_job))
assert response == "Time to stretch."
bus.publish_outbound.assert_awaited_once()
delivered = bus.publish_outbound.await_args.args[0]
assert delivered.channel == "websocket"
assert delivered.chat_id == "chat-1"
assert delivered.metadata["webui"] is True
assert delivered.metadata["workspace_scope"] == {"mode": "default"}
assert delivered.metadata["webui_turn_id"].startswith("cron:drink-water:")
assert delivered.metadata["webui_turn_id"] != old_turn_id
assert delivered.metadata["_webui_message_source"] == {
"kind": "cron",
"label": "drink water",
}
def test_gateway_cron_job_suppresses_intermediate_progress(
monkeypatch, tmp_path: Path
@ -1599,6 +1654,65 @@ def test_configure_desktop_gateway_forces_local_websocket_only() -> None:
assert extras["websocket"]["websocket_requires_token"] is True
def test_load_or_create_desktop_config_bootstraps_without_api_key(tmp_path: Path) -> None:
from nanobot.cli.commands import _load_or_create_desktop_config
config_path = tmp_path / "config.json"
loaded = _load_or_create_desktop_config(
str(config_path),
str(tmp_path / "workspace"),
)
assert loaded.agents.defaults.provider == "openai_codex"
assert loaded.agents.defaults.model == "openai-codex/gpt-5.1-codex"
assert loaded.agents.defaults.model_preset is None
assert config_path.exists()
saved = json.loads(config_path.read_text(encoding="utf-8"))
assert saved["agents"]["defaults"]["provider"] == ""
assert saved["agents"]["defaults"]["model"] == ""
assert make_provider(loaded).get_default_model() == "openai-codex/gpt-5.1-codex"
def test_load_or_create_desktop_config_repairs_existing_unconfigured_default(
tmp_path: Path,
) -> None:
from nanobot.cli.commands import _load_or_create_desktop_config
from nanobot.config.loader import save_config
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
loaded = _load_or_create_desktop_config(str(config_path), None)
saved = json.loads(config_path.read_text(encoding="utf-8"))
assert loaded.agents.defaults.provider == "openai_codex"
assert loaded.agents.defaults.model == "openai-codex/gpt-5.1-codex"
assert saved["agents"]["defaults"]["provider"] == ""
assert saved["agents"]["defaults"]["model"] == ""
assert make_provider(loaded).get_default_model() == "openai-codex/gpt-5.1-codex"
def test_load_or_create_desktop_config_unwinds_persisted_bootstrap(
tmp_path: Path,
) -> None:
from nanobot.cli.commands import _load_or_create_desktop_config
from nanobot.config.loader import save_config
config_path = tmp_path / "config.json"
config = Config()
config.agents.defaults.provider = "openai_codex"
config.agents.defaults.model = "openai-codex/gpt-5.1-codex"
save_config(config, config_path)
loaded = _load_or_create_desktop_config(str(config_path), None)
saved = json.loads(config_path.read_text(encoding="utf-8"))
assert loaded.agents.defaults.provider == "openai_codex"
assert loaded.agents.defaults.model == "openai-codex/gpt-5.1-codex"
assert saved["agents"]["defaults"]["provider"] == ""
assert saved["agents"]["defaults"]["model"] == ""
def test_gateway_health_endpoint_binds_and_serves_expected_responses(
monkeypatch, tmp_path: Path
) -> None:

View File

@ -214,8 +214,16 @@ class TestRestartCommand:
assert "Tasks: 3 active" in response.content
@pytest.mark.asyncio
async def test_run_agent_loop_resets_usage_when_provider_omits_it(self):
async def test_run_agent_loop_estimates_usage_when_provider_omits_it(self, monkeypatch):
loop, _bus = _make_loop()
monkeypatch.setattr(
"nanobot.agent.runner.estimate_prompt_tokens_chain",
lambda *_args, **_kwargs: (123, "test"),
)
monkeypatch.setattr(
"nanobot.agent.runner.estimate_message_tokens",
lambda _message: 7,
)
loop.provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse(content="first", usage={"prompt_tokens": 9, "completion_tokens": 4}),
LLMResponse(content="second", usage={}),
@ -226,8 +234,9 @@ class TestRestartCommand:
assert loop._last_usage["completion_tokens"] == 4
await loop._run_agent_loop([])
assert loop._last_usage["prompt_tokens"] == 0
assert loop._last_usage["completion_tokens"] == 0
assert loop._last_usage["prompt_tokens"] == 123
assert loop._last_usage["completion_tokens"] == 7
assert loop._last_usage["estimated_tokens"] == 130
@pytest.mark.asyncio
async def test_status_falls_back_to_last_usage_when_context_estimate_missing(self):

View File

@ -11,8 +11,8 @@ from loguru import logger
import nanobot.providers.base as provider_base
from nanobot.providers.openai_codex_provider import (
OpenAICodexProvider,
_codex_error_response,
_build_reasoning_options,
_codex_error_response,
_CodexHTTPError,
_friendly_error,
_request_codex,
@ -134,7 +134,7 @@ async def test_codex_prompt_cache_key_uses_stable_conversation_prefix(monkeypatc
):
_ = on_thinking_delta, on_tool_call_delta
bodies.append(body)
return "ok", [], "stop", None
return "ok", [], "stop", {}, None
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
@ -259,7 +259,7 @@ async def test_codex_retry_uses_structured_timeout_metadata(monkeypatch) -> None
calls += 1
if calls == 1:
raise httpx.ReadTimeout("")
return "ok", [], "stop", None
return "ok", [], "stop", {}, None
async def fake_sleep(delay: float) -> None:
delays.append(delay)
@ -429,7 +429,7 @@ async def test_codex_stream_surfaces_reasoning_summary(monkeypatch) -> None:
await on_content_delta("answer")
if on_thinking_delta:
await on_thinking_delta("summary")
return "answer", [], "stop", "summary"
return "answer", [], "stop", {"prompt_tokens": 10, "completion_tokens": 5}, "summary"
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
@ -447,6 +447,7 @@ async def test_codex_stream_surfaces_reasoning_summary(monkeypatch) -> None:
assert content_deltas == ["answer"]
assert thinking_deltas == ["summary"]
assert response.content == "answer"
assert response.usage == {"prompt_tokens": 10, "completion_tokens": 5}
assert response.reasoning_content == "summary"

View File

@ -19,7 +19,6 @@ from nanobot.providers.openai_responses.parsing import (
parse_response_output,
)
# ======================================================================
# converters - split_tool_call_id
# ======================================================================
@ -478,7 +477,7 @@ class TestConsumeSse:
async def on_reasoning(delta: str) -> None:
deltas.append(delta)
content, tool_calls, finish_reason, reasoning = await consume_sse_with_reasoning(
content, tool_calls, finish_reason, usage, reasoning = await consume_sse_with_reasoning(
response,
on_reasoning_delta=on_reasoning,
)
@ -486,6 +485,7 @@ class TestConsumeSse:
assert content == "answer"
assert tool_calls == []
assert finish_reason == "stop"
assert usage == {}
assert reasoning == "thinking briefly"
assert deltas == ["thinking ", "briefly"]
@ -506,7 +506,7 @@ class TestConsumeSse:
},
])
_, _, _, reasoning = await consume_sse_with_reasoning(response)
_, _, _, _, reasoning = await consume_sse_with_reasoning(response)
assert reasoning == "cached summary"
@ -527,7 +527,7 @@ class TestConsumeSse:
async def on_reasoning(delta: str) -> None:
deltas.append(delta)
_, _, _, reasoning = await consume_sse_with_reasoning(
_, _, _, _, reasoning = await consume_sse_with_reasoning(
response,
on_reasoning_delta=on_reasoning,
)
@ -545,10 +545,26 @@ class TestConsumeSse:
{"type": "response.completed", "response": {"status": "completed"}},
])
_, _, _, reasoning = await consume_sse_with_reasoning(response)
_, _, _, _, reasoning = await consume_sse_with_reasoning(response)
assert reasoning == "part summary"
@pytest.mark.asyncio
async def test_raw_sse_usage_extracted(self):
response = _SseResponse([
{
"type": "response.completed",
"response": {
"status": "completed",
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
},
},
])
_, _, _, usage, _ = await consume_sse_with_reasoning(response)
assert usage == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
@pytest.mark.asyncio
async def test_tool_call_done_arguments_callback(self):
response = _SseResponse([

View File

@ -12,7 +12,11 @@ import pytest
from nanobot.agent.tools import web as web_module
from nanobot.agent.tools.web import WebFetchTool
from nanobot.config.schema import WebFetchConfig
from nanobot.security.workspace_access import bind_workspace_scope, build_workspace_scope, reset_workspace_scope
from nanobot.security.workspace_access import (
bind_workspace_scope,
build_workspace_scope,
reset_workspace_scope,
)
_REAL_GETADDRINFO = socket.getaddrinfo
@ -147,6 +151,7 @@ async def test_web_fetch_can_skip_jina_and_use_custom_user_agent(monkeypatch):
return FakeResponse()
monkeypatch.setattr(tool, "_fetch_jina", _fail_jina)
monkeypatch.setattr(tool, "_extract_readable_html", lambda html, mode: "Hello world")
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient)
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
@ -160,6 +165,47 @@ async def test_web_fetch_can_skip_jina_and_use_custom_user_agent(monkeypatch):
]
@pytest.mark.asyncio
async def test_web_fetch_falls_back_when_readability_dependency_is_missing(monkeypatch):
tool = WebFetchTool(config=WebFetchConfig(use_jina_reader=False))
class FakeResponse:
status_code = 200
url = "https://example.com/page"
text = "<html><head><title>Test</title></head><body><p>Hello world</p></body></html>"
headers = {"content-type": "text/html"}
def raise_for_status(self):
return None
class FakeClient:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get(self, url, headers=None, follow_redirects=False, **kwargs):
return FakeResponse()
def _missing_readability(*args, **kwargs):
raise ModuleNotFoundError("No module named 'lxml_html_clean'")
monkeypatch.setattr(tool, "_extract_readable_html", _missing_readability)
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient)
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
result = await tool._fetch_readability("https://example.com/page", "markdown", 5000)
data = json.loads(result)
assert data["extractor"] == "html"
assert data["untrusted"] is True
assert "Hello world" in data["text"]
@pytest.mark.asyncio
async def test_web_fetch_blocks_private_redirect_before_readability_request(monkeypatch):
tool = WebFetchTool(config=WebFetchConfig(use_jina_reader=False))

View File

@ -43,6 +43,177 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
assert msgs[1]["latencyMs"] == 42
def test_replay_preserves_turn_metadata(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-turn"
for ev in (
{
"event": "user",
"chat_id": "t-turn",
"text": "q",
"turn_id": "turn-1",
"turn_phase": "user",
"turn_seq": 1,
},
{
"event": "reasoning_delta",
"chat_id": "t-turn",
"text": "think",
"turn_id": "turn-1",
"turn_phase": "reasoning",
"turn_seq": 2,
},
{
"event": "delta",
"chat_id": "t-turn",
"text": "a",
"turn_id": "turn-1",
"turn_phase": "answer",
"turn_seq": 3,
},
{
"event": "turn_end",
"chat_id": "t-turn",
"latency_ms": 12,
"turn_id": "turn-1",
"turn_phase": "complete",
"turn_seq": 4,
},
):
append_transcript_object(key, ev)
msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
assert msgs[0]["turnId"] == "turn-1"
assert msgs[0]["turnPhase"] == "user"
assert msgs[0]["turnSeq"] == 1
assert msgs[1]["turnId"] == "turn-1"
assert msgs[1]["turnPhase"] == "answer"
assert msgs[1]["turnSeq"] == 3
def test_replay_reused_turn_id_after_turn_end_starts_new_turn(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-reused-turn"
def event(
event: str,
phase: str,
seq: int,
text: str | None = None,
source: dict[str, str] | None = None,
) -> dict[str, object]:
out = {
"event": event,
"chat_id": "t-reused-turn",
"turn_id": "turn-1",
"turn_phase": phase,
"turn_seq": seq,
}
if text is not None:
out["text"] = text
if source is not None:
out["source"] = source
return out
for record in (
event("user", "user", 1, "remind me later"),
event("message", "answer", 2, "Reminder set."),
event("turn_end", "complete", 3),
event(
"message", "answer", 1, "Time to drink water.",
{"kind": "cron", "label": "drink water"},
),
event("turn_end", "complete", 2),
):
append_transcript_object(key, record)
msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
assert [m["content"] for m in msgs] == [
"remind me later",
"Reminder set.",
"Time to drink water.",
]
assert msgs[1]["turnId"] == "turn-1"
assert msgs[2]["turnId"].startswith("turn-1:replay:")
assert msgs[2]["turnId"] != msgs[1]["turnId"]
assert msgs[2]["source"] == {"kind": "cron", "label": "drink water"}
def test_build_response_restores_session_users_for_legacy_transcript(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:legacy-users"
append_transcript_object(
key,
{"event": "message", "chat_id": "legacy-users", "text": "assistant one"},
)
append_transcript_object(key, {"event": "turn_end", "chat_id": "legacy-users"})
append_transcript_object(
key,
{"event": "message", "chat_id": "legacy-users", "text": "assistant two"},
)
append_transcript_object(key, {"event": "turn_end", "chat_id": "legacy-users"})
out = build_webui_thread_response(
key,
session_messages=[
{"role": "user", "content": "prompt one", "timestamp": "2026-06-02T10:00:00"},
{"role": "assistant", "content": "assistant one"},
{"role": "user", "content": "prompt two", "timestamp": "2026-06-02T10:01:00"},
{"role": "assistant", "content": "assistant two"},
],
)
assert out is not None
assert [(m["role"], m["content"]) for m in out["messages"]] == [
("user", "prompt one"),
("assistant", "assistant one"),
("user", "prompt two"),
("assistant", "assistant two"),
]
def test_build_response_restores_session_users_without_duplicating_new_transcript_users(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:mixed-users"
append_transcript_object(
key,
{"event": "message", "chat_id": "mixed-users", "text": "old assistant"},
)
append_transcript_object(key, {"event": "turn_end", "chat_id": "mixed-users"})
append_transcript_object(key, {"event": "user", "chat_id": "mixed-users", "text": "new prompt"})
append_transcript_object(
key,
{"event": "message", "chat_id": "mixed-users", "text": "new assistant"},
)
append_transcript_object(key, {"event": "turn_end", "chat_id": "mixed-users"})
out = build_webui_thread_response(
key,
session_messages=[
{"role": "user", "content": "old prompt"},
{"role": "assistant", "content": "old assistant"},
{"role": "user", "content": "new prompt"},
{"role": "assistant", "content": "new assistant"},
],
)
assert out is not None
assert [(m["role"], m["content"]) for m in out["messages"]] == [
("user", "old prompt"),
("assistant", "old assistant"),
("user", "new prompt"),
("assistant", "new assistant"),
]
def test_replay_augments_assistant_text() -> None:
msgs = replay_transcript_to_ui_messages(
[
@ -675,8 +846,6 @@ def test_replay_keeps_new_file_edit_after_reasoning_in_order(tmp_path, monkeypat
def test_build_response_schema(monkeypatch, tmp_path) -> None:
from nanobot.webui.transcript import build_webui_thread_response
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t3"
append_transcript_object(key, {"event": "user", "chat_id": "t3", "text": "x"})

View File

@ -14,6 +14,7 @@ from nanobot.webui.settings_api import (
create_model_configuration,
provider_models_payload,
settings_payload,
settings_usage_payload,
update_agent_settings,
update_model_configuration,
update_network_safety_settings,
@ -242,6 +243,52 @@ def test_settings_payload_includes_network_safety_fields(
assert payload["advanced"]["ssrf_whitelist_count"] == 1
def test_settings_payload_includes_token_usage_summary(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
from nanobot.webui.token_usage import record_token_usage
record_token_usage({"prompt_tokens": 10, "completion_tokens": 5})
payload = settings_payload()
assert payload["usage"]["total_tokens_30d"] == 15
assert payload["usage"]["total_tokens"] == 15
assert payload["usage"]["peak_day_tokens"] == 15
assert payload["usage"]["current_streak_days"] == 1
assert payload["usage"]["longest_streak_days"] == 1
assert payload["usage"]["active_days_30d"] == 1
assert payload["usage"]["requests_30d"] == 1
def test_settings_usage_payload_returns_lightweight_token_usage(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
from nanobot.webui.token_usage import record_token_usage
record_token_usage({"prompt_tokens": 20, "completion_tokens": 2})
payload = settings_usage_payload()
assert payload["total_tokens"] == 22
assert payload["requests_30d"] == 1
assert "agent" not in payload
def test_update_network_safety_settings_writes_local_service_flag(
tmp_path,
monkeypatch: pytest.MonkeyPatch,

View File

@ -0,0 +1,148 @@
from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace
import pytest
from nanobot.agent.hook import AgentHookContext
from nanobot.webui.token_usage import (
TokenUsageHook,
record_response_token_usage,
record_token_usage,
token_usage_payload,
)
def test_record_token_usage_aggregates_by_local_day(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
record_token_usage(
{"prompt_tokens": 100, "completion_tokens": 40, "cached_tokens": 20},
timezone_name="Asia/Shanghai",
now=datetime(2026, 6, 2, 18, 0, tzinfo=timezone.utc),
)
record_token_usage(
{"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
timezone_name="Asia/Shanghai",
now=datetime(2026, 6, 2, 19, 0, tzinfo=timezone.utc),
)
payload = token_usage_payload(
timezone_name="Asia/Shanghai",
now=datetime(2026, 6, 3, 12, 0, tzinfo=timezone.utc),
)
assert payload["total_tokens_30d"] == 155
assert payload["active_days_30d"] == 1
assert payload["requests_30d"] == 2
assert payload["days"] == [
{
"date": "2026-06-03",
"prompt_tokens": 110,
"completion_tokens": 45,
"cached_tokens": 20,
"total_tokens": 155,
"provider_tokens": 155,
"estimated_tokens": 0,
"requests": 2,
"provider_requests": 2,
"estimated_requests": 0,
"sources": {
"user": {
"prompt_tokens": 110,
"completion_tokens": 45,
"cached_tokens": 20,
"total_tokens": 155,
"provider_tokens": 155,
"estimated_tokens": 0,
"requests": 2,
"provider_requests": 2,
"estimated_requests": 0,
}
},
}
]
def test_record_token_usage_skips_empty_usage(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
record_token_usage({"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0})
payload = token_usage_payload(now=datetime(2026, 6, 3, tzinfo=timezone.utc))
assert payload["days"] == []
assert payload["total_tokens_30d"] == 0
def test_record_token_usage_keeps_estimated_split(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
record_token_usage(
{"prompt_tokens": 100, "completion_tokens": 25, "estimated_tokens": 125},
now=datetime(2026, 6, 3, tzinfo=timezone.utc),
)
payload = token_usage_payload(now=datetime(2026, 6, 3, tzinfo=timezone.utc))
assert payload["days"][0]["total_tokens"] == 125
assert payload["days"][0]["provider_tokens"] == 0
assert payload["days"][0]["estimated_tokens"] == 125
assert payload["days"][0]["estimated_requests"] == 1
def test_record_token_usage_keeps_source_breakdown(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
record_token_usage(
{"prompt_tokens": 100, "completion_tokens": 25},
source="user",
now=datetime(2026, 6, 3, tzinfo=timezone.utc),
)
record_token_usage(
{"prompt_tokens": 20, "completion_tokens": 5},
source="dream",
now=datetime(2026, 6, 3, tzinfo=timezone.utc),
)
payload = token_usage_payload(now=datetime(2026, 6, 3, tzinfo=timezone.utc))
row = payload["days"][0]
assert row["total_tokens"] == 150
assert row["sources"]["user"]["total_tokens"] == 125
assert row["sources"]["user"]["requests"] == 1
assert row["sources"]["dream"]["total_tokens"] == 25
assert row["sources"]["dream"]["requests"] == 1
def test_record_response_token_usage_uses_response_usage(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
monkeypatch.setattr("nanobot.webui.token_usage._local_day", lambda *_, **__: "2026-06-03")
record_response_token_usage(
SimpleNamespace(usage={"prompt_tokens": 20, "completion_tokens": 5}),
source="dream",
)
payload = token_usage_payload(now=datetime(2026, 6, 3, tzinfo=timezone.utc))
assert payload["days"][0]["sources"]["dream"]["total_tokens"] == 25
@pytest.mark.asyncio
async def test_token_usage_hook_classifies_source_from_session_key(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
monkeypatch.setattr("nanobot.webui.token_usage._local_day", lambda *_, **__: "2026-06-03")
hook = TokenUsageHook()
await hook.after_iteration(
AgentHookContext(
iteration=0,
messages=[],
session_key="cron:drink-water",
usage={"prompt_tokens": 10, "completion_tokens": 5},
)
)
payload = token_usage_payload(now=datetime(2026, 6, 3, tzinfo=timezone.utc))
assert payload["days"][0]["sources"]["cron"]["total_tokens"] == 15

View File

@ -1,5 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Menu, Moon, Sun } from "lucide-react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { Moon, PanelLeft, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { DeleteConfirm } from "@/components/DeleteConfirm";
import { RenameChatDialog } from "@/components/RenameChatDialog";
@ -12,6 +19,7 @@ import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { useSessions } from "@/hooks/useSessions";
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
import { useSidebarState } from "@/hooks/useSidebarState";
import { useSkills } from "@/hooks/useSkills";
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
import { cn } from "@/lib/utils";
import {
@ -36,6 +44,7 @@ import { Input } from "@/components/ui/input";
import { fetchSettings, fetchWorkspaces } from "@/lib/api";
import {
createRuntimeHost,
getHostApi,
toRuntimeSurface,
} from "@/lib/runtime";
import { projectNameFromPath } from "@/lib/workspace";
@ -60,7 +69,7 @@ const SIDEBAR_WIDTH = 272;
const SIDEBAR_RAIL_WIDTH = 56;
const TOKEN_REFRESH_MARGIN_MS = 30_000;
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
type ShellView = "chat" | "settings" | "apps";
type ShellView = "chat" | "settings" | "apps" | "skills";
type ShellRoute = {
view: ShellView;
activeKey: string | null;
@ -74,6 +83,7 @@ const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [
"image",
"browser",
"apps",
"skills",
"runtime",
"advanced",
];
@ -86,6 +96,11 @@ function defaultShellRoute(): ShellRoute {
return { view: "chat", activeKey: null, settingsSection: "overview" };
}
function shellViewForSettingsSection(section: SettingsSectionKey): ShellView {
if (section === "apps" || section === "skills") return section;
return "settings";
}
function readShellRoute(): ShellRoute {
if (typeof window === "undefined") return defaultShellRoute();
const hash = window.location.hash.startsWith("#")
@ -102,11 +117,18 @@ function readShellRoute(): ShellRoute {
const activeKey = params.get("chat")?.trim() || null;
if (path === "/settings") {
return { view: "settings", activeKey, settingsSection };
return {
view: shellViewForSettingsSection(settingsSection),
activeKey,
settingsSection,
};
}
if (path === "/apps") {
return { view: "apps", activeKey, settingsSection: "apps" };
}
if (path === "/skills") {
return { view: "skills", activeKey, settingsSection: "skills" };
}
if (path.startsWith("/chat/")) {
const encoded = path.slice("/chat/".length);
try {
@ -264,51 +286,43 @@ function normalizeWorkspaceScope(scope: WorkspaceScopePayload): WorkspaceScopePa
function HostChrome({
onToggleSidebar,
theme,
onToggleTheme,
showThemeButton = true,
onSidebarPreviewEnter,
onSidebarPreviewLeave,
sidebarOpen = true,
rightAction,
}: {
onToggleSidebar?: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
showThemeButton?: boolean;
onSidebarPreviewEnter?: () => void;
onSidebarPreviewLeave?: () => void;
sidebarOpen?: boolean;
rightAction?: ReactNode;
}) {
const { t } = useTranslation();
return (
<header className="host-drag-region pointer-events-none absolute inset-x-0 top-0 z-40 flex h-11 items-start justify-between bg-transparent px-3 pt-2 text-foreground/90">
<div className="flex min-w-[8rem] items-center">
{onToggleSidebar ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleSidebar")}
onClick={onToggleSidebar}
className="host-no-drag pointer-events-auto ml-[88px] h-8 w-8 rounded-xl text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
<Menu className="h-4 w-4" />
</Button>
) : null}
</div>
{showThemeButton ? (
<header className="host-drag-region pointer-events-none absolute inset-x-0 top-0 z-40 h-11 bg-transparent text-foreground/90">
{onToggleSidebar ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleTheme")}
onClick={onToggleTheme}
className="host-no-drag pointer-events-auto h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
aria-label={t("thread.header.toggleSidebar")}
data-testid="host-sidebar-toggle"
onClick={onToggleSidebar}
onFocus={!sidebarOpen ? onSidebarPreviewEnter : undefined}
onBlur={!sidebarOpen ? onSidebarPreviewLeave : undefined}
onMouseEnter={!sidebarOpen ? onSidebarPreviewEnter : undefined}
onMouseLeave={!sidebarOpen ? onSidebarPreviewLeave : undefined}
className="host-no-drag pointer-events-auto absolute left-[88px] top-[8px] h-7 w-7 rounded-lg bg-transparent text-muted-foreground/85 shadow-none hover:bg-transparent hover:text-foreground"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
<PanelLeft className="h-[15px] w-[15px]" strokeWidth={1.75} />
</Button>
) : (
<div aria-hidden className="host-no-drag pointer-events-none h-8 w-8" />
)}
) : null}
{rightAction ? (
<div className="host-no-drag pointer-events-auto absolute right-3 top-2">
{rightAction}
</div>
) : null}
</header>
);
}
@ -318,6 +332,36 @@ export default function App() {
const [state, setState] = useState<BootState>({ status: "loading" });
const bootstrapSecretRef = useRef("");
const refreshReadyClient = useCallback(
async (client: NanobotClient, fallbackSurface: RuntimeSurface) => {
const boot = await fetchBootstrap("", bootstrapSecretRef.current);
const url = deriveWsUrl(boot.ws_path, boot.token, boot.ws_url);
const runtimeSurface = boot.runtime_surface
? toRuntimeSurface(boot.runtime_surface)
: fallbackSurface;
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
if (runtimeHost.socketFactory) {
client.updateUrl(url, runtimeHost.socketFactory);
} else {
client.updateUrl(url);
}
setState((current) =>
current.status === "ready" && current.client === client
? {
...current,
token: boot.token,
tokenExpiresAt,
modelName: boot.model_name ?? current.modelName,
runtimeSurface,
}
: current,
);
return { token: boot.token, url };
},
[],
);
const bootstrapWithSecret = useCallback(
(secret: string) => {
let cancelled = false;
@ -335,37 +379,8 @@ export default function App() {
socketFactory: runtimeHost.socketFactory,
onReauth: async () => {
try {
const refreshed = await fetchBootstrap("", bootstrapSecretRef.current);
const refreshedUrl = deriveWsUrl(
refreshed.ws_path,
refreshed.token,
refreshed.ws_url,
);
const refreshedSurface = refreshed.runtime_surface
? toRuntimeSurface(refreshed.runtime_surface)
: runtimeSurface;
const refreshedHost = createRuntimeHost(
refreshedSurface,
refreshed.runtime_capabilities,
);
const tokenExpiresAt = bootstrapTokenExpiresAt(refreshed.expires_in);
if (refreshedHost.socketFactory) {
client.updateUrl(refreshedUrl, refreshedHost.socketFactory);
} else {
client.updateUrl(refreshedUrl);
}
setState((current) =>
current.status === "ready" && current.client === client
? {
...current,
token: refreshed.token,
tokenExpiresAt,
modelName: refreshed.model_name ?? current.modelName,
runtimeSurface: refreshedSurface,
}
: current,
);
return refreshedUrl;
const refreshed = await refreshReadyClient(client, runtimeSurface);
return refreshed.url;
} catch {
return null;
}
@ -395,7 +410,7 @@ export default function App() {
cancelled = true;
};
},
[],
[refreshReadyClient],
);
useEffect(() => {
@ -403,29 +418,7 @@ export default function App() {
const client = state.client;
const timer = window.setTimeout(async () => {
try {
const boot = await fetchBootstrap("", bootstrapSecretRef.current);
const url = deriveWsUrl(boot.ws_path, boot.token, boot.ws_url);
const runtimeSurface = boot.runtime_surface
? toRuntimeSurface(boot.runtime_surface)
: state.runtimeSurface;
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
if (runtimeHost.socketFactory) {
client.updateUrl(url, runtimeHost.socketFactory);
} else {
client.updateUrl(url);
}
setState((current) =>
current.status === "ready" && current.client === client
? {
...current,
token: boot.token,
tokenExpiresAt,
modelName: boot.model_name ?? current.modelName,
runtimeSurface,
}
: current,
);
await refreshReadyClient(client, state.runtimeSurface);
} catch (e) {
const msg = (e as Error).message;
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
@ -434,7 +427,7 @@ export default function App() {
}
}, tokenRefreshDelayMs(state.tokenExpiresAt));
return () => window.clearTimeout(timer);
}, [state]);
}, [refreshReadyClient, state]);
useEffect(() => {
const saved = loadSavedSecret();
@ -492,6 +485,16 @@ export default function App() {
setState({ status: "auth" });
};
const handleNativeEngineRestart = async (): Promise<string> => {
const hostApi = getHostApi();
if (!hostApi?.restartEngine) {
throw new Error("native engine restart is unavailable");
}
await hostApi.restartEngine();
const refreshed = await refreshReadyClient(state.client, state.runtimeSurface);
return refreshed.token;
};
return (
<ClientProvider
client={state.client}
@ -502,6 +505,7 @@ export default function App() {
runtimeSurface={state.runtimeSurface}
onModelNameChange={handleModelNameChange}
onLogout={handleLogout}
onNativeEngineRestart={handleNativeEngineRestart}
/>
</ClientProvider>
);
@ -511,10 +515,12 @@ function Shell({
runtimeSurface,
onModelNameChange,
onLogout,
onNativeEngineRestart,
}: {
runtimeSurface: RuntimeSurface;
onModelNameChange: (modelName: string | null) => void;
onLogout: () => void;
onNativeEngineRestart: () => Promise<string>;
}) {
const { t, i18n } = useTranslation();
const { client, token } = useClient();
@ -532,6 +538,7 @@ function Shell({
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
const [hostSidebarOpen, setHostSidebarOpen] =
useState<boolean>(readSidebarOpen);
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
const [pendingDelete, setPendingDelete] = useState<{
@ -552,6 +559,7 @@ function Shell({
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
const [completedChatIds, setCompletedChatIds] = useState<Set<string>>(readCompletedRunChatIds);
const [workspaces, setWorkspaces] = useState<WorkspacesPayload | null>(null);
const skills = useSkills(token);
const [settingsSnapshot, setSettingsSnapshot] = useState<SettingsPayload | null>(null);
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const [draftWorkspaceScope, setDraftWorkspaceScope] =
@ -560,6 +568,11 @@ function Shell({
useState<Record<string, WorkspaceScopePayload>>({});
const runningChatIdsRef = useRef<Set<string>>(new Set());
const activeChatIdRef = useRef<string | null>(null);
const hostSidebarPreviewCloseTimerRef = useRef<number | null>(null);
const effectiveRuntimeSurface =
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
const showHostChrome = effectiveRuntimeSurface === "native";
const showMainSidebar = view !== "settings";
const navigate = useCallback(
(route: ShellRoute, options?: { replace?: boolean }) => {
@ -745,13 +758,74 @@ function Shell({
});
}, [client, loading, sessions]);
const closeHostSidebar = useCallback(() => {
setHostSidebarOpen(false);
const clearHostSidebarPreviewCloseTimer = useCallback(() => {
if (hostSidebarPreviewCloseTimerRef.current === null) return;
window.clearTimeout(hostSidebarPreviewCloseTimerRef.current);
hostSidebarPreviewCloseTimerRef.current = null;
}, []);
const closeHostSidebarPreview = useCallback(() => {
clearHostSidebarPreviewCloseTimer();
setHostSidebarPreviewOpen(false);
}, [clearHostSidebarPreviewCloseTimer]);
const openHostSidebarPreview = useCallback(() => {
if (!showHostChrome || !showMainSidebar || hostSidebarOpen) return;
clearHostSidebarPreviewCloseTimer();
setHostSidebarPreviewOpen(true);
}, [
clearHostSidebarPreviewCloseTimer,
hostSidebarOpen,
showHostChrome,
showMainSidebar,
]);
const scheduleHostSidebarPreviewClose = useCallback(() => {
clearHostSidebarPreviewCloseTimer();
if (!showHostChrome || !showMainSidebar || hostSidebarOpen) {
setHostSidebarPreviewOpen(false);
return;
}
hostSidebarPreviewCloseTimerRef.current = window.setTimeout(() => {
setHostSidebarPreviewOpen(false);
hostSidebarPreviewCloseTimerRef.current = null;
}, 160);
}, [
clearHostSidebarPreviewCloseTimer,
hostSidebarOpen,
showHostChrome,
showMainSidebar,
]);
useEffect(() => {
return () => clearHostSidebarPreviewCloseTimer();
}, [clearHostSidebarPreviewCloseTimer]);
useEffect(() => {
if (!showHostChrome || !showMainSidebar || hostSidebarOpen) {
closeHostSidebarPreview();
}
}, [
closeHostSidebarPreview,
hostSidebarOpen,
showHostChrome,
showMainSidebar,
]);
const closeHostSidebar = useCallback(() => {
closeHostSidebarPreview();
setHostSidebarOpen(false);
}, [closeHostSidebarPreview]);
const openHostSidebar = useCallback(() => {
closeHostSidebarPreview();
setHostSidebarOpen(true);
}, []);
}, [closeHostSidebarPreview]);
const toggleHostSidebar = useCallback(() => {
closeHostSidebarPreview();
setHostSidebarOpen((v) => !v);
}, [closeHostSidebarPreview]);
const closeMobileSidebar = useCallback(() => {
setMobileSidebarOpen(false);
@ -762,11 +836,12 @@ function Shell({
typeof window !== "undefined" &&
window.matchMedia("(min-width: 1024px)").matches;
if (isNativeHost) {
closeHostSidebarPreview();
setHostSidebarOpen((v) => !v);
} else {
setMobileSidebarOpen((v) => !v);
}
}, []);
}, [closeHostSidebarPreview]);
const applyWorkspaceScope = useCallback(
(scope: WorkspaceScopePayload) => {
@ -1041,16 +1116,26 @@ function Shell({
setMobileSidebarOpen(false);
}, [activeKey, navigate]);
const onOpenModelSettings = useCallback(() => {
onOpenSettings("models");
}, [onOpenSettings]);
const onOpenApps = useCallback(() => {
setSessionSearchOpen(false);
navigate({ view: "apps", activeKey, settingsSection: "apps" });
setMobileSidebarOpen(false);
}, [activeKey, navigate]);
const onOpenSkills = useCallback(() => {
setSessionSearchOpen(false);
navigate({ view: "skills", activeKey, settingsSection: "skills" });
setMobileSidebarOpen(false);
}, [activeKey, navigate]);
const onSettingsSectionChange = useCallback(
(section: SettingsSectionKey) => {
navigate({
view: section === "apps" ? "apps" : "settings",
view: shellViewForSettingsSection(section),
activeKey,
settingsSection: section,
});
@ -1202,6 +1287,12 @@ function Shell({
});
return;
}
if (view === "skills") {
document.title = t("app.documentTitle.chat", {
title: t("settings.nav.skills", { defaultValue: "Skills" }),
});
return;
}
document.title = activeSession
? t("app.documentTitle.chat", { title: headerTitle })
: t("app.documentTitle.base");
@ -1223,8 +1314,9 @@ function Shell({
onNewChatInProject,
onOpenSettings,
onOpenApps,
onOpenSkills,
onOpenSearch: onOpenSessionSearch,
activeUtility: view === "apps" ? "apps" as const : null,
activeUtility: view === "apps" || view === "skills" ? view : null,
onToggleArchived,
pinnedKeys: sidebarState.pinned_keys,
archivedKeys: sidebarState.archived_keys,
@ -1238,11 +1330,13 @@ function Shell({
archivedCount: sidebarState.archived_keys.length,
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
};
const effectiveRuntimeSurface =
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
const isNativeHostSetupSurface = effectiveRuntimeSurface === "native";
const showHostChrome = isNativeHostSetupSurface;
const showMainSidebar = view !== "settings";
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
const showHostSidebarPreview =
showMainSidebar && hostSidebarCollapsed && hostSidebarPreviewOpen;
const hostSidebarFlowWidth = showHostChrome
? (hostSidebarOpen ? SIDEBAR_WIDTH : 0)
: (hostSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH);
const renderHostSidebarFlowContent = !showHostChrome || hostSidebarOpen;
useEffect(() => {
document.documentElement.classList.toggle("native-host", showHostChrome);
@ -1261,9 +1355,28 @@ function Shell({
>
{showHostChrome ? (
<HostChrome
onToggleSidebar={showMainSidebar ? toggleSidebar : undefined}
theme={theme}
onToggleTheme={toggle}
onToggleSidebar={showMainSidebar ? toggleHostSidebar : undefined}
onSidebarPreviewEnter={openHostSidebarPreview}
onSidebarPreviewLeave={scheduleHostSidebarPreviewClose}
sidebarOpen={hostSidebarOpen}
rightAction={
view === "chat" ? undefined : (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleTheme")}
onClick={toggle}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
)
}
/>
) : null}
<div
@ -1274,25 +1387,47 @@ function Shell({
{/* Host sidebar: in normal flow, so the thread area width stays honest. */}
{showMainSidebar ? (
<aside
data-testid="host-sidebar-flow"
className={cn(
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
"transition-[width] duration-300 ease-out",
)}
style={{
width: hostSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH,
width: hostSidebarFlowWidth,
}}
>
<div
className={cn(
"absolute inset-y-0 left-0 h-full w-full overflow-hidden",
showHostChrome
? "host-sidebar-glass"
: "bg-sidebar shadow-inner-right",
)}
>
{renderHostSidebarFlowContent ? (
<div
className={cn(
"absolute inset-y-0 left-0 h-full w-full overflow-hidden",
showHostChrome
? "host-sidebar-glass"
: "bg-sidebar shadow-inner-right",
)}
>
<Sidebar
{...sidebarProps}
collapsed={!showHostChrome && !hostSidebarOpen}
hostChromeInset={showHostChrome}
onCollapse={closeHostSidebar}
onExpand={openHostSidebar}
/>
</div>
) : null}
</aside>
) : null}
{showHostSidebarPreview ? (
<aside
data-testid="host-sidebar-preview"
className="absolute inset-y-0 left-0 z-30 hidden overflow-hidden lg:block animate-in fade-in-0 slide-in-from-left-2 duration-150"
style={{ width: SIDEBAR_WIDTH }}
onMouseEnter={openHostSidebarPreview}
onMouseLeave={scheduleHostSidebarPreviewClose}
>
<div className="h-full w-full overflow-hidden host-sidebar-glass shadow-2xl">
<Sidebar
{...sidebarProps}
collapsed={!hostSidebarOpen}
hostChromeInset={showHostChrome}
onCollapse={closeHostSidebar}
onExpand={openHostSidebar}
@ -1335,7 +1470,7 @@ function Shell({
<main
className={cn(
"relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background",
showHostChrome && "border-l border-border/55",
showHostChrome && hostSidebarOpen && "border-l border-border/55",
)}
>
<div
@ -1354,7 +1489,7 @@ function Shell({
theme={theme}
onToggleTheme={toggle}
hideSidebarToggleForHostChrome
hideThemeButton={showHostChrome}
hostChromeTitleInset={hostSidebarCollapsed}
hideHeader={false}
workspaceScope={activeWorkspaceScope}
workspaceDefaultScope={workspaces?.default_scope ?? null}
@ -1363,6 +1498,7 @@ function Shell({
workspaceError={workspaceError}
onWorkspaceScopeChange={applyWorkspaceScope}
settingsSnapshot={settingsSnapshot}
onOpenModelSettings={onOpenModelSettings}
/>
</div>
{view !== "chat" && (
@ -1370,15 +1506,18 @@ function Shell({
<SettingsView
theme={theme}
initialSection={settingsInitialSection}
initialSettings={settingsSnapshot}
showSidebar={view === "settings"}
onToggleTheme={toggle}
onBackToChat={onBackToChat}
onModelNameChange={onModelNameChange}
onSettingsChange={setSettingsSnapshot}
skills={skills}
onWorkspaceSettingsChange={refreshWorkspaces}
onSectionChange={onSettingsSectionChange}
onLogout={onLogout}
onRestart={onRestart}
onNativeEngineRestart={onNativeEngineRestart}
isRestarting={isRestarting}
hostChromeInset={showHostChrome}
/>

View File

@ -9,15 +9,33 @@ interface CodeBlockProps {
language?: string;
code: string;
className?: string;
chrome?: "default" | "none";
highlight?: boolean;
showLineNumbers?: boolean;
wrapLongLines?: boolean;
}
interface HighlightedCodeProps {
language?: string;
code: string;
isDark: boolean;
chrome: "default" | "none";
showLineNumbers: boolean;
wrapLongLines: boolean;
}
const CODE_FONT_STACK = [
'"JetBrains Mono"',
'"SFMono-Regular"',
'"SF Mono"',
'"Fira Code"',
'"Cascadia Code"',
'"Source Code Pro"',
"Menlo",
"Consolas",
"monospace",
].join(", ");
const LazyHighlightedCode = lazy(async () => {
const [
{ default: SyntaxHighlighter },
@ -30,19 +48,56 @@ const LazyHighlightedCode = lazy(async () => {
]);
return {
default({ language, code, isDark }: HighlightedCodeProps) {
default({
language,
code,
isDark,
chrome,
showLineNumbers,
wrapLongLines,
}: HighlightedCodeProps) {
const theme = isDark ? oneDark : oneLight;
const transparentTheme = chrome === "none" ? {
...theme,
'pre[class*="language-"]': {
...theme['pre[class*="language-"]'],
background: "transparent",
},
'code[class*="language-"]': {
...theme['code[class*="language-"]'],
background: "transparent",
},
} : theme;
return (
<SyntaxHighlighter
language={language || "text"}
style={isDark ? oneDark : oneLight}
style={transparentTheme}
customStyle={{
background: chrome === "none" ? "transparent" : undefined,
margin: 0,
padding: "1rem",
fontSize: "0.875rem",
lineHeight: 1.6,
padding: chrome === "none" ? "0.75rem 1rem" : "1rem",
fontFamily: CODE_FONT_STACK,
fontSize: chrome === "none" ? "13px" : "0.875rem",
lineHeight: chrome === "none" ? 1.55 : 1.6,
tabSize: 2,
}}
codeTagProps={{
style: chrome === "none" ? {
background: "transparent",
fontFamily: CODE_FONT_STACK,
} : undefined,
}}
lineNumberStyle={{
minWidth: "2.6em",
paddingRight: "1.15rem",
color: isDark ? "rgba(212, 212, 216, 0.45)" : "rgba(63, 63, 70, 0.68)",
fontFamily: CODE_FONT_STACK,
userSelect: "none",
}}
PreTag="pre"
wrapLongLines
showLineNumbers={showLineNumbers}
wrapLongLines={wrapLongLines}
>
{code}
</SyntaxHighlighter>
@ -51,13 +106,39 @@ const LazyHighlightedCode = lazy(async () => {
};
});
function PlainCodeFallback({ code }: { code: string }) {
function PlainCodeFallback({
code,
chrome,
showLineNumbers,
}: {
code: string;
chrome: "default" | "none";
showLineNumbers: boolean;
}) {
const lines = code.split("\n");
return (
<pre
className="m-0 overflow-x-auto whitespace-pre-wrap bg-background p-4 font-mono text-sm leading-[1.6] text-foreground/90"
className={cn(
"m-0 overflow-x-auto p-4 font-mono text-sm leading-[1.6] text-foreground/90",
showLineNumbers ? "whitespace-pre" : "whitespace-pre-wrap",
chrome === "default" ? "bg-background" : "bg-transparent",
chrome === "none" && "p-3 text-[13px] leading-[1.55]",
)}
data-testid="plain-code-fallback"
>
<code className="text-inherit">{code}</code>
<code className="text-inherit">
{showLineNumbers ? (
lines.map((line, index) => (
<span key={index} className="flex min-w-max">
<span className="w-10 shrink-0 select-none pr-4 text-right text-muted-foreground/60">
{index + 1}
</span>
<span className="whitespace-pre">{line || " "}</span>
{index < lines.length - 1 ? "\n" : null}
</span>
))
) : code}
</code>
</pre>
);
}
@ -66,11 +147,15 @@ export function CodeBlock({
language,
code,
className,
chrome = "default",
highlight = true,
showLineNumbers = false,
wrapLongLines = true,
}: CodeBlockProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const isDark = useThemeValue() === "dark";
const hasChrome = chrome === "default";
const onCopy = useCallback(() => {
if (!navigator.clipboard) return;
@ -83,47 +168,69 @@ export function CodeBlock({
return (
<div
className={cn(
"overflow-hidden rounded-lg border",
isDark ? "border-white/10" : "border-black/10",
"overflow-hidden",
hasChrome && "rounded-lg border",
hasChrome && (isDark ? "border-white/10" : "border-black/10"),
className,
)}
>
<div
className={cn(
"flex items-center justify-between px-4 py-1.5 text-xs font-medium",
isDark
? "bg-zinc-800 text-zinc-300"
: "bg-zinc-100 text-zinc-600",
)}
>
<span className="lowercase font-mono">
{language || t("code.fallbackLanguage")}
</span>
<button
type="button"
onClick={onCopy}
{hasChrome ? (
<div
className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-mono transition-colors",
"flex items-center justify-between px-4 py-1.5 text-xs font-medium",
isDark
? "text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
: "text-zinc-500 hover:bg-zinc-200 hover:text-zinc-700",
? "bg-zinc-800 text-zinc-300"
: "bg-zinc-100 text-zinc-600",
)}
aria-label={t("code.copyAria")}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copied ? t("code.copied") : t("code.copy")}</span>
</button>
</div>
<span className="lowercase font-mono">
{language || t("code.fallbackLanguage")}
</span>
<button
type="button"
onClick={onCopy}
className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-mono transition-colors",
isDark
? "text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
: "text-zinc-500 hover:bg-zinc-200 hover:text-zinc-700",
)}
aria-label={t("code.copyAria")}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copied ? t("code.copied") : t("code.copy")}</span>
</button>
</div>
) : null}
{highlight ? (
<Suspense fallback={<PlainCodeFallback code={code} />}>
<LazyHighlightedCode language={language} code={code} isDark={isDark} />
<Suspense
fallback={
<PlainCodeFallback
code={code}
chrome={chrome}
showLineNumbers={showLineNumbers}
/>
}
>
<LazyHighlightedCode
language={language}
code={code}
isDark={isDark}
chrome={chrome}
showLineNumbers={showLineNumbers}
wrapLongLines={wrapLongLines}
/>
</Suspense>
) : (
<PlainCodeFallback code={code} />
<PlainCodeFallback
code={code}
chrome={chrome}
showLineNumbers={showLineNumbers}
/>
)}
</div>
);

View File

@ -0,0 +1,287 @@
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import { AlertCircle, ChevronRight, FileText, Loader2, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { CodeBlock } from "@/components/CodeBlock";
import { splitFilePath } from "@/components/FileReferenceChip";
import { ApiError, fetchFilePreview } from "@/lib/api";
import type { FilePreviewPayload } from "@/lib/types";
import { cn } from "@/lib/utils";
interface FilePreviewPanelProps {
sessionKey: string;
path: string;
token: string;
desktopWidth?: number;
isClosing?: boolean;
onResizeStart?: (event: ReactPointerEvent<HTMLButtonElement>) => void;
onClose: () => void;
}
type PreviewState =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "ready"; payload: FilePreviewPayload };
function supportsHoverCloseControl(): boolean {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia("(hover: hover) and (pointer: fine)").matches;
}
export function FilePreviewPanel({
sessionKey,
path,
token,
desktopWidth = 544,
isClosing = false,
onResizeStart,
onClose,
}: FilePreviewPanelProps) {
const { t } = useTranslation();
const [state, setState] = useState<PreviewState>({ status: "loading" });
const [entered, setEntered] = useState(false);
const [supportsHoverClose, setSupportsHoverClose] = useState(supportsHoverCloseControl);
useEffect(() => {
const frame = window.requestAnimationFrame(() => setEntered(true));
return () => window.cancelAnimationFrame(frame);
}, []);
useEffect(() => {
if (typeof window.matchMedia !== "function") return undefined;
const query = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setSupportsHoverClose(query.matches);
update();
if (typeof query.addEventListener === "function") {
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}
query.addListener(update);
return () => query.removeListener(update);
}, []);
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
fetchFilePreview(token, sessionKey, path)
.then((payload) => {
if (!cancelled) setState({ status: "ready", payload });
})
.catch((error: unknown) => {
if (cancelled) return;
const message = error instanceof ApiError
? (error.status === 404 && /API route not found/i.test(error.message)
? t("filePreview.routeMissing", {
defaultValue: "File preview needs the latest gateway. Restart nanobot gateway and try again.",
})
: error.message)
: t("filePreview.failed", { defaultValue: "Could not preview this file." });
setState({ status: "error", message });
});
return () => {
cancelled = true;
};
}, [path, sessionKey, t, token]);
const displayPath = state.status === "ready" ? state.payload.display_path : path;
const previewPath = state.status === "ready" ? state.payload.path : displayPath;
const normalizedPreviewPath = previewPath.replace(/\\/g, "/");
const hasRootPrefix = normalizedPreviewPath.startsWith("/");
const { name } = splitFilePath(displayPath);
const breadcrumbs = useMemo(
() => normalizedPreviewPath.split("/").filter(Boolean),
[normalizedPreviewPath],
);
const compactBreadcrumbs = useMemo(
() => (breadcrumbs.length > 2 ? breadcrumbs.slice(-2) : breadcrumbs),
[breadcrumbs],
);
const hasCompactPrefix = breadcrumbs.length > compactBreadcrumbs.length;
return (
<aside
aria-label={t("filePreview.aria", { defaultValue: "File preview" })}
style={{
"--file-preview-width": `${desktopWidth}px`,
"--file-preview-slot-width": !entered || isClosing ? "0px" : `${desktopWidth}px`,
} as CSSProperties}
className={cn(
"absolute inset-y-0 right-0 z-30 w-[min(92vw,var(--file-preview-slot-width))] overflow-hidden",
"transition-[width] duration-300 ease-out will-change-[width]",
"md:relative md:z-auto md:w-[var(--file-preview-slot-width)] md:min-w-0 md:shrink-0",
isClosing && "pointer-events-none",
)}
data-testid="file-preview-panel"
data-file-preview-panel
>
<div
className={cn(
"absolute inset-y-0 right-0 flex w-[min(92vw,var(--file-preview-width))] flex-col overflow-hidden md:w-[var(--file-preview-width)]",
"border-l border-border/70 bg-background shadow-2xl md:shadow-none",
"transition-[opacity,transform] duration-300 ease-out will-change-transform",
!entered || isClosing ? "translate-x-full opacity-0" : "translate-x-0 opacity-100",
"motion-reduce:translate-x-0",
)}
>
{onResizeStart ? (
<button
type="button"
aria-label={t("filePreview.resize", { defaultValue: "Resize file preview" })}
className={cn(
"group absolute inset-y-0 left-0 z-20 hidden w-3 -translate-x-1/2 cursor-col-resize touch-none md:flex",
"items-stretch justify-center focus-visible:outline-none",
)}
onPointerDown={onResizeStart}
>
<span
aria-hidden
className={cn(
"h-full w-px bg-foreground/25 opacity-0 transition-opacity",
"group-hover:opacity-100 group-focus-visible:bg-ring group-focus-visible:opacity-100",
)}
/>
</button>
) : null}
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border/60 px-3">
{supportsHoverClose ? (
<div
className={cn(
"group inline-flex max-w-full min-w-0 items-center gap-2 rounded-[12px]",
"bg-muted/70 px-2.5 py-1.5 text-sm font-medium",
)}
title={name || displayPath}
>
<button
type="button"
onClick={onClose}
className={cn(
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full",
"text-muted-foreground/75 transition-[background-color,color,opacity] duration-150 ease-out",
"group-hover:bg-foreground group-hover:text-background group-hover:opacity-100",
"group-focus-within:bg-foreground group-focus-within:text-background",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label={t("filePreview.close", { defaultValue: "Close file preview" })}
>
<FileText
className={cn(
"absolute h-4 w-4 transition-all duration-150 ease-out",
"opacity-100 group-hover:scale-75 group-hover:opacity-0",
"group-focus-within:scale-75 group-focus-within:opacity-0",
)}
aria-hidden
/>
<X
className={cn(
"absolute h-3.5 w-3.5 scale-75 opacity-0 transition-all duration-150 ease-out",
"group-hover:scale-100 group-hover:opacity-100",
"group-focus-within:scale-100 group-focus-within:opacity-100",
)}
aria-hidden
/>
</button>
<span className="min-w-0 truncate">{name || displayPath}</span>
</div>
) : (
<>
<button
type="button"
onClick={onClose}
className={cn(
"inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
"text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label={t("filePreview.close", { defaultValue: "Close file preview" })}
>
<X className="h-5 w-5" aria-hidden />
</button>
<span className="min-w-0 truncate text-sm font-medium">
{name || displayPath}
</span>
</>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col">
<div
className={cn(
"flex min-h-10 shrink-0 items-center gap-1.5 overflow-hidden",
"border-b border-border/45 px-4 text-[13px] text-muted-foreground",
)}
title={previewPath}
>
<div className="flex min-w-0 items-center gap-1.5">
{hasCompactPrefix ? (
<span className="shrink-0 text-muted-foreground/55">...</span>
) : hasRootPrefix ? (
<span className="shrink-0 text-muted-foreground/55">/</span>
) : null}
{compactBreadcrumbs.length > 0 ? (
compactBreadcrumbs.map((part, index) => (
<span key={`${part}-${index}`} className="flex min-w-0 items-center gap-1.5">
{index > 0 || hasCompactPrefix || hasRootPrefix ? (
<ChevronRight
className="h-3 w-3 shrink-0 text-muted-foreground/40"
aria-hidden
/>
) : null}
<span
className={cn(
"min-w-0 truncate",
index === compactBreadcrumbs.length - 1
? "font-medium text-foreground"
: "max-w-[42vw] shrink text-muted-foreground/76",
)}
>
{part}
</span>
</span>
))
) : (
<span className="truncate">{previewPath}</span>
)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{state.status === "loading" ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
{t("filePreview.loading", { defaultValue: "Loading preview..." })}
</div>
) : state.status === "error" ? (
<div className="flex h-full items-center justify-center px-8 text-center text-sm text-muted-foreground">
<div className="max-w-sm">
<AlertCircle className="mx-auto mb-3 h-5 w-5 text-muted-foreground/70" aria-hidden />
<p>{state.message}</p>
</div>
</div>
) : (
<div className="min-h-full">
{state.payload.truncated ? (
<div className="mx-4 mt-3 rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-200">
{t("filePreview.truncated", {
defaultValue: "Preview is truncated because this file is large.",
})}
</div>
) : null}
<CodeBlock
language={state.payload.language}
code={state.payload.content}
chrome="none"
showLineNumbers
wrapLongLines={false}
className="min-h-full"
/>
</div>
)}
</div>
</div>
</div>
</div>
</aside>
);
}

View File

@ -1,3 +1,5 @@
import type { KeyboardEvent, MouseEvent } from "react";
import {
Tooltip,
TooltipContent,
@ -6,10 +8,11 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
type FileReferenceKind =
export type FileReferenceKind =
| "default"
| "css"
| "html"
| "javascript"
| "json"
| "markdown"
| "notebook"
@ -24,6 +27,8 @@ interface FileReferenceChipProps {
active?: boolean;
className?: string;
textClassName?: string;
previewPath?: string;
onOpen?: (path: string) => void;
testId?: string;
}
@ -34,12 +39,26 @@ export function FileReferenceChip({
active = false,
className,
textClassName,
previewPath,
onOpen,
testId = "inline-file-path",
}: FileReferenceChipProps) {
const { directory, name } = splitFilePath(path);
const kind = fileKindForPath(path);
const displayText = display === "path" ? path.replace(/\\/g, "/") : name;
const fullPath = tooltipPath || path;
const targetPath = previewPath || tooltipPath || path;
const interactive = Boolean(onOpen);
const openPreview = (event: MouseEvent | KeyboardEvent) => {
if (!onOpen) return;
event.preventDefault();
event.stopPropagation();
onOpen(targetPath);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return;
openPreview(event);
};
return (
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
<Tooltip>
@ -50,10 +69,18 @@ export function FileReferenceChip({
<span
data-testid={testId}
aria-label={fullPath}
role={interactive ? "button" : undefined}
tabIndex={interactive ? 0 : undefined}
onClick={interactive ? openPreview : undefined}
onKeyDown={interactive ? onKeyDown : undefined}
className={cn(
"inline-flex max-w-full items-baseline gap-[0.28em] font-medium leading-[inherit]",
"text-sky-600 transition-colors hover:text-sky-700",
"dark:text-sky-300 dark:hover:text-sky-200",
interactive && [
"cursor-pointer rounded-[5px]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/45",
],
)}
>
<FileReferenceIcon kind={kind} />
@ -100,6 +127,7 @@ export function isLikelyFilePath(value: string): boolean {
const raw = value.trim();
if (!raw || raw.includes("\n")) return false;
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) return false;
if (isFilePatternReference(raw)) return false;
if (!/[\\/]/.test(raw) && !/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(raw)) {
return false;
}
@ -110,7 +138,11 @@ export function isLikelyFilePath(value: string): boolean {
return /\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(name);
}
function splitFilePath(path: string): { directory: string; name: string } {
export function isFilePatternReference(value: string): boolean {
return /[*?[\]{}]/.test(value.trim());
}
export function splitFilePath(path: string): { directory: string; name: string } {
const normalized = path.replace(/\\/g, "/");
const slash = normalized.lastIndexOf("/");
if (slash < 0) return { directory: "", name: path };
@ -120,7 +152,7 @@ function splitFilePath(path: string): { directory: string; name: string } {
};
}
function fileKindForPath(path: string): FileReferenceKind {
export function fileKindForPath(path: string): FileReferenceKind {
const normalized = path.toLowerCase();
const name = normalized.split(/[\\/]/).pop() ?? normalized;
const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
@ -134,7 +166,13 @@ function fileKindForPath(path: string): FileReferenceKind {
case "jsx":
case "tsx":
return "react";
case "js":
case "mjs":
case "cjs":
return "javascript";
case "ts":
case "mts":
case "cts":
return "typescript";
case "html":
case "htm":
@ -156,7 +194,27 @@ function fileKindForPath(path: string): FileReferenceKind {
}
}
function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
export function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
if (kind === "python") {
return (
<svg
aria-hidden
className="h-[1em] w-[1em] shrink-0 translate-y-[0.12em]"
viewBox="0 0 24 24"
>
<path
d="M11.9 2.3c-3 0-4.5.8-4.5 2.3v2.1h4.8v.8H5.5C4 7.5 3 8.8 3 10.8v2.1c0 1.8 1.1 3 2.7 3h1.6v-2.3c0-1.7 1.4-3.1 3.1-3.1h4.2c1.3 0 2.3-1 2.3-2.3V4.6c0-1.4-1.5-2.3-4.6-2.3h-.4Z"
fill="#3776AB"
/>
<path
d="M12.1 21.7c3 0 4.5-.8 4.5-2.3v-2.1h-4.8v-.8h6.7c1.5 0 2.5-1.3 2.5-3.3v-2.1c0-1.8-1.1-3-2.7-3h-1.6v2.3c0 1.7-1.4 3.1-3.1 3.1H9.4c-1.3 0-2.3 1-2.3 2.3v3.6c0 1.4 1.5 2.3 4.6 2.3h.4Z"
fill="#FFD43B"
/>
<circle cx="9" cy="5.1" r="0.8" fill="#fff" />
<circle cx="15" cy="18.9" r="0.8" fill="#5C3B00" opacity="0.85" />
</svg>
);
}
if (kind === "react") {
return (
<svg
@ -234,6 +292,8 @@ function fileKindLabel(kind: FileReferenceKind): string {
return "#";
case "html":
return "H";
case "javascript":
return "JS";
case "json":
return "{}";
case "markdown":

View File

@ -16,6 +16,7 @@ interface MarkdownTextProps {
children: string;
className?: string;
streaming?: boolean;
onOpenFilePreview?: (path: string) => void;
}
const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
@ -25,13 +26,19 @@ const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({
source,
className,
highlightCode,
onOpenFilePreview,
}: {
source: string;
className?: string;
highlightCode: boolean;
onOpenFilePreview?: (path: string) => void;
}) {
return (
<LazyMarkdownRenderer className={className} highlightCode={highlightCode}>
<LazyMarkdownRenderer
className={className}
highlightCode={highlightCode}
onOpenFilePreview={onOpenFilePreview}
>
{source}
</LazyMarkdownRenderer>
);
@ -55,6 +62,7 @@ export function MarkdownText({
children,
className,
streaming = false,
onOpenFilePreview,
}: MarkdownTextProps) {
const renderedSource = useStreamingMarkdownSource(children, streaming);
const highlightCode = streaming
@ -82,6 +90,7 @@ export function MarkdownText({
source={renderedSource}
className={className}
highlightCode={highlightCode}
onOpenFilePreview={onOpenFilePreview}
/>
</Suspense>
);

View File

@ -1,16 +1,29 @@
import { Children, isValidElement, useMemo, type ReactNode } from "react";
import {
Children,
isValidElement,
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import { Check } from "lucide-react";
import { Check, Globe2 } from "lucide-react";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { AttachmentTile } from "@/components/AttachmentTile";
import { CodeBlock } from "@/components/CodeBlock";
import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip";
import {
FileReferenceChip,
isFilePatternReference,
isLikelyFilePath,
} from "@/components/FileReferenceChip";
import { inferMediaKind } from "@/lib/media";
import { faviconUrls } from "@/lib/provider-brand";
import { cn } from "@/lib/utils";
import "katex/dist/katex.min.css";
@ -19,6 +32,7 @@ interface MarkdownTextRendererProps {
children: string;
className?: string;
highlightCode?: boolean;
onOpenFilePreview?: (path: string) => void;
}
type MarkdownAstNode = {
@ -32,10 +46,9 @@ type MarkdownAstNode = {
type InlineLinkPreview = {
href: string;
origin: string;
host: string;
prefix?: string;
title: string;
initials: string;
};
const SAFE_INLINE_HTML_TAGS = new Set(["mark", "sub", "sup"]);
@ -187,6 +200,45 @@ function nodeText(value: ReactNode): string {
.join("");
}
function cleanFileReferenceTarget(value: string): string {
let target = value.trim();
if (!target) return "";
try {
if (/^file:\/\//i.test(target)) {
target = decodeURIComponent(new URL(target).pathname);
} else {
target = decodeURIComponent(target);
}
} catch {
// Keep the raw value when URL/path decoding is not possible.
}
target = target.split("?", 1)[0]?.split("#", 1)[0]?.trim() ?? "";
if (!/^[A-Za-z]:[\\/]/.test(target)) {
target = target.replace(/:\d+(?::\d+)?$/, "");
}
return target;
}
function isPreviewableFileTarget(value: string): boolean {
if (isFilePatternReference(value)) return false;
if (isLikelyFilePath(value)) return true;
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return false;
if (/[\\/]/.test(value)) return false;
return /^[^?#]+\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(value);
}
function isNonNavigableFilePatternLink(href: string | undefined): boolean {
if (!href || /^https?:\/\//i.test(href) || href.startsWith("#")) return false;
const target = cleanFileReferenceTarget(href);
return Boolean(target && isFilePatternReference(target));
}
function fileReferenceFromLink(href: string | undefined): string | null {
if (!href || /^https?:\/\//i.test(href) || href.startsWith("#")) return null;
const target = cleanFileReferenceTarget(href);
return isPreviewableFileTarget(target) ? target : null;
}
function linkPreviewParts(value: ReactNode): { text: string; href?: string } {
let text = "";
let href: string | undefined;
@ -216,16 +268,6 @@ function cleanLinkPreviewText(value: string): string {
.trim();
}
function linkPreviewInitials(value: string): string {
const clean = value
.replace(/^https?:\/\//i, "")
.replace(/^www\./i, "")
.replace(/\.[a-z]{2,}$/i, "");
const parts = clean.split(/[\s.-]+/).filter(Boolean);
return (parts.length > 1 ? parts.slice(0, 2).map((part) => part[0]).join("") : clean.slice(0, 2))
.toUpperCase();
}
function inlineLinkPreviewFromChildren(children: ReactNode): InlineLinkPreview | null {
const { text: rawText, href } = linkPreviewParts(children);
if (!href) return null;
@ -253,17 +295,18 @@ function inlineLinkPreviewFromChildren(children: ReactNode): InlineLinkPreview |
return {
href,
origin: url.origin,
host: url.hostname,
prefix,
title,
initials: linkPreviewInitials(prefix || url.hostname),
};
}
function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
const { favicon, onFaviconError } = useFaviconFallback(link.host);
const label = link.prefix
? `${link.prefix}${link.title}`
: link.title;
return (
<a
href={link.href}
@ -278,20 +321,21 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
<span
className={cn(
"relative grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px]",
"border border-border/65 bg-background text-[0.5rem] font-semibold text-muted-foreground",
"border border-border/65 bg-background text-muted-foreground",
)}
aria-hidden
>
{link.initials}
<img
src={`${link.origin}/favicon.ico`}
alt=""
className="absolute h-3 w-3 rounded-[2px] object-contain"
loading="lazy"
onError={(event) => {
event.currentTarget.style.display = "none";
}}
/>
{favicon ? (
<img
src={favicon}
alt=""
className="h-3 w-3 rounded-[2px] object-contain"
loading="lazy"
onError={onFaviconError}
/>
) : (
<Globe2 className="h-3 w-3" />
)}
</span>
<span className="min-w-0 truncate leading-normal">
{label}
@ -300,6 +344,24 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
);
}
function useFaviconFallback(host: string) {
const faviconCandidates = useMemo(() => faviconUrls(host), [host]);
const [faviconIndex, setFaviconIndex] = useState(0);
useEffect(() => {
setFaviconIndex(0);
}, [host]);
const onFaviconError = useCallback(() => {
setFaviconIndex((index) => Math.min(index + 1, faviconCandidates.length));
}, [faviconCandidates.length]);
return {
favicon: faviconCandidates[faviconIndex] ?? null,
onFaviconError,
};
}
function isRenderedCodeBlock(value: ReactNode): boolean {
if (!isValidElement(value)) return false;
const props = value.props as { code?: unknown };
@ -326,6 +388,7 @@ export default function MarkdownTextRenderer({
children,
className,
highlightCode = true,
onOpenFilePreview,
}: MarkdownTextRendererProps) {
const components = useMemo<Components>(
() => ({
@ -344,7 +407,7 @@ export default function MarkdownTextRenderer({
}
const raw = String(kids).replace(/\n$/, "");
if (isLikelyFilePath(raw)) {
return <FileReferenceChip path={raw} />;
return <FileReferenceChip path={raw} onOpen={onOpenFilePreview} />;
}
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
const widePlainBlock = raw.includes("\n") || raw.length > 120;
@ -405,6 +468,21 @@ export default function MarkdownTextRenderer({
);
},
a({ href, children: markdownChildren, ...props }) {
const filePath = fileReferenceFromLink(href);
if (filePath) {
const label = nodeText(markdownChildren).trim();
return (
<FileReferenceChip
path={label || filePath}
tooltipPath={filePath}
previewPath={filePath}
onOpen={onOpenFilePreview}
/>
);
}
if (isNonNavigableFilePatternLink(href)) {
return <>{markdownChildren}</>;
}
return (
<a
href={href}
@ -495,7 +573,7 @@ export default function MarkdownTextRenderer({
);
},
}),
[highlightCode],
[highlightCode, onOpenFilePreview],
);
return (

View File

@ -6,7 +6,7 @@ import {
useState,
type ReactNode,
} from "react";
import { Check, ChevronRight, Copy, ImageIcon, Sparkles, Wrench } from "lucide-react";
import { Check, ChevronRight, Clock3, Copy, ImageIcon, Sparkles, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AttachmentTile } from "@/components/AttachmentTile";
@ -33,6 +33,7 @@ interface MessageBubbleProps {
showAssistantCopyAction?: boolean;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
onOpenFilePreview?: (path: string) => void;
}
/**
@ -49,6 +50,7 @@ export function MessageBubble({
showAssistantCopyAction = true,
cliApps = [],
mcpPresets = [],
onOpenFilePreview,
}: MessageBubbleProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
@ -129,6 +131,10 @@ export function MessageBubble({
const reasoning = message.role === "assistant" ? message.reasoning ?? "" : "";
const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming);
const hasReasoning = reasoning.length > 0 || reasoningStreaming;
const automationSourceLabel = message.source?.kind === "cron"
? (message.source.label?.trim() || t("message.automationSourceFallback"))
: "";
const automationTriggeredLabel = t("message.automationTriggered");
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
const showCopyButton = showAssistantCopyAction && showAssistantActions;
@ -142,13 +148,29 @@ export function MessageBubble({
return (
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
{hasReasoning ? (
<ReasoningBubble text={reasoning} streaming={reasoningStreaming} hasBodyBelow={!empty} />
<ReasoningBubble
text={reasoning}
streaming={reasoningStreaming}
hasBodyBelow={!empty}
onOpenFilePreview={onOpenFilePreview}
/>
) : null}
{empty && message.isStreaming && !hasReasoning ? (
<TypingDots />
) : empty && message.isStreaming ? null : (
<>
<MarkdownText streaming={!!message.isStreaming}>{message.content}</MarkdownText>
{automationSourceLabel ? (
<AutomationSourceBadge
label={automationSourceLabel}
triggerLabel={automationTriggeredLabel}
/>
) : null}
<MarkdownText
streaming={!!message.isStreaming}
onOpenFilePreview={onOpenFilePreview}
>
{message.content}
</MarkdownText>
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
{showAssistantFooterRow ? (
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
@ -187,6 +209,25 @@ export function MessageBubble({
);
}
function AutomationSourceBadge({ label, triggerLabel }: { label: string; triggerLabel: string }) {
return (
<div
className={cn(
"mb-2 inline-flex max-w-full items-center gap-1.5 rounded-full px-2 py-1",
"border border-sky-500/15 bg-sky-500/[0.06]",
"text-[11px] font-medium leading-none text-sky-700",
"dark:border-sky-300/15 dark:bg-sky-300/[0.08] dark:text-sky-200/80",
)}
title={triggerLabel}
>
<Clock3 className="h-3 w-3 shrink-0" aria-hidden />
<span className="min-w-0 truncate">{label}</span>
<span className="text-current/45" aria-hidden>·</span>
<span className="shrink-0">{triggerLabel}</span>
</div>
);
}
function mergeMcpMentionPresets(
presets: McpPresetInfo[],
attachments: UIMcpPresetAttachment[] | undefined,
@ -488,6 +529,7 @@ interface ReasoningBubbleProps {
hasBodyBelow: boolean;
/** When true, skip the slide-in wrapper (used inside ``AgentActivityCluster``). */
embeddedInCluster?: boolean;
onOpenFilePreview?: (path: string) => void;
}
/**
@ -509,6 +551,7 @@ export function ReasoningBubble({
streaming,
hasBodyBelow,
embeddedInCluster = false,
onOpenFilePreview,
}: ReasoningBubbleProps) {
const { t } = useTranslation();
const [userToggled, setUserToggled] = useState(false);
@ -567,6 +610,7 @@ export function ReasoningBubble({
>
<MarkdownText
streaming={streaming}
onOpenFilePreview={onOpenFilePreview}
className={cn(
"text-[12.5px] italic text-muted-foreground/88",
"prose-p:my-1.5 prose-li:my-0.5",

View File

@ -1,6 +1,7 @@
import { useState, type ReactNode } from "react";
import {
Archive,
Brain,
Menu,
Search,
Settings,
@ -34,8 +35,9 @@ interface SidebarProps {
onNewChatInProject: (projectPath: string, projectName: string) => void;
onOpenSettings: () => void;
onOpenApps: () => void;
onOpenSkills: () => void;
onOpenSearch: () => void;
activeUtility?: "apps" | null;
activeUtility?: "apps" | "skills" | null;
onToggleArchived: () => void;
onCollapse: () => void;
onExpand?: () => void;
@ -157,6 +159,13 @@ export function Sidebar(props: SidebarProps) {
active={props.activeUtility === "apps"}
icon={<Blocks className="h-4 w-4" />}
/>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.skills.title")}
onClick={props.onOpenSkills}
active={props.activeUtility === "skills"}
icon={<Brain className="h-4 w-4" />}
/>
{props.archivedCount ? (
<SidebarActionButton
collapsed={collapsed}

View File

@ -13,6 +13,7 @@ import {
Bot,
Brain,
Check,
CircleAlert,
ChevronDown,
ChevronLeft,
ChevronRight,
@ -52,6 +53,8 @@ import {
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings";
import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -73,6 +76,7 @@ import { Textarea } from "@/components/ui/textarea";
import {
createModelConfiguration,
fetchSettings,
fetchSettingsUsage,
fetchCliApps,
fetchMcpPresets,
fetchProviderModels,
@ -99,6 +103,7 @@ import {
providerDisplayLabel,
} from "@/lib/provider-brand";
import { cn } from "@/lib/utils";
import { shortWorkspacePath } from "@/lib/workspace";
import { useClient } from "@/providers/ClientProvider";
import type {
CliAppInfo,
@ -109,6 +114,7 @@ import type {
NetworkSafetySettingsUpdate,
ProviderModelsPayload,
SettingsPayload,
SkillSummary,
WebSearchSettingsUpdate,
WebuiDefaultAccessMode,
} from "@/lib/types";
@ -120,6 +126,7 @@ export type SettingsSectionKey =
| "image"
| "browser"
| "apps"
| "skills"
| "runtime"
| "advanced";
@ -167,7 +174,6 @@ type ProviderApiType = "auto" | "chat_completions" | "responses";
type ProviderForm = { apiKey: string; apiBase: string; apiType: ProviderApiType };
type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
const NANOBOT_ICON_SRC = "/brand/nanobot_icon.png";
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 262_144] as const;
const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
"aihubmix",
@ -265,15 +271,18 @@ const DEFAULT_CUSTOM_MCP_FORM: CustomMcpForm = {
interface SettingsViewProps {
theme: "light" | "dark";
initialSection?: SettingsSectionKey;
initialSettings?: SettingsPayload | null;
showSidebar?: boolean;
onToggleTheme: () => void;
onBackToChat: () => void;
onModelNameChange: (modelName: string | null) => void;
onSettingsChange?: (payload: SettingsPayload) => void;
skills?: SkillSummary[];
onWorkspaceSettingsChange?: () => void | Promise<void>;
onSectionChange?: (section: SettingsSectionKey) => void;
onLogout?: () => void;
onRestart?: () => void;
onNativeEngineRestart?: () => Promise<string>;
isRestarting?: boolean;
hostChromeInset?: boolean;
}
@ -311,27 +320,150 @@ function editableDefaultProvider(payload: SettingsPayload): string {
return base?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? "";
}
function settingsProviderRow(
payload: SettingsPayload,
provider: string | null | undefined,
): SettingsPayload["providers"][number] | null {
if (!provider) return null;
return payload.providers.find((row) => row.name === provider) ?? null;
}
function settingsProviderConfigured(
payload: SettingsPayload,
provider: string | null | undefined,
): boolean {
const row = settingsProviderRow(payload, provider);
if (row) return row.configured;
return payload.agent.has_api_key;
}
const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = {
model: "",
provider: "",
modelPreset: "default",
presetLabel: "Default",
contextWindowTokens: 65_536,
timezone: "UTC",
botName: "nanobot",
botIcon: "",
toolHintMaxLength: 40,
};
const DEFAULT_WEB_SEARCH_FORM: WebSearchSettingsUpdate = {
provider: "duckduckgo",
apiKey: "",
baseUrl: "",
maxResults: 5,
timeout: 30,
useJinaReader: true,
};
const DEFAULT_IMAGE_GENERATION_FORM: ImageGenerationSettingsUpdate = {
enabled: false,
provider: "openrouter",
model: "openai/gpt-5.4-image-2",
defaultAspectRatio: "1:1",
defaultImageSize: "1K",
maxImagesPerTurn: 4,
};
const DEFAULT_NETWORK_SAFETY_FORM: NetworkSafetySettingsUpdate = {
webuiAllowLocalServiceAccess: true,
webuiDefaultAccessMode: "default",
};
function agentDraftFromPayload(payload: SettingsPayload): AgentSettingsDraft {
const fallbackDefault = defaultPreset(payload);
const activePresetName = modelPresetValue(payload);
const activePreset =
payload.model_presets.find((preset) => preset.name === activePresetName) ?? fallbackDefault;
return {
model: activePreset?.model ?? payload.agent.model,
provider: activePreset?.is_default
? editableDefaultProvider(payload)
: activePreset?.provider ?? editableDefaultProvider(payload),
modelPreset: activePresetName,
presetLabel: activePreset?.label ?? activePresetName,
contextWindowTokens: normalizeContextWindowTokens(
activePreset?.context_window_tokens ?? payload.agent.context_window_tokens,
),
timezone: payload.agent.timezone,
botName: payload.agent.bot_name,
botIcon: payload.agent.bot_icon,
toolHintMaxLength: payload.agent.tool_hint_max_length,
};
}
function webSearchFormFromPayload(
payload: SettingsPayload,
previous?: WebSearchSettingsUpdate,
): WebSearchSettingsUpdate {
return {
provider: payload.web_search.provider,
apiKey: previous?.provider === payload.web_search.provider ? previous.apiKey ?? "" : "",
baseUrl: payload.web_search.base_url ?? "",
maxResults: payload.web_search.max_results,
timeout: payload.web_search.timeout,
useJinaReader: payload.web.fetch.use_jina_reader,
};
}
function imageGenerationFormFromPayload(payload: SettingsPayload): ImageGenerationSettingsUpdate {
return {
enabled: payload.image_generation.enabled,
provider: payload.image_generation.provider,
model: payload.image_generation.model,
defaultAspectRatio: payload.image_generation.default_aspect_ratio,
defaultImageSize: payload.image_generation.default_image_size,
maxImagesPerTurn: payload.image_generation.max_images_per_turn,
};
}
function networkSafetyFormFromPayload(payload: SettingsPayload): NetworkSafetySettingsUpdate {
return {
webuiAllowLocalServiceAccess:
payload.advanced.webui_allow_local_service_access ??
payload.advanced.allow_local_preview_access ??
true,
webuiDefaultAccessMode: visibleWebuiDefaultAccessMode(
payload.advanced.webui_default_access_mode,
),
};
}
function pendingRestartSectionsFromPayload(payload: SettingsPayload): PendingRestartSections {
const sections = payload.restart_required_sections ?? [];
return {
runtime: sections.includes("runtime"),
browser: sections.includes("browser"),
image: sections.includes("image"),
};
}
export function SettingsView({
theme,
initialSection = "overview",
initialSettings = null,
showSidebar = true,
onToggleTheme,
onBackToChat,
onModelNameChange,
onSettingsChange,
skills = [],
onWorkspaceSettingsChange,
onSectionChange,
onLogout,
onRestart,
onNativeEngineRestart,
isRestarting = false,
hostChromeInset = false,
}: SettingsViewProps) {
const { t } = useTranslation();
const { token } = useClient();
const [settings, setSettings] = useState<SettingsPayload | null>(null);
const [settings, setSettings] = useState<SettingsPayload | null>(() => initialSettings);
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(() => initialSettings === null);
const [cliAppsLoading, setCliAppsLoading] = useState(true);
const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true);
const [saving, setSaving] = useState(false);
@ -370,26 +502,18 @@ export function SettingsView({
EMPTY_PENDING_RESTART_SECTIONS,
);
const [localPrefs, setLocalPrefs] = useState<LocalPreferences>(() => readLocalPreferences());
const [webSearchForm, setWebSearchForm] = useState<WebSearchSettingsUpdate>({
provider: "duckduckgo",
apiKey: "",
baseUrl: "",
maxResults: 5,
timeout: 30,
useJinaReader: true,
});
const [imageGenerationForm, setImageGenerationForm] = useState<ImageGenerationSettingsUpdate>({
enabled: false,
provider: "openrouter",
model: "openai/gpt-5.4-image-2",
defaultAspectRatio: "1:1",
defaultImageSize: "1K",
maxImagesPerTurn: 4,
});
const [networkSafetyForm, setNetworkSafetyForm] = useState<NetworkSafetySettingsUpdate>({
webuiAllowLocalServiceAccess: true,
webuiDefaultAccessMode: "default",
});
const [webSearchForm, setWebSearchForm] = useState<WebSearchSettingsUpdate>(() =>
initialSettings ? webSearchFormFromPayload(initialSettings) : DEFAULT_WEB_SEARCH_FORM,
);
const [imageGenerationForm, setImageGenerationForm] = useState<ImageGenerationSettingsUpdate>(
() =>
initialSettings
? imageGenerationFormFromPayload(initialSettings)
: DEFAULT_IMAGE_GENERATION_FORM,
);
const [networkSafetyForm, setNetworkSafetyForm] = useState<NetworkSafetySettingsUpdate>(() =>
initialSettings ? networkSafetyFormFromPayload(initialSettings) : DEFAULT_NETWORK_SAFETY_FORM,
);
useEffect(() => {
setActiveSection(initialSection);
@ -404,17 +528,9 @@ export function SettingsView({
);
const [webSearchKeyVisible, setWebSearchKeyVisible] = useState(false);
const [webSearchKeyEditing, setWebSearchKeyEditing] = useState(false);
const [form, setForm] = useState<AgentSettingsDraft>({
model: "",
provider: "",
modelPreset: "default",
presetLabel: "Default",
contextWindowTokens: 65_536,
timezone: "UTC",
botName: "nanobot",
botIcon: "",
toolHintMaxLength: 40,
});
const [form, setForm] = useState<AgentSettingsDraft>(() =>
initialSettings ? agentDraftFromPayload(initialSettings) : DEFAULT_AGENT_SETTINGS_DRAFT,
);
const text = useCallback(
(key: string, fallback: string, options?: Record<string, unknown>) =>
@ -423,59 +539,27 @@ export function SettingsView({
);
const applyPayload = useCallback((payload: SettingsPayload) => {
const fallbackDefault = defaultPreset(payload);
const activePresetName = modelPresetValue(payload);
const activePreset =
payload.model_presets.find((preset) => preset.name === activePresetName) ?? fallbackDefault;
setSettings(payload);
setForm({
model: activePreset?.model ?? payload.agent.model,
provider: activePreset?.is_default
? editableDefaultProvider(payload)
: activePreset?.provider ?? editableDefaultProvider(payload),
modelPreset: activePresetName,
presetLabel: activePreset?.label ?? activePresetName,
contextWindowTokens: normalizeContextWindowTokens(
activePreset?.context_window_tokens ?? payload.agent.context_window_tokens,
),
timezone: payload.agent.timezone,
botName: payload.agent.bot_name,
botIcon: payload.agent.bot_icon,
toolHintMaxLength: payload.agent.tool_hint_max_length,
});
setWebSearchForm((prev) => ({
provider: payload.web_search.provider,
apiKey: prev.provider === payload.web_search.provider ? prev.apiKey ?? "" : "",
baseUrl: payload.web_search.base_url ?? "",
maxResults: payload.web_search.max_results,
timeout: payload.web_search.timeout,
useJinaReader: payload.web.fetch.use_jina_reader,
}));
setImageGenerationForm({
enabled: payload.image_generation.enabled,
provider: payload.image_generation.provider,
model: payload.image_generation.model,
defaultAspectRatio: payload.image_generation.default_aspect_ratio,
defaultImageSize: payload.image_generation.default_image_size,
maxImagesPerTurn: payload.image_generation.max_images_per_turn,
});
setNetworkSafetyForm({
webuiAllowLocalServiceAccess: payload.advanced.webui_allow_local_service_access ?? payload.advanced.allow_local_preview_access ?? true,
webuiDefaultAccessMode: visibleWebuiDefaultAccessMode(payload.advanced.webui_default_access_mode),
});
setForm(agentDraftFromPayload(payload));
setWebSearchForm((prev) => webSearchFormFromPayload(payload, prev));
setImageGenerationForm(imageGenerationFormFromPayload(payload));
setNetworkSafetyForm(networkSafetyFormFromPayload(payload));
if (payload.restart_required_sections) {
setPendingRestartSections({
runtime: payload.restart_required_sections.includes("runtime"),
browser: payload.restart_required_sections.includes("browser"),
image: payload.restart_required_sections.includes("image"),
});
setPendingRestartSections(pendingRestartSectionsFromPayload(payload));
}
onSettingsChange?.(payload);
}, [onSettingsChange]);
useEffect(() => {
if (!initialSettings || settings !== null) return;
applyPayload(initialSettings);
setLoading(false);
}, [applyPayload, initialSettings, settings]);
useEffect(() => {
let cancelled = false;
setLoading(true);
const showLoading = settings === null;
if (showLoading) setLoading(true);
fetchSettings(token)
.then((payload) => {
if (!cancelled) {
@ -484,7 +568,7 @@ export function SettingsView({
}
})
.catch((err) => {
if (!cancelled) setError((err as Error).message);
if (!cancelled && showLoading) setError((err as Error).message);
})
.finally(() => {
if (!cancelled) setLoading(false);
@ -494,6 +578,34 @@ export function SettingsView({
};
}, [applyPayload, token]);
const hasSettings = settings !== null;
useEffect(() => {
if (activeSection !== "overview" || !hasSettings) return;
let cancelled = false;
const refresh = () => {
fetchSettingsUsage(token)
.then((usage) => {
if (cancelled) return;
setSettings((current) => (current ? { ...current, usage } : current));
})
.catch(() => {});
};
void refresh();
const interval = window.setInterval(refresh, 5000);
const onFocus = () => refresh();
const onVisibilityChange = () => {
if (document.visibilityState === "visible") refresh();
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
cancelled = true;
window.clearInterval(interval);
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [activeSection, hasSettings, token]);
useEffect(() => {
if (activeSection !== "apps") return;
let cancelled = false;
@ -629,12 +741,15 @@ export function SettingsView({
const restartViaSettingsSurface = useCallback(async () => {
const isNativeHost = (settings?.surface ?? settings?.runtime_surface) === "native";
const hostApi = getHostApi();
if (isNativeHost && settings?.runtime_capabilities?.can_restart_engine && hostApi) {
if (
isNativeHost &&
settings?.runtime_capabilities?.can_restart_engine &&
onNativeEngineRestart
) {
setHostEngineApplying(true);
try {
await hostApi.restartEngine();
const payload = await fetchSettings(token);
const nextToken = await onNativeEngineRestart();
const payload = await fetchSettings(nextToken);
applyPayload(payload);
setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS);
setError(null);
@ -646,21 +761,25 @@ export function SettingsView({
return;
}
onRestart?.();
}, [applyPayload, onRestart, settings, token]);
}, [applyPayload, onNativeEngineRestart, onRestart, settings]);
const maybeRestartHostEngine = useCallback(
async (payload: RestartAwarePayload) => {
const surface = payload.surface ?? payload.runtime_surface ?? settings?.surface ?? settings?.runtime_surface;
const capabilities = payload.runtime_capabilities ?? settings?.runtime_capabilities;
const isNativeHost = surface === "native";
const hostApi = getHostApi();
if (!payload.requires_restart || !isNativeHost || !capabilities?.can_restart_engine || !hostApi) {
if (
!payload.requires_restart ||
!isNativeHost ||
!capabilities?.can_restart_engine ||
!onNativeEngineRestart
) {
return;
}
setHostEngineApplying(true);
try {
await hostApi.restartEngine();
const refreshed = await fetchSettings(token);
const nextToken = await onNativeEngineRestart();
const refreshed = await fetchSettings(nextToken);
applyPayload(refreshed);
setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS);
setError(null);
@ -670,7 +789,7 @@ export function SettingsView({
setHostEngineApplying(false);
}
},
[applyPayload, settings, token],
[applyPayload, onNativeEngineRestart, settings],
);
const saveModelSettings = async () => {
@ -1135,8 +1254,6 @@ export function SettingsView({
<OverviewSettings
settings={settings}
requiresRestart={hasPendingRestart}
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
showBrandLogos={localPrefs.brandLogos}
onSelectSection={selectSection}
/>
@ -1290,6 +1407,8 @@ export function SettingsView({
isRestarting={isRestarting || hostEngineApplying}
/>
);
case "skills":
return <SkillsCatalogSettings skills={skills} />;
case "runtime":
return (
<RuntimeSettings
@ -1354,10 +1473,20 @@ export function SettingsView({
)}
>
<div className="mb-7">
<p className="mb-2 text-[13px] font-medium text-muted-foreground">
{!showSidebar ? (
<button
type="button"
onClick={onBackToChat}
className="mb-4 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:hidden"
>
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
{t("settings.backToChat")}
</button>
) : null}
<p className="mb-2 text-[12px] font-normal text-muted-foreground">
{t("settings.sidebar.title")}
</p>
<h1 className="text-[28px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[34px]">
<h1 className="text-[24px] font-normal leading-tight tracking-normal text-foreground sm:text-[28px]">
{text(`settings.nav.${activeSection}`, titleForSection(activeSection))}
</h1>
</div>
@ -1437,7 +1566,7 @@ function SettingsSidebar({
{t("settings.backToChat")}
</button>
<div className="mb-3 px-1 md:mb-4 md:px-2">
<h2 className="text-[21px] font-semibold tracking-[-0.02em] text-foreground">
<h2 className="text-[18px] font-normal tracking-normal text-foreground">
{t("settings.sidebar.title")}
</h2>
</div>
@ -1488,15 +1617,11 @@ function SettingsSidebar({
function OverviewSettings({
settings,
requiresRestart,
onRestart,
isRestarting,
onSelectSection,
showBrandLogos,
}: {
settings: SettingsPayload;
requiresRestart: boolean;
onRestart?: () => void;
isRestarting?: boolean;
onSelectSection: (section: SettingsSectionKey) => void;
showBrandLogos: boolean;
}) {
@ -1504,6 +1629,16 @@ function OverviewSettings({
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const activePreset = settings.agent.model_preset || "default";
const activeProvider = settings.agent.resolved_provider ?? settings.agent.provider;
const activeProviderConfigured = settingsProviderConfigured(settings, activeProvider);
const activeProviderLabel = providerDisplayLabel(settings.providers, activeProvider);
const activeModelValue = activeProviderConfigured
? settings.agent.model
: tx("settings.values.notConfigured", "Not configured");
const activeModelCaption = activeProviderConfigured
? `${activeProvider} · ${activePreset}`
: activeProviderLabel || settings.agent.model
? [activeProviderLabel, settings.agent.model].filter(Boolean).join(" · ")
: tx("settings.byok.noConfiguredProviders", "No configured providers");
const webStatus = settings.web.enable
? tx("settings.values.enabled", "Enabled")
: tx("settings.values.disabled", "Disabled");
@ -1515,48 +1650,23 @@ function OverviewSettings({
? tx("settings.values.configured", "Configured")
: tx("settings.values.notConfigured", "Not configured")
}`;
const isNativeHost = (settings.surface ?? settings.runtime_surface) === "native";
const workspaceCaption = shortWorkspacePath(settings.runtime.workspace_path);
const runtimeTitle = isNativeHost
? tx("settings.rows.engine", "Engine")
: tx("settings.rows.gateway", "Gateway");
const runtimeValue = isNativeHost
? tx("settings.values.privateEngine", "Private engine")
: `${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`;
const runtimeCaption = isNativeHost
? tx("settings.values.unixSocket", "Unix socket")
: requiresRestart
? tx("settings.values.restartPending", "Restart pending")
: tx("settings.values.ready", "Ready");
return (
<div className="space-y-7">
<section>
<div className="overflow-hidden rounded-[22px] border border-border/45 bg-card/86 shadow-[0_18px_65px_rgba(15,23,42,0.075)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_18px_65px_rgba(0,0,0,0.24)]">
<div className="flex flex-col gap-4 px-5 py-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<NanobotBrandLogo size="lg" testId="overview-nanobot-logo" />
<div className="min-w-0">
<div className="text-[12px] font-medium text-muted-foreground">nanobot</div>
<div className="mt-0.5 truncate text-[18px] font-semibold leading-6 text-foreground">
{settings.agent.model}
</div>
<div className="mt-0.5 truncate text-[13px] leading-5 text-muted-foreground">
{activeProvider} · {activePreset}
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<StatusPill tone={requiresRestart ? "neutral" : "success"}>
{requiresRestart
? tx("settings.values.restartPending", "Restart pending")
: tx("settings.values.ready", "Ready")}
</StatusPill>
{requiresRestart && onRestart ? (
<Button
size="sm"
variant="ghost"
onClick={onRestart}
disabled={isRestarting}
className="rounded-full"
>
{isRestarting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{isRestarting ? t("app.system.restarting") : t("app.system.restart")}
</Button>
) : null}
</div>
</div>
</div>
<TokenUsageHeatmap usage={settings.usage} />
</section>
<section>
@ -1566,8 +1676,8 @@ function OverviewSettings({
icon={Bot}
valueLogoProvider={activeProvider}
title={tx("settings.overview.model", "Current model")}
value={settings.agent.model}
caption={`${activeProvider} · ${activePreset}`}
value={activeModelValue}
caption={activeModelCaption}
showBrandLogos={showBrandLogos}
onClick={() => onSelectSection("models")}
/>
@ -1603,20 +1713,16 @@ function OverviewSettings({
<SettingsGroup>
<OverviewListRow
icon={Server}
title={tx("settings.rows.gateway", "Gateway")}
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
caption={
requiresRestart
? tx("settings.values.restartPending", "Restart pending")
: tx("settings.values.ready", "Ready")
}
title={runtimeTitle}
value={runtimeValue}
caption={runtimeCaption}
onClick={() => onSelectSection("runtime")}
/>
<OverviewListRow
icon={HardDrive}
title={tx("settings.overview.workspace", "Workspace")}
value={settings.runtime.workspace_path}
caption={settings.runtime.config_path}
value={tx("settings.values.defaultWorkspace", "Default workspace")}
caption={workspaceCaption}
onClick={() => onSelectSection("runtime")}
/>
</SettingsGroup>
@ -1885,9 +1991,8 @@ function ModelsSettings({
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const configuredProviders = settings.providers.filter((provider) => provider.configured);
const oauthProviders = settings.providers.filter((provider) => provider.auth_type === "oauth");
const showAutoProvider = defaultPreset(settings)?.provider === "auto" || form.provider === "auto";
const selectableProviders = uniqueProviders([...configuredProviders, ...oauthProviders]);
const selectableProviders = uniqueProviders(configuredProviders);
const providerOptions = showAutoProvider
? [{ name: "auto", label: tx("settings.values.auto", "Auto") }, ...selectableProviders]
: selectableProviders;
@ -1900,6 +2005,7 @@ function ModelsSettings({
const selectedProviderNeedsSignIn =
selectedProvider?.auth_type === "oauth" && !selectedProvider.configured;
const selectedProviderSigningIn = providerSaving === selectedProvider?.name;
const selectedProviderConfigured = settingsProviderConfigured(settings, form.provider);
const modelFieldsMissing =
!form.model.trim() ||
!form.provider.trim() ||
@ -1918,6 +2024,7 @@ function ModelsSettings({
settings={settings}
draftModel={form.model}
draftProvider={form.provider}
providerConfigured={selectedProviderConfigured}
showProviderLogos={showBrandLogos}
onChange={(modelPreset) => {
const nextPreset = settings.model_presets.find((preset) => preset.name === modelPreset);
@ -2871,9 +2978,11 @@ function AppsCatalogSettings({
const loading = (cliAppsLoading || mcpPresetsLoading) && !cliApps && !mcpPresets;
const statusMessage = cliError || mcpError || (!focusedApp ? cliMessage || mcpMessage : null);
const statusIsError = Boolean(cliError || mcpError);
const caption = tx("settings.apps.caption", "{{cli}} CLI · {{mcp}} MCP")
.replace("{{cli}}", String(cliApps?.installed_count ?? 0))
.replace("{{mcp}}", String(mcpPresets?.installed_count ?? 0));
const caption = t("settings.apps.caption", {
cli: cliApps?.installed_count ?? 0,
mcp: mcpPresets?.installed_count ?? 0,
defaultValue: "{{cli}} CLI · {{mcp}} MCP",
});
return (
<div className="space-y-7">
@ -3255,7 +3364,10 @@ function McpAppsCatalogRow({
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-[12.5px] font-semibold text-foreground">
{tx("settings.mcp.connectTitle", "Connect {{name}}").replace("{{name}}", preset.display_name)}
{t("settings.mcp.connectTitle", {
name: preset.display_name,
defaultValue: "Connect {{name}}",
})}
</div>
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
{tx("settings.mcp.connectHint", "Add the key from your account settings.")}
@ -4060,10 +4172,12 @@ function RuntimeSettings({
<section>
<SettingsSectionTitle>{t("settings.sections.system")}</SettingsSectionTitle>
<SettingsGroup>
<ReadOnlyRow
title={tx("settings.rows.gateway", "Gateway")}
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
/>
{!isNativeHost ? (
<ReadOnlyRow
title={tx("settings.rows.gateway", "Gateway")}
value={`${settings.runtime.gateway_host}:${settings.runtime.gateway_port}`}
/>
) : null}
<ReadOnlyRow title={t("settings.rows.configPath")} value={settings.runtime.config_path} />
<ReadOnlyRow title={tx("settings.rows.workspacePath", "Default workspace")} value={settings.runtime.workspace_path} />
{onRestart && !requiresRestartPending ? (
@ -4369,7 +4483,14 @@ function ModelIdPicker({
const [error, setError] = useState<string | null>(null);
const effectiveProvider =
provider === "auto" ? settings.agent.resolved_provider ?? provider : provider;
const canFetchModels = Boolean(effectiveProvider && effectiveProvider !== "auto");
const hasConcreteProvider = Boolean(effectiveProvider && effectiveProvider !== "auto");
const providerRow = settingsProviderRow(settings, effectiveProvider);
const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
const providerRequiresConfiguration = hasConcreteProvider && !providerConfigured;
const providerUsesManualModelIds =
hasConcreteProvider && providerConfigured && providerRow?.auth_type === "oauth";
const canFetchModels =
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
const normalizedQuery = query.trim().toLowerCase();
const providerModels = payload?.models ?? [];
const visibleModels = providerModels
@ -4390,13 +4511,15 @@ function ModelIdPicker({
const hasModelList = payload?.status === "available";
const showModels = Boolean(hasModelList && payload && (!isCatalog || normalizedQuery));
const customCandidate = query.trim();
const allowCustomModel = !providerRequiresConfiguration;
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
const providerModelCount = payload?.model_count ?? providerModels.length;
const modelUnconfigured = !value.trim() || !providerConfigured;
useEffect(() => {
if (!open) return;
setQuery("");
}, [open, effectiveProvider]);
setQuery(providerUsesManualModelIds || !hasConcreteProvider ? value : "");
}, [open, effectiveProvider, hasConcreteProvider, providerUsesManualModelIds, value]);
useEffect(() => {
if (!open || !shouldFetchModels) {
@ -4443,7 +4566,11 @@ function ModelIdPicker({
)}
>
<span className="flex min-w-0 items-center gap-2">
<ProviderPickerIcon provider={effectiveProvider} showBrandLogos={showProviderLogos} />
<ProviderPickerIcon
provider={effectiveProvider}
showBrandLogos={showProviderLogos}
unconfigured={!providerConfigured}
/>
<span className="min-w-0 truncate font-medium text-foreground">
{model.label ?? model.id}
</span>
@ -4467,7 +4594,11 @@ function ModelIdPicker({
)}
>
<span className="flex min-w-0 items-center gap-2">
<ProviderPickerIcon provider={effectiveProvider} showBrandLogos={showProviderLogos} />
<ProviderPickerIcon
provider={effectiveProvider}
showBrandLogos={showProviderLogos}
unconfigured={modelUnconfigured}
/>
<span
className={cn(
"min-w-0 truncate font-medium",
@ -4500,7 +4631,15 @@ function ModelIdPicker({
</div>
</div>
{!canFetchModels ? (
{providerRequiresConfiguration ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.providerNotConfigured", "Configure this provider before loading models.")}
</div>
) : providerUsesManualModelIds ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.unsupportedModelList", "Type a model ID manually.")}
</div>
) : !canFetchModels ? (
<div className="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
{tx("settings.models.autoProviderCustomOnly", "Auto provider mode uses custom model IDs.")}
</div>
@ -4544,7 +4683,7 @@ function ModelIdPicker({
</div>
) : null}
{customCandidate && !exactQueryMatch && customCandidate !== value ? (
{allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value ? (
<>
{showModels ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
@ -4581,17 +4720,31 @@ function formatContextWindow(tokens: number): string {
function ProviderPickerIcon({
provider,
showBrandLogos,
unconfigured = false,
}: {
provider: string;
showBrandLogos: boolean;
unconfigured?: boolean;
}) {
const [logoIndex, setLogoIndex] = useState(0);
const brand = providerBrand(provider);
const Icon = PROVIDER_ICONS[provider] ?? Sparkles;
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
const logoUrl = brand?.logoUrls[logoIndex];
useEffect(() => setLogoIndex(0), [provider]);
if (unconfigured) {
return (
<span
data-testid="provider-picker-unconfigured-icon"
className="grid h-5 w-5 shrink-0 place-items-center text-amber-700 dark:text-amber-200"
aria-hidden
>
<CircleAlert className="h-4 w-4" strokeWidth={1.8} />
</span>
);
}
if (showBrandLogos && logoUrl) {
return (
<span
@ -4901,32 +5054,6 @@ function ProviderIcon({
);
}
function NanobotBrandLogo({
size = "sm",
testId,
}: {
size?: "sm" | "lg";
testId?: string;
}) {
return (
<span
data-testid={testId}
className={cn(
"grid shrink-0 place-items-center overflow-hidden border border-border/45 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)]",
size === "lg" ? "h-12 w-12 rounded-[16px]" : "h-9 w-9 rounded-[12px]",
)}
aria-hidden
>
<img
src={NANOBOT_ICON_SRC}
alt=""
className={cn("select-none object-contain", size === "lg" ? "h-10 w-10" : "h-7 w-7")}
draggable={false}
/>
</span>
);
}
function OverviewRowIcon({
icon: Icon,
}: {
@ -5090,6 +5217,7 @@ function ModelPresetPicker({
settings,
draftModel,
draftProvider,
providerConfigured,
showProviderLogos,
onChange,
onCreateConfiguration,
@ -5099,6 +5227,7 @@ function ModelPresetPicker({
settings: SettingsPayload;
draftModel: string;
draftProvider: string;
providerConfigured: boolean;
showProviderLogos: boolean;
onChange: (preset: string) => void;
onCreateConfiguration: () => void;
@ -5126,6 +5255,7 @@ function ModelPresetPicker({
settings={settings}
draftModel={draftModel}
draftProvider={draftProvider}
forceUnconfigured={selectedPreset?.is_default ? !providerConfigured : undefined}
showProviderLogos={showProviderLogos}
compact
/>
@ -5190,6 +5320,7 @@ function ModelPresetOptionContent({
settings,
draftModel,
draftProvider,
forceUnconfigured,
showProviderLogos,
compact = false,
}: {
@ -5197,27 +5328,50 @@ function ModelPresetOptionContent({
settings: SettingsPayload;
draftModel: string;
draftProvider: string;
forceUnconfigured?: boolean;
showProviderLogos: boolean;
compact?: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const provider = modelPresetProviderKey(preset, settings, {
draftProvider: preset.is_default ? draftProvider : undefined,
});
const model = preset.is_default ? draftModel : preset.model;
const providerName = providerDisplayLabel(settings.providers, provider);
const providerConfigured =
forceUnconfigured === undefined
? settingsProviderConfigured(settings, provider)
: !forceUnconfigured;
const title = providerConfigured ? model || preset.label : tx("settings.values.notConfigured", "Not configured");
const caption = providerConfigured
? `${providerName}${preset.label ? ` · ${preset.label}` : ""}`
: providerName || model || preset.label
? [providerName, model || preset.label].filter(Boolean).join(" · ")
: tx("settings.byok.noConfiguredProviders", "No configured providers");
return (
<span className="flex min-w-0 items-center gap-2.5">
<ProviderPickerIcon provider={provider} showBrandLogos={showProviderLogos} />
<ProviderPickerIcon
provider={provider}
showBrandLogos={showProviderLogos}
unconfigured={!providerConfigured}
/>
<span className="min-w-0 text-left leading-tight">
<span className="block truncate font-medium text-foreground">{model || preset.label}</span>
<span
className={cn(
"block truncate font-medium",
providerConfigured ? "text-foreground" : "text-amber-800 dark:text-amber-200",
)}
>
{title}
</span>
<span
className={cn(
"mt-0.5 block truncate text-muted-foreground",
compact ? "text-[11.5px]" : "text-[12px]",
)}
>
{providerName}
{preset.label ? ` · ${preset.label}` : ""}
{caption}
</span>
</span>
</span>

View File

@ -0,0 +1,417 @@
import { useEffect, useState, type ReactNode } from "react";
import type { TFunction } from "i18next";
import { Brain, Check, CircleAlert, KeyRound, Loader2, Terminal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet";
import { fetchSkillDetail } from "@/lib/api";
import type { SkillDetail, SkillSummary } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
const { t } = useTranslation();
const availableCount = skills.filter((skill) => skill.available).length;
const [selectedSkill, setSelectedSkill] = useState<SkillSummary | null>(null);
return (
<div className="space-y-7">
<section className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<p className="max-w-[680px] text-[13px] leading-5 text-muted-foreground">
{t("settings.skills.description", {
defaultValue: "Review the instruction skills this agent can load during a conversation.",
})}
</p>
<span className="text-[12px] font-medium text-muted-foreground">
{t("settings.skills.caption", {
available: availableCount,
total: skills.length,
defaultValue: "{{available}} available · {{total}} total",
})}
</span>
</section>
<section>
<div className="flex items-center justify-between border-b border-border/45 pb-3">
<h2 className="mb-2 px-1 text-[13px] font-semibold tracking-[-0.01em] text-foreground/85">
{t("settings.skills.featured", { defaultValue: "Agent skills" })}
</h2>
<span className="rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
{skills.length}
</span>
</div>
{skills.length ? (
<div className="grid gap-x-10 gap-y-1 py-3 md:grid-cols-2">
{skills.map((skill) => (
<SkillCatalogRow
key={`${skill.source}:${skill.name}`}
skill={skill}
onSelect={setSelectedSkill}
/>
))}
</div>
) : (
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
{t("settings.skills.empty", { defaultValue: "No skills are available." })}
</div>
)}
</section>
<SkillDetailSheet
skill={selectedSkill}
open={selectedSkill !== null}
onOpenChange={(open) => {
if (!open) setSelectedSkill(null);
}}
/>
</div>
);
}
function SkillCatalogRow({
skill,
onSelect,
}: {
skill: SkillSummary;
onSelect: (skill: SkillSummary) => void;
}) {
const { t } = useTranslation();
const sourceLabel = skillSourceLabel(skill.source, t);
const StatusIcon = skill.available ? Check : CircleAlert;
const statusLabel = skill.available
? t("settings.skills.statusAvailable", { defaultValue: "Available" })
: t("settings.skills.statusUnavailable", { defaultValue: "Unavailable" });
return (
<button
type="button"
aria-label={t("settings.skills.openDetails", {
name: skill.name,
defaultValue: "Open details for {{name}}",
})}
onClick={() => onSelect(skill)}
className={cn(
"group flex min-w-0 items-center gap-3 rounded-[16px] px-3 py-3 text-left transition-colors",
"hover:bg-muted/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
!skill.available && "opacity-65",
)}
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[14px] bg-muted/70 text-muted-foreground">
<Brain className="h-5 w-5" strokeWidth={1.8} aria-hidden />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-[15px] font-semibold leading-5 text-foreground">
{skill.name}
</h3>
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold leading-none text-muted-foreground">
{sourceLabel}
</span>
</div>
<p className="mt-1 line-clamp-2 text-[13px] leading-5 text-muted-foreground">
{skill.description}
</p>
{!skill.available && skill.unavailable_reason ? (
<p className="mt-1 truncate text-[12px] leading-4 text-muted-foreground/80">
{t("settings.skills.unavailableReason", {
reason: skill.unavailable_reason,
defaultValue: "Missing: {{reason}}",
})}
</p>
) : null}
</div>
<span
title={!skill.available && skill.unavailable_reason ? skill.unavailable_reason : undefined}
className={cn(
"hidden shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-[12px] font-medium sm:inline-flex",
skill.available
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-muted text-muted-foreground",
)}
>
<StatusIcon className="h-3.5 w-3.5" aria-hidden />
{statusLabel}
</span>
</button>
);
}
function SkillDetailSheet({
skill,
open,
onOpenChange,
}: {
skill: SkillSummary | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { token } = useClient();
const { t } = useTranslation();
const [detail, setDetail] = useState<SkillDetail | null>(null);
const [loading, setLoading] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
useEffect(() => {
if (!open || !skill) return;
let cancelled = false;
setDetail(null);
setLoading(true);
setLoadFailed(false);
fetchSkillDetail(token, skill.name)
.then((payload) => {
if (!cancelled) setDetail(payload);
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, skill, token]);
if (!skill) return null;
const activeSkill = detail ?? skill;
const sourceLabel = skillSourceLabel(activeSkill.source, t);
const statusLabel = activeSkill.available
? t("settings.skills.statusAvailable", { defaultValue: "Available" })
: t("settings.skills.statusUnavailable", { defaultValue: "Unavailable" });
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-[min(34rem,calc(100vw-1rem))] max-w-none gap-0 overflow-hidden p-0 sm:max-w-none"
>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div className="flex items-start gap-3 pr-8">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[15px] bg-muted/70 text-muted-foreground">
<Brain className="h-5 w-5" strokeWidth={1.8} aria-hidden />
</div>
<div className="min-w-0">
<SheetTitle className="truncate text-[20px] font-semibold">
{activeSkill.name}
</SheetTitle>
<SheetDescription className="sr-only">
{t("settings.skills.detailDescription", {
name: activeSkill.name,
defaultValue: "Details for {{name}}.",
})}
</SheetDescription>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
<Pill>{sourceLabel}</Pill>
<Pill tone={activeSkill.available ? "success" : "muted"}>{statusLabel}</Pill>
</div>
</div>
</div>
{loading ? (
<div className="mt-8 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
{t("settings.skills.loadingDetail", { defaultValue: "Loading skill details..." })}
</div>
) : loadFailed ? (
<div className="mt-8 rounded-[16px] bg-destructive/10 px-3 py-3 text-sm text-destructive">
{t("settings.skills.loadFailed", { defaultValue: "Could not load skill details." })}
</div>
) : (
<div className="mt-7 space-y-6">
<DetailSection title={t("settings.skills.descriptionTitle", { defaultValue: "Description" })}>
<p className="text-[14px] leading-6 text-muted-foreground">{activeSkill.description}</p>
</DetailSection>
<div className="grid grid-cols-2 gap-2">
<MetaItem
label={t("settings.skills.source", { defaultValue: "Source" })}
value={sourceLabel}
/>
<MetaItem
label={t("settings.skills.status", { defaultValue: "Status" })}
value={statusLabel}
/>
</div>
{!activeSkill.available && activeSkill.unavailable_reason ? (
<DetailSection
title={t("settings.skills.unavailableReasonLabel", {
defaultValue: "Unavailable reason",
})}
>
<p className="text-[13px] leading-5 text-destructive/85">
{activeSkill.unavailable_reason}
</p>
</DetailSection>
) : null}
{detail ? <RequirementsSection detail={detail} /> : null}
{detail ? <RawInstructionsBlock markdown={detail.raw_markdown} /> : null}
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}
function RawInstructionsBlock({ markdown }: { markdown: string }) {
const { t } = useTranslation();
const content =
markdown ||
t("settings.skills.rawInstructionsEmpty", {
defaultValue: "No raw instructions.",
});
return (
<details className="group rounded-[18px] border border-border/45 bg-muted/20 px-3 py-3">
<summary className="cursor-pointer select-none text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground">
{t("settings.skills.rawInstructions", { defaultValue: "Raw SKILL.md" })}
</summary>
<div className="mt-3 overflow-hidden rounded-[14px] border border-border/35 bg-background/70">
<pre
className={cn(
"max-h-[min(42vh,32rem)] overflow-auto overscroll-contain px-3.5 py-3 pr-4",
"whitespace-pre-wrap break-words font-mono text-[12px] leading-[1.7] text-foreground/62",
"scrollbar-thin scrollbar-track-transparent",
"[&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar]:w-1.5",
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/25",
)}
>
{content}
</pre>
</div>
</details>
);
}
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-[16px] bg-muted/35 px-3 py-2.5">
<div className="text-[11px] text-muted-foreground">{label}</div>
<div className="mt-0.5 truncate text-[13px] font-medium text-foreground">{value}</div>
</div>
);
}
function RequirementsSection({ detail }: { detail: SkillDetail }) {
const { t } = useTranslation();
const { bins, env, missing_bins, missing_env } = detail.requirements;
const hasRequirements = bins.length > 0 || env.length > 0;
return (
<DetailSection title={t("settings.skills.requirements", { defaultValue: "Requirements" })}>
{hasRequirements ? (
<div className="space-y-3">
{missing_bins.length ? (
<RequirementLine
title={t("settings.skills.missingCommands", { defaultValue: "Missing CLI" })}
items={missing_bins}
tone="danger"
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{missing_env.length ? (
<RequirementLine
title={t("settings.skills.missingEnvironment", { defaultValue: "Missing ENV" })}
items={missing_env}
tone="danger"
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{bins.length ? (
<RequirementLine
title={t("settings.skills.commands", { defaultValue: "Commands" })}
items={bins}
icon={<Terminal className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
{env.length ? (
<RequirementLine
title={t("settings.skills.environment", { defaultValue: "Environment variables" })}
items={env}
icon={<KeyRound className="h-3.5 w-3.5" aria-hidden />}
/>
) : null}
</div>
) : (
<p className="text-[13px] text-muted-foreground">
{t("settings.skills.noRequirements", { defaultValue: "No explicit requirements." })}
</p>
)}
</DetailSection>
);
}
function DetailSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section>
<h3 className="mb-2 text-[12px] font-medium text-muted-foreground">{title}</h3>
{children}
</section>
);
}
function RequirementLine({
title,
items,
icon,
tone = "muted",
}: {
title: string;
items: string[];
icon: ReactNode;
tone?: "muted" | "danger";
}) {
return (
<div className="space-y-1.5">
<div
className={cn(
"flex items-center gap-1.5 text-[12px]",
tone === "danger" ? "text-destructive" : "text-muted-foreground",
)}
>
{icon}
{title}
</div>
<div className="flex flex-wrap gap-1.5">
{items.map((item) => (
<Pill key={item}>{item}</Pill>
))}
</div>
</div>
);
}
function Pill({
children,
tone = "muted",
}: {
children: ReactNode;
tone?: "muted" | "success";
}) {
return (
<span
className={cn(
"inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
tone === "success"
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-muted text-muted-foreground",
)}
>
{children}
</span>
);
}
function skillSourceLabel(source: string, t: TFunction): string {
if (source === "workspace") {
return t("settings.skills.sourceWorkspace", { defaultValue: "Custom" });
}
if (source === "builtin") {
return t("settings.skills.sourceBuiltin", { defaultValue: "Built-in" });
}
return source;
}

View File

@ -0,0 +1,224 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { SettingsPayload } from "@/lib/types";
type TokenUsagePayload = NonNullable<SettingsPayload["usage"]>;
type TokenUsageDay = TokenUsagePayload["days"][number];
type TokenUsageCell = {
date: string;
total: number;
estimated: number;
requests: number;
sources: NonNullable<TokenUsageDay["sources"]>;
future: boolean;
};
type TokenUsageMonthLabel = {
label: string;
column: number;
};
const TOKEN_HEATMAP_CELLS = 371;
const TOKEN_HEATMAP_COLUMNS = Math.ceil(TOKEN_HEATMAP_CELLS / 7);
const TOKEN_USAGE_SOURCE_ORDER = ["user", "api", "cron", "dream", "system"] as const;
function startOfUtcDay(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}
function addUtcDays(date: Date, days: number): Date {
const next = new Date(date);
next.setUTCDate(next.getUTCDate() + days);
return next;
}
function isoDay(date: Date): string {
return date.toISOString().slice(0, 10);
}
function buildTokenUsageCalendar(
days: TokenUsageDay[] | undefined,
monthFormatter: Intl.DateTimeFormat,
): { cells: TokenUsageCell[]; monthLabels: TokenUsageMonthLabel[] } {
const byDate = new Map((days ?? []).map((day) => [day.date, day]));
const today = startOfUtcDay(new Date());
const end = addUtcDays(today, 6 - today.getUTCDay());
const start = addUtcDays(end, -(TOKEN_HEATMAP_CELLS - 1));
const seenMonths = new Set<string>();
const monthLabels: TokenUsageMonthLabel[] = [];
const cells = Array.from({ length: TOKEN_HEATMAP_CELLS }, (_, index) => {
const date = addUtcDays(start, index);
const key = isoDay(date);
const row = byDate.get(key);
const monthKey = key.slice(0, 7);
if (!seenMonths.has(monthKey)) {
seenMonths.add(monthKey);
monthLabels.push({
label: monthFormatter.format(date),
column: Math.floor(index / 7) + 1,
});
}
return {
date: key,
total: row?.total_tokens ?? 0,
estimated: row?.estimated_tokens ?? 0,
requests: row?.requests ?? 0,
sources: row?.sources ?? {},
future: date > today,
};
});
return { cells, monthLabels };
}
function tokenUsageSourceLabel(
source: string,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string {
if (source === "user") return tx("settings.usage.sources.user", "Chat");
if (source === "api") return tx("settings.usage.sources.api", "API");
if (source === "cron") return tx("settings.usage.sources.cron", "Automations");
if (source === "dream") return tx("settings.usage.sources.dream", "Memory");
return tx("settings.usage.sources.system", "System");
}
function tokenUsageSourceBreakdown(
cell: TokenUsageCell,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string {
const known = TOKEN_USAGE_SOURCE_ORDER.filter((source) => cell.sources[source]?.total_tokens > 0);
const extra = Object.keys(cell.sources)
.filter((source) => !TOKEN_USAGE_SOURCE_ORDER.includes(source as typeof TOKEN_USAGE_SOURCE_ORDER[number]))
.filter((source) => cell.sources[source]?.total_tokens > 0)
.sort();
return [...known, ...extra]
.map((source) => {
const label = tokenUsageSourceLabel(source, tx);
const tokens = formatCompactTokens(cell.sources[source]?.total_tokens ?? 0);
return `${label} ${tokens}`;
})
.join(" · ");
}
function formatCompactTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(tokens >= 10_000_000 ? 0 : 1)}M`;
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(tokens >= 10_000 ? 0 : 1)}K`;
return String(tokens);
}
function tokenUsageLevel(tokens: number, max: number): number {
if (tokens <= 0 || max <= 0) return 0;
const ratio = tokens / max;
if (ratio >= 0.75) return 4;
if (ratio >= 0.45) return 3;
if (ratio >= 0.2) return 2;
return 1;
}
function tokenUsageCellClass(level: number, future: boolean): string {
if (future) return "bg-transparent ring-1 ring-neutral-200/70 dark:ring-white/[0.045]";
if (level === 4) return "bg-sky-300 dark:bg-sky-300";
if (level === 3) return "bg-sky-400/85 dark:bg-sky-500/80";
if (level === 2) return "bg-sky-500/60 dark:bg-sky-700/85";
if (level === 1) return "bg-sky-500/30 dark:bg-sky-900/80";
return "bg-neutral-200/70 ring-1 ring-black/[0.025] dark:bg-white/[0.08] dark:ring-white/[0.035]";
}
export function TokenUsageHeatmap({ usage }: { usage?: TokenUsagePayload }) {
const { t, i18n } = useTranslation();
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(values ?? {}) });
const monthFormatter = useMemo(
() => new Intl.DateTimeFormat(i18n.language, { month: "short", timeZone: "UTC" }),
[i18n.language],
);
const { cells, monthLabels } = useMemo(
() => buildTokenUsageCalendar(usage?.days, monthFormatter),
[monthFormatter, usage?.days],
);
const maxTokens = Math.max(0, ...cells.map((cell) => cell.total));
return (
<div className="overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="mx-auto w-full min-w-[760px] max-w-[1054px] px-0.5">
<div className="mb-2 flex justify-end">
<span className="text-[11px] font-normal leading-none text-muted-foreground/64">
{tx("settings.usage.shortTitle", "Token Usage")}
</span>
</div>
<div
className="mb-2 grid h-4 gap-1.5 text-[10px] font-normal leading-4 text-muted-foreground/62"
style={{ gridTemplateColumns: `repeat(${TOKEN_HEATMAP_COLUMNS}, minmax(0, 1fr))` }}
aria-hidden
>
{monthLabels.map((month) => (
<span
key={`${month.label}-${month.column}`}
className="truncate"
style={{ gridColumnStart: month.column, gridColumnEnd: "span 4" }}
>
{month.label}
</span>
))}
</div>
<div
className="grid grid-flow-col grid-rows-7 gap-1.5"
style={{ gridTemplateColumns: `repeat(${TOKEN_HEATMAP_COLUMNS}, minmax(0, 1fr))` }}
aria-label={tx("settings.usage.title", "Token activity")}
>
<TooltipProvider delayDuration={120} skipDelayDuration={80}>
{cells.map((cell) => {
const level = tokenUsageLevel(cell.total, maxTokens);
const baseLabel = cell.future
? cell.date
: tx("settings.usage.cellTitle", "{{date}}: {{tokens}} tokens, {{requests}} requests", {
date: cell.date,
tokens: formatCompactTokens(cell.total),
requests: cell.requests,
});
const label = cell.future || cell.estimated <= 0
? baseLabel
: `${baseLabel} · ${
cell.estimated >= cell.total
? tx("settings.usage.estimated", "estimated")
: tx("settings.usage.includesEstimates", "includes estimates")
}`;
const breakdown = cell.future ? "" : tokenUsageSourceBreakdown(cell, tx);
const ariaLabel = breakdown ? `${label} · ${breakdown}` : label;
return (
<Tooltip key={cell.date}>
<TooltipTrigger asChild>
<span
aria-label={ariaLabel}
className={cn(
"aspect-square w-full rounded-[4px] transition-transform hover:scale-110",
tokenUsageCellClass(level, cell.future),
)}
/>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
className="rounded-[10px] border-border/45 bg-popover px-2.5 py-1.5 text-[11px] font-normal text-popover-foreground shadow-lg"
>
<span className="block">{label}</span>
{breakdown ? (
<span className="mt-1 block text-muted-foreground">{breakdown}</span>
) : null}
</TooltipContent>
</Tooltip>
);
})}
</TooltipProvider>
</div>
</div>
</div>
);
}

View File

@ -173,6 +173,7 @@ interface AgentActivityClusterProps {
turnLatencyMs?: number;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
onOpenFilePreview?: (path: string) => void;
}
/**
@ -186,6 +187,7 @@ export function AgentActivityCluster({
turnLatencyMs,
cliApps = [],
mcpPresets = [],
onOpenFilePreview,
}: AgentActivityClusterProps) {
const { t } = useTranslation();
const fileEdits = useMemo(
@ -423,6 +425,7 @@ export function AgentActivityCluster({
added={added}
deleted={deleted}
hasDiffStats={hasDiffStats}
onOpenFilePreview={onOpenFilePreview}
/>
);
}
@ -449,6 +452,8 @@ export function AgentActivityCluster({
<FileReferenceChip
path={singleFilePath}
tooltipPath={singleFileTooltipPath}
previewPath={singleFileTooltipPath || singleFilePath}
onOpen={onOpenFilePreview}
active={hasLiveEditingFiles}
className="-my-0.5 min-w-0"
textClassName="text-xs"
@ -494,6 +499,7 @@ export function AgentActivityCluster({
key={m.id}
text={m.reasoning ?? ""}
streaming={isTurnStreaming && !!m.reasoningStreaming}
onOpenFilePreview={onOpenFilePreview}
/>
);
}
@ -510,7 +516,12 @@ export function AgentActivityCluster({
}
return null;
})}
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null}
{fileEdits.length ? (
<FileEditGroup
edits={fileEdits}
onOpenFilePreview={onOpenFilePreview}
/>
) : null}
</div>
</div>
</div>
@ -537,6 +548,7 @@ function FileEditFlatActivity({
added,
deleted,
hasDiffStats,
onOpenFilePreview,
}: {
edits: FileEditSummary[];
active: boolean;
@ -550,6 +562,7 @@ function FileEditFlatActivity({
added: number;
deleted: number;
hasDiffStats: boolean;
onOpenFilePreview?: (path: string) => void;
}) {
const showRows = edits.length > 1 || edits.some((edit) => edit.status === "error" || edit.pending);
return (
@ -569,6 +582,8 @@ function FileEditFlatActivity({
<FileReferenceChip
path={singleFilePath}
tooltipPath={singleFileTooltipPath}
previewPath={singleFileTooltipPath || singleFilePath}
onOpen={onOpenFilePreview}
active={hasLiveEditingFiles}
className="-my-0.5 min-w-0"
textClassName="text-xs"
@ -583,7 +598,7 @@ function FileEditFlatActivity({
</div>
{showRows ? (
<div className="mt-0.5 pl-4">
<FileEditGroup edits={edits} />
<FileEditGroup edits={edits} onOpenFilePreview={onOpenFilePreview} />
</div>
) : null}
</div>

View File

@ -0,0 +1,149 @@
import { useMemo, useState } from "react";
import { ListTree, Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetTitle,
} from "@/components/ui/sheet";
import {
type PromptAnchor,
userPromptAnchors,
} from "@/components/thread/promptNavigation";
import { fmtDateTime } from "@/lib/format";
import type { UIMessage } from "@/lib/types";
import { cn } from "@/lib/utils";
interface PromptNavigatorProps {
messages: UIMessage[];
onJumpToPrompt: (promptId: string) => void;
}
export function PromptNavigator({
messages,
onJumpToPrompt,
}: PromptNavigatorProps) {
const { i18n, t } = useTranslation();
const prompts = useMemo(() => userPromptAnchors(messages), [messages]);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const filteredPrompts = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
if (!needle) return prompts;
return prompts.filter((prompt) =>
`${prompt.label}\n${prompt.preview}`.toLocaleLowerCase().includes(needle),
);
}, [prompts, query]);
if (prompts.length === 0) return null;
const jump = (promptId: string) => {
setOpen(false);
onJumpToPrompt(promptId);
};
return (
<>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"host-no-drag h-8 w-8 rounded-full text-muted-foreground/80",
"hover:bg-accent/40 hover:text-foreground",
)}
aria-label={t("thread.promptNavigator.open")}
onClick={() => setOpen(true)}
>
<ListTree className="h-4 w-4" />
</Button>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
aria-describedby={undefined}
className="w-[min(92vw,24rem)] gap-0 p-0 sm:max-w-[24rem]"
>
<div className="border-b px-5 pb-4 pt-5">
<SheetTitle className="text-base font-medium">
{t("thread.promptNavigator.title")}
</SheetTitle>
<div className="relative mt-4">
<Search
aria-hidden
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label={t("thread.promptNavigator.search")}
placeholder={t("thread.promptNavigator.search")}
className={cn(
"h-10 w-full rounded-full border border-border bg-background pl-9 pr-3 text-sm",
"outline-none transition focus:border-ring focus:ring-2 focus:ring-ring/20",
)}
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
{filteredPrompts.length > 0 ? (
<div className="space-y-1">
{filteredPrompts.map((prompt) => (
<PromptNavigatorRow
key={prompt.id}
locale={i18n.resolvedLanguage || i18n.language}
prompt={prompt}
onJump={jump}
/>
))}
</div>
) : (
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
{t("thread.promptNavigator.noResults")}
</div>
)}
</div>
</SheetContent>
</Sheet>
</>
);
}
interface PromptNavigatorRowProps {
locale: string;
onJump: (promptId: string) => void;
prompt: PromptAnchor;
}
function PromptNavigatorRow({
locale,
onJump,
prompt,
}: PromptNavigatorRowProps) {
const { t } = useTranslation();
const timestamp = fmtDateTime(prompt.createdAt, locale);
return (
<button
type="button"
className={cn(
"w-full rounded-xl px-3 py-3 text-left transition",
"hover:bg-accent focus-visible:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30",
)}
aria-label={t("thread.promptNavigator.jumpTo", { label: prompt.label })}
onClick={() => onJump(prompt.id)}
>
<div className="max-h-20 overflow-hidden whitespace-pre-wrap break-words text-sm leading-5 text-foreground">
{prompt.preview}
</div>
{timestamp ? (
<div className="mt-1 text-[10px] leading-4 text-muted-foreground/75">
{timestamp}
</div>
) : null}
</button>
);
}

View File

@ -9,6 +9,13 @@ import {
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
import {
findPromptElement,
jumpToPrompt,
type PromptAnchor,
promptTop,
userPromptAnchors,
} from "@/components/thread/promptNavigation";
interface PromptRailProps {
bottomOffset: number;
@ -16,11 +23,6 @@ interface PromptRailProps {
scrollRef: RefObject<HTMLDivElement>;
}
interface PromptAnchor {
id: string;
label: string;
}
interface MeasuredPrompt extends PromptAnchor {
top: number;
topPercent: number;
@ -30,18 +32,21 @@ interface PromptMarker {
count: number;
ids: string[];
label: string;
preview: string;
topPercent: number;
}
const MIN_PROMPTS_FOR_RAIL = 3;
const RAIL_MIN_SCROLL_RANGE_PX = 240;
const RAIL_MIN_SCROLL_RANGE_PX = 80;
const DENSE_PROMPT_THRESHOLD = 30;
const DENSE_BUCKET_HEIGHT_PX = 12;
const DENSE_BUCKET_FALLBACK_COUNT = 32;
const DENSE_BUCKET_MAX_COUNT = 42;
const MARKER_MIN_GAP_PX = 9;
const MARKER_BASE_WIDTH_PX = 26;
const MARKER_MAX_WIDTH_PX = 42;
const MARKER_BASE_WIDTH_PX = 16;
const MARKER_MAX_WIDTH_PX = 28;
const MEASURE_RETRY_FRAMES = 4;
const RAIL_REVEAL_MS = 1400;
export function PromptRail({
bottomOffset,
@ -52,6 +57,19 @@ export function PromptRail({
const promptAnchors = useMemo(() => userPromptAnchors(messages), [messages]);
const [markers, setMarkers] = useState<PromptMarker[]>([]);
const [activePromptId, setActivePromptId] = useState<string | null>(null);
const [revealed, setRevealed] = useState(false);
const revealTimeoutRef = useRef<number | null>(null);
const revealTemporarily = useCallback(() => {
setRevealed(true);
if (revealTimeoutRef.current !== null) {
window.clearTimeout(revealTimeoutRef.current);
}
revealTimeoutRef.current = window.setTimeout(() => {
setRevealed(false);
revealTimeoutRef.current = null;
}, RAIL_REVEAL_MS);
}, []);
const updateMarkers = useCallback(() => {
const scrollEl = scrollRef.current;
@ -74,8 +92,18 @@ export function PromptRail({
}, [promptAnchors, scrollRef]);
useEffect(() => {
updateMarkers();
}, [updateMarkers]);
let frame = 0;
let remainingFrames = MEASURE_RETRY_FRAMES;
const measure = () => {
updateMarkers();
remainingFrames -= 1;
if (remainingFrames > 0) {
frame = window.requestAnimationFrame(measure);
}
};
measure();
return () => window.cancelAnimationFrame(frame);
}, [bottomOffset, updateMarkers]);
useEffect(() => {
const scrollEl = scrollRef.current;
@ -84,6 +112,7 @@ export function PromptRail({
let frame = 0;
const schedule = () => {
window.cancelAnimationFrame(frame);
revealTemporarily();
frame = window.requestAnimationFrame(updateMarkers);
};
@ -94,7 +123,7 @@ export function PromptRail({
scrollEl.removeEventListener("scroll", schedule);
window.removeEventListener("resize", schedule);
};
}, [scrollRef, updateMarkers]);
}, [revealTemporarily, scrollRef, updateMarkers]);
useEffect(() => {
const scrollEl = scrollRef.current;
@ -105,63 +134,85 @@ export function PromptRail({
return () => observer.disconnect();
}, [scrollRef, updateMarkers]);
useEffect(() => {
return () => {
if (revealTimeoutRef.current !== null) {
window.clearTimeout(revealTimeoutRef.current);
}
};
}, []);
if (markers.length === 0) return null;
const maxMarkerCount = Math.max(...markers.map((marker) => marker.count));
const activeMarkerIndex = markers.findIndex((marker) =>
marker.ids.includes(activePromptId ?? ""),
);
return (
<div
ref={railRef}
aria-label="User prompt navigation"
className={cn(
"pointer-events-none absolute right-6 top-12 z-20 hidden w-12 md:block",
"group pointer-events-auto absolute right-4 top-14 z-20 hidden w-8 opacity-70 md:block",
"transition-opacity duration-200 hover:opacity-100",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200",
)}
style={{ bottom: Math.max(80, bottomOffset) }}
>
{markers.map((marker) => {
{markers.map((marker, index) => {
const active = marker.ids.includes(activePromptId ?? "");
const nearActive = activeMarkerIndex < 0 || Math.abs(index - activeMarkerIndex) <= 1;
return (
<button
key={marker.ids.join("|")}
type="button"
title={marker.label}
aria-label={`Jump to prompt: ${marker.label}`}
onClick={() => jumpToPrompt(scrollRef.current, marker.ids[marker.ids.length - 1])}
className={cn(
"pointer-events-auto absolute right-0 h-1.5 -translate-y-1/2 rounded-full",
"bg-muted-foreground/30 transition-all duration-150",
"hover:bg-blue-500/80 focus-visible:bg-blue-500",
"group/marker absolute right-0 h-5 -translate-y-1/2 overflow-visible rounded-full",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60",
marker.count > 1 && "bg-muted-foreground/45",
active && "bg-foreground shadow-sm",
)}
style={{
top: `${marker.topPercent}%`,
width: markerWidth(marker.count, maxMarkerCount, active),
}}
/>
>
<span
aria-hidden
className={cn(
"absolute right-0 top-1/2 h-[3px] w-full -translate-y-1/2 rounded-full",
"bg-foreground/20 transition-[background-color,opacity,transform,height] duration-200",
"group-hover/marker:bg-blue-500/70 group-hover/marker:opacity-100 group-hover/marker:scale-x-110",
"group-focus-visible/marker:bg-blue-500 group-focus-visible/marker:opacity-100 group-focus-visible/marker:scale-x-110",
marker.count > 1 && "bg-foreground/30",
active && "h-1 bg-foreground/65 opacity-80 shadow-sm",
!active && nearActive && "opacity-25 group-hover:opacity-55",
!active && !nearActive && !revealed && "opacity-0 group-hover:opacity-40",
!active && !nearActive && revealed && "opacity-35",
)}
/>
<span
aria-hidden
className={cn(
"pointer-events-none absolute right-9 top-1/2 z-30 w-64 -translate-y-1/2 rounded-lg px-3 py-2 text-left",
"bg-background/95 text-xs leading-5 text-foreground shadow-lg ring-1 ring-border/80 backdrop-blur",
"opacity-0 translate-x-1 transition-[opacity,transform] duration-150",
"group-hover/marker:opacity-100 group-hover/marker:translate-x-0",
"group-focus-visible/marker:opacity-100 group-focus-visible/marker:translate-x-0",
)}
>
<span className="block max-h-24 overflow-hidden whitespace-pre-wrap break-words">
{marker.preview}
</span>
</span>
</button>
);
})}
</div>
);
}
function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
return messages
.filter((message) => message.role === "user")
.map((message, index) => ({
id: message.id,
label: promptLabel(message.content, index),
}));
}
function promptLabel(content: string, index: number): string {
const text = content.replace(/\s+/g, " ").trim();
if (!text) return `Prompt ${index + 1}`;
return text.length > 80 ? `${text.slice(0, 77)}...` : text;
}
function measurePrompts(
scrollEl: HTMLElement,
anchors: PromptAnchor[],
@ -199,12 +250,14 @@ function groupPromptMarkers(
last.count += 1;
last.ids.push(prompt.id);
last.label = groupedPromptLabel(last.count, prompt.label);
last.preview = groupedPromptPreview(last.count, prompt.preview);
continue;
}
groups.push({
count: 1,
ids: [prompt.id],
label: prompt.label,
preview: prompt.preview,
topPercent: prompt.topPercent,
});
}
@ -245,6 +298,9 @@ function bucketPromptMarkers(
label: bucket.length === 1
? latest.label
: groupedPromptLabel(bucket.length, latest.label),
preview: bucket.length === 1
? latest.preview
: groupedPromptPreview(bucket.length, latest.preview),
topPercent,
}];
});
@ -271,6 +327,10 @@ function groupedPromptLabel(count: number, latestLabel: string): string {
return `${count} prompts, latest: ${latestLabel}`;
}
function groupedPromptPreview(count: number, latestPreview: string): string {
return `${count} prompts\n\n${latestPreview}`;
}
function markerWidth(count: number, maxCount: number, active: boolean): number {
if (maxCount <= 1) return active ? 34 : MARKER_BASE_WIDTH_PX;
const density = Math.log2(count + 1) / Math.log2(maxCount + 1);
@ -279,33 +339,6 @@ function markerWidth(count: number, maxCount: number, active: boolean): number {
return Math.round(active ? width + 4 : width);
}
function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void {
if (!scrollEl || !promptId) return;
const target = findPromptElement(scrollEl, promptId);
if (!target) return;
scrollEl.scrollTo({
top: Math.max(0, promptTop(scrollEl, target) - 16),
behavior: "smooth",
});
}
function findPromptElement(scrollEl: HTMLElement, promptId: string): HTMLElement | null {
const candidates = scrollEl.querySelectorAll<HTMLElement>("[data-user-prompt-id]");
return Array.from(candidates).find(
(candidate) => candidate.dataset.userPromptId === promptId,
) ?? null;
}
function promptTop(scrollEl: HTMLElement, target: HTMLElement): number {
const scrollRect = scrollEl.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const hasLayoutRect = scrollRect.top !== 0 || targetRect.top !== 0;
if (hasLayoutRect) {
return targetRect.top - scrollRect.top + scrollEl.scrollTop;
}
return target.offsetTop;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}

View File

@ -0,0 +1,224 @@
import { useState } from "react";
import {
CalendarClock,
CircleAlert,
ListTodo,
RefreshCcw,
} from "lucide-react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useSessionAutomationJobs } from "@/hooks/useSessionAutomationJobs";
import { currentLocale } from "@/i18n";
import { fmtDateTime } from "@/lib/format";
import type { SessionAutomationJob } from "@/lib/types";
import { cn } from "@/lib/utils";
const RELATIVE_THRESHOLDS: [number, Intl.RelativeTimeFormatUnit][] = [
[60, "second"],
[60, "minute"],
[24, "hour"],
[7, "day"],
[4.345, "week"],
[12, "month"],
[Number.POSITIVE_INFINITY, "year"],
];
interface SessionInfoPopoverProps {
sessionKey: string;
token: string;
title: string;
}
export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopoverProps) {
const { t } = useTranslation("common");
const [open, setOpen] = useState(false);
const { jobs, loading, loadFailed, now } = useSessionAutomationJobs(open, token, sessionKey);
const automationContent = loading ? (
<div className="flex items-center gap-2 rounded-[16px] bg-muted/45 px-3 py-3 text-[12.5px] text-muted-foreground">
<RefreshCcw className="h-3.5 w-3.5 animate-spin" />
{t("thread.sessionInfo.loading")}
</div>
) : loadFailed ? (
<div className="flex items-center gap-2 rounded-[16px] bg-destructive/10 px-3 py-3 text-[12.5px] text-destructive">
<CircleAlert className="h-3.5 w-3.5" />
{t("thread.sessionInfo.loadFailed")}
</div>
) : jobs.length ? (
<div className="space-y-1.5">
{jobs.map((job) => (
<AutomationRow key={job.id} job={job} now={now} />
))}
</div>
) : (
<div className="rounded-[16px] bg-muted/35 px-3 py-3 text-[12.5px] leading-relaxed text-muted-foreground">
{t("thread.sessionInfo.empty")}
</div>
);
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.sessionInfo")}
className={cn(
"host-no-drag h-8 w-8 rounded-full text-muted-foreground/85",
"hover:bg-accent/40 hover:text-foreground",
)}
>
<ListTodo className="h-4 w-4 stroke-[1.75]" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={8}
className="w-[min(23rem,calc(100vw-1.5rem))] rounded-[24px] p-0"
>
<div className="space-y-3 px-4 py-3.5">
<div className="min-w-0">
<div className="text-[12px] font-normal text-muted-foreground/75">
{t("thread.sessionInfo.title")}
</div>
<div className="mt-0.5 truncate text-[14px] font-medium text-foreground">
{title || t("thread.sessionInfo.untitled")}
</div>
</div>
<div className="h-px bg-border/45" />
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<CalendarClock className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" />
<span className="truncate text-[13px] font-medium text-foreground">
{t("thread.sessionInfo.automations")}
</span>
</div>
<span className="rounded-full bg-muted/70 px-2 py-0.5 text-[11px] text-muted-foreground">
{t("thread.sessionInfo.count", { count: jobs.length })}
</span>
</div>
{automationContent}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
function AutomationRow({ job, now }: { job: SessionAutomationJob; now: number }) {
const { t } = useTranslation("common");
const schedule = formatSchedule(job, t);
const nextRun = formatNextRun(job, t, now);
const statusClass = job.enabled
? job.state.last_status === "error"
? "bg-destructive"
: "bg-emerald-500"
: "bg-muted-foreground/35";
return (
<div className="rounded-[16px] px-3 py-2.5 transition-colors hover:bg-muted/40">
<div className="flex items-start gap-2.5">
<span className={cn("mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", statusClass)} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-[13px] font-medium text-foreground">{job.name}</span>
{!job.enabled ? (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10.5px] text-muted-foreground">
{t("thread.sessionInfo.disabled")}
</span>
) : null}
</div>
<div className="mt-1 line-clamp-2 text-[12px] leading-snug text-muted-foreground">
{job.payload.message}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11.5px] text-muted-foreground/80">
<span>{schedule}</span>
<span aria-hidden>·</span>
<span title={nextRun.title}>{nextRun.label}</span>
</div>
</div>
</div>
</div>
);
}
function formatSchedule(job: SessionAutomationJob, t: TFunction) {
const locale = currentLocale();
if (job.schedule.kind === "at" && job.schedule.at_ms) {
return t("thread.sessionInfo.schedule.at", { time: fmtDateTime(job.schedule.at_ms, locale) });
}
if (job.schedule.kind === "every" && job.schedule.every_ms) {
return t("thread.sessionInfo.schedule.every", {
duration: formatDuration(job.schedule.every_ms, locale),
});
}
if (job.schedule.kind === "cron" && job.schedule.expr) {
return job.schedule.tz
? t("thread.sessionInfo.schedule.cronWithTz", {
expr: job.schedule.expr,
tz: job.schedule.tz,
})
: t("thread.sessionInfo.schedule.cron", { expr: job.schedule.expr });
}
return t("thread.sessionInfo.schedule.unknown");
}
function formatNextRun(job: SessionAutomationJob, t: TFunction, now: number) {
const locale = currentLocale();
if (!job.enabled) {
return { label: t("thread.sessionInfo.next.disabled"), title: "" };
}
const next = job.state.next_run_at_ms;
if (!next) {
return { label: t("thread.sessionInfo.next.none"), title: "" };
}
return {
label: t("thread.sessionInfo.next.label", { time: relativeTimeFrom(next, now, locale) }),
title: fmtDateTime(next, locale),
};
}
function relativeTimeFrom(value: number, now: number, locale: string): string {
let delta = (value - now) / 1000;
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
for (const [step, unit] of RELATIVE_THRESHOLDS) {
if (Math.abs(delta) < step) {
return formatter.format(Math.round(delta), unit);
}
delta /= step;
}
return formatter.format(Math.round(delta), "year");
}
function formatDuration(ms: number, locale: string): string {
const units: Array<[Intl.NumberFormatOptions["unit"], number]> = [
["day", 86_400_000],
["hour", 3_600_000],
["minute", 60_000],
["second", 1000],
];
for (const [unit, size] of units) {
if (ms >= size && ms % size === 0) {
return new Intl.NumberFormat(locale, {
style: "unit",
unit,
unitDisplay: "long",
maximumFractionDigits: 0,
}).format(ms / size);
}
}
return new Intl.NumberFormat(locale, {
style: "unit",
unit: "minute",
unitDisplay: "long",
maximumFractionDigits: 1,
}).format(ms / 60_000);
}

View File

@ -94,6 +94,8 @@ interface ThreadComposerProps {
modelLabel?: string | null;
modelProvider?: string | null;
modelProviderLabel?: string | null;
modelNeedsSetup?: boolean;
onModelBadgeClick?: () => void;
variant?: "thread" | "hero";
slashCommands?: SlashCommand[];
cliApps?: CliAppInfo[];
@ -647,6 +649,8 @@ export function ThreadComposer({
modelLabel = null,
modelProvider = null,
modelProviderLabel = null,
modelNeedsSetup = false,
onModelBadgeClick,
variant = "thread",
slashCommands = [],
cliApps = [],
@ -759,17 +763,21 @@ export function ThreadComposer({
);
const hasErrors = images.some((img) => img.status === "error");
const hasComposerContent = value.trim().length > 0 || readyImages.length > 0;
const canSend =
!disabled
&& !modelNeedsSetup
&& !encoding
&& !hasErrors
&& (value.trim().length > 0 || readyImages.length > 0);
&& hasComposerContent;
const canOpenModelSettings = Boolean(modelNeedsSetup && onModelBadgeClick && !disabled);
const canQueueGuidance =
isStreaming
&& !disabled
&& !modelNeedsSetup
&& !encoding
&& !hasErrors
&& (value.trim().length > 0 || readyImages.length > 0)
&& hasComposerContent
&& !value.trimStart().startsWith("/");
const slashQuery = useMemo(() => {
@ -1181,6 +1189,10 @@ export function ThreadComposer({
}, [onStop, queuedPrompts.length]);
const submit = useCallback(() => {
if (modelNeedsSetup) {
onModelBadgeClick?.();
return;
}
if (!canSend) return;
const trimmed = value.trim();
const content = trimmed;
@ -1219,6 +1231,8 @@ export function ThreadComposer({
canSend,
clear,
clearComposerText,
modelNeedsSetup,
onModelBadgeClick,
onSend,
readyImages,
value,
@ -1533,24 +1547,32 @@ export function ThreadComposer({
label={modelLabel}
provider={modelProvider}
providerLabel={modelProviderLabel}
needsSetup={modelNeedsSetup}
isHero={isHero}
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
/>
) : null}
<Button
type={showStopButton ? "button" : "submit"}
type={showStopButton || modelNeedsSetup ? "button" : "submit"}
size="icon"
disabled={showStopButton ? disabled : !canSend}
aria-label={showStopButton ? t("thread.composer.stop") : t("thread.composer.send")}
onClick={showStopButton ? handleStop : undefined}
disabled={showStopButton ? disabled : !canSend && !canOpenModelSettings}
aria-label={
showStopButton
? t("thread.composer.stop")
: modelNeedsSetup
? t("thread.composer.configureModel", { defaultValue: "Configure model" })
: t("thread.composer.send")
}
onClick={showStopButton ? handleStop : modelNeedsSetup ? onModelBadgeClick : undefined}
className={cn(
"rounded-full transition-transform",
showStopButton
? "border border-border/70 bg-card text-foreground/85 shadow-[0_3px_10px_rgba(15,23,42,0.08)] hover:bg-muted/65 hover:text-foreground disabled:text-muted-foreground/50"
: isHero
? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
: "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground disabled:bg-foreground disabled:text-background"
: "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground disabled:bg-foreground disabled:text-background",
isHero ? "h-8 w-8" : "h-9 w-9",
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
(canSend || canOpenModelSettings || showStopButton) && "hover:scale-[1.03] active:scale-95",
)}
>
{showStopButton ? (
@ -1766,44 +1788,59 @@ function ComposerModelBadge({
label,
provider,
providerLabel,
needsSetup,
isHero,
onClick,
}: {
label: string;
provider?: string | null;
providerLabel?: string | null;
needsSetup?: boolean;
isHero: boolean;
onClick?: () => void;
}) {
const inferredProvider = provider || inferProviderFromModelName(label);
const inferredProvider = needsSetup ? null : provider || inferProviderFromModelName(label);
const brand = providerBrand(inferredProvider);
const [logoIndex, setLogoIndex] = useState(0);
const logoUrl = brand?.logoUrls[logoIndex];
const showLogo = !!logoUrl;
const title = providerLabel ? `${label} · ${providerLabel}` : label;
const interactive = Boolean(onClick);
const Container = interactive ? "button" : "span";
useEffect(() => setLogoIndex(0), [inferredProvider]);
return (
<span
<Container
title={title}
type={interactive ? "button" : undefined}
onClick={onClick}
className={cn(
"inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
interactive && "cursor-pointer hover:bg-accent/55 hover:text-foreground",
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
isHero ? "h-8 max-w-[12.5rem] gap-1.5 px-2 text-[11.5px]" : "h-9 max-w-[12rem] gap-2 px-2.5 text-[12px]",
)}
>
<span
data-testid={inferredProvider ? `composer-model-logo-${inferredProvider}` : "composer-model-logo"}
data-testid={needsSetup ? "composer-model-setup-icon" : inferredProvider ? `composer-model-logo-${inferredProvider}` : "composer-model-logo"}
className={cn(
"grid shrink-0 place-items-center overflow-hidden rounded-full border bg-background",
"grid shrink-0 place-items-center overflow-hidden",
needsSetup
? "text-amber-800 dark:text-amber-200"
: "rounded-full border bg-background",
isHero ? "h-[18px] w-[18px]" : "h-5 w-5",
)}
style={{
borderColor: brand ? `${brand.color}28` : undefined,
boxShadow: brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
borderColor: !needsSetup && brand ? `${brand.color}28` : undefined,
boxShadow: !needsSetup && brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
}}
aria-hidden
>
{showLogo ? (
{needsSetup ? (
<CircleHelp className={cn(isHero ? "h-3 w-3" : "h-3.5 w-3.5")} strokeWidth={1.8} />
) : showLogo ? (
<img
src={logoUrl}
alt=""
@ -1825,7 +1862,7 @@ function ComposerModelBadge({
)}
</span>
<span className="truncate">{label}</span>
</span>
</Container>
);
}

View File

@ -1,4 +1,5 @@
import { Menu, Moon, Sun } from "lucide-react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@ -10,8 +11,11 @@ interface ThreadHeaderProps {
theme: "light" | "dark";
onToggleTheme: () => void;
hideSidebarToggleForHostChrome?: boolean;
hostChromeTitleInset?: boolean;
hideThemeButton?: boolean;
minimal?: boolean;
promptNavigatorAction?: ReactNode;
sessionInfoAction?: ReactNode;
}
export function ThreadHeader({
@ -20,39 +24,22 @@ export function ThreadHeader({
theme,
onToggleTheme,
hideSidebarToggleForHostChrome = false,
hostChromeTitleInset = false,
hideThemeButton = false,
minimal = false,
promptNavigatorAction,
sessionInfoAction,
}: ThreadHeaderProps) {
const { t } = useTranslation();
if (minimal) {
return (
<div className="relative z-10 flex h-11 items-center justify-between gap-3 px-3 py-2">
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleSidebar")}
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleForHostChrome && "lg:hidden",
)}
>
<Menu className="h-3.5 w-3.5" />
</Button>
{!hideThemeButton ? (
<ThemeButton
theme={theme}
onToggleTheme={onToggleTheme}
label={t("thread.header.toggleTheme")}
className="ml-auto"
/>
) : null}
</div>
);
}
return (
<div className="relative z-10 flex items-center justify-between gap-3 px-3 py-2">
<div
className={cn(
"relative z-10 flex items-center justify-between gap-3 px-3 py-2",
minimal && "h-11",
!minimal && hostChromeTitleInset && "lg:pl-[128px]",
)}
>
<div className="relative flex min-w-0 items-center gap-2">
<Button
variant="ghost"
@ -66,21 +53,28 @@ export function ThreadHeader({
>
<Menu className="h-3.5 w-3.5" />
</Button>
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
</div>
{!minimal ? (
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
</div>
) : null}
</div>
{!hideThemeButton ? (
<ThemeButton
theme={theme}
onToggleTheme={onToggleTheme}
label={t("thread.header.toggleTheme")}
className="ml-auto shrink-0"
/>
) : null}
<div className="ml-auto flex shrink-0 items-center gap-1">
{sessionInfoAction}
{promptNavigatorAction}
{!hideThemeButton ? (
<ThemeButton
theme={theme}
onToggleTheme={onToggleTheme}
label={t("thread.header.toggleTheme")}
/>
) : null}
</div>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
{!minimal ? (
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
) : null}
</div>
);
}

View File

@ -14,6 +14,7 @@ interface ThreadMessagesProps {
onLoadEarlier?: () => void;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
onOpenFilePreview?: (path: string) => void;
}
export type DisplayUnit = TurnUnit;
@ -33,8 +34,13 @@ export function isFinalAssistantSliceBeforeNextUser(
return true;
}
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
return normalizeActivityTimeline(messages);
export function buildDisplayUnits(
messages: UIMessage[],
isStreaming = false,
): DisplayUnit[] {
return normalizeActivityTimeline(messages, {
preserveTrailingActivity: isStreaming,
});
}
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
@ -61,9 +67,10 @@ export function ThreadMessages({
onLoadEarlier,
cliApps = [],
mcpPresets = [],
onOpenFilePreview,
}: ThreadMessagesProps) {
const { t } = useTranslation();
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]);
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
const liveActivityClusterIndices = useMemo(
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
@ -117,6 +124,7 @@ export function ThreadMessages({
turnLatencyMs={unit.turnLatencyMs}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
/>
) : (
<MessageBubble
@ -128,6 +136,7 @@ export function ThreadMessages({
}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
/>
)}
</div>

View File

@ -1,10 +1,14 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { useTranslation } from "react-i18next";
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
import { PromptNavigator } from "@/components/thread/PromptNavigator";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { ThreadHeader } from "@/components/thread/ThreadHeader";
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport } from "@/components/thread/ThreadViewport";
import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport";
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
import { fetchCliApps, fetchMcpPresets, fetchSettings, listSlashCommands } from "@/lib/api";
@ -21,8 +25,6 @@ import {
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
import type {
ChatSummary,
CliAppInfo,
McpPresetInfo,
SettingsPayload,
SlashCommand,
UIMessage,
@ -51,6 +53,23 @@ function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boo
return snapshot.every((message, index) => sameMessageShape(current[index], message));
}
const FILE_PREVIEW_DEFAULT_WIDTH = 544;
const FILE_PREVIEW_MIN_WIDTH = 360;
const FILE_PREVIEW_MAX_WIDTH = 860;
const FILE_PREVIEW_MIN_MAIN_WIDTH = 420;
const FILE_PREVIEW_CLOSE_ANIMATION_MS = 320;
function clampFilePreviewWidth(width: number, maxWidth: number): number {
return Math.min(Math.max(width, FILE_PREVIEW_MIN_WIDTH), maxWidth);
}
function maxFilePreviewWidth(containerWidth: number): number {
return Math.max(
FILE_PREVIEW_MIN_WIDTH,
Math.min(FILE_PREVIEW_MAX_WIDTH, containerWidth - FILE_PREVIEW_MIN_MAIN_WIDTH),
);
}
interface ThreadShellProps {
session: ChatSummary | null;
title: string;
@ -62,6 +81,7 @@ interface ThreadShellProps {
theme?: "light" | "dark";
onToggleTheme?: () => void;
hideSidebarToggleForHostChrome?: boolean;
hostChromeTitleInset?: boolean;
hideThemeButton?: boolean;
hideHeader?: boolean;
workspaceScope?: WorkspaceScopePayload | null;
@ -71,6 +91,7 @@ interface ThreadShellProps {
workspaceError?: string | null;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
settingsSnapshot?: SettingsPayload | null;
onOpenModelSettings?: () => void;
}
function toModelBadgeLabel(modelName: string | null): string | null {
@ -85,6 +106,7 @@ interface ModelBadgeInfo {
label: string | null;
provider: string | null;
providerLabel: string | null;
needsSetup: boolean;
}
function activeModelPreset(settings: SettingsPayload | null): SettingsPayload["model_presets"][number] | null {
@ -107,12 +129,20 @@ function resolvedModelProvider(settings: SettingsPayload | null, modelName: stri
}
function toModelBadgeInfo(modelName: string | null, settings: SettingsPayload | null): ModelBadgeInfo {
const label = toModelBadgeLabel(modelName || settings?.agent.model || null);
const provider = resolvedModelProvider(settings, modelName || settings?.agent.model || null);
const model = modelName || settings?.agent.model || null;
const label = toModelBadgeLabel(model);
const provider = resolvedModelProvider(settings, model);
const providerRow = provider
? settings?.providers.find((item) => item.name === provider)
: null;
const needsSetup = Boolean(
settings && (!model || !provider || !providerRow || !providerRow.configured),
);
return {
label,
provider,
providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null,
needsSetup,
};
}
@ -134,6 +164,63 @@ interface PendingFirstMessage {
options?: SendOptions;
}
interface InstalledSettingItemsOptions<Payload, Item> {
token: string;
eventName: string;
fetchPayload: (token: string) => Promise<Payload>;
isPayload: (value: unknown) => value is Payload;
selectItems: (payload: Payload) => Item[];
}
function useInstalledSettingItems<Payload, Item>({
token,
eventName,
fetchPayload,
isPayload,
selectItems,
}: InstalledSettingItemsOptions<Payload, Item>): Item[] {
const [items, setItems] = useState<Item[]>([]);
const refresh = useCallback(async (isCancelled?: () => boolean) => {
try {
const payload = await fetchPayload(token);
if (!isCancelled?.()) setItems(selectItems(payload));
} catch {
if (!isCancelled?.()) setItems([]);
}
}, [fetchPayload, selectItems, token]);
useEffect(() => {
let cancelled = false;
void refresh(() => cancelled);
const refreshOnFocus = () => {
if (document.visibilityState === "hidden") return;
void refresh();
};
const refreshOnChanged = (event: Event) => {
const payload = (event as CustomEvent<unknown>).detail;
if (isPayload(payload)) {
setItems(selectItems(payload));
return;
}
void refresh();
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
window.addEventListener(eventName, refreshOnChanged);
return () => {
cancelled = true;
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
window.removeEventListener(eventName, refreshOnChanged);
};
}, [eventName, isPayload, refresh, selectItems]);
return items;
}
export function ThreadShell({
session,
title,
@ -143,6 +230,7 @@ export function ThreadShell({
theme = "light",
onToggleTheme = () => {},
hideSidebarToggleForHostChrome = false,
hostChromeTitleInset = false,
hideThemeButton = false,
hideHeader = false,
workspaceScope = null,
@ -152,6 +240,7 @@ export function ThreadShell({
workspaceError = null,
onWorkspaceScopeChange,
settingsSnapshot = null,
onOpenModelSettings,
}: ThreadShellProps) {
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
@ -166,12 +255,31 @@ export function ThreadShell({
const { client, modelName, token } = useClient();
const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
const [mcpPresets, setMcpPresets] = useState<McpPresetInfo[]>([]);
const cliApps = useInstalledSettingItems({
token,
eventName: CLI_APPS_CHANGED_EVENT,
fetchPayload: fetchCliApps,
isPayload: isCliAppsPayload,
selectItems: installedCliAppsFromPayload,
});
const mcpPresets = useInstalledSettingItems({
token,
eventName: MCP_PRESETS_CHANGED_EVENT,
fetchPayload: fetchMcpPresets,
isPayload: isMcpPresetsPayload,
selectItems: installedMcpPresetsFromPayload,
});
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH);
const shellRef = useRef<HTMLElement | null>(null);
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
const filePreviewCloseTimerRef = useRef<number | null>(null);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
const viewportRef = useRef<ThreadViewportHandle | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
const prevChatIdForCacheRef = useRef<string | null>(null);
@ -204,6 +312,27 @@ export function ThreadShell({
if (chatId && historyKey) sessionKeyByChatIdRef.current.set(chatId, historyKey);
}, [chatId, historyKey]);
useEffect(() => {
filePreviewWidthRef.current = filePreviewWidth;
}, [filePreviewWidth]);
useEffect(() => {
if (filePreviewCloseTimerRef.current !== null) {
window.clearTimeout(filePreviewCloseTimerRef.current);
filePreviewCloseTimerRef.current = null;
}
setFilePreviewClosing(false);
setFilePreviewPath(null);
}, [historyKey]);
useEffect(() => {
return () => {
if (filePreviewCloseTimerRef.current !== null) {
window.clearTimeout(filePreviewCloseTimerRef.current);
}
};
}, []);
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const showHeroComposer = messages.length === 0 && !loading;
@ -212,6 +341,9 @@ export function ThreadShell({
() => toModelBadgeInfo(modelName, settings),
[modelName, settings],
);
const modelBadgeLabel = modelBadge.needsSetup
? t("thread.composer.modelNotConfigured", { defaultValue: "Model not configured" })
: modelBadge.label;
useEffect(() => {
if (showHeroComposer && !wasShowingHeroComposerRef.current) {
setHeroGreetingKey(randomHeroGreetingKey());
@ -372,94 +504,6 @@ export function ThreadShell({
};
}, [token]);
const refreshCliApps = useCallback(async () => {
try {
const payload = await fetchCliApps(token);
setCliApps(installedCliAppsFromPayload(payload));
} catch {
setCliApps([]);
}
}, [token]);
const refreshMcpPresets = useCallback(async () => {
try {
const payload = await fetchMcpPresets(token);
setMcpPresets(installedMcpPresetsFromPayload(payload));
} catch {
setMcpPresets([]);
}
}, [token]);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const payload = await fetchCliApps(token);
if (!cancelled) setCliApps(installedCliAppsFromPayload(payload));
} catch {
if (!cancelled) setCliApps([]);
}
};
load();
const refreshOnFocus = () => {
if (document.visibilityState === "hidden") return;
void refreshCliApps();
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
const refreshOnCliAppsChanged = (event: Event) => {
const payload = (event as CustomEvent<unknown>).detail;
if (isCliAppsPayload(payload)) {
setCliApps(installedCliAppsFromPayload(payload));
return;
}
void refreshCliApps();
};
window.addEventListener(CLI_APPS_CHANGED_EVENT, refreshOnCliAppsChanged);
return () => {
cancelled = true;
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
window.removeEventListener(CLI_APPS_CHANGED_EVENT, refreshOnCliAppsChanged);
};
}, [refreshCliApps, token]);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const payload = await fetchMcpPresets(token);
if (!cancelled) setMcpPresets(installedMcpPresetsFromPayload(payload));
} catch {
if (!cancelled) setMcpPresets([]);
}
};
load();
const refreshOnFocus = () => {
if (document.visibilityState === "hidden") return;
void refreshMcpPresets();
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
const refreshOnMcpPresetsChanged = (event: Event) => {
const payload = (event as CustomEvent<unknown>).detail;
if (isMcpPresetsPayload(payload)) {
setMcpPresets(installedMcpPresetsFromPayload(payload));
return;
}
void refreshMcpPresets();
};
window.addEventListener(MCP_PRESETS_CHANGED_EVENT, refreshOnMcpPresetsChanged);
return () => {
cancelled = true;
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
window.removeEventListener(MCP_PRESETS_CHANGED_EVENT, refreshOnMcpPresetsChanged);
};
}, [refreshMcpPresets, token]);
const handleWelcomeSend = useCallback(
async (content: string, images?: SendImage[], options?: SendOptions) => {
if (booting) return;
@ -482,6 +526,94 @@ export function ThreadShell({
[send, withWorkspaceScope],
);
const handleOpenFilePreview = useCallback((path: string) => {
if (filePreviewCloseTimerRef.current !== null) {
window.clearTimeout(filePreviewCloseTimerRef.current);
filePreviewCloseTimerRef.current = null;
}
setFilePreviewClosing(false);
setFilePreviewPath(path);
}, []);
const handleCloseFilePreview = useCallback(() => {
if (!filePreviewPath || filePreviewClosing) return;
setFilePreviewClosing(true);
filePreviewCloseTimerRef.current = window.setTimeout(() => {
filePreviewCloseTimerRef.current = null;
setFilePreviewPath(null);
setFilePreviewClosing(false);
}, FILE_PREVIEW_CLOSE_ANIMATION_MS);
}, [filePreviewClosing, filePreviewPath]);
const handleFilePreviewResizeStart = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
const panel = event.currentTarget.closest<HTMLElement>("[data-file-preview-panel]");
const shellRect = shellRef.current?.getBoundingClientRect();
const rightEdge = shellRect?.right ?? window.innerWidth;
const maxWidth = maxFilePreviewWidth(shellRect?.width ?? window.innerWidth);
const originalBodyCursor = document.body.style.cursor;
const originalBodyUserSelect = document.body.style.userSelect;
const originalPanelTransition = panel?.style.transition ?? "";
let nextWidth = filePreviewWidthRef.current;
let frame: number | null = null;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
if (panel) panel.style.transition = "none";
const applyWidth = (clientX: number) => {
nextWidth = clampFilePreviewWidth(rightEdge - clientX, maxWidth);
filePreviewWidthRef.current = nextWidth;
if (frame !== null) return;
frame = window.requestAnimationFrame(() => {
frame = null;
panel?.style.setProperty("--file-preview-width", `${nextWidth}px`);
panel?.style.setProperty("--file-preview-slot-width", `${nextWidth}px`);
});
};
const handlePointerMove = (moveEvent: PointerEvent) => {
moveEvent.preventDefault();
applyWidth(moveEvent.clientX);
};
const handlePointerUp = () => {
if (frame !== null) {
window.cancelAnimationFrame(frame);
frame = null;
}
panel?.style.setProperty("--file-preview-width", `${nextWidth}px`);
panel?.style.setProperty("--file-preview-slot-width", `${nextWidth}px`);
if (panel) panel.style.transition = originalPanelTransition;
setFilePreviewWidth(nextWidth);
document.body.style.cursor = originalBodyCursor;
document.body.style.userSelect = originalBodyUserSelect;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
};
applyWidth(event.clientX);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
}, []);
useEffect(() => {
if (!filePreviewPath) return;
const clampToShell = () => {
const shellWidth = shellRef.current?.getBoundingClientRect().width ?? window.innerWidth;
const maxWidth = maxFilePreviewWidth(shellWidth);
const nextWidth = clampFilePreviewWidth(filePreviewWidthRef.current, maxWidth);
filePreviewWidthRef.current = nextWidth;
setFilePreviewWidth(nextWidth);
};
clampToShell();
window.addEventListener("resize", clampToShell);
return () => {
window.removeEventListener("resize", clampToShell);
};
}, [filePreviewPath]);
const composer = (
<>
{streamError ? (
@ -500,9 +632,11 @@ export function ThreadShell({
? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread")
}
modelLabel={modelBadge.label}
modelLabel={modelBadgeLabel}
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
modelNeedsSetup={modelBadge.needsSetup}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands}
cliApps={cliApps}
@ -528,9 +662,11 @@ export function ThreadShell({
? t("thread.composer.placeholderOpening")
: t("thread.composer.placeholderHero")
}
modelLabel={modelBadge.label}
modelLabel={modelBadgeLabel}
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
modelNeedsSetup={modelBadge.needsSetup}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant="hero"
slashCommands={slashCommands}
cliApps={cliApps}
@ -559,31 +695,58 @@ export function ThreadShell({
</h1>
</div>
);
const sessionInfoAction = historyKey ? (
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
) : undefined;
const promptNavigatorAction = historyKey ? (
<PromptNavigator
messages={displayMessages}
onJumpToPrompt={(promptId) => viewportRef.current?.jumpToUserPrompt(promptId)}
/>
) : undefined;
return (
<section className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
{!hideHeader ? (
<ThreadHeader
title={title}
onToggleSidebar={onToggleSidebar}
theme={theme}
onToggleTheme={onToggleTheme}
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
hideThemeButton={hideThemeButton}
minimal={!session && !loading}
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
{!hideHeader ? (
<ThreadHeader
title={title}
onToggleSidebar={onToggleSidebar}
theme={theme}
onToggleTheme={onToggleTheme}
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
hostChromeTitleInset={hostChromeTitleInset}
hideThemeButton={hideThemeButton}
minimal={!session && !loading}
promptNavigatorAction={promptNavigatorAction}
sessionInfoAction={sessionInfoAction}
/>
) : null}
<ThreadViewport
ref={viewportRef}
messages={displayMessages}
isStreaming={isStreaming}
emptyState={emptyState}
composer={composer}
scrollToBottomSignal={scrollToBottomSignal}
conversationKey={historyKey}
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
/>
</div>
{filePreviewPath && historyKey ? (
<FilePreviewPanel
sessionKey={historyKey}
path={filePreviewPath}
token={token}
desktopWidth={filePreviewWidth}
isClosing={filePreviewClosing}
onResizeStart={handleFilePreviewResizeStart}
onClose={handleCloseFilePreview}
/>
) : null}
<ThreadViewport
messages={displayMessages}
isStreaming={isStreaming}
emptyState={emptyState}
composer={composer}
scrollToBottomSignal={scrollToBottomSignal}
conversationKey={historyKey}
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
/>
</section>
);
}

View File

@ -1,7 +1,9 @@
import {
forwardRef,
type ReactNode,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
@ -14,9 +16,17 @@ import { PromptRail } from "@/components/thread/PromptRail";
import { ThreadMessages } from "@/components/thread/ThreadMessages";
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
import { Button } from "@/components/ui/button";
import {
findPromptElement,
jumpToPrompt,
} from "@/components/thread/promptNavigation";
import { cn } from "@/lib/utils";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
export interface ThreadViewportHandle {
jumpToUserPrompt: (promptId: string) => void;
}
interface ThreadViewportProps {
messages: UIMessage[];
isStreaming: boolean;
@ -27,6 +37,7 @@ interface ThreadViewportProps {
showScrollToBottomButton?: boolean;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
onOpenFilePreview?: (path: string) => void;
}
const NEAR_BOTTOM_PX = 48;
@ -48,7 +59,7 @@ export function windowMessages(messages: UIMessage[], visibleCount: number): UIM
return messages.slice(start);
}
export function ThreadViewport({
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
messages,
isStreaming,
composer,
@ -58,7 +69,8 @@ export function ThreadViewport({
showScrollToBottomButton = true,
cliApps = [],
mcpPresets = [],
}: ThreadViewportProps) {
onOpenFilePreview,
}, ref) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
@ -66,6 +78,7 @@ export function ThreadViewport({
const bottomRef = useRef<HTMLDivElement>(null);
const lastConversationKeyRef = useRef<string | null>(conversationKey);
const pendingConversationScrollRef = useRef(true);
const pendingPromptJumpRef = useRef<string | null>(null);
const scrollFrameIdsRef = useRef<number[]>([]);
const restoreScrollAfterPrependRef =
useRef<{ height: number; top: number } | null>(null);
@ -139,6 +152,22 @@ export function ThreadViewport({
);
}, [messages.length]);
const jumpToUserPrompt = useCallback((promptId: string) => {
const scrollEl = scrollRef.current;
if (scrollEl && findPromptElement(scrollEl, promptId)) {
jumpToPrompt(scrollEl, promptId);
return;
}
const index = messages.findIndex((message) => message.id === promptId);
if (index < 0) return;
pendingPromptJumpRef.current = promptId;
userReadingHistoryRef.current = true;
setAtBottom(false);
setVisibleMessageCount((count) => Math.max(count, messages.length - index));
}, [messages]);
useImperativeHandle(ref, () => ({ jumpToUserPrompt }), [jumpToUserPrompt]);
const measureComposerDock = useCallback(() => {
const el = composerDockRef.current;
if (!el) return;
@ -180,6 +209,15 @@ export function ThreadViewport({
el.scrollTop = pending.top + delta;
}, [visibleMessages.length]);
useLayoutEffect(() => {
const promptId = pendingPromptJumpRef.current;
const scrollEl = scrollRef.current;
if (!promptId || !scrollEl || !findPromptElement(scrollEl, promptId)) return;
pendingPromptJumpRef.current = null;
const frame = window.requestAnimationFrame(() => jumpToPrompt(scrollEl, promptId));
return () => window.cancelAnimationFrame(frame);
}, [visibleMessages.length]);
useLayoutEffect(() => {
if (!pendingConversationScrollRef.current) return;
if (!conversationKey) {
@ -256,6 +294,7 @@ export function ThreadViewport({
onLoadEarlier={loadEarlierMessages}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
/>
</div>
</div>
@ -299,22 +338,25 @@ export function ThreadViewport({
) : null}
{showScrollToBottomButton && !atBottom && (
<Button
variant="outline"
size="icon"
onClick={() => scrollToBottom(true, 1, { force: true })}
className={cn(
/* Keep clear of sticky composer (textarea + toolbar + optional goal strip). */
"absolute left-1/2 z-20 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
"bg-background/90 backdrop-blur",
"animate-in fade-in-0 zoom-in-95",
)}
<div
className="absolute left-1/2 z-20 -translate-x-1/2"
style={{ bottom: scrollButtonBottom }}
aria-label={t("thread.scrollToBottom")}
>
<ArrowDown className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => scrollToBottom(true, 1, { force: true })}
className={cn(
"h-8 w-8 rounded-full shadow-md",
"bg-background/90 backdrop-blur",
"animate-in fade-in-0 zoom-in-95",
)}
aria-label={t("thread.scrollToBottom")}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
}
});

View File

@ -106,7 +106,7 @@ export function WorkspaceProjectPicker({
if (nativeProjectPicker) {
return (
<div className="flex items-center border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
<div className="flex items-center rounded-b-[28px] border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
<button
type="button"
disabled={disabled || pickingFolder}
@ -133,7 +133,7 @@ export function WorkspaceProjectPicker({
}
return (
<div className="flex items-center border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
<div className="flex items-center rounded-b-[28px] border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button

View File

@ -22,18 +22,34 @@ export interface FileEditSummary {
error?: string;
}
export function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
export function FileEditGroup({
edits,
onOpenFilePreview,
}: {
edits: FileEditSummary[];
onOpenFilePreview?: (path: string) => void;
}) {
if (edits.length === 0) return null;
return (
<ul className="space-y-1">
{edits.map((edit) => (
<FileEditRow key={edit.key} edit={edit} />
<FileEditRow
key={edit.key}
edit={edit}
onOpenFilePreview={onOpenFilePreview}
/>
))}
</ul>
);
}
function FileEditRow({ edit }: { edit: FileEditSummary }) {
function FileEditRow({
edit,
onOpenFilePreview,
}: {
edit: FileEditSummary;
onOpenFilePreview?: (path: string) => void;
}) {
const { t } = useTranslation();
const editing = edit.status === "editing";
const failed = edit.status === "error";
@ -76,6 +92,8 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
<FileReferenceChip
path={edit.path}
tooltipPath={edit.absolute_path}
previewPath={edit.absolute_path || edit.path}
onOpen={onOpenFilePreview}
display="path"
active={editing}
className="min-w-0"

View File

@ -10,9 +10,11 @@ import { ActivityStep } from "./ActivityStep";
export function ReasoningRow({
text,
streaming,
onOpenFilePreview,
}: {
text: string;
streaming: boolean;
onOpenFilePreview?: (path: string) => void;
}) {
const { t } = useTranslation();
useEffect(() => {
@ -30,6 +32,7 @@ export function ReasoningRow({
{text.trim() ? (
<MarkdownText
streaming={streaming}
onOpenFilePreview={onOpenFilePreview}
className={cn(
"min-w-0 text-[12.5px] italic text-muted-foreground/78",
"prose-p:my-1 prose-li:my-0.5",

View File

@ -0,0 +1,64 @@
import type { UIMessage } from "@/lib/types";
export interface PromptAnchor {
id: string;
label: string;
preview: string;
createdAt: number;
index: number;
}
export function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
let index = 0;
return messages.flatMap((message) => {
if (message.role !== "user") return [];
const anchor: PromptAnchor = {
id: message.id,
label: promptLabel(message.content, index),
preview: promptPreview(message.content, index),
createdAt: message.createdAt,
index,
};
index += 1;
return [anchor];
});
}
export function promptLabel(content: string, index: number): string {
const text = content.replace(/\s+/g, " ").trim();
if (!text) return `Prompt ${index + 1}`;
return text.length > 80 ? `${text.slice(0, 77)}...` : text;
}
export function promptPreview(content: string, index: number): string {
const text = content.replace(/\n{3,}/g, "\n\n").trim();
if (!text) return `Prompt ${index + 1}`;
return text.length > 320 ? `${text.slice(0, 317)}...` : text;
}
export function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void {
if (!scrollEl || !promptId) return;
const target = findPromptElement(scrollEl, promptId);
if (!target) return;
scrollEl.scrollTo({
top: Math.max(0, promptTop(scrollEl, target) - 16),
behavior: "smooth",
});
}
export function findPromptElement(scrollEl: HTMLElement, promptId: string): HTMLElement | null {
const candidates = scrollEl.querySelectorAll<HTMLElement>("[data-user-prompt-id]");
return Array.from(candidates).find(
(candidate) => candidate.dataset.userPromptId === promptId,
) ?? null;
}
export function promptTop(scrollEl: HTMLElement, target: HTMLElement): number {
const scrollRect = scrollEl.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const hasLayoutRect = scrollRect.top !== 0 || targetRect.top !== 0;
if (hasLayoutRect) {
return targetRect.top - scrollRect.top + scrollEl.scrollTop;
}
return target.offsetTop;
}

View File

@ -100,4 +100,16 @@ const SheetTitle = React.forwardRef<
));
SheetTitle.displayName = DialogPrimitive.Title.displayName;
export { Sheet, SheetContent, SheetTitle };
const SheetDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
SheetDescription.displayName = DialogPrimitive.Description.displayName;
export { Sheet, SheetContent, SheetDescription, SheetTitle };

View File

@ -5,6 +5,7 @@
/* Design tokens — HSL form, sourced from shadcn/ui's "neutral" palette. */
@layer base {
:root {
color-scheme: light;
--background: 0 0% 100%;
--foreground: 240 3% 12%;
--card: 0 0% 100%;
@ -30,9 +31,12 @@
--sidebar-accent: 0 0% 95.8%;
--sidebar-accent-foreground: 0 0% 9%;
--sidebar-border: 0 0% 89.8%;
--scrollbar-thumb: hsl(var(--muted-foreground) / 0.26);
--scrollbar-thumb-hover: hsl(var(--muted-foreground) / 0.42);
}
.dark {
color-scheme: dark;
--background: 0 0% 10%;
--foreground: 240 4% 96%;
--card: 0 0% 12%;
@ -57,6 +61,8 @@
--sidebar-accent: 0 0% 15.5%;
--sidebar-accent-foreground: 0 0% 98%;
--sidebar-border: 0 0% 18%;
--scrollbar-thumb: hsl(var(--muted-foreground) / 0.28);
--scrollbar-thumb-hover: hsl(var(--muted-foreground) / 0.44);
}
}
@ -75,6 +81,33 @@
@apply bg-background text-foreground font-sans antialiased;
}
* {
scrollbar-color: var(--scrollbar-thumb) transparent;
scrollbar-width: thin;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb);
border-radius: 9999px;
}
*::-webkit-scrollbar-thumb:hover {
background-color: var(--scrollbar-thumb-hover);
}
*::-webkit-scrollbar-corner {
background: transparent;
}
::selection {
@apply bg-primary/15;
}
@ -121,10 +154,13 @@
}
.host-sidebar-glass {
background: hsl(var(--sidebar) / 0.94);
-webkit-backdrop-filter: saturate(145%) blur(18px);
backdrop-filter: saturate(145%) blur(18px);
box-shadow:
inset -1px 0 0 hsl(var(--border) / 0.36),
inset 1px 0 0 hsl(var(--background) / 0.34),
18px 0 44px -42px rgb(0 0 0 / 0.42);
inset -1px 0 0 hsl(var(--border) / 0.32),
inset 1px 0 0 hsl(var(--background) / 0.52),
14px 0 32px -30px rgb(0 0 0 / 0.22);
}
.dark .host-window-shell,
@ -135,10 +171,11 @@
}
.dark .host-sidebar-glass {
background: hsl(var(--sidebar) / 0.96);
box-shadow:
inset -1px 0 0 hsl(var(--border) / 0.42),
inset 1px 0 0 hsl(var(--foreground) / 0.05),
18px 0 46px -42px rgb(0 0 0 / 0.72);
14px 0 34px -30px rgb(0 0 0 / 0.62);
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {

View File

@ -20,6 +20,7 @@ import type {
UIImage,
UIFileEdit,
UIMessage,
UITurnPhase,
WorkspaceScopePayload,
} from "@/lib/types";
@ -34,22 +35,50 @@ interface ActiveAssistantCursor {
}
type PendingStreamEvent =
| { kind: "delta"; text: string }
| { kind: "reasoning"; text: string };
| { kind: "delta"; text: string; turn: UIMessageTurnFields }
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
type UIMessageTurnFields = Pick<UIMessage, "turnId" | "turnPhase" | "turnSeq">;
const FILE_EDIT_TOOL_NAMES = new Set(["write_file", "edit_file", "apply_patch"]);
function turnFieldsFromEvent(
ev: { turn_id?: string; turn_phase?: UITurnPhase; turn_seq?: number },
fallbackPhase?: UITurnPhase,
): UIMessageTurnFields {
const fields: UIMessageTurnFields = {};
if (typeof ev.turn_id === "string" && ev.turn_id.length > 0) {
fields.turnId = ev.turn_id;
}
const phase = ev.turn_phase ?? fallbackPhase;
if (phase) fields.turnPhase = phase;
if (typeof ev.turn_seq === "number" && Number.isFinite(ev.turn_seq)) {
fields.turnSeq = ev.turn_seq;
}
return fields;
}
function matchesTurn(message: UIMessage, turn: UIMessageTurnFields): boolean {
return !turn.turnId || !message.turnId || message.turnId === turn.turnId;
}
/** Find a still-open streamed assistant turn. Closed stream segments stay visible
* as streaming until ``turn_end`` for visual continuity, but they must not
* receive later delta segments. */
function findStreamingAssistantIndex(
prev: UIMessage[],
closedStreamIds: ReadonlySet<string>,
turn: UIMessageTurnFields = {},
): number | null {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i];
if (m.kind === "trace") continue;
if (m.role === "assistant" && m.isStreaming && !closedStreamIds.has(m.id)) return i;
if (
m.role === "assistant"
&& m.isStreaming
&& !closedStreamIds.has(m.id)
&& matchesTurn(m, turn)
) return i;
if (m.role === "user") break;
}
return null;
@ -69,6 +98,7 @@ function attachReasoningChunk(
segments?: {
ensure: () => string;
},
turn: UIMessageTurnFields = {},
): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const candidate = prev[i];
@ -80,6 +110,7 @@ function attachReasoningChunk(
// that produced those tool calls.
if (candidate.kind === "trace") break;
if (candidate.role !== "assistant") continue;
if (!matchesTurn(candidate, turn)) break;
const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure();
const hasAnswer = candidate.content.length > 0;
if (hasAnswer) break;
@ -93,6 +124,7 @@ function attachReasoningChunk(
reasoning: (candidate.reasoning ?? "") + chunk,
reasoningStreaming: true,
...(activitySegmentId ? { activitySegmentId } : {}),
...turn,
};
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
}
@ -109,6 +141,7 @@ function attachReasoningChunk(
reasoning: chunk,
reasoningStreaming: true,
...(activitySegmentId ? { activitySegmentId } : {}),
...turn,
createdAt: Date.now(),
},
];
@ -122,12 +155,16 @@ function attachReasoningChunk(
* the model already produced an answer in a previous turn, so the new
* delta belongs in a fresh row.
*/
function findActiveAssistantPlaceholderIndex(prev: UIMessage[]): number | null {
function findActiveAssistantPlaceholderIndex(
prev: UIMessage[],
turn: UIMessageTurnFields = {},
): number | null {
const last = prev[prev.length - 1];
if (!last) return null;
if (last.role !== "assistant" || last.kind === "trace") return null;
if (last.content.length > 0) return null;
if (!last.isStreaming) return null;
if (!matchesTurn(last, turn)) return null;
return prev.length - 1;
}
@ -187,10 +224,18 @@ function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] {
});
}
function stampLastAssistantLatency(prev: UIMessage[], latencyMs: number): UIMessage[] {
function stampLastAssistantLatency(
prev: UIMessage[],
latencyMs: number,
turnId?: string,
): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i];
if (m.role === "assistant" && m.kind !== "trace") {
if (
m.role === "assistant"
&& m.kind !== "trace"
&& (!turnId || !m.turnId || m.turnId === turnId)
) {
const merged: UIMessage = { ...m, latencyMs, isStreaming: false };
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
}
@ -203,7 +248,7 @@ function absorbCompleteAssistantMessage(
message: Omit<UIMessage, "id" | "role" | "createdAt">,
): UIMessage[] {
const last = prev[prev.length - 1];
if (!last || !isReasoningOnlyPlaceholder(last)) {
if (!last || !isReasoningOnlyPlaceholder(last) || !matchesTurn(last, message)) {
return [
...prev,
{
@ -482,7 +527,10 @@ export function useNanobotStream(
return !!closedStreamId;
}, []);
const resolveActiveAssistantIndex = useCallback((prev: UIMessage[]): number | null => {
const resolveActiveAssistantIndex = useCallback((
prev: UIMessage[],
turn: UIMessageTurnFields = {},
): number | null => {
const cursor = activeAssistantRef.current;
if (!cursor) return null;
const indexed = prev[cursor.index];
@ -491,6 +539,7 @@ export function useNanobotStream(
&& indexed.role === "assistant"
&& indexed.kind !== "trace"
&& indexed.isStreaming
&& matchesTurn(indexed, turn)
) {
return cursor.index;
}
@ -500,7 +549,12 @@ export function useNanobotStream(
return null;
}
const found = prev[idx];
if (found.role !== "assistant" || found.kind === "trace" || !found.isStreaming) {
if (
found.role !== "assistant"
|| found.kind === "trace"
|| !found.isStreaming
|| !matchesTurn(found, turn)
) {
activeAssistantRef.current = null;
return null;
}
@ -509,15 +563,15 @@ export function useNanobotStream(
}, []);
const appendAnswerChunk = useCallback(
(prev: UIMessage[], chunk: string): UIMessage[] => {
(prev: UIMessage[], chunk: string, turn: UIMessageTurnFields = {}): UIMessage[] => {
let next = prev;
let targetIndex = resolveActiveAssistantIndex(next);
let targetIndex = resolveActiveAssistantIndex(next, turn);
if (targetIndex === null) {
targetIndex = findActiveAssistantPlaceholderIndex(next);
targetIndex = findActiveAssistantPlaceholderIndex(next, turn);
}
if (targetIndex === null) {
targetIndex = findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current);
targetIndex = findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current, turn);
}
if (targetIndex === null) {
const id = crypto.randomUUID();
@ -539,6 +593,7 @@ export function useNanobotStream(
...target,
content: target.content + chunk,
isStreaming: true,
...turn,
};
closedAssistantStreamIdsRef.current.delete(merged.id);
activeAssistantRef.current = { id: merged.id, index: targetIndex };
@ -551,20 +606,17 @@ export function useNanobotStream(
const applyPendingStreamEvents = useCallback(
(prev: UIMessage[], events: PendingStreamEvent[]): UIMessage[] => {
let next = prev;
for (let i = 0; i < events.length;) {
const kind = events[i].kind;
let text = "";
while (i < events.length && events[i].kind === kind) {
text += events[i].text;
i += 1;
}
if (kind === "delta") {
next = appendAnswerChunk(next, text);
for (const event of events) {
if (event.kind === "delta") {
next = appendAnswerChunk(next, event.text, event.turn);
} else {
if (closeActiveAssistantStream()) clearActivitySegment();
next = attachReasoningChunk(next, text, {
ensure: ensureActivitySegmentId,
});
next = attachReasoningChunk(
next,
event.text,
{ ensure: ensureActivitySegmentId },
event.turn,
);
}
}
return next;
@ -575,6 +627,7 @@ export function useNanobotStream(
const flushPendingStreamEvents = useCallback((options?: {
closeAnswerSegment?: boolean;
finalAnswerText?: string;
turn?: UIMessageTurnFields;
}) => {
if (streamFrameRef.current !== null) {
window.cancelAnimationFrame(streamFrameRef.current);
@ -582,6 +635,7 @@ export function useNanobotStream(
}
const events = pendingStreamEventsRef.current;
const finalAnswerText = options?.finalAnswerText;
const turn = options?.turn ?? {};
if (events.length === 0 && finalAnswerText === undefined) {
if (options?.closeAnswerSegment) closeActiveAssistantStream();
return;
@ -591,14 +645,15 @@ export function useNanobotStream(
let next = events.length > 0 ? applyPendingStreamEvents(prev, events) : prev;
if (finalAnswerText !== undefined) {
const targetIndex =
resolveActiveAssistantIndex(next)
?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current);
resolveActiveAssistantIndex(next, turn)
?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current, turn);
if (targetIndex !== null) {
const target = next[targetIndex];
next = replaceMessageAt(next, targetIndex, {
...target,
content: finalAnswerText,
isStreaming: true,
...turn,
});
} else {
const id = crypto.randomUUID();
@ -610,6 +665,7 @@ export function useNanobotStream(
role: "assistant",
content: finalAnswerText,
isStreaming: true,
...turn,
createdAt: Date.now(),
},
];
@ -679,7 +735,11 @@ export function useNanobotStream(
if (!chunk) return;
clearActivitySegment();
setIsStreaming(true);
pendingStreamEventsRef.current.push({ kind: "delta", text: chunk });
pendingStreamEventsRef.current.push({
kind: "delta",
text: chunk,
turn: turnFieldsFromEvent(ev, "answer"),
});
schedulePendingStreamFlush();
return;
}
@ -690,7 +750,11 @@ export function useNanobotStream(
if (!chunk) return;
if (fileEditSegmentRef.current) clearActivitySegment();
setIsStreaming(true);
pendingStreamEventsRef.current.push({ kind: "reasoning", text: chunk });
pendingStreamEventsRef.current.push({
kind: "reasoning",
text: chunk,
turn: turnFieldsFromEvent(ev, "reasoning"),
});
schedulePendingStreamFlush();
return;
}
@ -699,6 +763,7 @@ export function useNanobotStream(
flushPendingStreamEvents({
closeAnswerSegment: true,
...(typeof ev.text === "string" ? { finalAnswerText: ev.text } : {}),
turn: turnFieldsFromEvent(ev, "answer"),
});
if (suppressStreamUntilTurnEndRef.current) return;
// stream_end only means the text segment finished — the model may
@ -751,7 +816,11 @@ export function useNanobotStream(
let finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
finalized = pruneReasoningOnlyPlaceholders(finalized);
if (typeof ev.latency_ms === "number" && ev.latency_ms >= 0) {
finalized = stampLastAssistantLatency(finalized, Math.round(ev.latency_ms));
finalized = stampLastAssistantLatency(
finalized,
Math.round(ev.latency_ms),
ev.turn_id,
);
}
buffer.current = null;
activeAssistantRef.current = null;
@ -778,9 +847,12 @@ export function useNanobotStream(
const line = ev.text;
if (!line) return;
if (fileEditSegmentRef.current) clearActivitySegment();
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line, {
ensure: ensureActivitySegmentId,
})));
setMessages((prev) => closeReasoningStream(attachReasoningChunk(
prev,
line,
{ ensure: ensureActivitySegmentId },
turnFieldsFromEvent(ev, "reasoning"),
)));
return;
}
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
@ -788,6 +860,7 @@ export function useNanobotStream(
// so a sequence of calls collapses into one compact trace group.
if (ev.kind === "tool_hint" || ev.kind === "progress") {
const structuredEvents = normalizeToolProgressEvents(ev.tool_events);
const turn = turnFieldsFromEvent(ev, "activity");
setMessages((prev) => {
const segmentId = ensureActivitySegmentId();
const base = prev;
@ -826,6 +899,7 @@ export function useNanobotStream(
? mergeToolProgressEvents(last.toolEvents, visibleStructuredEvents)
: last.toolEvents,
activitySegmentId: last.activitySegmentId ?? segmentId,
...turn,
};
return [...base.slice(0, -1), merged];
}
@ -839,6 +913,7 @@ export function useNanobotStream(
traces: lines,
...(visibleStructuredEvents.length ? { toolEvents: visibleStructuredEvents } : {}),
activitySegmentId: segmentId,
...turn,
createdAt: Date.now(),
},
];
@ -870,6 +945,8 @@ export function useNanobotStream(
content,
...(hasMedia ? { media } : {}),
...(lat !== undefined ? { latencyMs: lat } : {}),
...(ev.source ? { source: ev.source } : {}),
...turnFieldsFromEvent(ev, "answer"),
});
});
if (hasMedia) {
@ -882,6 +959,7 @@ export function useNanobotStream(
if (edits.length === 0) return;
const normalized = mergeFileEdits(undefined, edits);
if (normalized.length === 0) return;
const turn = turnFieldsFromEvent(ev, "activity");
const opensFileEditPhase = normalized.some(
(edit) => edit.status === "editing" || edit.phase === "start",
);
@ -903,6 +981,7 @@ export function useNanobotStream(
...cleanedTarget,
fileEdits: mergeFileEdits(cleanedTarget.fileEdits, normalized),
activitySegmentId: segmentId,
...turn,
};
return replaceMessageAt(base, targetIndex, merged);
}
@ -918,6 +997,7 @@ export function useNanobotStream(
traces: [],
fileEdits: normalized,
activitySegmentId: segmentId,
...turn,
createdAt: Date.now(),
},
];
@ -962,6 +1042,7 @@ export function useNanobotStream(
if (!hasImages && !content.trim()) return;
flushPendingStreamEvents();
const turnId = crypto.randomUUID();
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
setMessages((prev) => {
buffer.current = null;
@ -974,6 +1055,9 @@ export function useNanobotStream(
id: crypto.randomUUID(),
role: "user",
content,
turnId,
turnPhase: "user",
turnSeq: 0,
createdAt: Date.now(),
...(previews ? { images: previews } : {}),
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
@ -985,11 +1069,7 @@ export function useNanobotStream(
// right away, before the first delta arrives from the server.
setIsStreaming(true);
const wireMedia = hasImages ? images!.map((i) => i.media) : undefined;
if (options) {
client.sendMessage(chatId, content, wireMedia, options);
} else {
client.sendMessage(chatId, content, wireMedia);
}
client.sendMessage(chatId, content, wireMedia, { ...options, turnId });
},
[chatId, clearActivitySegment, client, flushPendingStreamEvents],
);

View File

@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import { fetchSessionAutomations } from "@/lib/api";
import type { SessionAutomationJob } from "@/lib/types";
const AUTOMATIONS_REFRESH_MS = 3000;
export function useSessionAutomationJobs(open: boolean, token: string, sessionKey: string) {
const [jobs, setJobs] = useState<SessionAutomationJob[]>([]);
const [loading, setLoading] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!open) return;
let cancelled = false;
let loadedOnce = false;
const refresh = async (showLoading = false) => {
if (showLoading) {
setLoading(true);
setLoadFailed(false);
setJobs([]);
}
try {
const next = await fetchSessionAutomations(token, sessionKey);
if (cancelled) return;
setJobs(next.jobs);
setLoadFailed(false);
loadedOnce = true;
} catch {
if (!cancelled && !loadedOnce) setLoadFailed(true);
} finally {
if (!cancelled && showLoading) setLoading(false);
}
};
void refresh(true);
const refreshId = window.setInterval(() => void refresh(false), AUTOMATIONS_REFRESH_MS);
const refreshOnFocus = () => {
if (document.visibilityState !== "hidden") void refresh(false);
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
return () => {
cancelled = true;
window.clearInterval(refreshId);
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
};
}, [open, sessionKey, token]);
useEffect(() => {
if (!open) return;
setNow(Date.now());
const tickId = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(tickId);
}, [open]);
return { jobs, loading, loadFailed, now };
}

View File

@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
import { fetchSkills } from "@/lib/api";
import type { SkillSummary } from "@/lib/types";
export function useSkills(token: string): SkillSummary[] {
const [skills, setSkills] = useState<SkillSummary[]>([]);
useEffect(() => {
let cancelled = false;
fetchSkills(token)
.then(({ skills: nextSkills }) => !cancelled && setSkills(nextSkills))
.catch(() => !cancelled && setSkills([]));
return () => {
cancelled = true;
};
}, [token]);
return skills;
}

View File

@ -54,7 +54,10 @@
"label": "Language",
"ariaLabel": "Change language"
},
"apps": "Apps"
"apps": "Apps",
"skills": {
"title": "Skills"
}
},
"settings": {
"backToChat": "Back to chat",
@ -75,7 +78,8 @@
"mcp": "MCP",
"runtime": "System",
"advanced": "Security",
"apps": "Apps"
"apps": "Apps",
"skills": "Skills"
},
"sections": {
"interface": "Interface",
@ -295,6 +299,9 @@
"disabled": "Disabled",
"restartPending": "Restart pending",
"ready": "Ready",
"privateEngine": "Private engine",
"unixSocket": "Unix socket",
"defaultWorkspace": "Default workspace",
"comfortable": "Comfortable",
"compact": "Compact",
"auto": "Auto",
@ -386,6 +393,31 @@
"imageGeneration": "Image generation",
"workspace": "Workspace"
},
"usage": {
"title": "Token activity",
"shortTitle": "Token Usage",
"subtitle": "Provider-reported usage over the last 12 months.",
"empty": "Token activity will appear after new model replies.",
"totalTokens": "Total tokens",
"peakTokens": "Peak tokens",
"thirtyDayTokens": "30-day tokens",
"currentStreak": "Current streak",
"longestStreak": "Longest streak",
"daysValue": "{{count}}d",
"last30": "30 days",
"activeDays": "Active days",
"requests": "Requests",
"estimated": "estimated",
"includesEstimates": "includes estimates",
"cellTitle": "{{date}}: {{tokens}} tokens, {{requests}} requests",
"sources": {
"user": "Chat",
"api": "API",
"cron": "Automations",
"dream": "Memory",
"system": "System"
}
},
"providers": {
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search.",
@ -427,6 +459,33 @@
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
"signedIn": "Signed in",
"notSignedIn": "Not signed in"
},
"skills": {
"description": "Review the instruction skills this agent can load during a conversation.",
"caption": "{{available}} available · {{total}} total",
"featured": "Agent skills",
"empty": "No skills are available.",
"sourceWorkspace": "Custom",
"sourceBuiltin": "Built-in",
"statusAvailable": "Available",
"statusUnavailable": "Unavailable",
"unavailableReason": "Missing: {{reason}}",
"openDetails": "Open details for {{name}}",
"loadingDetail": "Loading skill details...",
"loadFailed": "Could not load skill details.",
"descriptionTitle": "Description",
"source": "Source",
"status": "Status",
"requirements": "Requirements",
"noRequirements": "No explicit requirements.",
"commands": "Commands",
"environment": "Environment variables",
"missingCommands": "Missing CLI",
"missingEnvironment": "Missing ENV",
"unavailableReasonLabel": "Unavailable reason",
"rawInstructions": "Raw SKILL.md",
"rawInstructionsEmpty": "No raw instructions.",
"detailDescription": "Details for {{name}}."
}
},
"chat": {
@ -548,7 +607,30 @@
"toggleSidebar": "Toggle sidebar",
"newChat": "Start a new chat",
"toggleTheme": "Toggle theme from header",
"settings": "Open settings"
"settings": "Open settings",
"sessionInfo": "Session details"
},
"sessionInfo": {
"title": "Session",
"untitled": "Untitled chat",
"automations": "Automations",
"count": "{{count}}",
"loading": "Loading automations...",
"loadFailed": "Could not load automations.",
"empty": "No automations in this session yet.",
"disabled": "Off",
"schedule": {
"at": "{{time}}",
"every": "Every {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "Custom schedule"
},
"next": {
"label": "{{time}}",
"disabled": "Paused",
"none": "No next run"
}
},
"composer": {
"placeholderThread": "Type your message…",
@ -565,6 +647,8 @@
"goalStateCloseAria": "Close goal",
"send": "Send message",
"stop": "Stop response",
"modelNotConfigured": "Model not configured",
"configureModel": "Configure model",
"queued": {
"label": "Queued guidance",
"guide": "Guide",
@ -691,7 +775,14 @@
}
},
"scrollToBottom": "Scroll to bottom",
"loadEarlier": "Load earlier messages"
"loadEarlier": "Load earlier messages",
"promptNavigator": {
"open": "Open prompt navigator",
"title": "Prompts",
"search": "Search prompts",
"noResults": "No matching prompts.",
"jumpTo": "Jump to prompt: {{label}}"
}
},
"message": {
"streaming": "streaming",
@ -722,6 +813,8 @@
"cliRunRan": "Used",
"cliRunFailed": "Failed",
"imageAttachment": "Image attachment",
"automationSourceFallback": "Automation",
"automationTriggered": "Triggered automatically",
"copyReply": "Copy reply",
"copiedReply": "Copied reply",
"turnLatencyTitle": "Response time (end-to-end)"
@ -733,6 +826,15 @@
"next": "Next image",
"close": "Close image preview"
},
"filePreview": {
"aria": "File preview",
"close": "Close file preview",
"loading": "Loading preview...",
"failed": "Could not preview this file.",
"routeMissing": "File preview needs the latest gateway. Restart nanobot gateway and try again.",
"resize": "Resize file preview",
"truncated": "Preview is truncated because this file is large."
},
"code": {
"fallbackLanguage": "code",
"copyAria": "Copy code",

View File

@ -54,7 +54,10 @@
"label": "Idioma",
"ariaLabel": "Cambiar idioma"
},
"apps": "Apps"
"apps": "Apps",
"skills": {
"title": "Habilidades"
}
},
"settings": {
"backToChat": "Volver al chat",
@ -75,7 +78,8 @@
"advanced": "Seguridad",
"cliApps": "Apps CLI",
"mcp": "MCP",
"apps": "Aplicaciones"
"apps": "Aplicaciones",
"skills": "Habilidades"
},
"sections": {
"interface": "Interfaz",
@ -187,6 +191,9 @@
"disabled": "Desactivado",
"restartPending": "Reinicio pendiente",
"ready": "Listo",
"privateEngine": "Motor privado",
"unixSocket": "Socket Unix",
"defaultWorkspace": "Espacio predeterminado",
"comfortable": "Cómodo",
"compact": "Compacto",
"auto": "Automático",
@ -278,6 +285,31 @@
"imageGeneration": "Generación de imágenes",
"workspace": "Espacio de trabajo"
},
"usage": {
"title": "Actividad de tokens",
"shortTitle": "Token Usage",
"subtitle": "Uso reportado por el proveedor durante los últimos 12 meses.",
"empty": "La actividad de tokens aparecerá después de nuevas respuestas del modelo.",
"totalTokens": "Tokens totales",
"peakTokens": "Pico de tokens",
"thirtyDayTokens": "Tokens en 30 días",
"currentStreak": "Racha actual",
"longestStreak": "Racha más larga",
"daysValue": "{{count}} d",
"last30": "30 días",
"activeDays": "Días activos",
"requests": "Solicitudes",
"estimated": "estimado",
"includesEstimates": "incluye estimaciones",
"cellTitle": "{{date}}: {{tokens}} tokens, {{requests}} solicitudes",
"sources": {
"user": "Chat",
"api": "API",
"cron": "Automatizaciones",
"dream": "Memoria",
"system": "Sistema"
}
},
"providers": {
"searchPlaceholder": "Buscar proveedores",
"noMatches": "Ningún proveedor coincide con esta búsqueda.",
@ -427,6 +459,33 @@
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Sesión iniciada",
"notSignedIn": "Sin sesión"
},
"skills": {
"description": "Revisa las habilidades de instrucciones que este agente puede cargar durante una conversación.",
"caption": "{{available}} disponibles · {{total}} en total",
"featured": "Habilidades del agente",
"empty": "No hay habilidades disponibles.",
"sourceWorkspace": "Personalizada",
"sourceBuiltin": "Integradas",
"statusAvailable": "Disponible",
"statusUnavailable": "No disponible",
"unavailableReason": "Falta: {{reason}}",
"openDetails": "Abrir detalles de {{name}}",
"loadingDetail": "Cargando detalles de la habilidad...",
"loadFailed": "No se pudieron cargar los detalles.",
"descriptionTitle": "Descripción",
"source": "Origen",
"status": "Estado",
"requirements": "Requisitos",
"noRequirements": "Sin requisitos explícitos.",
"commands": "Comandos",
"environment": "Variables de entorno",
"missingCommands": "Falta CLI",
"missingEnvironment": "Falta ENV",
"unavailableReasonLabel": "Motivo de indisponibilidad",
"rawInstructions": "SKILL.md original",
"rawInstructionsEmpty": "No hay instrucciones originales.",
"detailDescription": "Detalles de {{name}}."
}
},
"chat": {
@ -548,7 +607,30 @@
"toggleSidebar": "Mostrar u ocultar la barra lateral",
"newChat": "Iniciar un chat nuevo",
"toggleTheme": "Cambiar tema desde el encabezado",
"settings": "Abrir configuración"
"settings": "Abrir configuración",
"sessionInfo": "Detalles de la sesión"
},
"sessionInfo": {
"title": "Sesión",
"untitled": "Chat sin título",
"automations": "Automatizaciones",
"count": "{{count}}",
"loading": "Cargando automatizaciones...",
"loadFailed": "No se pudieron cargar las automatizaciones.",
"empty": "Esta sesión aún no tiene automatizaciones.",
"disabled": "Desactivado",
"schedule": {
"at": "A las {{time}}",
"every": "Cada {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "Programación personalizada"
},
"next": {
"label": "Siguiente {{time}}",
"disabled": "En pausa",
"none": "Sin próxima ejecución"
}
},
"composer": {
"placeholderThread": "Escribe tu mensaje…",
@ -565,6 +647,8 @@
"goalStateCloseAria": "Cerrar objetivo",
"send": "Enviar mensaje",
"stop": "Detener respuesta",
"modelNotConfigured": "Modelo no configurado",
"configureModel": "Configurar modelo",
"queued": {
"label": "Guía en cola",
"guide": "Guiar",
@ -691,7 +775,14 @@
}
},
"scrollToBottom": "Desplazarse al final",
"loadEarlier": "Cargar mensajes anteriores"
"loadEarlier": "Cargar mensajes anteriores",
"promptNavigator": {
"open": "Abrir navegador de prompts",
"title": "Prompts",
"search": "Buscar prompts",
"noResults": "No hay prompts coincidentes.",
"jumpTo": "Ir al prompt: {{label}}"
}
},
"message": {
"streaming": "transmitiendo",
@ -724,7 +815,9 @@
"cliActivityFailedMany": "Fallaron {{count}} apps CLI",
"cliRunRunning": "Usando",
"cliRunRan": "Usado",
"cliRunFailed": "Falló"
"cliRunFailed": "Falló",
"automationSourceFallback": "Automatización",
"automationTriggered": "Activada automáticamente"
},
"lightbox": {
"title": "Vista previa de imagen",
@ -733,6 +826,15 @@
"next": "Imagen siguiente",
"close": "Cerrar vista previa"
},
"filePreview": {
"aria": "Vista previa de archivo",
"close": "Cerrar vista previa de archivo",
"loading": "Cargando vista previa...",
"failed": "No se pudo previsualizar este archivo.",
"routeMissing": "La vista previa necesita el gateway más reciente. Reinicia nanobot gateway e inténtalo de nuevo.",
"resize": "Cambiar el tamaño de la vista previa",
"truncated": "La vista previa está truncada porque el archivo es grande."
},
"code": {
"fallbackLanguage": "código",
"copyAria": "Copiar código",

View File

@ -54,7 +54,10 @@
"label": "Langue",
"ariaLabel": "Changer de langue"
},
"apps": "Apps"
"apps": "Apps",
"skills": {
"title": "Compétences"
}
},
"settings": {
"backToChat": "Retour au chat",
@ -75,7 +78,8 @@
"advanced": "Sécurité",
"cliApps": "Apps CLI",
"mcp": "MCP",
"apps": "Applications"
"apps": "Applications",
"skills": "Compétences"
},
"sections": {
"interface": "Interface utilisateur",
@ -187,6 +191,9 @@
"disabled": "Désactivé",
"restartPending": "Redémarrage en attente",
"ready": "Prêt",
"privateEngine": "Moteur privé",
"unixSocket": "Socket Unix",
"defaultWorkspace": "Espace par défaut",
"comfortable": "Confortable",
"compact": "Compacte",
"auto": "Automatique",
@ -278,6 +285,31 @@
"imageGeneration": "Génération dimages",
"workspace": "Espace de travail"
},
"usage": {
"title": "Activité des tokens",
"shortTitle": "Token Usage",
"subtitle": "Usage signalé par le fournisseur sur les 12 derniers mois.",
"empty": "Lactivité des tokens apparaîtra après les nouvelles réponses du modèle.",
"totalTokens": "Tokens cumulés",
"peakTokens": "Pic de tokens",
"thirtyDayTokens": "Tokens sur 30 jours",
"currentStreak": "Série actuelle",
"longestStreak": "Plus longue série",
"daysValue": "{{count}} j",
"last30": "30 jours",
"activeDays": "Jours actifs",
"requests": "Requêtes",
"estimated": "estimé",
"includesEstimates": "inclut des estimations",
"cellTitle": "{{date}} : {{tokens}} tokens, {{requests}} requêtes",
"sources": {
"user": "Chat",
"api": "API",
"cron": "Automatisations",
"dream": "Mémoire",
"system": "Système"
}
},
"providers": {
"searchPlaceholder": "Rechercher des fournisseurs",
"noMatches": "Aucun fournisseur ne correspond.",
@ -427,6 +459,33 @@
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Connecté",
"notSignedIn": "Non connecté"
},
"skills": {
"description": "Consultez les compétences dinstruction que cet agent peut charger pendant une conversation.",
"caption": "{{available}} disponibles · {{total}} au total",
"featured": "Compétences agent",
"empty": "Aucune compétence disponible.",
"sourceWorkspace": "Personnalisée",
"sourceBuiltin": "Intégrée",
"statusAvailable": "Disponible",
"statusUnavailable": "Indisponible",
"unavailableReason": "Manquant : {{reason}}",
"openDetails": "Ouvrir les détails de {{name}}",
"loadingDetail": "Chargement des détails...",
"loadFailed": "Impossible de charger les détails.",
"descriptionTitle": "Description",
"source": "Source",
"status": "Statut",
"requirements": "Prérequis",
"noRequirements": "Aucun prérequis explicite.",
"commands": "Commandes",
"environment": "Variables denvironnement",
"missingCommands": "CLI manquant",
"missingEnvironment": "ENV manquant",
"unavailableReasonLabel": "Raison dindisponibilité",
"rawInstructions": "SKILL.md brut",
"rawInstructionsEmpty": "Aucune instruction brute.",
"detailDescription": "Détails de {{name}}."
}
},
"chat": {
@ -548,7 +607,30 @@
"toggleSidebar": "Afficher ou masquer la barre latérale",
"newChat": "Démarrer un nouveau chat",
"toggleTheme": "Changer le thème depuis len-tête",
"settings": "Ouvrir les paramètres"
"settings": "Ouvrir les paramètres",
"sessionInfo": "Détails de la session"
},
"sessionInfo": {
"title": "Session",
"untitled": "Chat sans titre",
"automations": "Automatisations",
"count": "{{count}}",
"loading": "Chargement des automatisations...",
"loadFailed": "Impossible de charger les automatisations.",
"empty": "Aucune automatisation dans cette session pour le moment.",
"disabled": "Désactivé",
"schedule": {
"at": "À {{time}}",
"every": "Toutes les {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "Planification personnalisée"
},
"next": {
"label": "Prochaine {{time}}",
"disabled": "En pause",
"none": "Aucune prochaine exécution"
}
},
"composer": {
"placeholderThread": "Saisissez votre message…",
@ -565,6 +647,8 @@
"goalStateCloseAria": "Fermer lobjectif",
"send": "Envoyer le message",
"stop": "Arrêter la réponse",
"modelNotConfigured": "Modèle non configuré",
"configureModel": "Configurer le modèle",
"queued": {
"label": "Guidage en attente",
"guide": "Guider",
@ -691,7 +775,14 @@
}
},
"scrollToBottom": "Faire défiler vers le bas",
"loadEarlier": "Charger les messages précédents"
"loadEarlier": "Charger les messages précédents",
"promptNavigator": {
"open": "Ouvrir le navigateur de prompts",
"title": "Prompts",
"search": "Rechercher des prompts",
"noResults": "Aucun prompt correspondant.",
"jumpTo": "Aller au prompt : {{label}}"
}
},
"message": {
"streaming": "en cours de génération",
@ -724,7 +815,9 @@
"cliActivityFailedMany": "Échec de {{count}} apps CLI",
"cliRunRunning": "Utilisation",
"cliRunRan": "Utilisé",
"cliRunFailed": "Échec"
"cliRunFailed": "Échec",
"automationSourceFallback": "Automatisation",
"automationTriggered": "Déclenché automatiquement"
},
"lightbox": {
"title": "Aperçu de limage",
@ -733,6 +826,15 @@
"next": "Image suivante",
"close": "Fermer laperçu"
},
"filePreview": {
"aria": "Aperçu du fichier",
"close": "Fermer laperçu du fichier",
"loading": "Chargement de laperçu...",
"failed": "Impossible de prévisualiser ce fichier.",
"routeMissing": "Laperçu du fichier nécessite le dernier gateway. Redémarrez nanobot gateway puis réessayez.",
"resize": "Redimensionner laperçu du fichier",
"truncated": "Laperçu est tronqué car le fichier est volumineux."
},
"code": {
"fallbackLanguage": "code",
"copyAria": "Copier le code",

View File

@ -54,7 +54,10 @@
"label": "Bahasa",
"ariaLabel": "Ganti bahasa"
},
"apps": "Aplikasi"
"apps": "Aplikasi",
"skills": {
"title": "Skill"
}
},
"settings": {
"backToChat": "Kembali ke chat",
@ -75,7 +78,8 @@
"advanced": "Keamanan",
"cliApps": "Aplikasi CLI",
"mcp": "MCP",
"apps": "Aplikasi"
"apps": "Aplikasi",
"skills": "Skill"
},
"sections": {
"interface": "Antarmuka",
@ -187,6 +191,9 @@
"disabled": "Nonaktif",
"restartPending": "Menunggu mulai ulang",
"ready": "Siap",
"privateEngine": "Mesin privat",
"unixSocket": "Soket Unix",
"defaultWorkspace": "Workspace default",
"comfortable": "Nyaman",
"compact": "Ringkas",
"auto": "Otomatis",
@ -278,6 +285,31 @@
"imageGeneration": "Pembuatan gambar",
"workspace": "Ruang kerja"
},
"usage": {
"title": "Aktivitas token",
"shortTitle": "Token Usage",
"subtitle": "Penggunaan yang dilaporkan penyedia selama 12 bulan terakhir.",
"empty": "Aktivitas token akan muncul setelah balasan model baru.",
"totalTokens": "Total token",
"peakTokens": "Puncak token",
"thirtyDayTokens": "Token 30 hari",
"currentStreak": "Rentetan saat ini",
"longestStreak": "Rentetan terpanjang",
"daysValue": "{{count}} h",
"last30": "30 hari",
"activeDays": "Hari aktif",
"requests": "Permintaan",
"estimated": "perkiraan",
"includesEstimates": "termasuk perkiraan",
"cellTitle": "{{date}}: {{tokens}} token, {{requests}} permintaan",
"sources": {
"user": "Chat",
"api": "API",
"cron": "Otomasi",
"dream": "Memori",
"system": "Sistem"
}
},
"providers": {
"searchPlaceholder": "Cari penyedia",
"noMatches": "Tidak ada penyedia yang cocok.",
@ -427,6 +459,33 @@
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Sudah masuk",
"notSignedIn": "Belum masuk"
},
"skills": {
"description": "Tinjau skill instruksi yang dapat dimuat agent ini selama percakapan.",
"caption": "{{available}} tersedia · {{total}} total",
"featured": "Skill agent",
"empty": "Tidak ada skill yang tersedia.",
"sourceWorkspace": "Kustom",
"sourceBuiltin": "Bawaan",
"statusAvailable": "Tersedia",
"statusUnavailable": "Tidak tersedia",
"unavailableReason": "Kurang: {{reason}}",
"openDetails": "Buka detail {{name}}",
"loadingDetail": "Memuat detail skill...",
"loadFailed": "Tidak dapat memuat detail skill.",
"descriptionTitle": "Deskripsi",
"source": "Sumber",
"status": "Status",
"requirements": "Kebutuhan",
"noRequirements": "Tidak ada kebutuhan eksplisit.",
"commands": "Perintah",
"environment": "Variabel lingkungan",
"missingCommands": "CLI hilang",
"missingEnvironment": "ENV hilang",
"unavailableReasonLabel": "Alasan tidak tersedia",
"rawInstructions": "SKILL.md mentah",
"rawInstructionsEmpty": "Tidak ada instruksi mentah.",
"detailDescription": "Detail untuk {{name}}."
}
},
"chat": {
@ -548,7 +607,30 @@
"toggleSidebar": "Tampilkan atau sembunyikan sidebar",
"newChat": "Mulai chat baru",
"toggleTheme": "Alihkan tema dari header",
"settings": "Buka pengaturan"
"settings": "Buka pengaturan",
"sessionInfo": "Detail sesi"
},
"sessionInfo": {
"title": "Sesi",
"untitled": "Chat tanpa judul",
"automations": "Otomasi",
"count": "{{count}}",
"loading": "Memuat otomasi...",
"loadFailed": "Tidak dapat memuat otomasi.",
"empty": "Belum ada otomasi dalam sesi ini.",
"disabled": "Mati",
"schedule": {
"at": "Pada {{time}}",
"every": "Setiap {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "Jadwal khusus"
},
"next": {
"label": "Berikutnya {{time}}",
"disabled": "Dijeda",
"none": "Tidak ada jadwal berikutnya"
}
},
"composer": {
"placeholderThread": "Ketik pesan Anda…",
@ -565,6 +647,8 @@
"goalStateCloseAria": "Tutup tujuan",
"send": "Kirim pesan",
"stop": "Hentikan respons",
"modelNotConfigured": "Model belum dikonfigurasi",
"configureModel": "Konfigurasi model",
"queued": {
"label": "Panduan antrean",
"guide": "Pandu",
@ -691,7 +775,14 @@
}
},
"scrollToBottom": "Gulir ke bawah",
"loadEarlier": "Muat pesan sebelumnya"
"loadEarlier": "Muat pesan sebelumnya",
"promptNavigator": {
"open": "Buka navigator prompt",
"title": "Prompt",
"search": "Cari prompt",
"noResults": "Tidak ada prompt yang cocok.",
"jumpTo": "Lompat ke prompt: {{label}}"
}
},
"message": {
"streaming": "sedang mengalir",
@ -724,7 +815,9 @@
"cliActivityFailedMany": "{{count}} aplikasi CLI gagal",
"cliRunRunning": "Menggunakan",
"cliRunRan": "Digunakan",
"cliRunFailed": "Gagal"
"cliRunFailed": "Gagal",
"automationSourceFallback": "Otomatisasi",
"automationTriggered": "Dipicu otomatis"
},
"lightbox": {
"title": "Pratinjau gambar",
@ -733,6 +826,15 @@
"next": "Gambar berikutnya",
"close": "Tutup pratinjau"
},
"filePreview": {
"aria": "Pratinjau file",
"close": "Tutup pratinjau file",
"loading": "Memuat pratinjau...",
"failed": "Tidak dapat mempratinjau file ini.",
"routeMissing": "Pratinjau file memerlukan gateway terbaru. Mulai ulang nanobot gateway lalu coba lagi.",
"resize": "Ubah ukuran pratinjau file",
"truncated": "Pratinjau dipotong karena file ini besar."
},
"code": {
"fallbackLanguage": "kode",
"copyAria": "Salin kode",

View File

@ -54,7 +54,10 @@
"label": "言語",
"ariaLabel": "言語を変更"
},
"apps": "アプリ"
"apps": "アプリ",
"skills": {
"title": "スキル"
}
},
"settings": {
"backToChat": "チャットに戻る",
@ -75,7 +78,8 @@
"advanced": "セキュリティ",
"cliApps": "CLI アプリ",
"mcp": "MCP",
"apps": "アプリ"
"apps": "アプリ",
"skills": "スキル"
},
"sections": {
"interface": "インターフェース",
@ -187,6 +191,9 @@
"disabled": "無効",
"restartPending": "再起動待ち",
"ready": "準備完了",
"privateEngine": "プライベートエンジン",
"unixSocket": "Unix ソケット",
"defaultWorkspace": "デフォルトワークスペース",
"comfortable": "標準",
"compact": "コンパクト",
"auto": "自動",
@ -278,6 +285,31 @@
"imageGeneration": "画像生成",
"workspace": "ワークスペース"
},
"usage": {
"title": "Token アクティビティ",
"shortTitle": "Token Usage",
"subtitle": "直近 12 か月にプロバイダーが報告した使用量。",
"empty": "新しいモデル返信の後に token アクティビティが表示されます。",
"totalTokens": "累計 Token 数",
"peakTokens": "ピーク Token 数",
"thirtyDayTokens": "30 日 Token 数",
"currentStreak": "現在の連続日数",
"longestStreak": "最長連続日数",
"daysValue": "{{count}} 日",
"last30": "30 日",
"activeDays": "アクティブ日数",
"requests": "リクエスト",
"estimated": "推定",
"includesEstimates": "推定を含む",
"cellTitle": "{{date}}: {{tokens}} tokens, {{requests}} 件のリクエスト",
"sources": {
"user": "チャット",
"api": "API",
"cron": "自動タスク",
"dream": "メモリ整理",
"system": "システム"
}
},
"providers": {
"searchPlaceholder": "プロバイダーを検索",
"noMatches": "一致するプロバイダーはありません。",
@ -427,6 +459,33 @@
"signInBeforeSaving": "この OAuth プロバイダーをアクティブなモデルプロバイダーとして保存する前にサインインしてください。",
"signedIn": "サインイン済み",
"notSignedIn": "未サインイン"
},
"skills": {
"description": "このエージェントが会話中に読み込める指示スキルを確認します。",
"caption": "{{available}} 利用可能 · 合計 {{total}}",
"featured": "エージェントスキル",
"empty": "利用可能なスキルはありません。",
"sourceWorkspace": "カスタム",
"sourceBuiltin": "組み込み",
"statusAvailable": "利用可能",
"statusUnavailable": "利用不可",
"unavailableReason": "不足: {{reason}}",
"openDetails": "{{name}} の詳細を開く",
"loadingDetail": "スキル詳細を読み込み中...",
"loadFailed": "スキル詳細を読み込めませんでした。",
"descriptionTitle": "説明",
"source": "ソース",
"status": "状態",
"requirements": "要件",
"noRequirements": "明示的な要件はありません。",
"commands": "コマンド",
"environment": "環境変数",
"missingCommands": "CLI 不足",
"missingEnvironment": "ENV 不足",
"unavailableReasonLabel": "利用不可の理由",
"rawInstructions": "元の SKILL.md",
"rawInstructionsEmpty": "元の説明はありません。",
"detailDescription": "{{name}} の詳細。"
}
},
"chat": {
@ -548,7 +607,30 @@
"toggleSidebar": "サイドバーを切り替える",
"newChat": "新しいチャットを開始",
"toggleTheme": "ヘッダーからテーマを切り替える",
"settings": "設定を開く"
"settings": "設定を開く",
"sessionInfo": "セッション詳細"
},
"sessionInfo": {
"title": "セッション",
"untitled": "無題のチャット",
"automations": "自動タスク",
"count": "{{count}}",
"loading": "自動タスクを読み込み中...",
"loadFailed": "自動タスクを読み込めませんでした。",
"empty": "このセッションにはまだ自動タスクがありません。",
"disabled": "オフ",
"schedule": {
"at": "{{time}}",
"every": "{{duration}}ごと",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "カスタムスケジュール"
},
"next": {
"label": "次回 {{time}}",
"disabled": "一時停止",
"none": "次回実行なし"
}
},
"composer": {
"placeholderThread": "メッセージを入力…",
@ -565,6 +647,8 @@
"goalStateCloseAria": "目標を閉じる",
"send": "メッセージを送信",
"stop": "応答を停止",
"modelNotConfigured": "モデルが未設定です",
"configureModel": "モデルを設定",
"queued": {
"label": "保留中のガイド",
"guide": "ガイド",
@ -691,7 +775,14 @@
}
},
"scrollToBottom": "一番下へスクロール",
"loadEarlier": "以前のメッセージを読み込む"
"loadEarlier": "以前のメッセージを読み込む",
"promptNavigator": {
"open": "プロンプトナビゲーターを開く",
"title": "プロンプト",
"search": "プロンプトを検索",
"noResults": "一致するプロンプトがありません。",
"jumpTo": "プロンプトへ移動: {{label}}"
}
},
"message": {
"streaming": "生成中",
@ -724,7 +815,9 @@
"cliActivityFailedMany": "{{count}} 個の CLI アプリが失敗しました",
"cliRunRunning": "使用中",
"cliRunRan": "使用済み",
"cliRunFailed": "失敗"
"cliRunFailed": "失敗",
"automationSourceFallback": "自動化",
"automationTriggered": "自動実行"
},
"lightbox": {
"title": "画像プレビュー",
@ -733,6 +826,15 @@
"next": "次の画像",
"close": "プレビューを閉じる"
},
"filePreview": {
"aria": "ファイルプレビュー",
"close": "ファイルプレビューを閉じる",
"loading": "プレビューを読み込み中...",
"failed": "このファイルをプレビューできませんでした。",
"routeMissing": "ファイルプレビューには最新の gateway が必要です。nanobot gateway を再起動してから再試行してください。",
"resize": "ファイルプレビューの幅を変更",
"truncated": "ファイルが大きいため、プレビューは途中まで表示されています。"
},
"code": {
"fallbackLanguage": "コード",
"copyAria": "コードをコピー",

View File

@ -54,7 +54,10 @@
"label": "언어",
"ariaLabel": "언어 변경"
},
"apps": "앱"
"apps": "앱",
"skills": {
"title": "스킬"
}
},
"settings": {
"backToChat": "채팅으로 돌아가기",
@ -75,7 +78,8 @@
"advanced": "보안",
"cliApps": "CLI 앱",
"mcp": "MCP",
"apps": "앱"
"apps": "앱",
"skills": "스킬"
},
"sections": {
"interface": "인터페이스",
@ -187,6 +191,9 @@
"disabled": "비활성화됨",
"restartPending": "재시작 대기",
"ready": "준비됨",
"privateEngine": "비공개 엔진",
"unixSocket": "Unix 소켓",
"defaultWorkspace": "기본 작업 공간",
"comfortable": "편안함",
"compact": "컴팩트",
"auto": "자동",
@ -278,6 +285,31 @@
"imageGeneration": "이미지 생성",
"workspace": "작업공간"
},
"usage": {
"title": "Token 활동",
"shortTitle": "Token Usage",
"subtitle": "최근 12개월 동안 제공자가 보고한 사용량입니다.",
"empty": "새 모델 응답 이후 token 활동이 표시됩니다.",
"totalTokens": "누적 Token 수",
"peakTokens": "최고 Token 수",
"thirtyDayTokens": "30일 Token 수",
"currentStreak": "현재 연속 일수",
"longestStreak": "최장 연속 일수",
"daysValue": "{{count}}일",
"last30": "30일",
"activeDays": "활성 일수",
"requests": "요청",
"estimated": "추정",
"includesEstimates": "추정 포함",
"cellTitle": "{{date}}: {{tokens}} tokens, 요청 {{requests}}회",
"sources": {
"user": "채팅",
"api": "API",
"cron": "자동화",
"dream": "메모리 정리",
"system": "시스템"
}
},
"providers": {
"searchPlaceholder": "제공자 검색",
"noMatches": "일치하는 제공자가 없습니다.",
@ -427,6 +459,33 @@
"signInBeforeSaving": "이 OAuth 제공자를 활성 모델 제공자로 저장하기 전에 로그인하세요.",
"signedIn": "로그인됨",
"notSignedIn": "로그인 안 됨"
},
"skills": {
"description": "이 에이전트가 대화 중에 불러올 수 있는 지시 스킬을 확인합니다.",
"caption": "{{available}}개 사용 가능 · 총 {{total}}개",
"featured": "에이전트 스킬",
"empty": "사용 가능한 스킬이 없습니다.",
"sourceWorkspace": "사용자 지정",
"sourceBuiltin": "내장",
"statusAvailable": "사용 가능",
"statusUnavailable": "사용 불가",
"unavailableReason": "누락: {{reason}}",
"openDetails": "{{name}} 상세 열기",
"loadingDetail": "스킬 상세를 불러오는 중...",
"loadFailed": "스킬 상세를 불러올 수 없습니다.",
"descriptionTitle": "설명",
"source": "출처",
"status": "상태",
"requirements": "요구 사항",
"noRequirements": "명시된 요구 사항이 없습니다.",
"commands": "명령",
"environment": "환경 변수",
"missingCommands": "CLI 누락",
"missingEnvironment": "ENV 누락",
"unavailableReasonLabel": "사용 불가 이유",
"rawInstructions": "원본 SKILL.md",
"rawInstructionsEmpty": "원본 지침이 없습니다.",
"detailDescription": "{{name}} 세부 정보."
}
},
"chat": {
@ -548,7 +607,30 @@
"toggleSidebar": "사이드바 전환",
"newChat": "새 채팅 시작",
"toggleTheme": "헤더에서 테마 전환",
"settings": "설정 열기"
"settings": "설정 열기",
"sessionInfo": "세션 세부 정보"
},
"sessionInfo": {
"title": "세션",
"untitled": "제목 없는 채팅",
"automations": "자동화",
"count": "{{count}}",
"loading": "자동화를 불러오는 중...",
"loadFailed": "자동화를 불러오지 못했습니다.",
"empty": "이 세션에는 아직 자동화가 없습니다.",
"disabled": "꺼짐",
"schedule": {
"at": "{{time}}",
"every": "{{duration}}마다",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "사용자 지정 일정"
},
"next": {
"label": "다음 {{time}}",
"disabled": "일시 중지됨",
"none": "다음 실행 없음"
}
},
"composer": {
"placeholderThread": "메시지를 입력하세요…",
@ -565,6 +647,8 @@
"goalStateCloseAria": "목표 닫기",
"send": "메시지 보내기",
"stop": "응답 중지",
"modelNotConfigured": "모델이 설정되지 않음",
"configureModel": "모델 설정",
"queued": {
"label": "대기 중인 안내",
"guide": "안내",
@ -691,7 +775,14 @@
}
},
"scrollToBottom": "맨 아래로 스크롤",
"loadEarlier": "이전 메시지 불러오기"
"loadEarlier": "이전 메시지 불러오기",
"promptNavigator": {
"open": "프롬프트 탐색기 열기",
"title": "프롬프트",
"search": "프롬프트 검색",
"noResults": "일치하는 프롬프트가 없습니다.",
"jumpTo": "프롬프트로 이동: {{label}}"
}
},
"message": {
"streaming": "생성 중",
@ -724,7 +815,9 @@
"cliActivityFailedMany": "CLI 앱 {{count}}개 실패",
"cliRunRunning": "사용 중",
"cliRunRan": "사용함",
"cliRunFailed": "실패"
"cliRunFailed": "실패",
"automationSourceFallback": "자동화",
"automationTriggered": "자동 실행됨"
},
"lightbox": {
"title": "이미지 미리보기",
@ -733,6 +826,15 @@
"next": "다음 이미지",
"close": "미리보기 닫기"
},
"filePreview": {
"aria": "파일 미리보기",
"close": "파일 미리보기 닫기",
"loading": "미리보기 로딩 중...",
"failed": "이 파일을 미리 볼 수 없습니다.",
"routeMissing": "파일 미리보기에는 최신 gateway가 필요합니다. nanobot gateway를 다시 시작한 뒤 다시 시도하세요.",
"resize": "파일 미리보기 크기 조절",
"truncated": "파일이 커서 미리보기가 잘렸습니다."
},
"code": {
"fallbackLanguage": "코드",
"copyAria": "코드 복사",

View File

@ -54,7 +54,10 @@
"label": "Ngôn ngữ",
"ariaLabel": "Đổi ngôn ngữ"
},
"apps": "Ứng dụng"
"apps": "Ứng dụng",
"skills": {
"title": "Kỹ năng"
}
},
"settings": {
"backToChat": "Quay lại chat",
@ -75,7 +78,8 @@
"advanced": "Bảo mật",
"cliApps": "Ứng dụng CLI",
"mcp": "MCP",
"apps": "Ứng dụng"
"apps": "Ứng dụng",
"skills": "Kỹ năng"
},
"sections": {
"interface": "Giao diện",
@ -187,6 +191,9 @@
"disabled": "Đã tắt",
"restartPending": "Chờ khởi động lại",
"ready": "Sẵn sàng",
"privateEngine": "Bộ máy riêng",
"unixSocket": "Socket Unix",
"defaultWorkspace": "Workspace mặc định",
"comfortable": "Thoải mái",
"compact": "Gọn",
"auto": "Tự động",
@ -278,6 +285,31 @@
"imageGeneration": "Tạo hình ảnh",
"workspace": "Không gian làm việc"
},
"usage": {
"title": "Hoạt động token",
"shortTitle": "Token Usage",
"subtitle": "Mức dùng do nhà cung cấp báo cáo trong 12 tháng gần nhất.",
"empty": "Hoạt động token sẽ xuất hiện sau các phản hồi mô hình mới.",
"totalTokens": "Tổng token",
"peakTokens": "Đỉnh token",
"thirtyDayTokens": "Token 30 ngày",
"currentStreak": "Chuỗi hiện tại",
"longestStreak": "Chuỗi dài nhất",
"daysValue": "{{count}} ngày",
"last30": "30 ngày",
"activeDays": "Ngày hoạt động",
"requests": "Yêu cầu",
"estimated": "ước tính",
"includesEstimates": "bao gồm ước tính",
"cellTitle": "{{date}}: {{tokens}} tokens, {{requests}} yêu cầu",
"sources": {
"user": "Trò chuyện",
"api": "API",
"cron": "Tự động hóa",
"dream": "Bộ nhớ",
"system": "Hệ thống"
}
},
"providers": {
"searchPlaceholder": "Tìm nhà cung cấp",
"noMatches": "Không có nhà cung cấp phù hợp.",
@ -427,6 +459,33 @@
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
"signedIn": "Đã đăng nhập",
"notSignedIn": "Chưa đăng nhập"
},
"skills": {
"description": "Xem các kỹ năng chỉ dẫn mà agent này có thể tải trong cuộc trò chuyện.",
"caption": "{{available}} khả dụng · tổng {{total}}",
"featured": "Kỹ năng agent",
"empty": "Không có kỹ năng nào khả dụng.",
"sourceWorkspace": "Tùy chỉnh",
"sourceBuiltin": "Tích hợp",
"statusAvailable": "Khả dụng",
"statusUnavailable": "Không khả dụng",
"unavailableReason": "Thiếu: {{reason}}",
"openDetails": "Mở chi tiết {{name}}",
"loadingDetail": "Đang tải chi tiết kỹ năng...",
"loadFailed": "Không tải được chi tiết kỹ năng.",
"descriptionTitle": "Mô tả",
"source": "Nguồn",
"status": "Trạng thái",
"requirements": "Yêu cầu",
"noRequirements": "Không có yêu cầu rõ ràng.",
"commands": "Lệnh",
"environment": "Biến môi trường",
"missingCommands": "Thiếu CLI",
"missingEnvironment": "Thiếu ENV",
"unavailableReasonLabel": "Lý do không khả dụng",
"rawInstructions": "SKILL.md gốc",
"rawInstructionsEmpty": "Không có hướng dẫn gốc.",
"detailDescription": "Chi tiết cho {{name}}."
}
},
"chat": {
@ -548,7 +607,30 @@
"toggleSidebar": "Bật/tắt thanh bên",
"newChat": "Bắt đầu chat mới",
"toggleTheme": "Chuyển chủ đề từ header",
"settings": "Mở cài đặt"
"settings": "Mở cài đặt",
"sessionInfo": "Chi tiết phiên"
},
"sessionInfo": {
"title": "Phiên",
"untitled": "Chat chưa đặt tên",
"automations": "Tự động hóa",
"count": "{{count}}",
"loading": "Đang tải tự động hóa...",
"loadFailed": "Không thể tải tự động hóa.",
"empty": "Phiên này chưa có tự động hóa.",
"disabled": "Tắt",
"schedule": {
"at": "Lúc {{time}}",
"every": "Mỗi {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "Lịch tùy chỉnh"
},
"next": {
"label": "Tiếp theo {{time}}",
"disabled": "Đã tạm dừng",
"none": "Không có lần chạy tiếp theo"
}
},
"composer": {
"placeholderThread": "Nhập tin nhắn…",
@ -565,6 +647,8 @@
"goalStateCloseAria": "Đóng mục tiêu",
"send": "Gửi tin nhắn",
"stop": "Dừng phản hồi",
"modelNotConfigured": "Chưa cấu hình mô hình",
"configureModel": "Cấu hình mô hình",
"queued": {
"label": "Hướng dẫn đang chờ",
"guide": "Hướng dẫn",
@ -691,7 +775,14 @@
}
},
"scrollToBottom": "Cuộn xuống cuối",
"loadEarlier": "Tải tin nhắn trước đó"
"loadEarlier": "Tải tin nhắn trước đó",
"promptNavigator": {
"open": "Mở trình điều hướng prompt",
"title": "Prompt",
"search": "Tìm prompt",
"noResults": "Không có prompt phù hợp.",
"jumpTo": "Nhảy tới prompt: {{label}}"
}
},
"message": {
"streaming": "đang truyền",
@ -724,7 +815,9 @@
"cliActivityFailedMany": "{{count}} ứng dụng CLI thất bại",
"cliRunRunning": "Đang dùng",
"cliRunRan": "Đã dùng",
"cliRunFailed": "Thất bại"
"cliRunFailed": "Thất bại",
"automationSourceFallback": "Tự động hóa",
"automationTriggered": "Tự động kích hoạt"
},
"lightbox": {
"title": "Xem trước ảnh",
@ -733,6 +826,15 @@
"next": "Ảnh tiếp theo",
"close": "Đóng xem trước"
},
"filePreview": {
"aria": "Xem trước tệp",
"close": "Đóng xem trước tệp",
"loading": "Đang tải bản xem trước...",
"failed": "Không thể xem trước tệp này.",
"routeMissing": "Xem trước tệp cần gateway mới nhất. Hãy khởi động lại nanobot gateway rồi thử lại.",
"resize": "Đổi kích thước bản xem trước tệp",
"truncated": "Bản xem trước bị cắt vì tệp này lớn."
},
"code": {
"fallbackLanguage": "mã",
"copyAria": "Sao chép mã",

View File

@ -54,7 +54,10 @@
"label": "语言",
"ariaLabel": "切换语言"
},
"apps": "应用"
"apps": "应用",
"skills": {
"title": "技能"
}
},
"settings": {
"backToChat": "返回聊天",
@ -75,7 +78,8 @@
"mcp": "MCP",
"runtime": "系统",
"advanced": "安全",
"apps": "应用"
"apps": "应用",
"skills": "技能"
},
"sections": {
"interface": "界面",
@ -295,6 +299,9 @@
"disabled": "已禁用",
"restartPending": "等待重启",
"ready": "就绪",
"privateEngine": "私有引擎",
"unixSocket": "Unix socket",
"defaultWorkspace": "默认工作区",
"comfortable": "舒适",
"compact": "紧凑",
"auto": "自动",
@ -386,6 +393,31 @@
"imageGeneration": "图片生成",
"workspace": "工作区"
},
"usage": {
"title": "Token 活动",
"shortTitle": "Token Usage",
"subtitle": "最近 12 个月由提供商上报的 token 用量。",
"empty": "新的模型回复产生后,这里会显示 token 活动。",
"totalTokens": "累计 Token 数",
"peakTokens": "峰值 Token 数",
"thirtyDayTokens": "30 天 Token 数",
"currentStreak": "当前连续天数",
"longestStreak": "最长连续天数",
"daysValue": "{{count}} 天",
"last30": "30 天",
"activeDays": "活跃天数",
"requests": "请求数",
"estimated": "估算",
"includesEstimates": "包含估算",
"cellTitle": "{{date}}{{tokens}} tokens{{requests}} 次请求",
"sources": {
"user": "对话",
"api": "API",
"cron": "自动任务",
"dream": "记忆整理",
"system": "系统"
}
},
"providers": {
"searchPlaceholder": "搜索提供商",
"noMatches": "没有匹配的提供商。",
@ -427,6 +459,33 @@
"signInBeforeSaving": "将此 OAuth 提供商设为当前模型提供商前,请先登录。",
"signedIn": "已登录",
"notSignedIn": "未登录"
},
"skills": {
"description": "查看此 agent 在对话中可以加载的指令技能。",
"caption": "{{available}} 个可用 · 共 {{total}} 个",
"featured": "Agent 技能",
"empty": "暂无可用技能。",
"sourceWorkspace": "自定义",
"sourceBuiltin": "内置",
"statusAvailable": "可用",
"statusUnavailable": "不可用",
"unavailableReason": "缺少:{{reason}}",
"openDetails": "查看 {{name}} 详情",
"loadingDetail": "正在加载技能详情...",
"loadFailed": "无法加载技能详情。",
"descriptionTitle": "完整描述",
"source": "来源",
"status": "状态",
"requirements": "需求",
"noRequirements": "没有显式需求。",
"commands": "命令",
"environment": "环境变量",
"missingCommands": "缺 CLI",
"missingEnvironment": "缺 ENV",
"unavailableReasonLabel": "不可用原因",
"rawInstructions": "原始 SKILL.md",
"rawInstructionsEmpty": "没有原始说明。",
"detailDescription": "{{name}} 的详情。"
}
},
"chat": {
@ -548,7 +607,30 @@
"toggleSidebar": "切换侧边栏",
"newChat": "从顶部新建对话",
"toggleTheme": "从顶部切换主题",
"settings": "打开设置"
"settings": "打开设置",
"sessionInfo": "会话详情"
},
"sessionInfo": {
"title": "会话",
"untitled": "未命名对话",
"automations": "自动任务",
"count": "{{count}}",
"loading": "正在加载自动任务...",
"loadFailed": "无法加载自动任务。",
"empty": "这个会话暂时没有自动任务。",
"disabled": "已关闭",
"schedule": {
"at": "{{time}}",
"every": "每 {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "自定义计划"
},
"next": {
"label": "下次 {{time}}",
"disabled": "已暂停",
"none": "没有下次运行"
}
},
"composer": {
"placeholderThread": "输入消息…",
@ -564,6 +646,8 @@
"goalStateSheetTitle": "目标",
"send": "发送消息",
"stop": "停止响应",
"modelNotConfigured": "模型未配置",
"configureModel": "配置模型",
"queued": {
"label": "待引导提示",
"guide": "引导",
@ -691,7 +775,14 @@
}
},
"scrollToBottom": "滚动到底部",
"loadEarlier": "加载更早消息"
"loadEarlier": "加载更早消息",
"promptNavigator": {
"open": "打开输入导航",
"title": "输入列表",
"search": "搜索输入",
"noResults": "没有匹配的输入。",
"jumpTo": "跳转到输入:{{label}}"
}
},
"message": {
"streaming": "流式输出中",
@ -722,6 +813,8 @@
"cliRunRan": "已使用",
"cliRunFailed": "失败",
"imageAttachment": "图片附件",
"automationSourceFallback": "自动化",
"automationTriggered": "自动触发",
"copyReply": "复制回复",
"copiedReply": "已复制回复",
"turnLatencyTitle": "本轮耗时(端到端)"
@ -733,6 +826,15 @@
"next": "下一张",
"close": "关闭预览"
},
"filePreview": {
"aria": "文件预览",
"close": "关闭文件预览",
"loading": "正在加载预览...",
"failed": "无法预览这个文件。",
"routeMissing": "文件预览需要最新的 gateway。请重启 nanobot gateway 后再试。",
"resize": "调整文件预览宽度",
"truncated": "文件较大,当前只显示前半部分预览。"
},
"code": {
"fallbackLanguage": "代码",
"copyAria": "复制代码",

View File

@ -54,7 +54,10 @@
"label": "語言",
"ariaLabel": "切換語言"
},
"apps": "應用"
"apps": "應用",
"skills": {
"title": "技能"
}
},
"settings": {
"backToChat": "返回聊天",
@ -75,7 +78,8 @@
"advanced": "安全",
"cliApps": "CLI 應用",
"mcp": "MCP",
"apps": "應用"
"apps": "應用",
"skills": "技能"
},
"sections": {
"interface": "介面",
@ -187,6 +191,9 @@
"disabled": "已停用",
"restartPending": "等待重啟",
"ready": "就緒",
"privateEngine": "私有引擎",
"unixSocket": "Unix socket",
"defaultWorkspace": "預設工作區",
"comfortable": "舒適",
"compact": "緊湊",
"auto": "自動",
@ -278,6 +285,31 @@
"imageGeneration": "圖片生成",
"workspace": "工作區"
},
"usage": {
"title": "Token 活動",
"shortTitle": "Token Usage",
"subtitle": "最近 12 個月由供應商回報的 token 用量。",
"empty": "新的模型回覆產生後,這裡會顯示 token 活動。",
"totalTokens": "累計 Token 數",
"peakTokens": "峰值 Token 數",
"thirtyDayTokens": "30 天 Token 數",
"currentStreak": "目前連續天數",
"longestStreak": "最長連續天數",
"daysValue": "{{count}} 天",
"last30": "30 天",
"activeDays": "活躍天數",
"requests": "請求數",
"estimated": "估算",
"includesEstimates": "包含估算",
"cellTitle": "{{date}}{{tokens}} tokens{{requests}} 次請求",
"sources": {
"user": "對話",
"api": "API",
"cron": "自動任務",
"dream": "記憶整理",
"system": "系統"
}
},
"providers": {
"searchPlaceholder": "搜尋供應商",
"noMatches": "沒有符合的供應商。",
@ -427,6 +459,33 @@
"signInBeforeSaving": "將此 OAuth 供應商設為目前模型供應商前,請先登入。",
"signedIn": "已登入",
"notSignedIn": "未登入"
},
"skills": {
"description": "查看此 agent 在對話中可以載入的指令技能。",
"caption": "{{available}} 個可用 · 共 {{total}} 個",
"featured": "Agent 技能",
"empty": "暫無可用技能。",
"sourceWorkspace": "自訂",
"sourceBuiltin": "內建",
"statusAvailable": "可用",
"statusUnavailable": "不可用",
"unavailableReason": "缺少:{{reason}}",
"openDetails": "查看 {{name}} 詳情",
"loadingDetail": "正在載入技能詳情...",
"loadFailed": "無法載入技能詳情。",
"descriptionTitle": "完整描述",
"source": "來源",
"status": "狀態",
"requirements": "需求",
"noRequirements": "沒有明確需求。",
"commands": "命令",
"environment": "環境變數",
"missingCommands": "缺 CLI",
"missingEnvironment": "缺 ENV",
"unavailableReasonLabel": "不可用原因",
"rawInstructions": "原始 SKILL.md",
"rawInstructionsEmpty": "沒有原始說明。",
"detailDescription": "{{name}} 的詳細資訊。"
}
},
"chat": {
@ -548,7 +607,30 @@
"toggleSidebar": "切換側邊欄",
"newChat": "開始新對話",
"toggleTheme": "從頂部切換主題",
"settings": "開啟設定"
"settings": "開啟設定",
"sessionInfo": "會話詳情"
},
"sessionInfo": {
"title": "會話",
"untitled": "未命名對話",
"automations": "自動任務",
"count": "{{count}}",
"loading": "正在載入自動任務...",
"loadFailed": "無法載入自動任務。",
"empty": "這個會話暫時沒有自動任務。",
"disabled": "已關閉",
"schedule": {
"at": "{{time}}",
"every": "每 {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "自訂計畫"
},
"next": {
"label": "下次 {{time}}",
"disabled": "已暫停",
"none": "沒有下次執行"
}
},
"composer": {
"placeholderThread": "輸入訊息…",
@ -565,6 +647,8 @@
"goalStateCloseAria": "關閉目標",
"send": "送出訊息",
"stop": "停止回覆",
"modelNotConfigured": "模型未配置",
"configureModel": "配置模型",
"queued": {
"label": "待引導提示",
"guide": "引導",
@ -691,7 +775,14 @@
}
},
"scrollToBottom": "捲動到底部",
"loadEarlier": "載入更早訊息"
"loadEarlier": "載入更早訊息",
"promptNavigator": {
"open": "開啟輸入導覽",
"title": "輸入列表",
"search": "搜尋輸入",
"noResults": "沒有符合的輸入。",
"jumpTo": "跳到輸入:{{label}}"
}
},
"message": {
"streaming": "串流輸出中",
@ -724,7 +815,9 @@
"cliActivityFailedMany": "{{count}} 個 CLI 應用失敗",
"cliRunRunning": "使用中",
"cliRunRan": "已使用",
"cliRunFailed": "失敗"
"cliRunFailed": "失敗",
"automationSourceFallback": "自動化",
"automationTriggered": "自動觸發"
},
"lightbox": {
"title": "圖片預覽",
@ -733,6 +826,15 @@
"next": "下一張",
"close": "關閉預覽"
},
"filePreview": {
"aria": "檔案預覽",
"close": "關閉檔案預覽",
"loading": "正在載入預覽...",
"failed": "無法預覽這個檔案。",
"routeMissing": "檔案預覽需要最新的 gateway。請重啟 nanobot gateway 後再試。",
"resize": "調整檔案預覽寬度",
"truncated": "檔案較大,目前只顯示前半部分預覽。"
},
"code": {
"fallbackLanguage": "程式碼",
"copyAria": "複製程式碼",

View File

@ -38,6 +38,10 @@ export type TurnUnit =
| { type: "activity"; messages: UIMessage[]; items: ActivityItem[]; turnLatencyMs?: number }
| { type: "message"; message: UIMessage };
interface NormalizeActivityTimelineOptions {
preserveTrailingActivity?: boolean;
}
export function isReasoningOnlyAssistant(message: UIMessage): boolean {
if (message.role !== "assistant" || message.kind === "trace") return false;
if (message.content.trim().length > 0) return false;
@ -48,24 +52,30 @@ export function isAgentActivityMember(message: UIMessage): boolean {
return isReasoningOnlyAssistant(message) || message.kind === "trace";
}
export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
export function normalizeActivityTimeline(
messages: UIMessage[],
options: NormalizeActivityTimelineOptions = {},
): TurnUnit[] {
const units: TurnUnit[] = [];
let turnMessages: UIMessage[] = [];
let activeTurnId: string | undefined;
const flushTurn = () => {
const flushTurn = (flushOptions: NormalizeActivityTimelineOptions = {}) => {
if (turnMessages.length === 0) return;
const visibleMessages = visibleMessagesForTurn(turnMessages);
const turnUnits: TurnUnit[] = [];
const orderedTurnMessages = orderMessagesByTurnSeq(turnMessages);
const visibleMessages = visibleMessagesForTurn(orderedTurnMessages);
let visibleIndex = 0;
let activityMessages: UIMessage[] = [];
const flushActivityMessages = () => {
if (!activityMessages.length) return;
pushActivityUnits(units, activityMessages, visibleMessages.slice(visibleIndex));
pushActivityUnits(turnUnits, activityMessages, visibleMessages.slice(visibleIndex));
activityMessages = [];
};
for (const message of turnMessages) {
for (const message of orderedTurnMessages) {
if (isAgentActivityMember(message)) {
activityMessages.push(message);
continue;
@ -74,34 +84,87 @@ export function normalizeActivityTimeline(messages: UIMessage[]): TurnUnit[] {
if (assistantHasInlineReasoning(message)) {
activityMessages.push(reasoningOnlyMessageFromAnswer(message));
flushActivityMessages();
units.push({ type: "message", message: stripInlineReasoning(message) });
turnUnits.push({ type: "message", message: stripInlineReasoning(message) });
visibleIndex += 1;
continue;
}
flushActivityMessages();
units.push({ type: "message", message });
turnUnits.push({ type: "message", message });
visibleIndex += 1;
}
flushActivityMessages();
units.push(...normalizeCompletedTurnUnits(turnUnits, flushOptions));
turnMessages = [];
activeTurnId = undefined;
};
for (const message of messages) {
if (message.role === "user") {
flushTurn();
units.push({ type: "message", message });
activeTurnId = message.turnId;
continue;
}
if (message.turnId && activeTurnId && message.turnId !== activeTurnId) {
flushTurn();
}
if (message.turnId) {
activeTurnId = message.turnId;
}
turnMessages.push(message);
}
flushTurn();
flushTurn(options);
return units;
}
function orderMessagesByTurnSeq(messages: UIMessage[]): UIMessage[] {
if (
messages.length < 2
|| !messages.every((message) => Number.isFinite(message.turnSeq))
) {
return messages;
}
return messages
.map((message, index) => ({ message, index }))
.sort((left, right) => {
const bySeq = (left.message.turnSeq ?? 0) - (right.message.turnSeq ?? 0);
return bySeq || left.index - right.index;
})
.map(({ message }) => message);
}
function normalizeCompletedTurnUnits(
turnUnits: TurnUnit[],
options: NormalizeActivityTimelineOptions,
): TurnUnit[] {
if (options.preserveTrailingActivity || turnUnits.length < 2) return turnUnits;
if (turnUnits[turnUnits.length - 1]?.type !== "activity") return turnUnits;
let trailingStart = turnUnits.length - 1;
while (trailingStart > 0 && turnUnits[trailingStart - 1]?.type === "activity") {
trailingStart -= 1;
}
const previous = turnUnits[trailingStart - 1];
if (
!previous
|| previous.type !== "message"
|| previous.message.role !== "assistant"
) {
return turnUnits;
}
return [
...turnUnits.slice(0, trailingStart - 1),
...turnUnits.slice(trailingStart),
previous,
];
}
function visibleMessagesForTurn(messages: UIMessage[]): UIMessage[] {
const visibleMessages: UIMessage[] = [];
for (const message of messages) {

View File

@ -1,6 +1,7 @@
import type {
ChatSummary,
CliAppsPayload,
FilePreviewPayload,
ImageGenerationSettingsUpdate,
McpPresetsPayload,
ModelConfigurationCreate,
@ -8,9 +9,12 @@ import type {
NetworkSafetySettingsUpdate,
ProviderModelsPayload,
ProviderSettingsUpdate,
SessionAutomationsPayload,
SettingsPayload,
SettingsUpdate,
SidebarStatePayload,
SkillDetail,
SkillsPayload,
SlashCommand,
WebSearchSettingsUpdate,
WorkspacesPayload,
@ -134,6 +138,60 @@ export async function fetchWebuiThread(
return (await res.json()) as WebuiThreadPersistedPayload;
}
export async function fetchFilePreview(
token: string,
key: string,
path: string,
base: string = "",
): Promise<FilePreviewPayload> {
const query = new URLSearchParams();
query.set("path", path);
return request<FilePreviewPayload>(
`${base}/api/sessions/${encodeURIComponent(key)}/file-preview?${query}`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchSessionAutomations(
token: string,
key: string,
base: string = "",
): Promise<SessionAutomationsPayload> {
return request<SessionAutomationsPayload>(
`${base}/api/sessions/${encodeURIComponent(key)}/automations`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchSkills(
token: string,
base: string = "",
): Promise<SkillsPayload> {
return request<SkillsPayload>(
`${base}/api/webui/skills`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchSkillDetail(
token: string,
name: string,
base: string = "",
): Promise<SkillDetail> {
return request<SkillDetail>(
`${base}/api/webui/skills/${encodeURIComponent(name)}`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function deleteSession(
token: string,
key: string,
@ -158,6 +216,18 @@ export async function fetchSettings(
);
}
export async function fetchSettingsUsage(
token: string,
base: string = "",
): Promise<NonNullable<SettingsPayload["usage"]>> {
return request<NonNullable<SettingsPayload["usage"]>>(
`${base}/api/settings/usage`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchWorkspaces(
token: string,
base: string = "",

View File

@ -336,6 +336,7 @@ export class NanobotClient {
cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[];
workspaceScope?: WorkspaceScopePayload | null;
turnId?: string;
},
): void {
this.knownChats.add(chatId);
@ -348,6 +349,7 @@ export class NanobotClient {
...(options?.cliApps?.length ? { cli_apps: options.cliApps } : {}),
...(options?.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
...(options?.turnId ? { turn_id: options.turnId } : {}),
webui: true,
};
this.queueSend(frame);

View File

@ -4,6 +4,8 @@ export type Role = "user" | "assistant" | "tool" | "system";
* progress pings) that should not be rendered as conversational replies. */
export type MessageKind = "message" | "trace";
export type UITurnPhase = "user" | "reasoning" | "activity" | "answer" | "complete";
/** One image attached to a UIMessage.
*
* ``url`` can arrive in three different shapes, which the bubble renders
@ -30,6 +32,8 @@ export interface UIMediaAttachment {
name?: string;
}
export interface UIMessageSource { kind: "cron"; label?: string; }
export interface UIMessage {
id: string;
role: Role;
@ -64,6 +68,12 @@ export interface UIMessage {
reasoningStreaming?: boolean;
/** End-to-end wall time for this assistant turn (persisted ``latency_ms`` / ``turn_end``). */
latencyMs?: number;
/** Lightweight provenance for proactive assistant messages. */
source?: UIMessageSource;
/** Stable protocol metadata for grouping all activity emitted by one user turn. */
turnId?: string;
turnPhase?: UITurnPhase;
turnSeq?: number;
}
export interface UICliAppAttachment {
@ -86,6 +96,50 @@ export interface UIMcpPresetAttachment {
brand_color?: string | null;
}
export interface SessionAutomationJob {
id: string;
name: string;
enabled: boolean;
schedule: {
kind: "at" | "every" | "cron" | string;
at_ms?: number | null;
every_ms?: number | null;
expr?: string | null;
tz?: string | null;
};
payload: {
message: string;
};
state: {
next_run_at_ms?: number | null;
last_status?: "ok" | "error" | "skipped" | string | null;
};
}
export interface SessionAutomationsPayload { jobs: SessionAutomationJob[]; }
export interface SkillSummary {
name: string;
description: string;
source: "workspace" | "builtin" | string;
available: boolean;
unavailable_reason?: string;
}
export interface SkillRequirements {
bins: string[];
env: string[];
missing_bins: string[];
missing_env: string[];
}
export interface SkillDetail extends SkillSummary {
requirements: SkillRequirements;
raw_markdown: string;
}
export interface SkillsPayload { skills: SkillSummary[]; }
/** Structured UI blob on ``progress`` WS frames; channels may add more ``kind`` values later. */
export interface AgentUIBlob {
kind: string;
@ -352,6 +406,43 @@ export interface SettingsPayload {
};
unified_session: boolean;
};
usage?: {
days: Array<{
date: string;
prompt_tokens: number;
completion_tokens: number;
cached_tokens: number;
total_tokens: number;
provider_tokens?: number;
estimated_tokens?: number;
requests: number;
provider_requests?: number;
estimated_requests?: number;
sources?: Record<
"user" | "api" | "cron" | "dream" | "system" | string,
{
prompt_tokens: number;
completion_tokens: number;
cached_tokens: number;
total_tokens: number;
provider_tokens?: number;
estimated_tokens?: number;
requests: number;
provider_requests?: number;
estimated_requests?: number;
}
>;
}>;
total_tokens: number;
total_tokens_30d: number;
total_tokens_365d: number;
peak_day_tokens: number;
current_streak_days: number;
longest_streak_days: number;
active_days_30d: number;
requests_30d: number;
updated_at?: string | null;
};
advanced: {
restrict_to_workspace: boolean;
workspace_sandbox?: {
@ -605,10 +696,16 @@ export type ConnectionStatus =
| "closed"
| "error";
export interface InboundTurnMetadata {
turn_id?: string;
turn_phase?: UITurnPhase;
turn_seq?: number;
}
export type InboundEvent =
| { event: "ready"; chat_id: string; client_id: string }
| { event: "attached"; chat_id: string }
| {
| ({
event: "message";
chat_id: string;
text: string;
@ -621,49 +718,51 @@ export type InboundEvent =
kind?: "tool_hint" | "progress" | "reasoning";
/** Server-measured turn wall time when this frame finishes an assistant reply. */
latency_ms?: number;
/** Lightweight provenance for proactive assistant messages. */
source?: UIMessageSource;
/** Optional structured payload on progress frames (channel-specific). */
agent_ui?: AgentUIBlob;
}
| {
} & InboundTurnMetadata)
| ({
event: "file_edit";
chat_id: string;
edits: UIFileEdit[];
}
| {
} & InboundTurnMetadata)
| ({
event: "delta";
chat_id: string;
text: string;
stream_id?: string;
}
| {
} & InboundTurnMetadata)
| ({
event: "stream_end";
chat_id: string;
stream_id?: string;
text?: string;
}
| {
} & InboundTurnMetadata)
| ({
event: "reasoning_delta";
chat_id: string;
text: string;
stream_id?: string;
}
| {
} & InboundTurnMetadata)
| ({
event: "reasoning_end";
chat_id: string;
stream_id?: string;
}
} & InboundTurnMetadata)
| {
event: "runtime_model_updated";
model_name: string;
model_preset?: string | null;
}
| {
| ({
event: "turn_end";
chat_id: string;
latency_ms?: number;
/** Authoritative sustained-goal snapshot for this chat (same shape as ``goal_state`` events). */
goal_state?: GoalStateWsPayload;
}
} & InboundTurnMetadata)
| {
event: "goal_status";
chat_id: string;
@ -732,6 +831,16 @@ export interface WebuiThreadPersistedPayload {
workspace_scope?: WorkspaceScopePayload;
}
export interface FilePreviewPayload {
path: string;
display_path: string;
project_path: string;
language: string;
content: string;
size: number;
truncated: boolean;
}
export type Outbound =
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
| { type: "attach"; chat_id: string }
@ -745,6 +854,7 @@ export type Outbound =
cli_apps?: OutboundCliAppMention[];
mcp_presets?: OutboundMcpPresetMention[];
workspace_scope?: WorkspaceScopePayload;
turn_id?: string;
/** Marks messages sent by the embedded WebUI, without changing the
* generic websocket protocol for other clients. */
webui?: true;

View File

@ -3,10 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createModelConfiguration,
deleteSession,
fetchFilePreview,
fetchCliApps,
fetchMcpPresets,
fetchProviderModels,
fetchSessionAutomations,
fetchSettingsUsage,
fetchSidebarState,
fetchSkillDetail,
fetchSkills,
fetchWebuiThread,
fetchWorkspaces,
importMcpConfig,
@ -55,6 +60,51 @@ describe("webui API helpers", () => {
);
});
it("percent-encodes websocket keys and paths when fetching file previews", async () => {
await fetchFilePreview("tok", "websocket:chat-1", "/tmp/project/hook.py:12");
expect(fetch).toHaveBeenCalledWith(
"/api/sessions/websocket%3Achat-1/file-preview?path=%2Ftmp%2Fproject%2Fhook.py%3A12",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
credentials: "same-origin",
}),
);
});
it("percent-encodes websocket keys when fetching session automations", async () => {
await fetchSessionAutomations("tok", "websocket:chat-1");
expect(fetch).toHaveBeenCalledWith(
"/api/sessions/websocket%3Achat-1/automations",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("fetches the WebUI skill summary", async () => {
await fetchSkills("tok");
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("percent-encodes skill names when fetching skill details", async () => {
await fetchSkillDetail("tok", "current web");
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/current%20web",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("percent-encodes websocket keys when deleting a session", async () => {
await deleteSession("tok", "websocket:chat-1");
@ -86,6 +136,17 @@ describe("webui API helpers", () => {
);
});
it("fetches token usage through the lightweight settings endpoint", async () => {
await fetchSettingsUsage("tok");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/usage",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes model configuration creation", async () => {
await createModelConfiguration("tok", {
label: "Fast writing",

View File

@ -30,6 +30,18 @@ function jsonResponse(body: unknown): Response {
} as Response;
}
function mockFetchRoutes(routes: Record<string, unknown>): void {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const body = routes[String(input)];
return body === undefined
? ({ ok: false, status: 404, json: async () => ({}) } as Response)
: jsonResponse(body);
}),
);
}
function baseSettingsPayload() {
return {
agent: {
@ -208,6 +220,7 @@ describe("App layout", () => {
runStatusHandlers.clear();
window.history.replaceState(null, "", "/");
setNavigatorPlatform("Linux x86_64");
localStorage.removeItem("nanobot-webui.sidebar");
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
token: "tok",
@ -243,6 +256,129 @@ describe("App layout", () => {
expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true);
});
it("opens Skills from the main sidebar", async () => {
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
"/api/settings/mcp-presets": { presets: [], installed_count: 0 },
"/api/webui/skills": {
skills: [
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
{
name: "github",
description: "Work with GitHub.",
source: "builtin",
available: false,
unavailable_reason: "CLI: gh",
},
],
},
"/api/webui/skills/github": {
name: "github",
description: "Work with GitHub.",
source: "builtin",
available: false,
unavailable_reason: "CLI: gh",
requirements: {
bins: ["gh"],
env: [],
missing_bins: ["gh"],
missing_env: [],
},
raw_markdown: "---\nname: github\n---\nUse GitHub CLI.",
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const skillsButton = within(sidebar).getByRole("button", { name: "Skills" });
fireEvent.click(skillsButton);
expect(await screen.findByRole("heading", { name: "Skills" })).toBeInTheDocument();
expect(screen.getByText("cron")).toBeInTheDocument();
expect(screen.getByText("github")).toBeInTheDocument();
expect(screen.getByText("Missing: CLI: gh")).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument();
expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument();
expect(within(sidebar).getByRole("button", { name: "Skills" })).toHaveAttribute(
"aria-current",
"page",
);
expect(document.title).toBe("Skills · nanobot");
fireEvent.click(screen.getByRole("button", { name: "Back to chat" }));
expect(await screen.findByText(HERO_GREETING_PATTERN)).toBeInTheDocument();
fireEvent.click(within(sidebar).getByRole("button", { name: "Skills" }));
expect(await screen.findByRole("heading", { name: "Skills" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open details for github" }));
expect(await screen.findByRole("heading", { name: "github" })).toBeInTheDocument();
expect(screen.getByText("Unavailable reason")).toBeInTheDocument();
expect(screen.getAllByText("CLI: gh").length).toBeGreaterThan(0);
expect(screen.getByText("Missing CLI")).toBeInTheDocument();
fireEvent.click(screen.getByText("Raw SKILL.md"));
expect(screen.getByText(/Use GitHub CLI/)).toBeInTheDocument();
});
it("fully collapses the native host sidebar and previews it on hover", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Desktop chat",
},
];
vi.mocked(fetchBootstrap).mockResolvedValue({
token: "tok",
ws_path: "/",
expires_in: 300,
runtime_surface: "native",
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const flowSidebar = screen.getByTestId("host-sidebar-flow");
const toggle = screen.getByTestId("host-sidebar-toggle");
expect(flowSidebar).toHaveStyle({ width: "272px" });
expect(
screen.getByRole("navigation", { name: "Sidebar navigation" }),
).toBeInTheDocument();
fireEvent.click(toggle);
await waitFor(() => expect(flowSidebar).toHaveStyle({ width: "0px" }));
expect(
screen.queryByRole("navigation", { name: "Sidebar navigation" }),
).not.toBeInTheDocument();
fireEvent.mouseEnter(toggle);
const previewSidebar = await screen.findByTestId("host-sidebar-preview");
expect(flowSidebar).toHaveStyle({ width: "0px" });
expect(previewSidebar).toHaveStyle({ width: "272px" });
expect(
within(previewSidebar).getByRole("navigation", {
name: "Sidebar navigation",
}),
).toBeInTheDocument();
fireEvent.click(toggle);
await waitFor(() =>
expect(screen.queryByTestId("host-sidebar-preview")).not.toBeInTheDocument(),
);
expect(flowSidebar).toHaveStyle({ width: "272px" });
expect(
screen.getByRole("navigation", { name: "Sidebar navigation" }),
).toBeInTheDocument();
});
it("switches to the next session when deleting the active chat", async () => {
mockSessions = [
{
@ -907,7 +1043,6 @@ describe("App layout", () => {
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
expect(document.title).toBe("Settings · nanobot");
expect(screen.getByTestId("overview-nanobot-logo")).toBeInTheDocument();
expect(screen.getByTestId("overview-logo-openai")).toBeInTheDocument();
expect(screen.getByTestId("overview-logo-brave")).toBeInTheDocument();
expect(screen.getByTestId("overview-logo-openrouter")).toBeInTheDocument();
@ -1036,15 +1171,7 @@ describe("App layout", () => {
});
it("restores the settings section from the URL hash after a page reload", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
if (String(input) === "/api/settings") {
return jsonResponse(baseSettingsPayload());
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
window.history.replaceState(null, "", "/#/settings?section=models");
render(<App />);
@ -1055,15 +1182,7 @@ describe("App layout", () => {
});
it("updates the URL hash when switching settings sections", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
if (String(input) === "/api/settings") {
return jsonResponse(baseSettingsPayload());
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
render(<App />);
@ -1081,22 +1200,11 @@ describe("App layout", () => {
});
it("opens Apps from the main sidebar without replacing the sidebar", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const href = String(input);
if (href === "/api/settings") {
return jsonResponse(baseSettingsPayload());
}
if (href === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" });
}
if (href === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
"/api/settings/mcp-presets": { presets: [], installed_count: 0 },
});
render(<App />);

View File

@ -51,6 +51,25 @@ describe("CodeBlock", () => {
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("text-foreground/90");
});
it("can render without chat-style chrome for file previews", () => {
render(
<ThemeProvider theme="light">
<CodeBlock
language="html"
code="<main />"
chrome="none"
highlight={false}
showLineNumbers
/>
</ThemeProvider>,
);
expect(screen.queryByText("html")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /copy/i })).not.toBeInTheDocument();
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByTestId("plain-code-fallback")).toHaveClass("bg-transparent");
});
it("falls back to 'text' language when language is undefined", async () => {
render(
<ThemeProvider theme="dark">

View File

@ -1,5 +1,5 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import MarkdownTextRenderer from "@/components/MarkdownTextRenderer";
@ -12,6 +12,67 @@ describe("MarkdownTextRenderer", () => {
expect(link).toHaveClass("text-blue-500", "dark:text-blue-300");
});
it("renders local file links as previewable file references", () => {
const onOpenFilePreview = vi.fn();
render(
<MarkdownTextRenderer onOpenFilePreview={onOpenFilePreview}>
{"Edited [hook.py](/Users/test/project/nanobot/agent/hook.py:12)"}
</MarkdownTextRenderer>,
);
const reference = screen.getByTestId("inline-file-path");
expect(reference).toHaveTextContent("hook.py");
expect(reference).toHaveAttribute(
"aria-label",
"/Users/test/project/nanobot/agent/hook.py",
);
fireEvent.click(reference);
expect(onOpenFilePreview).toHaveBeenCalledWith(
"/Users/test/project/nanobot/agent/hook.py",
);
});
it("does not treat non-file hrefs as previews just because the label looks like a file", () => {
const onOpenFilePreview = vi.fn();
render(
<MarkdownTextRenderer onOpenFilePreview={onOpenFilePreview}>
{"Download [index.html](/api/media/sig/html)"}
</MarkdownTextRenderer>,
);
expect(screen.queryByTestId("inline-file-path")).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "index.html" })).toHaveAttribute(
"href",
"/api/media/sig/html",
);
});
it("renders glob file links as plain text instead of preview targets", () => {
const onOpenFilePreview = vi.fn();
const { container } = render(
<MarkdownTextRenderer onOpenFilePreview={onOpenFilePreview}>
{"原始对话通常还在 [*.json](*.json)。"}
</MarkdownTextRenderer>,
);
expect(screen.queryByTestId("inline-file-path")).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "*.json" })).not.toBeInTheDocument();
expect(container).toHaveTextContent("*.json");
});
it("keeps glob inline code as code instead of a file preview chip", () => {
render(
<MarkdownTextRenderer>
{"检查 `src/**/*.json`。"}
</MarkdownTextRenderer>,
);
expect(screen.queryByTestId("inline-file-path")).not.toBeInTheDocument();
expect(screen.getByText("src/**/*.json").tagName).toBe("CODE");
});
it("does not wrap complete fenced code blocks in an extra pre", () => {
const { container } = render(
<MarkdownTextRenderer highlightCode={false}>
@ -117,6 +178,42 @@ describe("MarkdownTextRenderer", () => {
).toHaveAttribute("href", "https://polymarket.com/event/when-will-gpt-5pt6-be-released");
});
it("falls back through favicon sources before showing a globe for compact link rows", () => {
const { container } = render(
<MarkdownTextRenderer>
{
"Useful links:\n\n- Savills Hong Kong Corporate Relocation — Corporate relocation services\n https://www.savills.com.hk/services/corporate-relocation.aspx"
}
</MarkdownTextRenderer>,
);
const link = screen.getByRole("link", {
name: "Open link: Savills Hong Kong Corporate Relocation — Corporate relocation services",
});
const favicon = () => link.querySelector("img");
expect(favicon()).toHaveAttribute(
"src",
"https://www.savills.com.hk/favicon.ico",
);
fireEvent.error(favicon()!);
expect(favicon()).toHaveAttribute(
"src",
"https://icons.duckduckgo.com/ip3/www.savills.com.hk.ico",
);
fireEvent.error(favicon()!);
expect(favicon()).toHaveAttribute(
"src",
"https://www.google.com/s2/favicons?domain=www.savills.com.hk&sz=64",
);
fireEvent.error(favicon()!);
expect(favicon()).not.toBeInTheDocument();
expect(link.querySelector("svg")).toBeInTheDocument();
expect(container).not.toHaveTextContent("SC");
});
it("renders media attachments without an extra preview/code wrapper", () => {
render(<MarkdownTextRenderer>![Diagram](/api/media/sig/payload)</MarkdownTextRenderer>);

View File

@ -101,6 +101,22 @@ describe("MessageBubble", () => {
expect(screen.getByText(/not @krita/)).toBeInTheDocument();
});
it("renders a lightweight automation source label for cron replies", () => {
const message: UIMessage = {
id: "a-cron",
role: "assistant",
content: "Time to drink water.",
source: { kind: "cron", label: "drink water" },
createdAt: Date.now(),
};
render(<MessageBubble message={message} />);
expect(screen.getByText("drink water")).toBeInTheDocument();
expect(screen.getByText("Triggered automatically")).toBeInTheDocument();
expect(screen.getByText("Time to drink water.")).toBeInTheDocument();
});
it("renders structured CLI app attachments even without the installed catalog", () => {
const message: UIMessage = {
id: "u-cli-attached",

View File

@ -429,6 +429,24 @@ describe("NanobotClient", () => {
);
});
it("includes an explicit turn id on outbound WebUI messages", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-x", "hello", undefined, { turnId: "turn-1" });
expect(JSON.parse(lastSocket().sent.at(-1) as string)).toEqual({
type: "message",
chat_id: "chat-x",
content: "hello",
turn_id: "turn-1",
webui: true,
});
});
it("includes image generation options in outbound messages", () => {
const client = new NanobotClient({
url: "ws://test",

View File

@ -0,0 +1,117 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
import { setAppLanguage } from "@/i18n";
function automationJob(nextRunAt = Date.now() + 3_600_000) {
return {
id: "job-1",
name: "Morning check",
enabled: true,
schedule: { kind: "every", every_ms: 3_600_000 },
payload: { message: "Check the project status" },
state: { next_run_at_ms: nextRunAt },
};
}
function automationsResponse(jobs: unknown[]) {
return {
ok: true,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({
jobs,
}),
} as Response;
}
describe("SessionInfoPopover", () => {
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(automationsResponse([automationJob()])),
);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("loads and displays session automations when opened", async () => {
const user = userEvent.setup();
render(
<SessionInfoPopover
sessionKey="websocket:chat-1"
token="tok"
title="Release work"
/>,
);
await user.click(screen.getByRole("button", { name: "Session details" }));
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
"/api/sessions/websocket%3Achat-1/automations",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
expect(await screen.findByText("Morning check")).toBeInTheDocument();
expect(screen.getByText("Check the project status")).toBeInTheDocument();
});
it("localizes the panel chrome in Simplified Chinese", async () => {
await setAppLanguage("zh-CN");
const user = userEvent.setup();
render(
<SessionInfoPopover
sessionKey="websocket:chat-1"
token="tok"
title="@hyperframes 使用指南"
/>,
);
await user.click(screen.getByRole("button", { name: "会话详情" }));
expect(await screen.findByText("会话")).toBeInTheDocument();
expect(screen.getByText("自动任务")).toBeInTheDocument();
expect(screen.getByText("Morning check")).toBeInTheDocument();
expect(screen.getByText(/下次/)).toBeInTheDocument();
expect(screen.queryByText("Session")).not.toBeInTheDocument();
expect(screen.queryByText("Automations")).not.toBeInTheDocument();
});
it("refreshes while open so completed one-shot automations disappear", async () => {
vi.stubGlobal(
"fetch",
vi.fn()
.mockResolvedValueOnce(automationsResponse([automationJob(Date.now() + 1000)]))
.mockResolvedValue(automationsResponse([])),
);
const user = userEvent.setup();
render(
<SessionInfoPopover
sessionKey="websocket:chat-1"
token="tok"
title="Release work"
/>,
);
await user.click(screen.getByRole("button", { name: "Session details" }));
expect(await screen.findByText("Morning check")).toBeInTheDocument();
await waitFor(
() => {
expect(screen.queryByText("Morning check")).not.toBeInTheDocument();
},
{ timeout: 4500 },
);
expect(screen.getByText("No automations in this session yet.")).toBeInTheDocument();
}, 8000);
});

View File

@ -118,8 +118,9 @@ const installedAnyGen = {
function renderSettingsView(
options: {
initialSection?: "apps" | "advanced" | "models";
initialSection?: "overview" | "apps" | "advanced" | "models";
onSettingsChange?: (payload: SettingsPayload) => void;
onNativeEngineRestart?: () => Promise<string>;
} = {},
) {
render(
@ -131,6 +132,7 @@ function renderSettingsView(
onBackToChat={() => {}}
onModelNameChange={() => {}}
onSettingsChange={options.onSettingsChange}
onNativeEngineRestart={options.onNativeEngineRestart}
/>
</ClientProvider>,
);
@ -219,6 +221,55 @@ describe("SettingsView Apps catalog", () => {
await waitFor(() => expect(onSettingsChange).toHaveBeenCalledWith(payload));
});
it("shows token activity on the overview", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
usage: {
days: [
{
date: "2026-06-03",
prompt_tokens: 1200,
completion_tokens: 300,
cached_tokens: 500,
total_tokens: 1500,
requests: 2,
},
],
total_tokens: 1500,
total_tokens_30d: 1500,
total_tokens_365d: 1500,
peak_day_tokens: 1500,
current_streak_days: 1,
longest_streak_days: 1,
active_days_30d: 1,
requests_30d: 2,
updated_at: "2026-06-03T00:00:00Z",
},
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "overview" });
expect(await screen.findByLabelText("Token activity")).toBeInTheDocument();
expect(screen.getByText("Token Usage")).toBeInTheDocument();
expect(screen.queryByText("Token activity")).not.toBeInTheDocument();
expect(screen.queryByText("Total tokens")).not.toBeInTheDocument();
expect(screen.queryByText("Peak tokens")).not.toBeInTheDocument();
});
it("shows context window options in model settings", async () => {
vi.stubGlobal(
"fetch",
@ -242,6 +293,280 @@ describe("SettingsView Apps catalog", () => {
expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument();
});
it("marks the current model as unconfigured when its provider needs setup", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
agent: {
...settingsPayload().agent,
model: "openai-codex/gpt-5.1-codex",
provider: "openai_codex",
resolved_provider: "openai_codex",
has_api_key: false,
},
model_presets: [
{
...settingsPayload().model_presets[0],
model: "openai-codex/gpt-5.1-codex",
provider: "openai_codex",
},
],
providers: [
{
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: null,
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
},
],
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "models" });
const configurationButton = await screen.findByRole("button", {
name: "Current configuration",
});
expect(configurationButton).toHaveTextContent("Not configured");
expect(configurationButton).toHaveTextContent("OpenAI Codex · openai-codex/gpt-5.1-codex");
expect(await screen.findByRole("button", { name: "Sign in" })).toBeInTheDocument();
});
it("keeps unsigned OAuth providers out of the active provider picker", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
agent: {
...settingsPayload().agent,
model: "deepseek-chat",
provider: "deepseek",
resolved_provider: "deepseek",
},
model_presets: [
{
...settingsPayload().model_presets[0],
model: "deepseek-chat",
provider: "deepseek",
},
],
providers: [
{
name: "deepseek",
label: "DeepSeek",
configured: true,
auth_type: "api_key",
api_key_required: true,
api_key_hint: "sk-...",
api_base: "https://api.deepseek.com",
default_api_base: "https://api.deepseek.com",
},
{
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: null,
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
},
{
name: "github_copilot",
label: "GitHub Copilot",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://api.githubcopilot.com",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
},
],
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "models" });
const deepseekButtons = await screen.findAllByRole("button", { name: /DeepSeek/ });
const providerPicker = deepseekButtons.find(
(button) => button.getAttribute("aria-haspopup") === "menu",
);
if (!providerPicker) throw new Error("provider picker was not found");
fireEvent.pointerDown(providerPicker);
expect(await screen.findByRole("menuitem", { name: /DeepSeek/ })).toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: /OpenAI Codex/ })).not.toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: /GitHub Copilot/ })).not.toBeInTheDocument();
});
it("does not fetch model lists for unsigned OAuth providers", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
agent: {
...settingsPayload().agent,
model: "",
provider: "openai_codex",
resolved_provider: "openai_codex",
},
model_presets: [
{
...settingsPayload().model_presets[0],
model: "",
provider: "openai_codex",
},
],
providers: [
{
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: null,
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
},
{
name: "github_copilot",
label: "GitHub Copilot",
configured: false,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://api.githubcopilot.com",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models" });
fireEvent.pointerDown(await screen.findByRole("button", { name: /Select model/i }));
expect(
await screen.findByText("Configure this provider before loading models."),
).toBeInTheDocument();
expect(
fetchMock.mock.calls.some(([input]) =>
String(input).startsWith("/api/settings/provider-models"),
),
).toBe(false);
});
it("prefills manual model ids for configured OAuth providers", async () => {
const payload: SettingsPayload = {
...settingsPayload(),
agent: {
...settingsPayload().agent,
model: "open-codex/gpt-5.5",
provider: "openai_codex",
resolved_provider: "openai_codex",
},
model_presets: [
{
...settingsPayload().model_presets[0],
model: "open-codex/gpt-5.5",
provider: "openai_codex",
},
],
providers: [
{
name: "openai_codex",
label: "OpenAI Codex",
configured: true,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: null,
oauth_account: "acct-test",
oauth_expires_at: null,
oauth_login_supported: true,
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models" });
const modelButtons = await screen.findAllByRole("button", { name: /open-codex\/gpt-5\.5/i });
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
const input = (await screen.findByPlaceholderText("Search or type model ID")) as HTMLInputElement;
expect(input.value).toBe("open-codex/gpt-5.5");
fireEvent.change(input, { target: { value: "openai-codex/gpt-5.5" } });
expect(await screen.findByText("“openai-codex/gpt-5.5”")).toBeInTheDocument();
expect(
fetchMock.mock.calls.some(([input]) =>
String(input).startsWith("/api/settings/provider-models"),
),
).toBe(false);
});
it("can close the new configuration dialog without trapping the settings page", async () => {
vi.stubGlobal(
"fetch",
@ -443,4 +768,64 @@ describe("SettingsView Apps catalog", () => {
expect(screen.queryByText("Web safety")).not.toBeInTheDocument();
expect(screen.getByText("Allow Full Access shell commands to reach services on this Mac.")).toBeInTheDocument();
});
it("refreshes settings with a fresh token after native engine restart", async () => {
const payload = {
...settingsPayload(),
surface: "native" as const,
runtime_surface: "native" as const,
runtime_capabilities: {
can_restart_engine: true,
can_pick_folder: true,
can_open_logs: true,
can_export_diagnostics: true,
},
};
const restartedPayload = {
...payload,
advanced: { ...payload.advanced, webui_allow_local_service_access: false },
requires_restart: true,
restart_required_sections: ["runtime"],
};
const refreshedPayload = {
...restartedPayload,
requires_restart: false,
restart_required_sections: [],
};
const restartEngine = vi.fn(async () => "fresh-token");
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const auth = (init?.headers as Record<string, string> | undefined)?.Authorization;
if (url === "/api/settings" && auth === "Bearer fresh-token") {
return jsonResponse(refreshedPayload);
}
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
if (url === "/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=default") {
return jsonResponse(restartedPayload);
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({
initialSection: "advanced",
onNativeEngineRestart: restartEngine,
});
expect(await screen.findByText("App safety")).toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: "Local services" }));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(restartEngine).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings",
expect.objectContaining({
headers: { Authorization: "Bearer fresh-token" },
}),
),
);
});
});

View File

@ -151,7 +151,7 @@ describe("ThreadMessages", () => {
]);
});
it("renders a later tool segment after the visible answer that preceded it", () => {
it("moves orphan trailing activity before the completed assistant answer", () => {
const messages: UIMessage[] = [
{
id: "r1",
@ -182,14 +182,14 @@ describe("ThreadMessages", () => {
expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
expect(units[1]).toMatchObject({
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
expect(units[2]).toMatchObject({
type: "message",
message: {
id: "a1",
content: "Let me search the latest data.",
},
});
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
});
it("only marks the current activity timeline as live while streaming", () => {
@ -324,7 +324,7 @@ describe("ThreadMessages", () => {
},
];
const units = buildDisplayUnits(messages);
const units = buildDisplayUnits(messages, true);
expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["t0"]);
@ -344,7 +344,7 @@ describe("ThreadMessages", () => {
expect(answer.compareDocumentPosition(liveActivity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("keeps late activity after a completed assistant answer", () => {
it("moves late activity before a completed assistant answer", () => {
const messages: UIMessage[] = [
{
id: "r1",
@ -376,21 +376,164 @@ describe("ThreadMessages", () => {
expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
expect(units[1]).toMatchObject({
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
expect(units[2]).toMatchObject({
type: "message",
message: {
id: "a1",
content: "Hong Kong is hot today.",
},
});
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
render(<ThreadMessages messages={messages} isStreaming={false} />);
const answer = screen.getByText("Hong Kong is hot today.");
const laterActivity = screen.getAllByText(/thought/i).at(-1);
expect(laterActivity).toBeTruthy();
expect(answer.compareDocumentPosition(laterActivity!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(laterActivity!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("does not leave a completed web-search thought below the final answer", () => {
const messages: UIMessage[] = [
{
id: "user",
role: "user",
content: "最近科隆major开打了你知道不",
createdAt: 1,
},
{
id: "thought",
role: "assistant",
content: "",
reasoning: "I should verify the current event details.",
activitySegmentId: "seg-major",
createdAt: 2,
},
{
id: "answer",
role: "assistant",
content: "知道IEM Cologne Major 2026 今天开打了。",
latencyMs: 18_000,
createdAt: 3,
},
{
id: "web",
role: "tool",
kind: "trace",
content: "Searching query: 2026 Cologne Major esports started 科隆 Major 开打了 2026",
traces: ["Searching query: 2026 Cologne Major esports started 科隆 Major 开打了 2026"],
activitySegmentId: "seg-major",
createdAt: 4,
},
];
render(<ThreadMessages messages={messages} isStreaming={false} />);
const thought = screen.getAllByText(/thought/i).at(-1);
const answer = screen.getByText("知道IEM Cologne Major 2026 今天开打了。");
expect(thought).toBeTruthy();
expect(thought!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("normalizes completed prior turns while the next user turn is streaming", () => {
const messages: UIMessage[] = [
{
id: "thought",
role: "assistant",
content: "",
reasoning: "I should verify the current event details.",
activitySegmentId: "seg-major",
createdAt: 1,
},
{
id: "answer",
role: "assistant",
content: "Yep — IEM Cologne Major 2026 is in Cologne.",
latencyMs: 20_000,
createdAt: 2,
},
{
id: "web",
role: "tool",
kind: "trace",
content: "Searching query: site:counter-strike.net majors 2026",
traces: ["Searching query: site:counter-strike.net majors 2026"],
activitySegmentId: "seg-major",
createdAt: 3,
},
{
id: "next-user",
role: "user",
content: "看一下目前的赛果,整个表哥",
createdAt: 4,
},
];
const units = buildDisplayUnits(messages, true);
expect(units).toHaveLength(4);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"thought",
]);
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual([
"web",
]);
expect(units[2]).toMatchObject({
type: "message",
message: { id: "answer" },
});
expect(units[3]).toMatchObject({
type: "message",
message: { id: "next-user" },
});
});
it("orders live turn activity by causal turn sequence before the final answer", () => {
const messages: UIMessage[] = [
{
id: "web-1",
role: "tool",
kind: "trace",
content: "Searching query: 2026 Counter-Strike 2 Major location",
traces: ["Searching query: 2026 Counter-Strike 2 Major location"],
turnId: "turn-major",
turnSeq: 3,
activitySegmentId: "seg-1",
createdAt: 1,
},
{
id: "answer",
role: "assistant",
content: "Yep — IEM Cologne Major 2026 is in Cologne.",
isStreaming: true,
turnId: "turn-major",
turnSeq: 84,
createdAt: 3,
},
{
id: "web-2",
role: "tool",
kind: "trace",
content: "Searching query: site:counter-strike.net majors 2026",
traces: ["Searching query: site:counter-strike.net majors 2026"],
turnId: "turn-major",
turnSeq: 83,
activitySegmentId: "seg-2",
createdAt: 2,
},
];
const units = buildDisplayUnits(messages, true);
expect(units).toHaveLength(2);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"web-1",
"web-2",
]);
expect(units[1]).toMatchObject({
type: "message",
message: { id: "answer" },
});
});
it("renders interrupted pre-tool text as activity before the final answer", () => {
@ -509,6 +652,30 @@ describe("ThreadMessages", () => {
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
});
it("uses turn ids as activity grouping boundaries when available", () => {
const units = buildDisplayUnits([
{ id: "u1", role: "user", content: "one", turnId: "turn-1", createdAt: 1 },
{ id: "a1", role: "assistant", content: "answer one", turnId: "turn-1", createdAt: 2 },
{
id: "t2",
role: "tool",
kind: "trace",
content: "search()",
traces: ["search()"],
turnId: "turn-2",
createdAt: 3,
},
{ id: "a2", role: "assistant", content: "answer two", turnId: "turn-2", createdAt: 4 },
]);
expect(units.map((unit) => unit.type === "message" ? unit.message.id : "activity")).toEqual([
"u1",
"a1",
"activity",
"a2",
]);
});
it("computes final assistant copy flags with user-boundary semantics", () => {
const units = buildDisplayUnits([
{ id: "u1", role: "user", content: "one", createdAt: 1 },

Some files were not shown because too many files have changed in this diff Show More