mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 14:23:58 +00:00
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:
parent
a1b9577224
commit
ab9f49970d
9
.gitignore
vendored
9
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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
129
desktop/README.md
Normal 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
594
desktop/bun.lock
Normal 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
116
desktop/docs/development.md
Normal 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.
|
||||
94
desktop/docs/host-contract.md
Normal file
94
desktop/docs/host-contract.md
Normal 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.
|
||||
71
desktop/docs/webui-sync.md
Normal file
71
desktop/docs/webui-sync.md
Normal 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
55
desktop/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
265
desktop/scripts/prepare-engine.mjs
Normal file
265
desktop/scripts/prepare-engine.mjs
Normal 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
783
desktop/src/main.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
159
desktop/src/notifications.ts
Normal file
159
desktop/src/notifications.ts
Normal 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
55
desktop/src/preload.cts
Normal 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);
|
||||
},
|
||||
});
|
||||
208
desktop/src/unixWebSocket.ts
Normal file
208
desktop/src/unixWebSocket.ts
Normal 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
16
desktop/tsconfig.json
Normal 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/**/*"]
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>',
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ")
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
137
nanobot/webui/file_preview.py
Normal file
137
nanobot/webui/file_preview.py
Normal 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")
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
56
nanobot/webui/session_automations.py
Normal file
56
nanobot/webui/session_automations.py
Normal 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,
|
||||
},
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
61
nanobot/webui/skills_api.py
Normal file
61
nanobot/webui/skills_api.py
Normal 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
|
||||
357
nanobot/webui/token_usage.py
Normal file
357
nanobot/webui/token_usage.py
Normal 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")
|
||||
@ -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}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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",
|
||||
[],
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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"})
|
||||
|
||||
@ -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,
|
||||
|
||||
148
tests/webui/test_token_usage.py
Normal file
148
tests/webui/test_token_usage.py
Normal 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
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
287
webui/src/components/FilePreviewPanel.tsx
Normal file
287
webui/src/components/FilePreviewPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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":
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
417
webui/src/components/settings/SkillsCatalogSettings.tsx
Normal file
417
webui/src/components/settings/SkillsCatalogSettings.tsx
Normal 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;
|
||||
}
|
||||
224
webui/src/components/settings/TokenUsageHeatmap.tsx
Normal file
224
webui/src/components/settings/TokenUsageHeatmap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
149
webui/src/components/thread/PromptNavigator.tsx
Normal file
149
webui/src/components/thread/PromptNavigator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
|
||||
224
webui/src/components/thread/SessionInfoPopover.tsx
Normal file
224
webui/src/components/thread/SessionInfoPopover.tsx
Normal 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);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
64
webui/src/components/thread/promptNavigation.ts
Normal file
64
webui/src/components/thread/promptNavigation.ts
Normal 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;
|
||||
}
|
||||
@ -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 };
|
||||
|
||||
@ -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))) {
|
||||
|
||||
@ -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],
|
||||
);
|
||||
|
||||
61
webui/src/hooks/useSessionAutomationJobs.ts
Normal file
61
webui/src/hooks/useSessionAutomationJobs.ts
Normal 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 };
|
||||
}
|
||||
20
webui/src/hooks/useSkills.ts
Normal file
20
webui/src/hooks/useSkills.ts
Normal 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;
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 d’images",
|
||||
"workspace": "Espace de travail"
|
||||
},
|
||||
"usage": {
|
||||
"title": "Activité des tokens",
|
||||
"shortTitle": "Token Usage",
|
||||
"subtitle": "Usage signalé par le fournisseur sur les 12 derniers mois.",
|
||||
"empty": "L’activité 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 d’instruction 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 d’environnement",
|
||||
"missingCommands": "CLI manquant",
|
||||
"missingEnvironment": "ENV manquant",
|
||||
"unavailableReasonLabel": "Raison d’indisponibilité",
|
||||
"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 l’en-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 l’objectif",
|
||||
"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 l’image",
|
||||
@ -733,6 +826,15 @@
|
||||
"next": "Image suivante",
|
||||
"close": "Fermer l’aperçu"
|
||||
},
|
||||
"filePreview": {
|
||||
"aria": "Aperçu du fichier",
|
||||
"close": "Fermer l’aperçu du fichier",
|
||||
"loading": "Chargement de l’aperçu...",
|
||||
"failed": "Impossible de prévisualiser ce fichier.",
|
||||
"routeMissing": "L’aperçu du fichier nécessite le dernier gateway. Redémarrez nanobot gateway puis réessayez.",
|
||||
"resize": "Redimensionner l’aperçu du fichier",
|
||||
"truncated": "L’aperçu est tronqué car le fichier est volumineux."
|
||||
},
|
||||
"code": {
|
||||
"fallbackLanguage": "code",
|
||||
"copyAria": "Copier le code",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "コードをコピー",
|
||||
|
||||
@ -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": "코드 복사",
|
||||
|
||||
@ -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ã",
|
||||
|
||||
@ -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": "复制代码",
|
||||
|
||||
@ -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": "複製程式碼",
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 = "",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 />);
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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></MarkdownTextRenderer>);
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
117
webui/src/tests/session-info-popover.test.tsx
Normal file
117
webui/src/tests/session-info-popover.test.tsx
Normal 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);
|
||||
});
|
||||
@ -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" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user