From ab9f49970d95cca40b172d3bf3a6ae0e76ea88c5 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sat, 6 Jun 2026 19:49:33 +0800 Subject: [PATCH] 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> --- .gitignore | 9 +- README.md | 2 +- desktop/README.md | 129 +++ desktop/bun.lock | 594 +++++++++++++ desktop/docs/development.md | 116 +++ desktop/docs/host-contract.md | 94 +++ desktop/docs/webui-sync.md | 71 ++ desktop/package.json | 55 ++ desktop/scripts/prepare-engine.mjs | 265 ++++++ desktop/src/main.ts | 783 ++++++++++++++++++ desktop/src/notifications.ts | 159 ++++ desktop/src/preload.cts | 55 ++ desktop/src/unixWebSocket.ts | 208 +++++ desktop/tsconfig.json | 16 + nanobot/agent/hook.py | 1 + nanobot/agent/runner.py | 74 +- nanobot/agent/skills.py | 18 + nanobot/agent/tools/web.py | 20 +- nanobot/apps/cli/service.py | 3 + nanobot/channels/manager.py | 4 + nanobot/channels/websocket.py | 167 ++-- nanobot/cli/commands.py | 132 ++- nanobot/command/builtin.py | 7 + nanobot/providers/openai_codex_provider.py | 7 +- nanobot/providers/openai_responses/parsing.py | 41 +- nanobot/webui/file_preview.py | 137 +++ nanobot/webui/gateway_services.py | 11 + nanobot/webui/mcp_presets_api.py | 2 +- nanobot/webui/session_automations.py | 56 ++ nanobot/webui/settings_api.py | 8 + nanobot/webui/settings_routes.py | 8 + nanobot/webui/skills_api.py | 61 ++ nanobot/webui/token_usage.py | 357 ++++++++ nanobot/webui/transcript.py | 232 +++++- nanobot/webui/ws_http.py | 91 +- pyproject.toml | 1 + tests/agent/test_dream.py | 1 - tests/agent/test_loop_save_turn.py | 24 +- tests/agent/test_runner_hooks.py | 49 +- tests/channels/test_websocket_channel.py | 175 +++- tests/channels/test_websocket_http_routes.py | 161 +++- tests/cli/test_commands.py | 122 ++- tests/cli/test_restart_command.py | 15 +- tests/providers/test_openai_codex_provider.py | 9 +- tests/providers/test_openai_responses.py | 26 +- tests/tools/test_web_fetch_security.py | 48 +- tests/utils/test_webui_transcript.py | 173 +++- tests/webui/test_settings_api.py | 47 ++ tests/webui/test_token_usage.py | 148 ++++ webui/src/App.tsx | 377 ++++++--- webui/src/components/CodeBlock.tsx | 187 ++++- webui/src/components/FilePreviewPanel.tsx | 287 +++++++ webui/src/components/FileReferenceChip.tsx | 68 +- webui/src/components/MarkdownText.tsx | 11 +- webui/src/components/MarkdownTextRenderer.tsx | 138 ++- webui/src/components/MessageBubble.tsx | 50 +- webui/src/components/Sidebar.tsx | 11 +- .../src/components/settings/SettingsView.tsx | 548 +++++++----- .../settings/SkillsCatalogSettings.tsx | 417 ++++++++++ .../components/settings/TokenUsageHeatmap.tsx | 224 +++++ .../thread/AgentActivityCluster.tsx | 19 +- .../src/components/thread/PromptNavigator.tsx | 149 ++++ webui/src/components/thread/PromptRail.tsx | 157 ++-- .../components/thread/SessionInfoPopover.tsx | 224 +++++ .../src/components/thread/ThreadComposer.tsx | 71 +- webui/src/components/thread/ThreadHeader.tsx | 72 +- .../src/components/thread/ThreadMessages.tsx | 15 +- webui/src/components/thread/ThreadShell.tsx | 399 ++++++--- .../src/components/thread/ThreadViewport.tsx | 74 +- .../components/thread/WorkspaceControls.tsx | 4 +- .../thread/activity/FileEditRow.tsx | 24 +- .../thread/activity/ReasoningRow.tsx | 3 + .../src/components/thread/promptNavigation.ts | 64 ++ webui/src/components/ui/sheet.tsx | 14 +- webui/src/globals.css | 45 +- webui/src/hooks/useNanobotStream.ts | 156 +++- webui/src/hooks/useSessionAutomationJobs.ts | 61 ++ webui/src/hooks/useSkills.ts | 20 + webui/src/i18n/locales/en/common.json | 110 ++- webui/src/i18n/locales/es/common.json | 112 ++- webui/src/i18n/locales/fr/common.json | 112 ++- webui/src/i18n/locales/id/common.json | 112 ++- webui/src/i18n/locales/ja/common.json | 112 ++- webui/src/i18n/locales/ko/common.json | 112 ++- webui/src/i18n/locales/vi/common.json | 112 ++- webui/src/i18n/locales/zh-CN/common.json | 110 ++- webui/src/i18n/locales/zh-TW/common.json | 112 ++- webui/src/lib/activity-timeline.ts | 79 +- webui/src/lib/api.ts | 70 ++ webui/src/lib/nanobot-client.ts | 2 + webui/src/lib/types.ts | 138 ++- webui/src/tests/api.test.ts | 61 ++ webui/src/tests/app-layout.test.tsx | 178 +++- webui/src/tests/code-block.test.tsx | 19 + .../src/tests/markdown-text-renderer.test.tsx | 101 ++- webui/src/tests/message-bubble.test.tsx | 16 + webui/src/tests/nanobot-client.test.ts | 18 + webui/src/tests/session-info-popover.test.tsx | 117 +++ webui/src/tests/settings-view.test.tsx | 387 ++++++++- webui/src/tests/thread-messages.test.tsx | 183 +++- webui/src/tests/thread-shell.test.tsx | 79 +- webui/src/tests/thread-viewport.test.tsx | 124 ++- webui/src/tests/useNanobotStream.test.tsx | 29 +- 103 files changed, 10483 insertions(+), 1003 deletions(-) create mode 100644 desktop/README.md create mode 100644 desktop/bun.lock create mode 100644 desktop/docs/development.md create mode 100644 desktop/docs/host-contract.md create mode 100644 desktop/docs/webui-sync.md create mode 100644 desktop/package.json create mode 100644 desktop/scripts/prepare-engine.mjs create mode 100644 desktop/src/main.ts create mode 100644 desktop/src/notifications.ts create mode 100644 desktop/src/preload.cts create mode 100644 desktop/src/unixWebSocket.ts create mode 100644 desktop/tsconfig.json create mode 100644 nanobot/webui/file_preview.py create mode 100644 nanobot/webui/session_automations.py create mode 100644 nanobot/webui/skills_api.py create mode 100644 nanobot/webui/token_usage.py create mode 100644 tests/webui/test_token_usage.py create mode 100644 webui/src/components/FilePreviewPanel.tsx create mode 100644 webui/src/components/settings/SkillsCatalogSettings.tsx create mode 100644 webui/src/components/settings/TokenUsageHeatmap.tsx create mode 100644 webui/src/components/thread/PromptNavigator.tsx create mode 100644 webui/src/components/thread/SessionInfoPopover.tsx create mode 100644 webui/src/components/thread/promptNavigation.ts create mode 100644 webui/src/hooks/useSessionAutomationJobs.ts create mode 100644 webui/src/hooks/useSkills.ts create mode 100644 webui/src/tests/session-info-popover.test.tsx diff --git a/.gitignore b/.gitignore index cddec5083..043d34184 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 16a9091c1..e07956b1e 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@

-🐈 **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 diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 000000000..ba2e30b6a --- /dev/null +++ b/desktop/README.md @@ -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) diff --git a/desktop/bun.lock b/desktop/bun.lock new file mode 100644 index 000000000..c98bc6922 --- /dev/null +++ b/desktop/bun.lock @@ -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=="], + } +} diff --git a/desktop/docs/development.md b/desktop/docs/development.md new file mode 100644 index 000000000..b5adeed36 --- /dev/null +++ b/desktop/docs/development.md @@ -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. diff --git a/desktop/docs/host-contract.md b/desktop/docs/host-contract.md new file mode 100644 index 000000000..9cb2d28bc --- /dev/null +++ b/desktop/docs/host-contract.md @@ -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; + restartEngine(): Promise; + pickFolder(): Promise; + openLogs(): Promise; + exportDiagnostics(): Promise; + checkForUpdates(): Promise<{ supported: boolean; message?: string }>; + openSocket(url: string): Promise; + sendSocket(id: string, data: string): Promise; + closeSocket(id: string): Promise; + 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. diff --git a/desktop/docs/webui-sync.md b/desktop/docs/webui-sync.md new file mode 100644 index 000000000..905e4bfbc --- /dev/null +++ b/desktop/docs/webui-sync.md @@ -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`. diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 000000000..83b816845 --- /dev/null +++ b/desktop/package.json @@ -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" + ] + } + } +} diff --git a/desktop/scripts/prepare-engine.mjs b/desktop/scripts/prepare-engine.mjs new file mode 100644 index 000000000..19980eaba --- /dev/null +++ b/desktop/scripts/prepare-engine.mjs @@ -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); +}); diff --git a/desktop/src/main.ts b/desktop/src/main.ts new file mode 100644 index 000000000..8ace493c9 --- /dev/null +++ b/desktop/src/main.ts @@ -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(); +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, +): 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; + method: string; + }, +): Promise { + 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; + method: string; + }, +): Promise { + 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((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 { + 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> { + 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; +} + +async function waitForGateway(current: HostRuntime): Promise { + 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 { + if (!current || current.gateway.exitCode !== null) return; + current.status = "stopped"; + closeHostSockets(); + current.gateway.kill("SIGTERM"); + await new Promise((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 { + notifyRuntimeStatus("starting"); + runtime = await startGateway(); + await waitForGateway(runtime); +} + +async function restartRuntime(): Promise { + 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 { + 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; + 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 { + 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"); + } +}); diff --git a/desktop/src/notifications.ts b/desktop/src/notifications.ts new file mode 100644 index 000000000..d7bf93507 --- /dev/null +++ b/desktop/src/notifications.ts @@ -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(); + +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(() => {}); +} diff --git a/desktop/src/preload.cts b/desktop/src/preload.cts new file mode 100644 index 000000000..0d8aa338a --- /dev/null +++ b/desktop/src/preload.cts @@ -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 => + ipcRenderer.invoke("nanobot:get-runtime-info"), + restartEngine: (): Promise => ipcRenderer.invoke("nanobot:restart-engine"), + pickFolder: (): Promise => ipcRenderer.invoke("nanobot:pick-folder"), + openLogs: (): Promise => ipcRenderer.invoke("nanobot:open-logs"), + exportDiagnostics: (): Promise => + ipcRenderer.invoke("nanobot:export-diagnostics"), + checkForUpdates: (): Promise<{ supported: boolean; message?: string }> => + ipcRenderer.invoke("nanobot:check-for-updates"), + openSocket: (url: string): Promise => + ipcRenderer.invoke("nanobot:ws-connect", url), + sendSocket: (id: string, data: string): Promise => + ipcRenderer.invoke("nanobot:ws-send", id, data), + closeSocket: (id: string): Promise => + 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); + }, +}); diff --git a/desktop/src/unixWebSocket.ts b/desktop/src/unixWebSocket.ts new file mode 100644 index 000000000..8fc0f8e65 --- /dev/null +++ b/desktop/src/unixWebSocket.ts @@ -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; +} diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json new file mode 100644 index 000000000..c379aff9f --- /dev/null +++ b/desktop/tsconfig.json @@ -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/**/*"] +} diff --git a/nanobot/agent/hook.py b/nanobot/agent/hook.py index ed2c95498..b0a722468 100644 --- a/nanobot/agent/hook.py +++ b/nanobot/agent/hook.py @@ -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) diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index a6eda06fa..8746b5c27 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -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(): diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py index b01ca74ee..b22724347 100644 --- a/nanobot/agent/skills.py +++ b/nanobot/agent/skills.py @@ -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) diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 4c202eaee..f4221ca5b 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -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((" 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']*href=["\']([^"\']+)["\'][^>]*>([\s\S]*?)', diff --git a/nanobot/apps/cli/service.py b/nanobot/apps/cli/service.py index eeaa040df..b0ed09376 100644 --- a/nanobot/apps/cli/service.py +++ b/nanobot/apps/cli/service.py @@ -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"), diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 5bbc8879d..ffa5cca67 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -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 diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index d0de15787..8675b6252 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -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 ") diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 62f804ca2..8f60fd9ed 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -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, diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index fbcf46e1b..10eb995cf 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -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) diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index fc92e8ae8..aeee832e4 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -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: diff --git a/nanobot/providers/openai_responses/parsing.py b/nanobot/providers/openai_responses/parsing.py index 846165562..fbfc9813c 100644 --- a/nanobot/providers/openai_responses/parsing.py +++ b/nanobot/providers/openai_responses/parsing.py @@ -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) diff --git a/nanobot/webui/file_preview.py b/nanobot/webui/file_preview.py new file mode 100644 index 000000000..6e1048823 --- /dev/null +++ b/nanobot/webui/file_preview.py @@ -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") diff --git a/nanobot/webui/gateway_services.py b/nanobot/webui/gateway_services.py index cf3eede19..15649d08d 100644 --- a/nanobot/webui/gateway_services.py +++ b/nanobot/webui/gateway_services.py @@ -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, ) diff --git a/nanobot/webui/mcp_presets_api.py b/nanobot/webui/mcp_presets_api.py index 6ae4fe828..d982feb6f 100644 --- a/nanobot/webui/mcp_presets_api.py +++ b/nanobot/webui/mcp_presets_api.py @@ -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 diff --git a/nanobot/webui/session_automations.py b/nanobot/webui/session_automations.py new file mode 100644 index 000000000..52d503f54 --- /dev/null +++ b/nanobot/webui/session_automations.py @@ -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, + }, + } diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index d4c8849c9..3f3df3957 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -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 diff --git a/nanobot/webui/settings_routes.py b/nanobot/webui/settings_routes.py index 9e0caab57..ff5b7d7df 100644 --- a/nanobot/webui/settings_routes.py +++ b/nanobot/webui/settings_routes.py @@ -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() diff --git a/nanobot/webui/skills_api.py b/nanobot/webui/skills_api.py new file mode 100644 index 000000000..6473dbb39 --- /dev/null +++ b/nanobot/webui/skills_api.py @@ -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 diff --git a/nanobot/webui/token_usage.py b/nanobot/webui/token_usage.py new file mode 100644 index 000000000..761cb63f8 --- /dev/null +++ b/nanobot/webui/token_usage.py @@ -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") diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index f0a073618..2d9b6da2f 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -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} diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 2b60bfca8..d21261681 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -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") diff --git a/pyproject.toml b/pyproject.toml index 7adbb9c51..915bd8c3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/agent/test_dream.py b/tests/agent/test_dream.py index 5b9bd32a0..937b7ae41 100644 --- a/tests/agent/test_dream.py +++ b/tests/agent/test_dream.py @@ -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.""" diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index 874f0b435..295bc4888 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -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", [], diff --git a/tests/agent/test_runner_hooks.py b/tests/agent/test_runner_hooks.py index 7ba107618..7eb8c2496 100644 --- a/tests/agent/test_runner_hooks.py +++ b/tests/agent/test_runner_hooks.py @@ -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), diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index a441547bd..3e358b076 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -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, diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 3eee4074c..8eba67588 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -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, diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 353d04ecb..3e30de858 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -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: diff --git a/tests/cli/test_restart_command.py b/tests/cli/test_restart_command.py index e35d52d89..d0ff8ca91 100644 --- a/tests/cli/test_restart_command.py +++ b/tests/cli/test_restart_command.py @@ -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): diff --git a/tests/providers/test_openai_codex_provider.py b/tests/providers/test_openai_codex_provider.py index e1994555c..cbd3ed488 100644 --- a/tests/providers/test_openai_codex_provider.py +++ b/tests/providers/test_openai_codex_provider.py @@ -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" diff --git a/tests/providers/test_openai_responses.py b/tests/providers/test_openai_responses.py index 49ae86493..e9d8545e1 100644 --- a/tests/providers/test_openai_responses.py +++ b/tests/providers/test_openai_responses.py @@ -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([ diff --git a/tests/tools/test_web_fetch_security.py b/tests/tools/test_web_fetch_security.py index 89ff9d9f9..6fb1d0f64 100644 --- a/tests/tools/test_web_fetch_security.py +++ b/tests/tools/test_web_fetch_security.py @@ -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 = "Test

Hello world

" + 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)) diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index 1c7372902..5b0e35b17 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -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"}) diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index 80a56ed91..d48dd6bd1 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -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, diff --git a/tests/webui/test_token_usage.py b/tests/webui/test_token_usage.py new file mode 100644 index 000000000..470c10230 --- /dev/null +++ b/tests/webui/test_token_usage.py @@ -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 diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 8fa428a7b..95e4c57ec 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -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 ( -
-
- {onToggleSidebar ? ( - - ) : null} -
- {showThemeButton ? ( +
+ {onToggleSidebar ? ( - ) : ( -
- )} + ) : null} + {rightAction ? ( +
+ {rightAction} +
+ ) : null}
); } @@ -318,6 +332,36 @@ export default function App() { const [state, setState] = useState({ 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 => { + 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 ( ); @@ -511,10 +515,12 @@ function Shell({ runtimeSurface, onModelNameChange, onLogout, + onNativeEngineRestart, }: { runtimeSurface: RuntimeSurface; onModelNameChange: (modelName: string | null) => void; onLogout: () => void; + onNativeEngineRestart: () => Promise; }) { const { t, i18n } = useTranslation(); const { client, token } = useClient(); @@ -532,6 +538,7 @@ function Shell({ useState(initialRouteRef.current.settingsSection); const [hostSidebarOpen, setHostSidebarOpen] = useState(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>(() => new Set()); const [completedChatIds, setCompletedChatIds] = useState>(readCompletedRunChatIds); const [workspaces, setWorkspaces] = useState(null); + const skills = useSkills(token); const [settingsSnapshot, setSettingsSnapshot] = useState(null); const [workspaceError, setWorkspaceError] = useState(null); const [draftWorkspaceScope, setDraftWorkspaceScope] = @@ -560,6 +568,11 @@ function Shell({ useState>({}); const runningChatIdsRef = useRef>(new Set()); const activeChatIdRef = useRef(null); + const hostSidebarPreviewCloseTimerRef = useRef(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 ? ( + {theme === "dark" ? ( + + ) : ( + + )} + + ) + } /> ) : null} )} ); -} +}); diff --git a/webui/src/components/thread/WorkspaceControls.tsx b/webui/src/components/thread/WorkspaceControls.tsx index 8188722a6..8c80ac249 100644 --- a/webui/src/components/thread/WorkspaceControls.tsx +++ b/webui/src/components/thread/WorkspaceControls.tsx @@ -106,7 +106,7 @@ export function WorkspaceProjectPicker({ if (nativeProjectPicker) { return ( -
+