diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 628d9a9f..09ce3cbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,10 +24,12 @@ jobs: runs-on: windows-latest timeout-minutes: 25 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: "26.1.0" cache: "npm" @@ -82,7 +84,9 @@ jobs: runs-on: windows-latest timeout-minutes: 40 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Setup Rust uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable @@ -136,3 +140,61 @@ jobs: with: name: rust-lcov path: src-tauri/lcov.info + + check-cross-platform: + name: Cross-platform Compatibility (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Install Linux system dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf \ + libxdo-dev + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "26.1.0" + cache: "npm" + cache-dependency-path: src/package-lock.json + + - name: Setup Rust + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + with: + toolchain: 1.95.0 + + - name: Rust Cache + uses: Swatinem/rust-cache@65012b490220f477f20ab979e35ae732e6de4e68 # node24 + continue-on-error: true + with: + workspaces: "src-tauri -> target" + cache-targets: false + + - name: Install Dependencies + run: | + cd src + npm ci + + - name: Frontend Type Check + run: | + cd src + npm run typecheck + + - name: Backend Target Check + run: | + cd src-tauri + cargo check --all-targets --all-features diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e1f645a1..7128b4ae 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -35,7 +35,9 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Setup Rust if: matrix.language == 'rust' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index da75412f..6aafd93f 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -24,7 +24,9 @@ jobs: timeout-minutes: 10 steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Review dependency changes uses: actions/dependency-review-action@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19c650c1..1be50e8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,8 +31,9 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: + persist-credentials: false fetch-depth: 0 ref: ${{ env.RELEASE_TAG }} @@ -59,7 +60,7 @@ jobs: } - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: "26.1.0" cache: "npm" diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index c285e414..91b14c96 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -19,10 +19,12 @@ jobs: timeout-minutes: 20 steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: "26.1.0" cache: "npm" @@ -44,7 +46,9 @@ jobs: timeout-minutes: 25 steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Setup Rust uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 diff --git a/SECURITY.md b/SECURITY.md index 100f8de5..559b0af7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -34,6 +34,8 @@ Release tags must match project versions and point to commits reachable from `ma Current application security posture: - provider secrets are backend-owned +- provider secrets are stored in an encrypted backend file today; platform + keystore storage is planned but not implemented yet - frontend/backend contracts are generated from Rust types - local integration API tokens are runtime-issued and scoped - import paths, runtime entry paths, settings UI paths, archive entries, and log diff --git a/docs/examples/sdk/javascript/axelate-client.mjs b/docs/examples/sdk/javascript/axelate-client.mjs index 0b5916a2..9f6011e9 100644 --- a/docs/examples/sdk/javascript/axelate-client.mjs +++ b/docs/examples/sdk/javascript/axelate-client.mjs @@ -35,7 +35,7 @@ export class AxelateClient { saveSettings(settings) { return this.request( - 'PUT', + 'PATCH', `/v1/modules/${encodeURIComponent(this.moduleId)}/settings`, settings, ); diff --git a/docs/examples/sdk/python/axelate_sdk.py b/docs/examples/sdk/python/axelate_sdk.py index 8abe009d..96e3d1d1 100644 --- a/docs/examples/sdk/python/axelate_sdk.py +++ b/docs/examples/sdk/python/axelate_sdk.py @@ -61,7 +61,7 @@ def settings(self) -> dict[str, Any]: def save_settings(self, settings: dict[str, Any]) -> dict[str, Any]: return self.request( - "PUT", + "PATCH", f"/v1/modules/{self.encoded_module_id}/settings", settings, ) diff --git a/docs/localization/en/AGENT_CONTROL.md b/docs/localization/en/AGENT_CONTROL.md new file mode 100644 index 00000000..46f6954e --- /dev/null +++ b/docs/localization/en/AGENT_CONTROL.md @@ -0,0 +1,339 @@ +# Agent Control + +Agent Control is Axelate's local automation layer for trusted tools such as +Codex, local CLIs, IDE assistants, and private scripts. + +It is not a remote access feature. The API listens on `127.0.0.1`, uses bearer +tokens, and is meant for tools running on the same machine as the launcher. + +## User Setup + +Open Settings, scroll to Agent Control, and enable local agent access. +When Agent Control is disabled, user-created agent profile tokens do not +authenticate. + +Create one of the local access tokens: + +- `Trusted Local`: normal local automation for observing, operating, configuring, + and drafting integrations. +- `Full Access`: manual override for development and advanced local workflows. + Treat it like an admin token. + +The full token is shown only once. Store it in the local tool that will control +Axelate. The Settings screen keeps only public profile metadata: profile name, +scopes, token prefix, creation time, last seen, and revoked state. + +Users can rotate or delete a token from Settings. Rotation creates a new +one-time token and invalidates the old token. Delete removes the profile and its +pending approvals. + +## Development Token + +`AXELATE_AGENT_API_TOKEN` still exists for development, CI, and live tests. It is +not the normal desktop UX. + +For regular `.exe`, `.app`, and Linux app launches, agents should use a token +created in the Settings UI. Do not require users to set environment variables for +normal agent control. + +## Authentication + +Every Agent Control route except `GET /v1/health` requires: + +```http +Authorization: Bearer +``` + +The development launcher token from `AXELATE_AGENT_API_TOKEN` can also authorize +launcher-wide routes during development and tests. User-created agent profile +tokens are the intended normal path. + +Module-scoped integration tokens are not launcher-wide agent tokens. They can +call their own module routes, but they cannot read global agent state or create +agent approval requests. + +## Scopes + +Agent profiles can have these scopes: + +- `observe`: read launcher state, module lists, statuses, provider/model + inventory, pending approvals, and sanitized console logs. +- `operate`: open launcher pages, select cards/modules, start modules, stop + modules, restart modules, repair modules, and run AI text/image requests. +- `configure`: read and update non-secret module settings and report module + stage/status. +- `draft-create`: create integration draft folders without installing or running + them. +- `full-access`: user-granted local override. It satisfies all current scope + checks, but dangerous actions should still use approval requests when they can + install code, delete data, expose secrets, or broaden permissions. + +## Approval Model + +Normal `observe` and routine `operate` calls run after token authentication. + +`configure` calls require the scope and are audit-logged when they mutate state. + +Dangerous actions should not mutate state directly. The agent should create an +approval request with: + +- agent identity from the bearer token +- action name +- target +- dry-run or diff summary +- risk label + +The UI shows the pending approval. The user can approve or deny it. The initial +approval request returns `202 Accepted`; it does not mean the action has already +run. + +## Audit Log + +Agent-initiated actions are recorded in the backend-owned Agent Control audit +log. The Settings page does not show the full activity log; the Console has an +Agent tab for agent activity. + +Audit entries include actor, action, target, result, and timestamp. They are for +operator visibility and debugging, not for storing secrets. + +## Current Endpoints + +### Health + +`GET /v1/health` + +No authentication. Returns whether the local launcher API is alive. + +### Launcher State + +`GET /v1/agent/state` + +Requires `observe`. + +Returns a safer launcher snapshot: + +- selected module cards +- installed module summaries +- provider and model inventory +- engine state + +Provider secrets, private files, and raw provider credentials are not returned. + +### Capabilities + +`GET /v1/agent/capabilities` + +Requires `observe`. + +Returns the authenticated actor, granted scopes, supported endpoint groups, and +current safety rules. Agents should call this first instead of hardcoding +assumptions about which launcher actions are available. + +### Logs + +`GET /v1/agent/logs?viewId=&since=0&limit=200` + +Requires `observe`. + +Returns recent sanitized console logs from the in-memory console store for one +explicit console view. `viewId` is required for useful output. If it is omitted, +the route returns an empty `logs` array instead of a combined stream. This keeps +agent access from accidentally mixing unrelated debug or info logs. `limit` +defaults to `200` and is capped at `1000`. + +The Agent Control API redacts common bearer tokens, API keys, passwords, tokens, +and secret assignment patterns before returning logs. It does not expose raw log +files. + +### Pending Approvals + +`GET /v1/agent/approvals` + +Requires `observe`. + +Returns pending and recent agent approval requests visible to the launcher UI. + +### Create Approval Request + +`POST /v1/agent/approval-requests` + +Requires an agent profile token. + +```json +{ + "action": "package.install", + "target": "example-integration", + "diff": "Would download and install package files into the integration directory.", + "risk": "high" +} +``` + +Returns `202 Accepted` with the created approval request. + +### Open Launcher Page + +`POST /v1/launcher/open-page` + +Requires `operate`. + +```json +{ + "pageId": "settings" +} +``` + +Updates launcher UI state and asks the frontend to open the page. Page ids must +be simple ASCII ids. + +### Select Module Card + +`POST /v1/launcher/select-module` + +Requires `operate`. + +```json +{ + "category": "ai_text", + "moduleId": "custom-text" +} +``` + +Supported categories are `ai_text`, `ai_image`, and `services`. + +### Modules + +`GET /v1/modules` + +Requires `observe`. + +Launcher-wide agents see installed module metadata. Module-scoped integration +tokens only see their own module. Use `/v1/agent/state` when an agent only needs +the safer launcher snapshot. + +`GET /v1/modules/{moduleId}/status` + +Requires `observe`. + +`GET /v1/modules/{moduleId}/context` + +Requires `observe`. + +`POST /v1/modules/{moduleId}/start` + +Requires `operate`. + +`POST /v1/modules/{moduleId}/stop` + +Requires `operate`. + +`POST /v1/modules/{moduleId}/restart` + +Requires `operate`. + +`POST /v1/modules/{moduleId}/repair` + +Requires `operate`. + +Repair stops the module, rebuilds the launcher-managed dependency environment +for Python, Node, and Bun modules, then starts the module again. For modules that +use their own lifecycle commands, repair behaves like a controlled restart. It +does not delete the integration folder, settings, logs, or module runtime data. + +When an agent starts or restarts a runtime module, Axelate syncs the selected +launcher card where possible so the UI reflects the running module. Repair uses +the same selection sync after a successful start. + +### Module Settings + +`GET /v1/modules/{moduleId}/settings` + +Requires `configure`. + +`PATCH /v1/modules/{moduleId}/settings` + +Requires `configure` and writes an audit entry. + +Merges safe settings into the existing settings object. Full replacement through +`PUT /v1/modules/{moduleId}/settings` is intentionally blocked. Agents cannot +write sensitive keys such as tokens, API keys, passwords, or secrets through +this route. + +Agents should use this route instead of editing Axelate config files directly. + +### Integration Drafts + +`POST /v1/integration-drafts` + +Requires `draft-create`. + +```json +{ + "id": "my-draft-tool", + "name": "My Draft Tool", + "runtimeKind": "python", + "entry": "src/main.py", + "description": "Optional short description" +} +``` + +Creates a draft folder under Axelate's managed runtime drafts directory and +writes `axelate-module.toml`, `README.md`, and a minimal entry file. Supported +runtime kinds are `python`, `node`, and `bun`. + +This endpoint does not install the draft, does not run code, and does not write +into the installed integrations directory. The user or a later reviewed flow must +import/install the draft explicitly. + +### AI Requests + +`POST /v1/ai/text` + +Requires `operate`. + +```json +{ + "prompt": "Summarize the current launcher state.", + "provider": "custom-text", + "model": "gpt-5.5", + "thinkingLevel": "medium", + "webSearch": { "enabled": false } +} +``` + +`POST /v1/ai/image` + +Requires `operate`. + +If `provider` is omitted, Axelate uses the active launcher selection. If +`provider` is provided, Axelate validates and uses that provider for this request +without changing the visible selected card. + +## Agent Rules + +Agents should: + +- use the documented HTTP API, not UI scraping +- use explicit `open-page` and `select-module` calls instead of guessing UI state +- read sanitized console logs through `/v1/agent/logs`, not by opening raw log + files +- never request provider secrets through the API +- create approval requests for install, delete, update, raw logs, secret changes, + filesystem permissions, and network permission changes +- keep tokens local and rotate them if exposed + +Agents should not: + +- read Axelate private files directly +- store tokens in the repository +- assume a fixed local port +- treat `Full Access` as permission to skip user-visible approval for dangerous + mutations +- use MCP as a bypass around scopes, audit, or approvals + +## MCP Direction + +MCP should come later as an adapter over this same Agent Control API. + +An MCP server should not receive separate trust. It should call the same local +HTTP endpoints, use the same agent tokens, follow the same scopes, write the +same audit entries, and create the same approval requests for risky work. diff --git a/docs/localization/en/ARCHITECTURE.md b/docs/localization/en/ARCHITECTURE.md index 02b64a6b..9082526b 100644 --- a/docs/localization/en/ARCHITECTURE.md +++ b/docs/localization/en/ARCHITECTURE.md @@ -130,6 +130,7 @@ viable: - [User Guide](USER_GUIDE.md) - [Development Workflow](DEVELOPMENT_WORKFLOW.md) - [Integration API](INTEGRATION_API.md) +- [Agent Control](AGENT_CONTROL.md) - [Integration Development](INTEGRATION_DEVELOPMENT.md) - [Custom Integrations](CUSTOM_INTEGRATIONS.md) - [Trust Model](TRUST_MODEL.md) diff --git a/docs/localization/en/CURRENT_STATE.md b/docs/localization/en/CURRENT_STATE.md index 94a82e95..5b2cc847 100644 --- a/docs/localization/en/CURRENT_STATE.md +++ b/docs/localization/en/CURRENT_STATE.md @@ -1,6 +1,6 @@ # Axelate Current State -> Repository-grounded snapshot as of 2026-05-16. +> Repository-grounded snapshot as of 2026-05-22. > This document describes what exists now, not what the future product aspires to become. For setup and contributor workflow, use [Getting Started](GETTING_STARTED.md) and [Development Workflow](DEVELOPMENT_WORKFLOW.md). @@ -17,6 +17,9 @@ Today the repository is closest to: - a launcher for local AI runtimes and script modules - a BYOK cloud model client centered on OpenRouter - a control surface for downloads, monitoring, logs, and settings +- the start of a local API surface that integrations can use to call AI, manage + module settings, report progress, and control their own lifecycle +- the first local Agent Control surface for trusted local tools Today the repository is not yet: @@ -24,6 +27,7 @@ Today the repository is not yet: - a full package distribution platform - a managed runtime platform - a mature MCP-first workstation +- a complete permissioned package and MCP control platform - a finished public product with stable distribution and operations ## Current Stack @@ -121,6 +125,31 @@ Confirmed current direction from the codebase and recent fixes: - session summaries are hidden system context, not meant to leak into visible replies - rate-limit and payment errors are separated more cleanly than before +### Local Integration API + +The repository has a loopback-only local HTTP API for launcher-managed +integrations and trusted local agents. It currently supports: + +- health checks +- listing installed integrations +- reading integration status and runtime context +- reading and updating module-owned settings +- reporting module stage/progress +- starting, stopping, and restarting modules +- text and image AI requests through backend-owned routing +- agent profile tokens created from Settings +- safer launcher state reads and sanitized console log reads for agents +- explicit launcher page open and module-card selection actions +- pending approval requests for dangerous agent work +- backend-owned audit entries for agent actions +- integration draft creation that writes draft files without installing or + running them + +The current Agent Control layer is local-only and intentionally conservative. +It uses separate agent profiles and scopes instead of reusing module tokens. +Package install/delete, secrets, raw logs, broad filesystem access, and remote +agent access still need the fuller permission model described in the roadmap. + ### Image Provider Path The repository contains API provider catalog data for image-capable providers. @@ -276,6 +305,7 @@ The repository still contains surfaces or ideas that are ahead of the stable pro What does not exist yet as a finished system: +- an Axelate MCP server backed by documented launcher capabilities - package signing service - verified package distribution - managed runtime orchestration @@ -337,9 +367,11 @@ The next useful work should stay in this order: should be boring and repeatable. 2. Integration safety: imported folders, archives, URLs, runtime paths, settings, tokens, and logs should have explicit ownership boundaries. -3. Trust visibility: users should see the difference between local manual imports, +3. Agent-ready local control: agents should inspect status, logs, settings, and + lifecycle through documented APIs instead of UI scraping or private files. +4. Trust visibility: users should see the difference between local manual imports, future verified packages, and future managed or hybrid execution. -4. Provider clarity: cloud routing should remain useful without making OpenRouter +5. Provider clarity: cloud routing should remain useful without making OpenRouter the permanent product identity. Recent hardening direction: diff --git a/docs/localization/en/DEVELOPMENT_WORKFLOW.md b/docs/localization/en/DEVELOPMENT_WORKFLOW.md index 36573a19..2a5da019 100644 --- a/docs/localization/en/DEVELOPMENT_WORKFLOW.md +++ b/docs/localization/en/DEVELOPMENT_WORKFLOW.md @@ -209,6 +209,7 @@ Use these as current truth: - [Releases](RELEASES.md) - [Current State](CURRENT_STATE.md) - [Trust Model](TRUST_MODEL.md) +- [Agent Control](AGENT_CONTROL.md) Use these as planning-only documents: @@ -225,7 +226,9 @@ When changing behavior, update docs in the same scope: bindings - integration runtime behavior: update `INTEGRATION_API`, `INTEGRATION_DEVELOPMENT`, and `CUSTOM_INTEGRATIONS` -- secrets, filesystem, shell, process, token, or permission behavior: update - `TRUST_MODEL` and the relevant user/developer guide +- agent API behavior: update `AGENT_CONTROL`, `INTEGRATION_API`, and + `TRUST_MODEL` +- secrets, filesystem, shell, process, token, or permission behavior outside + Agent Control: update `TRUST_MODEL` and the relevant user/developer guide - future product direction only: update `VISION` or `ROADMAP`, not current-state onboarding docs diff --git a/docs/localization/en/INTEGRATION_API.md b/docs/localization/en/INTEGRATION_API.md index b942edba..d4db9cc9 100644 --- a/docs/localization/en/INTEGRATION_API.md +++ b/docs/localization/en/INTEGRATION_API.md @@ -1,9 +1,14 @@ # Integration API -This guide describes the current versioned contract external integrations use to -control Axelate. The contract is language-neutral: every integration talks to the -launcher through a local HTTP API. Language clients can wrap this contract later, -but the HTTP API is the source of truth. +This guide describes the current versioned contract launcher-managed +integrations use to talk to Axelate. The contract is language-neutral: every +integration talks to the launcher through a local HTTP API. Language clients can +wrap this contract later, but the HTTP API is the source of truth. + +This API is also the base for Agent Control. Module-scoped integration tokens +remain conservative. Trusted local agent profiles use separate launcher-wide +tokens with scopes, audit logging, and approval requests for risky work. See +[Agent Control](AGENT_CONTROL.md) for the agent-facing contract. For scaffolding, validation, and examples, start with [Integration Development](INTEGRATION_DEVELOPMENT.md). @@ -34,6 +39,16 @@ Standalone tools that are not launched by Axelate are not the primary public contract yet. They should use a launcher-managed integration flow instead of persisting or guessing local API credentials. +External agents should not scrape the desktop UI or read Axelate data files +directly. The supported path is a launcher-issued agent profile token and +documented `/v1` endpoints. + +For local development and explicit agent testing, Axelate also accepts +`AXELATE_AGENT_API_TOKEN` as a launcher-wide bearer token when the launcher +process is started with that environment variable. Use a high-entropy temporary +value and do not persist it in the repository. Normal desktop usage should create +tokens from Settings instead. + Script integrations declare their runtime in `axelate-module.toml`. ```toml @@ -67,6 +82,18 @@ Module-scoped tokens can access shared AI endpoints and only that module's own `/v1/modules/{moduleId}/...` routes. They are not durable credentials and should not be stored outside the running process. +Agent profile tokens do not reuse module tokens. They have their own scopes: + +- `observe`: read health, status, module lists, and sanitized console logs +- `operate`: open launcher pages, select cards, start, stop, restart, repair, and + run AI requests +- `configure`: update settings after user approval where needed +- `draft-create`: create integration draft folders without installing or running + them +- `full-access`: user-granted local override for advanced workflows + +Secrets stay out of agent responses. + ## Client Rules - Treat `AXELATE_HTTP_API_BASE` and `AXELATE_HTTP_API_TOKEN` as runtime values. @@ -179,13 +206,70 @@ settings = requests.get( Does not require authentication. Returns whether the local API server is alive. +### Agent Control + +The current `/v1/modules` and `/v1/ai` endpoints are used by both +launcher-managed integrations and trusted local agents. Launcher-wide agent +state uses an agent profile token, not a module-scoped integration token. + +`GET /v1/agent/state` + +Returns a safer launcher snapshot: + +- selected module cards +- installed module summaries without module paths or settings +- provider/model inventory without secrets or provider endpoints +- current engine state + +Module-scoped integration tokens cannot call this route. + +`GET /v1/agent/logs?viewId=engine:llama-cpp&since=0&limit=200` + +Returns recent sanitized console logs from memory for one explicit console view. +`viewId` is required for useful log output. If it is omitted, the route returns +an empty `logs` array instead of a combined stream. This keeps agent access from +accidentally mixing unrelated debug or info logs. `limit` defaults to 200 and is +capped at 1000. Raw log files are not exposed through this route. + +Module-scoped integration tokens cannot call this route. + +`GET /v1/agent/approvals` + +Returns pending and recent approval requests. + +`POST /v1/agent/approval-requests` + +Creates a pending approval request for dangerous work. The request should include +an action, target, dry-run or diff text, and risk label. It returns `202 +Accepted`; it does not run the requested action. + +`POST /v1/launcher/open-page` + +Requires `operate`. Opens a launcher page by id. + +`POST /v1/launcher/select-module` + +Requires `operate`. Selects a visible card/module for `ai_text`, `ai_image`, or +`services`. + +`POST /v1/integration-drafts` + +Requires `draft-create`. Creates a local draft folder with a manifest, README, +and minimal runtime entry file. It does not install or run the draft. + +Mutating operations should stay behind explicit scopes and user approval where +the action can install code, delete data, expose logs, or change credentials. + +For the complete Agent Control contract, see [AGENT_CONTROL.md](AGENT_CONTROL.md). + ### Integrations `GET /v1/modules` Returns installed integrations with launcher status, category, install state, and metadata. A launcher-wide token can see all installed integrations. A -module-scoped token only sees the integration that received the token. +module-scoped token only sees the integration that received the token. Agents +that need a safer launcher snapshot should use `/v1/agent/state`. `GET /v1/modules/{moduleId}/status` @@ -217,20 +301,14 @@ platform-specific strings and use path utilities such as `path.join` and Returns the JSON settings object owned by the integration. -`PUT /v1/modules/{moduleId}/settings` - -Replaces the integration settings object. - -```json -{ - "chatId": "12345", - "enabled": true -} -``` - `PATCH /v1/modules/{moduleId}/settings` Merges the request JSON object into the existing integration settings object. +This is the only public write path for module settings. Full replacement through +`PUT /v1/modules/{moduleId}/settings` is intentionally blocked by the launcher +because it can erase unknown settings or secret references. Agents also cannot +write sensitive keys such as tokens, API keys, passwords, or secrets through +this route. `POST /v1/modules/{moduleId}/stage` @@ -262,6 +340,13 @@ Stops the running module script. Restarts the module script. +`POST /v1/modules/{moduleId}/repair` + +Stops the module, rebuilds the launcher-managed dependency environment for +Python, Node, and Bun modules, then starts the module again. For modules with +custom lifecycle commands, repair behaves like a controlled restart. It does not +delete the integration folder, settings, logs, or module runtime data. + ### AI Text `POST /v1/ai/text` diff --git a/docs/localization/en/INTEGRATION_DEVELOPMENT.md b/docs/localization/en/INTEGRATION_DEVELOPMENT.md index 58405122..46dc15c2 100644 --- a/docs/localization/en/INTEGRATION_DEVELOPMENT.md +++ b/docs/localization/en/INTEGRATION_DEVELOPMENT.md @@ -39,6 +39,10 @@ local API, then run `integration:doctor` before importing the folder. These helpers are developer tools. The runtime contract is still the local HTTP API documented in [Integration API](INTEGRATION_API.md). +Trusted local agents use the same local HTTP server, but not the same token +model. For launcher-wide automation, use [Agent Control](AGENT_CONTROL.md) +instead of a module runtime token. + ## Integration Layout ```text diff --git a/docs/localization/en/README.md b/docs/localization/en/README.md index 72c5e72f..8ced9505 100644 --- a/docs/localization/en/README.md +++ b/docs/localization/en/README.md @@ -10,12 +10,15 @@ English documentation is the canonical reference for Axelate. - [Development Workflow](DEVELOPMENT_WORKFLOW.md) - daily contributor workflow - [Architecture](ARCHITECTURE.md) - frontend, backend, runtime, and contract map - [Trust Model](TRUST_MODEL.md) - current and future trust boundaries +- [Agent Control](AGENT_CONTROL.md) - trusted local agent setup, scopes, API, and + approvals - [Releases](RELEASES.md) - release workflow and tag rules ## Integrations - [Integration Development](INTEGRATION_DEVELOPMENT.md) - build local integrations -- [Integration API](INTEGRATION_API.md) - local HTTP API contract +- [Integration API](INTEGRATION_API.md) - local HTTP API contract for integrations + and agent control - [Custom Integrations](CUSTOM_INTEGRATIONS.md) - manifest and import rules ## Planning diff --git a/docs/localization/en/ROADMAP.md b/docs/localization/en/ROADMAP.md index 6ee683c0..58d31047 100644 --- a/docs/localization/en/ROADMAP.md +++ b/docs/localization/en/ROADMAP.md @@ -1,6 +1,6 @@ # Axelate Roadmap -> Product execution roadmap as of 2026-05-06. +> Product execution roadmap as of 2026-05-21. > Planning document only. It is not a setup guide and it does not mean every > listed feature already exists in the repository today. @@ -25,7 +25,7 @@ Overall product ambition: `8/10`. Phase difficulty: - workstation core: `6/10` -- local integrations and SDKs: `6/10` +- local integrations, agent control, and SDKs: `6/10` to `7/10` - trusted package layer: `7/10` - managed or hybrid execution layer: `9/10` @@ -75,6 +75,9 @@ priorities: prompt box - one-click integration install from folders, archives, and trusted URLs, with clear permissions and uninstall behavior +- agent-accessible control surfaces: tools that can inspect launcher state, + read logs, configure integrations, and start repair actions without scraping + the UI These are product requirements, not nice-to-have polish. If they are weak, users will fall back to the existing toolchain. @@ -137,7 +140,12 @@ Work: - remove stale claims when backend behavior changes - document the exact local API, environment variables, runtime directories, and settings ownership rules +- document what the launcher can expose to agents today and what remains a + future permissioned control layer - keep examples runnable +- move provider secrets from the current encrypted file store to platform + keystores: DPAPI on Windows, Keychain on macOS, and libsecret/KWallet-compatible + storage on Linux, with migration from existing `secure.enc` Exit criteria: @@ -202,7 +210,42 @@ Exit criteria: - common OpenAI SDK clients can use Axelate for local and BYOK cloud routes without a custom adapter -### 5. Add SDKs For Real Integration Development +### 5. Stabilize Agent Control API + +Difficulty: `5/10` to `7/10` + +Status: first local version exists. It has UI-created trusted agent profiles, +scoped bearer tokens, safer state reads, sanitized console log reads, explicit +open/select actions, +module lifecycle routes, AI routes, approval requests, and audit entries. + +Work: + +- keep the current read-only launcher state endpoints stable: + - installed modules + - active providers and models + - runtime health + - download status + - recent sanitized console logs +- harden controlled operation endpoints: + - keep start, stop, restart, and repair reliable + - update module settings + - run AI text and image requests + - create integration drafts from templates +- dry-run responses for install, delete, repair, and settings changes +- audit log entries for every agent-initiated mutation +- clear split between module-scoped tokens, development launcher tokens, and + user-created agent profile tokens + +Exit criteria: + +- an external agent can help a user inspect and operate the launcher without + screen scraping or reading private files directly +- dangerous actions require a user approval step before they mutate launcher + state +- provider secrets are never exposed through the agent-facing API + +### 6. Add SDKs For Real Integration Development Difficulty: `5/10` to `7/10` @@ -210,7 +253,8 @@ Work: - TypeScript SDK - Python SDK -- helpers for chat, image, settings, stage reporting, and module control +- helpers for chat, image, settings, stage reporting, module control, and agent + observe workflows - typed errors - examples that match the integration template - version compatibility checks using `AXELATE_INTEGRATION_API_VERSION` @@ -220,7 +264,7 @@ Exit criteria: - integration authors can build useful tools without hand-writing local HTTP plumbing -### 6. Make Trust And Permissions Visible +### 7. Make Trust And Permissions Visible Difficulty: `6/10` to `8/10` @@ -231,32 +275,36 @@ Work: - install-time permission review - verified/signed package state - visible module token boundaries +- visible agent token boundaries - explicit MCP server and tool approvals - clear warning for manually imported unverified integrations Exit criteria: -- users can see what an integration is allowed to do before running it +- users can see what an integration, agent, or MCP server is allowed to do before + it runs -### 7. Add MCP Foundation +### 8. Add MCP Foundation Difficulty: `7/10` to `8/10` Work: -- MCP server registry +- Axelate-owned MCP server backed by the Agent Control API +- MCP server registry for user-added servers - connection state - tool discovery - user approval for server and tool access +- safe defaults for read-only tools - failure handling and logs - no hidden automatic unsafe execution Exit criteria: -- MCP works as a controlled workstation feature, not as an invisible execution - side channel +- agents can use Axelate through MCP, but MCP remains a controlled adapter over + documented launcher capabilities -### 8. Prepare Package Signing And Update Trust +### 9. Prepare Package Signing And Update Trust Difficulty: `7/10` to `9/10` @@ -274,7 +322,7 @@ Exit criteria: - the desktop can distinguish trusted official packages from manual local imports -### 9. Add Trusted Package Discovery And Ownership +### 10. Add Trusted Package Discovery And Ownership Difficulty: `8/10` to `9/10` @@ -291,7 +339,7 @@ Exit criteria: - reviewed packages can be discovered, installed, updated, and revoked predictably. -### 10. Build Managed And Hybrid Runtime Support +### 11. Build Managed And Hybrid Runtime Support Difficulty: `9/10` to `10/10` @@ -377,6 +425,16 @@ Turn the current shell into a reliable daily-use Windows AI workstation. - keep hardware-aware resolution readable and debuggable - keep ComfyUI out of the core promise until it is truly product-ready +### Workstream E: Local API And Agent Readiness + +- keep the current integration HTTP API stable +- add read-only launcher state endpoints before adding mutating agent actions +- expose logs and health reports through backend-owned responses that sanitize + common secrets and avoid raw private files +- keep OpenAI-compatible routes separate from launcher-control routes +- design agent permissions before exposing install, delete, or secret-adjacent + operations + ### Exit Criteria - a new user can install the app and complete a first useful workflow @@ -384,20 +442,27 @@ Turn the current shell into a reliable daily-use Windows AI workstation. - logs, monitoring, and repair tools explain failures - provider settings are understandable - local integrations can run through the launcher without manual path hacks +- agents and integrations can inspect basic launcher state through documented + APIs instead of scraping UI or internal files ### Why This Phase Matters If this phase fails, later package and platform layers should not launch. -## Phase 2: Integration And Package Foundation +## Phase 2: Integration, Agent Control, And Package Foundation ### Goal -Create the technical base for user-installed integrations and future reviewed -packages. +Create the technical base for user-installed integrations, agent-assisted +launcher control, and future reviewed packages. ### Work +- stabilize the local Integration API as the source of truth +- add Agent Control API scopes for observe, operate, configure, and draft-create +- add an integration draft generator that creates manifests, entry files, + settings schema, and a test run plan +- add audit logging for agent-initiated actions - define package manifest format - define package permission model - define settings schema model for packages @@ -419,10 +484,12 @@ The package system must support three modes from the start: - package lifecycle is deterministic - packages can be installed and removed safely - package permissions are visible to the user +- agent actions are scoped, logged, and reviewable - the desktop understands package metadata without ad-hoc code paths ### Why This Phase Matters +Without a real local control model, agents become a risky automation shortcut. Without a real package model, package discovery is just marketing. ## Phase 3: Trusted Discovery And Ownership diff --git a/docs/localization/en/TRUST_MODEL.md b/docs/localization/en/TRUST_MODEL.md index 370407e8..126f717b 100644 --- a/docs/localization/en/TRUST_MODEL.md +++ b/docs/localization/en/TRUST_MODEL.md @@ -12,6 +12,7 @@ That only works if the launcher is explicit about: - what lives in the backend - what the frontend is allowed to do - what local modules are allowed to do +- what agents are allowed to do through launcher APIs - what future MCP and package permissions should look like ## What Is Protected Today @@ -22,7 +23,8 @@ Current repository-grounded trust decisions: - Rust owns domain logic and persisted state - frontend bindings are generated from Rust types - process and module lifecycle are controlled from the backend side -- secure storage infrastructure exists for provider secrets +- encrypted file-based secure storage infrastructure exists for provider + secrets; it is backend-owned, but it is not yet backed by the OS keystore - local integration API tokens are issued at runtime and scoped to the launcher process or a specific module - module-owned local API routes reject access to other module ids @@ -45,10 +47,19 @@ These controls exist in the current codebase and should stay protected by tests: entries, file count limits, single-file size limits, and total-size limits - explicit validation before opening console log target folders - external URL protocol allowlisting before frontend shell-open calls +- local integration API routes that keep module-owned operations scoped to the + owning module These controls reduce accidental trust escalation. They do not make imported integrations sandboxed or verified packages. +Current secure storage uses AES-GCM with a key derived from the machine identity +and an application pepper. That is better than plaintext and keeps secrets out +of frontend state, but it is weaker than DPAPI on Windows, Keychain on macOS, or +libsecret/KWallet on Linux against a local user or process with filesystem +access. Moving provider secrets to platform keystores is tracked as security +debt, not claimed as done. + ## Current Security Boundaries ### Backend @@ -88,6 +99,44 @@ Current practical rule: Future package and module UX should make this much more visible. +### Agents And Automation + +Agents should use documented launcher APIs, not the UI DOM and not private files. + +Agent Control is local-only and token-based. Users create Trusted Local or Full +Access profiles in Settings. The token is retrieved through the copy-only +`copyAgentProfileToken` flow, not exposed as persistent frontend state, and can +be rotated or deleted by the user. + +The normal agent scopes are: + +- `observe`: launcher health, installed module list, module status, provider and + model inventory, pending approvals, and recent sanitized console logs +- `operate`: open launcher pages, select module cards, start, stop, restart, + repair, and run AI requests +- `configure`: read and update non-secret module settings +- `draft-create`: create integration draft folders without installing or running + them +- `full-access`: explicit user-granted local override + +Mutating actions need a stronger scope and should be logged: + +- start, stop, or restart an integration +- update integration settings +- request a repair action +- create an integration draft + +Some actions should require user approval even after an agent is connected: + +- install, delete, or update packages +- change provider secrets +- expose raw logs that may contain sensitive data +- grant broader filesystem or network permissions + +This keeps agents useful without turning them into a hidden admin surface. + +The detailed API contract lives in [Agent Control](AGENT_CONTROL.md). + ## What Users Should Be Able To Trust Current repository direction already supports these expectations: @@ -114,6 +163,7 @@ What still needs clearer product-level documentation and UX: - secret storage model - package permission model - module capability boundaries +- agent scopes and approval rules - MCP server and tool permission prompts - package verification and signing flow - difference between local, managed, and hybrid execution @@ -126,6 +176,7 @@ The launcher should move toward explicit permission surfaces for: - network access - local process execution - model/provider usage +- launcher control actions - MCP server connection - MCP tool invocation - package install and update trust @@ -138,6 +189,10 @@ The important rule is simple: MCP support should be opt-in and permissioned. +Axelate should expose its own MCP server only as an adapter over the documented +Agent Control API. The MCP server should not get private shortcuts around +authorization, audit logs, or approval prompts. + Good future behavior: - users see which server is connected diff --git a/docs/localization/en/VISION.md b/docs/localization/en/VISION.md index 4575ebbc..e32b5b32 100644 --- a/docs/localization/en/VISION.md +++ b/docs/localization/en/VISION.md @@ -1,6 +1,6 @@ # Axelate Vision -> Product direction as of 2026-05-06. +> Product direction as of 2026-05-21. > Planning document only. Use `CURRENT_STATE.md`, `GETTING_STARTED.md`, and > `DEVELOPMENT_WORKFLOW.md` for the repository as it works today. @@ -36,6 +36,7 @@ Axelate wins only if it stays narrow and honest: - one desktop shell for local and cloud AI work - one integration runtime for user-installed AI tools - one local API surface for chat, image, settings, status, logs, and lifecycle +- one agent-facing control layer for safe launcher automation - one trust model for secrets, permissions, runtime folders, updates, and future verified packages @@ -69,6 +70,7 @@ It should provide: - backend-owned credential storage - integration settings, runtime folders, and logs - downloads, console logs, monitoring, and repair actions +- agent-readable status, logs, and health reports through backend-owned APIs The workstation core should stay Windows-first until the product model is proven. @@ -106,6 +108,30 @@ The current repository does not yet ship full package signing, publisher verification, package review, or managed execution. Those belong to later platform layers. +### 4. Agent Control Layer + +Agents should be able to help users operate the launcher, but they should not +become an invisible admin account. + +The useful version is practical and limited: + +- inspect launcher state +- read sanitized logs +- start, stop, and restart integrations +- update integration settings +- run text and image requests through the same backend paths as the UI +- create integration drafts from templates + +The unsafe version is easy to imagine and should be avoided: + +- no direct access to provider secrets +- no silent package installs or deletes +- no filesystem access outside documented runtime and log folders +- no hidden MCP tools that mutate state without user approval + +This layer should start as a local Agent Control API. MCP can sit on top of it +once permissions, audit logs, and approval flows are in place. + ## Immediate Focus The next product work should prioritize: @@ -113,10 +139,11 @@ The next product work should prioritize: 1. runtime reliability 2. custom integration import and lifecycle 3. OpenAI-compatible local API -4. TypeScript and Python SDKs -5. integration templates and examples -6. visible trust and permission UX -7. MCP foundation after runtime and permissions are stable +4. Agent Control API for observe and operate workflows +5. TypeScript and Python SDKs +6. integration templates and examples +7. visible trust and permission UX +8. MCP foundation after runtime, agent scopes, and permissions are stable Package discovery, account-backed ownership, and managed execution should not lead the roadmap until the workstation and local integration path are reliable. @@ -145,17 +172,21 @@ Ship a reliable Windows desktop with: This phase proves product value to users and developers. -### Phase 2: Trusted Package Layer +### Phase 2: Integration, Agent Control, And Package Foundation Ship: +- stable local Integration API contract +- Agent Control API scopes for observe, operate, configure, and draft-create +- audit logging and approval flow for agent-initiated actions - package manifest and permission model - verified package metadata - signing and update trust - reviewed package install/update/remove flow - user-visible execution mode labels -This phase proves that Axelate can safely move beyond manual local imports. +This phase proves that Axelate can safely move beyond manual local imports and +UI-only operation. ### Phase 3: Platform Layer diff --git a/docs/localization/ru/INTEGRATION_DEVELOPMENT.md b/docs/localization/ru/INTEGRATION_DEVELOPMENT.md index 823a8d7f..36b00d0d 100644 --- a/docs/localization/ru/INTEGRATION_DEVELOPMENT.md +++ b/docs/localization/ru/INTEGRATION_DEVELOPMENT.md @@ -42,6 +42,9 @@ npm run integration:doctor -- ./my-integration Главный контракт все равно описан в [Integration API](../en/INTEGRATION_API.md) (англ., в `docs/localization/en/INTEGRATION_API.md`): это локальный HTTP API лаунчера. +Он подходит для launcher-managed интеграций и для текущего локального Agent +Control слоя. Внешние агенты должны использовать agent profile token, scopes, +audit и approvals, а не обходить permission-модель через файлы или UI. ## Структура интеграции @@ -88,6 +91,31 @@ entry = "src/main.py" Используй эти значения при старте процесса. Не хардкодь порт и пути. +## Agent Control + +Текущий Integration API остается модульным и ограниченным для обычных +интеграций: интеграция получает runtime token, работает со своими настройками, +статусом, логами и AI-запросами. + +Agent Control теперь описан отдельным каноническим документом: +[Agent Control](../en/AGENT_CONTROL.md). Это локальный `127.0.0.1` API для +доверенных инструментов вроде Codex, CLI-агентов, IDE-ассистентов и локальных +скриптов. + +Основные scopes: + +- `observe` для чтения статуса, здоровья, списка модулей и очищенных консольных + логов +- `operate` для открытия страниц, выбора карточек, start, stop, restart и repair +- `configure` для изменений настроек, где может понадобиться подтверждение +- `draft-create` для создания папок-черновиков интеграций без установки и + запуска +- `full-access` для ручного полного локального доступа + +Агенты не должны читать внутренние файлы Axelate, скрейпить UI или получать +секреты провайдеров. Для опасных действий нужны scopes, audit log и подтверждение +пользователя. + ## Вызов AI Python: diff --git a/docs/localization/ru/README.md b/docs/localization/ru/README.md index f5e25974..7689189a 100644 --- a/docs/localization/ru/README.md +++ b/docs/localization/ru/README.md @@ -10,14 +10,22 @@ ## Канонические английские документы - [Current State](../en/CURRENT_STATE.md) - что реально есть в проекте сейчас +- [Vision](../en/VISION.md) - направление продукта и границы будущей платформы +- [Roadmap](../en/ROADMAP.md) - порядок работ, включая Integration API, + Agent Control API, SDK, permissions и MCP - [User Guide](../en/USER_GUIDE.md) - пользовательский обзор текущего приложения - [Getting Started](../en/GETTING_STARTED.md) - установка и запуск из исходников - [Development Workflow](../en/DEVELOPMENT_WORKFLOW.md) - ежедневная разработка - [Architecture](../en/ARCHITECTURE.md) - карта frontend/backend/integration слоев - [Trust Model](../en/TRUST_MODEL.md) - текущие и будущие границы доверия -- [Integration API](../en/INTEGRATION_API.md) - локальный HTTP API интеграций +- [Agent Control](../en/AGENT_CONTROL.md) - локальное управление лаунчером через + доверенные agent-токены, scopes, approvals и audit +- [Integration API](../en/INTEGRATION_API.md) - локальный HTTP API интеграций и + agent-control слоя - [Custom Integrations](../en/CUSTOM_INTEGRATIONS.md) - формат пользовательских интеграций - [Releases](../en/RELEASES.md) - релизный процесс -`VISION.md` и `ROADMAP.md` в английской папке описывают планы, а не уже -поставленные возможности. +Английские `VISION.md` и `ROADMAP.md` описывают планы, а не только уже +поставленные возможности. Первая локальная версия Agent Control уже описана в +`AGENT_CONTROL.md`; MCP, SDK и полноценная permission-модель для пакетов остаются +следующими этапами. diff --git a/docs/localization/zh/INTEGRATION_DEVELOPMENT.md b/docs/localization/zh/INTEGRATION_DEVELOPMENT.md index 2afa53d3..41459fd0 100644 --- a/docs/localization/zh/INTEGRATION_DEVELOPMENT.md +++ b/docs/localization/zh/INTEGRATION_DEVELOPMENT.md @@ -37,6 +37,9 @@ npm run integration:doctor -- ./my-integration 真正的运行时契约仍然是 [Integration API](../en/INTEGRATION_API.md) 中描述的本地 HTTP API。 +它适合 launcher-managed 集成,也支撑当前的本地 Agent Control 层。外部 agent +必须使用 agent profile token、scopes、audit 和 approvals,不能通过文件或 UI +绕过权限模型。 ## 集成结构 @@ -83,6 +86,26 @@ Axelate 启动 script-runtime 集成时会设置: 在进程启动时读取这些值。不要硬编码端口或数据路径。 +## Agent Control + +当前 Integration API 对普通集成仍然是模块级、受限制的接口:集成拿到 +runtime token 后,只能使用自己的设置、状态、日志目录和 AI 请求能力。 + +Agent Control 已由单独的英文规范文档说明: +[Agent Control](../en/AGENT_CONTROL.md)。它是面向 Codex、本地 CLI agent、 +IDE assistant 和本地脚本的 `127.0.0.1` 本地控制 API。 + +主要 scopes: + +- `observe` 读取状态、健康信息、模块列表和清理后的控制台日志 +- `operate` 打开页面、选择卡片、start、stop、restart 和 repair +- `configure` 修改设置,必要时需要用户确认 +- `draft-create` 创建集成草稿文件夹,但不安装或运行 +- `full-access` 由用户手动授予完整本地访问 + +Agent 不应该读取 Axelate 内部文件、抓取 UI,或拿到 provider secrets。 +危险操作需要 scopes、audit log 和用户确认。 + ## 调用 AI Python: diff --git a/docs/localization/zh/README.md b/docs/localization/zh/README.md index 0ea2a0cb..50f41c01 100644 --- a/docs/localization/zh/README.md +++ b/docs/localization/zh/README.md @@ -9,13 +9,20 @@ ## 英文规范文档 - [Current State](../en/CURRENT_STATE.md) - 当前仓库已经实现的内容 +- [Vision](../en/VISION.md) - 产品方向和未来平台边界 +- [Roadmap](../en/ROADMAP.md) - 工作顺序,包括 Integration API、Agent Control + API、SDK、权限和 MCP - [User Guide](../en/USER_GUIDE.md) - 当前桌面应用使用说明 - [Getting Started](../en/GETTING_STARTED.md) - 从源码安装和运行 - [Development Workflow](../en/DEVELOPMENT_WORKFLOW.md) - 日常开发流程 - [Architecture](../en/ARCHITECTURE.md) - frontend/backend/integration 分层 - [Trust Model](../en/TRUST_MODEL.md) - 当前和未来的信任边界 -- [Integration API](../en/INTEGRATION_API.md) - 集成本地 HTTP API +- [Agent Control](../en/AGENT_CONTROL.md) - 使用可信本地 agent token、scopes、 + approvals 和 audit 控制启动器 +- [Integration API](../en/INTEGRATION_API.md) - 集成本地 HTTP API,也是 + agent-control 层的基础 - [Custom Integrations](../en/CUSTOM_INTEGRATIONS.md) - 自定义集成格式 - [Releases](../en/RELEASES.md) - 发布流程 -英文目录中的 `VISION.md` 和 `ROADMAP.md` 是规划文档,不代表当前已经发布的功能。 +英文目录中的 `VISION.md` 和 `ROADMAP.md` 是规划文档,不只描述已经发布的功能。 +第一版本地 Agent Control 已在 `AGENT_CONTROL.md` 中说明。 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index aa956e93..14a2d78d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -312,6 +312,7 @@ dependencies = [ "flate2", "futures-util", "hex", + "libc", "log", "machine-uid", "notify", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6ae01cde..96310a5d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -102,6 +102,9 @@ tar = "0.4.46" sevenz-rust2 = "0.21.0" notify = "8.2.0" +[target.'cfg(unix)'.dependencies] +libc = "0.2.178" + [target.'cfg(windows)'.dependencies] windows = { version = "0.62.2", features = [ "Foundation", diff --git a/src-tauri/resources/locales/en.json b/src-tauri/resources/locales/en.json index ec3c9fab..8a48e495 100644 --- a/src-tauri/resources/locales/en.json +++ b/src-tauri/resources/locales/en.json @@ -105,6 +105,29 @@ "ui.debug.logs_copied": "Logs copied", "ui.debug.logs_copy_failed": "Failed to copy logs", "ui.debug.logs_empty": "No logs to copy", + "ui.debug.logs_filter_empty": "No logs match selected levels", + "ui.debug.logs_actions": "Actions", + "ui.debug.logs_clear": "Clear Console", + "ui.debug.logs_clear_all_confirm": "Confirm clear all console logs", + "ui.debug.logs_clear_all_confirm_title": "Right-click again to clear all logs", + "ui.debug.logs_clear_confirm": "Confirm clear console logs", + "ui.debug.logs_clear_confirm_title": "Click again to clear logs", + "ui.debug.logs_controls": "Console controls", + "ui.debug.logs_copy": "Copy Logs", + "ui.debug.logs_folder_agent_disabled": "No folder for agent log", + "ui.debug.logs_folder_unavailable": "Agent actions are stored in the launcher audit log", + "ui.debug.logs_agent_result_recorded": "recorded", + "ui.debug.logs_agent_target_launcher": "launcher", + "ui.debug.logs_level_debug": "Debug", + "ui.debug.logs_level_error": "Error", + "ui.debug.logs_level_info": "Info", + "ui.debug.logs_level_warn": "Warn", + "ui.debug.logs_levels": "Levels", + "ui.debug.logs_none": "No logs yet", + "ui.debug.logs_open_folder": "Open Logs Folder", + "ui.debug.logs_open_folder_failed": "Failed to open logs folder", + "ui.debug.logs_tabs_next": "Next log tabs", + "ui.debug.logs_tabs_previous": "Previous log tabs", "ui.deepseek.model.v4_flash.desc": "Efficiency-optimized Mixture-of-Experts model for fast inference, high throughput, reasoning, and coding.", "ui.deepseek.model.v4_pro.desc": "Large-scale Mixture-of-Experts model for advanced reasoning, coding, and long-horizon agent workflows.", "ui.downloads.no_active": "No active downloads", @@ -147,6 +170,10 @@ "ui.launcher.app.gemini_image.desc": "Google Nano Banana family for image generation and editing.", "ui.launcher.app.gpt_image.desc": "OpenAI GPT Image family for image generation and editing.", "ui.launcher.app.seedream_image.desc": "ByteDance Seedream family for image generation and editing.", + "ui.launcher.app.custom_text.name": "Custom", + "ui.launcher.app.custom_text.desc": "Any text model by manual model ID.", + "ui.launcher.app.custom_image.name": "Custom", + "ui.launcher.app.custom_image.desc": "Any image model by manual model ID.", "ui.launcher.app.gpt.desc": "OpenAI models for chat, coding, reasoning, and image workflows.", "ui.module.gemini-image": "Nano Banana", "ui.module.gpt-image": "GPT Image", @@ -222,6 +249,68 @@ "ui.launcher.settings.monitor_vram": "VRAM", "ui.launcher.settings.taskbar_desc": "Configure sidebar page visibility", "ui.launcher.settings.taskbar_title": "Sidebar Management", + "ui.launcher.settings.section_jump": "Switch settings section", + "ui.launcher.settings.agent_control_title": "External Agent API", + "ui.launcher.settings.agent_control_loading": "Loading...", + "ui.launcher.settings.agent_control_load_failed": "Failed to load external agents", + "ui.launcher.settings.agent_control_enabled": "Enabled", + "ui.launcher.settings.agent_control_disabled": "Disabled", + "ui.launcher.settings.agent_control_enable": "Enable", + "ui.launcher.settings.agent_control_disable": "Disable", + "ui.launcher.settings.agent_control_docs_help": "What this API is and how to use it", + "ui.launcher.settings.agent_control_connection": "Connection", + "ui.launcher.settings.agent_control_base_url": "Base URL", + "ui.launcher.settings.agent_control_copy_base": "Copy URL", + "ui.launcher.settings.agent_control_copy_config": "Copy config", + "ui.launcher.settings.agent_control_create_profile": "Selected Permissions", + "ui.launcher.settings.agent_control_create_profile_hint": "Create a token with the selected permissions", + "ui.launcher.settings.agent_control_create_full_access": "Full Access", + "ui.launcher.settings.agent_control_create_full_access_hint": "Create a token with all permissions. Dangerous actions still require approval.", + "ui.launcher.settings.agent_control_trusted_local": "Trusted Local", + "ui.launcher.settings.agent_control_full_access": "Full Access", + "ui.launcher.settings.agent_control_full_access_confirm": "Create a token for an external agent with full local launcher access?", + "ui.launcher.settings.agent_control_confirm_action": "Confirm", + "ui.launcher.settings.agent_control_profile_created": "Profile created", + "ui.launcher.settings.agent_control_profile_created_and_copied": "Profile created and copied", + "ui.launcher.settings.agent_control_token_once": "Token shown once", + "ui.launcher.settings.agent_control_token_hidden": "Hidden. Copy it now or reveal it explicitly.", + "ui.launcher.settings.agent_control_copy_token": "Copy token", + "ui.launcher.settings.agent_control_show_token": "Show token", + "ui.launcher.settings.agent_control_hide_token": "Hide token", + "ui.launcher.settings.agent_control_profiles": "Access tokens", + "ui.launcher.settings.agent_control_no_profiles": "No access tokens yet", + "ui.launcher.settings.agent_control_revoked": "Revoked", + "ui.launcher.settings.agent_control_active": "Active", + "ui.launcher.settings.agent_control_rotate": "Generate new token", + "ui.launcher.settings.agent_control_rotate_hint": "Create a new token and copy it immediately", + "ui.launcher.settings.agent_control_delete_profile": "Delete", + "ui.launcher.settings.agent_control_delete_profile_hint": "Delete the token and its pending approvals", + "ui.launcher.settings.agent_control_delete_profile_confirm": "Delete this agent profile?", + "ui.launcher.settings.agent_control_token_rotated": "New token created", + "ui.launcher.settings.agent_control_token_rotated_and_copied": "Token created and copied", + "ui.launcher.settings.agent_control_profile_deleted": "Token deleted", + "ui.launcher.settings.agent_control_approvals": "Approvals", + "ui.launcher.settings.agent_control_no_approvals": "No pending approvals", + "ui.launcher.settings.agent_control_approve": "Approve", + "ui.launcher.settings.agent_control_deny": "Deny", + "ui.launcher.settings.agent_control_approval_approved": "Approved", + "ui.launcher.settings.agent_control_approval_denied": "Denied", + "ui.launcher.settings.agent_control_action_failed": "Action failed", + "ui.launcher.settings.agent_control_copied": "Copied", + "ui.launcher.settings.agent_control_copy_failed": "Copy failed", + "ui.launcher.settings.agent_control_never_seen": "Never connected", + "ui.launcher.settings.agent_control_last_seen": "Last seen", + "ui.launcher.settings.agent_control_permissions": "Permissions", + "ui.launcher.settings.agent_control_scope_observe": "observe", + "ui.launcher.settings.agent_control_scope_observe_hint": "Read state, statuses, and sanitized logs", + "ui.launcher.settings.agent_control_scope_operate": "operate", + "ui.launcher.settings.agent_control_scope_operate_hint": "Start, stop, and select modules", + "ui.launcher.settings.agent_control_scope_configure": "configure", + "ui.launcher.settings.agent_control_scope_configure_hint": "Change safe launcher settings", + "ui.launcher.settings.agent_control_scope_draft-create": "draft-create", + "ui.launcher.settings.agent_control_scope_draft-create_hint": "Create integration drafts without installing them", + "ui.launcher.settings.agent_control_scope_full-access": "full access", + "ui.launcher.settings.agent_control_scope_full-access_hint": "All agent permissions. Dangerous actions go through approval.", "ui.launcher.web.app_title": "Axelate", "ui.launcher.web.chat": "Chat", "ui.launcher.web.chat_clear": "Clear chat", @@ -251,6 +340,7 @@ "ui.launcher.web.home": "Home", "ui.launcher.web.home_title": "Main Menu", "ui.launcher.web.information": "Information", + "ui.launcher.web.logs_agent": "Agent", "ui.launcher.web.logs_general": "Platform", "ui.launcher.web.main_menu": "Main Menu", "ui.connectivity.offline_title": "No internet connection", @@ -285,6 +375,7 @@ "ui.settings.api_key_label": "API Key", "ui.settings.api_key_label_openrouter": "OpenRouter API Key", "ui.settings.api_key_label_custom": "Custom provider API key", + "ui.settings.manage_openrouter_keys_title": "Manage your OpenRouter API keys", "ui.settings.api_endpoint": "API Endpoint", "ui.settings.api_endpoint_custom": "Custom", "ui.settings.api_endpoint_custom_desc": "Other OpenAI-compatible API", diff --git a/src-tauri/resources/locales/ru.json b/src-tauri/resources/locales/ru.json index 67f230d1..3bfd5483 100644 --- a/src-tauri/resources/locales/ru.json +++ b/src-tauri/resources/locales/ru.json @@ -105,6 +105,29 @@ "ui.debug.logs_copied": "Логи скопированы", "ui.debug.logs_copy_failed": "Не удалось скопировать логи", "ui.debug.logs_empty": "Нет логов для копирования", + "ui.debug.logs_filter_empty": "Нет логов выбранных уровней", + "ui.debug.logs_actions": "Действия", + "ui.debug.logs_clear": "Очистить консоль", + "ui.debug.logs_clear_all_confirm": "Подтвердить очистку всех логов", + "ui.debug.logs_clear_all_confirm_title": "Нажмите правой кнопкой еще раз", + "ui.debug.logs_clear_confirm": "Подтвердить очистку логов", + "ui.debug.logs_clear_confirm_title": "Нажмите еще раз, чтобы очистить", + "ui.debug.logs_controls": "Управление консолью", + "ui.debug.logs_copy": "Копировать логи", + "ui.debug.logs_folder_agent_disabled": "У журнала агента нет папки", + "ui.debug.logs_folder_unavailable": "Действия агента хранятся в audit-журнале лаунчера", + "ui.debug.logs_agent_result_recorded": "записано", + "ui.debug.logs_agent_target_launcher": "лаунчер", + "ui.debug.logs_level_debug": "Debug", + "ui.debug.logs_level_error": "Error", + "ui.debug.logs_level_info": "Info", + "ui.debug.logs_level_warn": "Warn", + "ui.debug.logs_levels": "Уровни", + "ui.debug.logs_none": "Логов пока нет", + "ui.debug.logs_open_folder": "Открыть папку логов", + "ui.debug.logs_open_folder_failed": "Не удалось открыть папку логов", + "ui.debug.logs_tabs_next": "Следующие вкладки логов", + "ui.debug.logs_tabs_previous": "Предыдущие вкладки логов", "ui.deepseek.model.v4_flash.desc": "Оптимизированная по эффективности Mixture-of-Experts модель для быстрого инференса, высокой пропускной способности, reasoning и кода", "ui.deepseek.model.v4_pro.desc": "Крупная Mixture-of-Experts модель для продвинутого reasoning, кода и долгих агентных рабочих процессов", "ui.downloads.no_active": "Нет активных загрузок", @@ -193,6 +216,9 @@ "ui.launcher.models_subtitle": "Выберите движок или интеграцию для работы", "ui.launcher.module.delete": "Удалить", "ui.launcher.module.download": "Скачать", + "ui.launcher.module.downloading": "Скачивание", + "ui.launcher.module.extracting": "Распаковка...", + "ui.launcher.module.connecting": "Подключение...", "ui.launcher.module.no_settings": "Нет доступных настроек для этого модуля", "ui.launcher.module.remove": "Удалить модуль", "ui.launcher.module.remove_short": "Закрыть", @@ -223,6 +249,68 @@ "ui.launcher.settings.monitor_vram": "VRAM", "ui.launcher.settings.taskbar_desc": "Настройка видимости страниц в боковой панели", "ui.launcher.settings.taskbar_title": "Управление боковой панелью", + "ui.launcher.settings.section_jump": "Перейти к другой секции настроек", + "ui.launcher.settings.agent_control_title": "API для внешних агентов", + "ui.launcher.settings.agent_control_loading": "Загрузка...", + "ui.launcher.settings.agent_control_load_failed": "Не удалось загрузить внешних агентов", + "ui.launcher.settings.agent_control_enabled": "Включено", + "ui.launcher.settings.agent_control_disabled": "Выключено", + "ui.launcher.settings.agent_control_enable": "Включить", + "ui.launcher.settings.agent_control_disable": "Выключить", + "ui.launcher.settings.agent_control_docs_help": "Что это за API и как его использовать", + "ui.launcher.settings.agent_control_connection": "Подключение", + "ui.launcher.settings.agent_control_base_url": "Базовый URL", + "ui.launcher.settings.agent_control_copy_base": "Копировать URL", + "ui.launcher.settings.agent_control_copy_config": "Копировать конфиг", + "ui.launcher.settings.agent_control_create_profile": "Выбранные права", + "ui.launcher.settings.agent_control_create_profile_hint": "Создать токен с выбранными правами", + "ui.launcher.settings.agent_control_create_full_access": "Полный доступ", + "ui.launcher.settings.agent_control_create_full_access_hint": "Создать токен со всеми правами. Опасные действия все равно требуют подтверждения.", + "ui.launcher.settings.agent_control_trusted_local": "Доверенный локальный", + "ui.launcher.settings.agent_control_full_access": "Полный доступ", + "ui.launcher.settings.agent_control_full_access_confirm": "Создать токен для внешнего агента с полным локальным доступом к лаунчеру?", + "ui.launcher.settings.agent_control_confirm_action": "Подтвердить", + "ui.launcher.settings.agent_control_profile_created": "Профиль создан", + "ui.launcher.settings.agent_control_profile_created_and_copied": "Профиль создан и скопирован", + "ui.launcher.settings.agent_control_token_once": "Токен показан один раз", + "ui.launcher.settings.agent_control_token_hidden": "Скрыт. Скопируй сейчас или открой вручную.", + "ui.launcher.settings.agent_control_copy_token": "Копировать токен", + "ui.launcher.settings.agent_control_show_token": "Показать токен", + "ui.launcher.settings.agent_control_hide_token": "Скрыть токен", + "ui.launcher.settings.agent_control_profiles": "Токены доступа", + "ui.launcher.settings.agent_control_no_profiles": "Токенов доступа пока нет", + "ui.launcher.settings.agent_control_revoked": "Отозван", + "ui.launcher.settings.agent_control_active": "Активен", + "ui.launcher.settings.agent_control_rotate": "Сгенерировать новый токен", + "ui.launcher.settings.agent_control_rotate_hint": "Создать новый токен и сразу скопировать его", + "ui.launcher.settings.agent_control_delete_profile": "Удалить", + "ui.launcher.settings.agent_control_delete_profile_hint": "Удалить токен и его ожидающие подтверждения", + "ui.launcher.settings.agent_control_delete_profile_confirm": "Удалить этот профиль агента?", + "ui.launcher.settings.agent_control_token_rotated": "Новый токен создан", + "ui.launcher.settings.agent_control_token_rotated_and_copied": "Токен создан и скопирован", + "ui.launcher.settings.agent_control_profile_deleted": "Токен удален", + "ui.launcher.settings.agent_control_approvals": "Подтверждения", + "ui.launcher.settings.agent_control_no_approvals": "Нет ожидающих подтверждений", + "ui.launcher.settings.agent_control_approve": "Разрешить", + "ui.launcher.settings.agent_control_deny": "Отклонить", + "ui.launcher.settings.agent_control_approval_approved": "Разрешено", + "ui.launcher.settings.agent_control_approval_denied": "Отклонено", + "ui.launcher.settings.agent_control_action_failed": "Действие не выполнено", + "ui.launcher.settings.agent_control_copied": "Скопировано", + "ui.launcher.settings.agent_control_copy_failed": "Не удалось скопировать", + "ui.launcher.settings.agent_control_never_seen": "Еще не подключался", + "ui.launcher.settings.agent_control_last_seen": "Был в сети", + "ui.launcher.settings.agent_control_permissions": "Права", + "ui.launcher.settings.agent_control_scope_observe": "просмотр", + "ui.launcher.settings.agent_control_scope_observe_hint": "Читать состояние, статусы и безопасные логи", + "ui.launcher.settings.agent_control_scope_operate": "управление", + "ui.launcher.settings.agent_control_scope_operate_hint": "Запускать, останавливать и выбирать модули", + "ui.launcher.settings.agent_control_scope_configure": "настройки", + "ui.launcher.settings.agent_control_scope_configure_hint": "Менять безопасные настройки лаунчера", + "ui.launcher.settings.agent_control_scope_draft-create": "черновики", + "ui.launcher.settings.agent_control_scope_draft-create_hint": "Создавать черновики интеграций без установки", + "ui.launcher.settings.agent_control_scope_full-access": "полный доступ", + "ui.launcher.settings.agent_control_scope_full-access_hint": "Все права агента. Опасные действия идут через подтверждение.", "ui.launcher.web.app_title": "Axelate", "ui.launcher.web.chat": "Чат", "ui.launcher.web.chat_clear": "Очистить чат", @@ -252,6 +340,7 @@ "ui.launcher.web.home": "Главное меню", "ui.launcher.web.home_title": "Главное меню", "ui.launcher.web.information": "Информация", + "ui.launcher.web.logs_agent": "Агент", "ui.launcher.web.logs_general": "Платформа", "ui.launcher.web.main_menu": "Главное меню", "ui.connectivity.offline_title": "Нет подключения к интернету", @@ -286,6 +375,7 @@ "ui.settings.api_key_label": "API Ключ", "ui.settings.api_key_label_openrouter": "OpenRouter API ключ", "ui.settings.api_key_label_custom": "API ключ другого провайдера", + "ui.settings.manage_openrouter_keys_title": "Управление API ключами OpenRouter", "ui.settings.api_endpoint": "API endpoint", "ui.settings.api_endpoint_custom": "Свой", "ui.settings.api_endpoint_custom_desc": "Другой OpenAI-compatible API", diff --git a/src-tauri/resources/locales/zh.json b/src-tauri/resources/locales/zh.json index 588347da..8c6edb5b 100644 --- a/src-tauri/resources/locales/zh.json +++ b/src-tauri/resources/locales/zh.json @@ -105,6 +105,29 @@ "ui.debug.logs_copied": "日志已复制", "ui.debug.logs_copy_failed": "复制日志失败", "ui.debug.logs_empty": "没有可复制的日志", + "ui.debug.logs_filter_empty": "没有匹配所选级别的日志", + "ui.debug.logs_actions": "操作", + "ui.debug.logs_clear": "清空控制台", + "ui.debug.logs_clear_all_confirm": "确认清空全部日志", + "ui.debug.logs_clear_all_confirm_title": "再次右键清空全部日志", + "ui.debug.logs_clear_confirm": "确认清空控制台日志", + "ui.debug.logs_clear_confirm_title": "再次点击清空日志", + "ui.debug.logs_controls": "控制台控制", + "ui.debug.logs_copy": "复制日志", + "ui.debug.logs_folder_agent_disabled": "代理日志没有文件夹", + "ui.debug.logs_folder_unavailable": "代理操作保存在启动器审计日志中", + "ui.debug.logs_agent_result_recorded": "已记录", + "ui.debug.logs_agent_target_launcher": "启动器", + "ui.debug.logs_level_debug": "Debug", + "ui.debug.logs_level_error": "Error", + "ui.debug.logs_level_info": "Info", + "ui.debug.logs_level_warn": "Warn", + "ui.debug.logs_levels": "级别", + "ui.debug.logs_none": "暂无日志", + "ui.debug.logs_open_folder": "打开日志文件夹", + "ui.debug.logs_open_folder_failed": "无法打开日志文件夹", + "ui.debug.logs_tabs_next": "下一组日志标签", + "ui.debug.logs_tabs_previous": "上一组日志标签", "ui.deepseek.model.v4_flash.desc": "面向快速推理、高吞吐、推理与编码的效率优化 Mixture-of-Experts 模型", "ui.deepseek.model.v4_pro.desc": "面向高级推理、编码和长周期智能体工作流的大规模 Mixture-of-Experts 模型", "ui.downloads.no_active": "无活动下载", @@ -147,6 +170,10 @@ "ui.launcher.app.gemini_image.desc": "Google Nano Banana 系列,支持图像生成与编辑。", "ui.launcher.app.gpt_image.desc": "OpenAI GPT Image 系列,支持图像生成与编辑。", "ui.launcher.app.seedream_image.desc": "ByteDance Seedream 系列,支持图像生成与编辑。", + "ui.launcher.app.custom_text.name": "Custom", + "ui.launcher.app.custom_text.desc": "通过手动模型 ID 使用任意文本模型。", + "ui.launcher.app.custom_image.name": "Custom", + "ui.launcher.app.custom_image.desc": "通过手动模型 ID 使用任意图像模型。", "ui.launcher.app.gpt.desc": "用于聊天、编程、推理和图像工作流的 OpenAI 模型。", "ui.module.gemini-image": "Nano Banana", "ui.module.gpt-image": "GPT Image", @@ -189,6 +216,9 @@ "ui.launcher.models_subtitle": "选择引擎或集成以开始工作", "ui.launcher.module.delete": "删除", "ui.launcher.module.download": "下载", + "ui.launcher.module.downloading": "下载中", + "ui.launcher.module.extracting": "解压中...", + "ui.launcher.module.connecting": "连接中...", "ui.launcher.module.no_settings": "此模块暂无可用设置", "ui.launcher.module.remove": "移除模块", "ui.launcher.module.remove_short": "关闭", @@ -219,6 +249,68 @@ "ui.launcher.settings.monitor_vram": "显存", "ui.launcher.settings.taskbar_desc": "配置侧边栏页面可见性", "ui.launcher.settings.taskbar_title": "侧边栏管理", + "ui.launcher.settings.section_jump": "切换设置区域", + "ui.launcher.settings.agent_control_title": "外部代理 API", + "ui.launcher.settings.agent_control_loading": "正在加载...", + "ui.launcher.settings.agent_control_load_failed": "无法加载外部代理", + "ui.launcher.settings.agent_control_enabled": "已启用", + "ui.launcher.settings.agent_control_disabled": "已停用", + "ui.launcher.settings.agent_control_enable": "启用", + "ui.launcher.settings.agent_control_disable": "停用", + "ui.launcher.settings.agent_control_docs_help": "了解这个 API 以及如何使用", + "ui.launcher.settings.agent_control_connection": "连接", + "ui.launcher.settings.agent_control_base_url": "基础 URL", + "ui.launcher.settings.agent_control_copy_base": "复制 URL", + "ui.launcher.settings.agent_control_copy_config": "复制配置", + "ui.launcher.settings.agent_control_create_profile": "所选权限", + "ui.launcher.settings.agent_control_create_profile_hint": "使用所选权限创建令牌", + "ui.launcher.settings.agent_control_create_full_access": "完全访问", + "ui.launcher.settings.agent_control_create_full_access_hint": "创建拥有全部权限的令牌。危险操作仍需确认。", + "ui.launcher.settings.agent_control_trusted_local": "可信本地", + "ui.launcher.settings.agent_control_full_access": "完全访问", + "ui.launcher.settings.agent_control_full_access_confirm": "为外部代理创建拥有启动器本地完全访问权限的令牌?", + "ui.launcher.settings.agent_control_confirm_action": "确认", + "ui.launcher.settings.agent_control_profile_created": "配置已创建", + "ui.launcher.settings.agent_control_profile_created_and_copied": "配置已创建并复制", + "ui.launcher.settings.agent_control_token_once": "令牌仅显示一次", + "ui.launcher.settings.agent_control_token_hidden": "已隐藏。请立即复制,或手动显示。", + "ui.launcher.settings.agent_control_copy_token": "复制令牌", + "ui.launcher.settings.agent_control_show_token": "显示令牌", + "ui.launcher.settings.agent_control_hide_token": "隐藏令牌", + "ui.launcher.settings.agent_control_profiles": "访问令牌", + "ui.launcher.settings.agent_control_no_profiles": "暂无访问令牌", + "ui.launcher.settings.agent_control_revoked": "已撤销", + "ui.launcher.settings.agent_control_active": "活动", + "ui.launcher.settings.agent_control_rotate": "生成新令牌", + "ui.launcher.settings.agent_control_rotate_hint": "创建新令牌并立即复制", + "ui.launcher.settings.agent_control_delete_profile": "删除", + "ui.launcher.settings.agent_control_delete_profile_hint": "删除令牌及其待审批请求", + "ui.launcher.settings.agent_control_delete_profile_confirm": "删除此代理配置?", + "ui.launcher.settings.agent_control_token_rotated": "新令牌已创建", + "ui.launcher.settings.agent_control_token_rotated_and_copied": "令牌已创建并复制", + "ui.launcher.settings.agent_control_profile_deleted": "令牌已删除", + "ui.launcher.settings.agent_control_approvals": "审批", + "ui.launcher.settings.agent_control_no_approvals": "没有待审批请求", + "ui.launcher.settings.agent_control_approve": "批准", + "ui.launcher.settings.agent_control_deny": "拒绝", + "ui.launcher.settings.agent_control_approval_approved": "已批准", + "ui.launcher.settings.agent_control_approval_denied": "已拒绝", + "ui.launcher.settings.agent_control_action_failed": "操作失败", + "ui.launcher.settings.agent_control_copied": "已复制", + "ui.launcher.settings.agent_control_copy_failed": "复制失败", + "ui.launcher.settings.agent_control_never_seen": "从未连接", + "ui.launcher.settings.agent_control_last_seen": "上次连接", + "ui.launcher.settings.agent_control_permissions": "权限", + "ui.launcher.settings.agent_control_scope_observe": "观察", + "ui.launcher.settings.agent_control_scope_observe_hint": "读取状态、运行信息和脱敏日志", + "ui.launcher.settings.agent_control_scope_operate": "操作", + "ui.launcher.settings.agent_control_scope_operate_hint": "启动、停止和选择模块", + "ui.launcher.settings.agent_control_scope_configure": "配置", + "ui.launcher.settings.agent_control_scope_configure_hint": "更改安全的启动器设置", + "ui.launcher.settings.agent_control_scope_draft-create": "草稿", + "ui.launcher.settings.agent_control_scope_draft-create_hint": "创建集成草稿但不安装", + "ui.launcher.settings.agent_control_scope_full-access": "完全访问", + "ui.launcher.settings.agent_control_scope_full-access_hint": "全部代理权限。危险操作仍需审批。", "ui.launcher.web.app_title": "Axelate", "ui.launcher.web.chat": "聊天", "ui.launcher.web.chat_clear": "清除聊天", @@ -248,6 +340,7 @@ "ui.launcher.web.home": "首页", "ui.launcher.web.home_title": "主菜单", "ui.launcher.web.information": "信息", + "ui.launcher.web.logs_agent": "代理", "ui.launcher.web.logs_general": "平台", "ui.launcher.web.main_menu": "主菜单", "ui.connectivity.offline_title": "网络连接不可用", @@ -282,6 +375,7 @@ "ui.settings.api_key_label": "API 密钥", "ui.settings.api_key_label_openrouter": "OpenRouter API 密钥", "ui.settings.api_key_label_custom": "自定义提供商 API 密钥", + "ui.settings.manage_openrouter_keys_title": "管理 OpenRouter API 密钥", "ui.settings.api_endpoint": "API 端点", "ui.settings.api_endpoint_custom": "自定义", "ui.settings.api_endpoint_custom_desc": "其他 OpenAI 兼容 API", diff --git a/src-tauri/src/api/agent_control.rs b/src-tauri/src/api/agent_control.rs new file mode 100644 index 00000000..9c643750 --- /dev/null +++ b/src-tauri/src/api/agent_control.rs @@ -0,0 +1,93 @@ +//! Tauri commands for trusted local Agent Control. + +use crate::domain::agent_control::{ + AgentControlService, AgentControlState, AgentProfileTokenResponse, AgentScope, +}; +use crate::errors::AppError; +use tauri::AppHandle; +use tauri_plugin_clipboard_manager::ClipboardExt; + +fn api_base_url() -> String { + crate::domain::integration_api::api_base_url().to_string() +} + +/// Returns redacted Agent Control state. +#[tauri::command] +#[specta::specta] +pub async fn get_agent_control_state( + service: tauri::State<'_, AgentControlService>, +) -> Result { + service.state(api_base_url()).await +} + +/// Enables or disables trusted local Agent Control profiles. +#[tauri::command] +#[specta::specta] +pub async fn set_agent_control_enabled( + service: tauri::State<'_, AgentControlService>, + enabled: bool, +) -> Result { + service.set_enabled(enabled, api_base_url()).await +} + +/// Creates a trusted local agent profile and returns its one-time token. +#[tauri::command] +#[specta::specta] +pub async fn create_agent_profile( + service: tauri::State<'_, AgentControlService>, + name: Option, + scopes: Option>, +) -> Result { + service.create_profile(name, scopes).await +} + +/// Rotates a trusted local agent token and returns the replacement token once. +#[tauri::command] +#[specta::specta] +pub async fn rotate_agent_profile( + service: tauri::State<'_, AgentControlService>, + id: String, +) -> Result { + service.rotate_profile(&id).await +} + +/// Copies a one-time agent token to the OS clipboard without exposing it to the frontend. +#[tauri::command] +#[specta::specta] +pub async fn copy_agent_profile_token( + app: AppHandle, + service: tauri::State<'_, AgentControlService>, + id: String, +) -> Result<(), AppError> { + service + .copy_pending_token_with(&id, |token| { + app.clipboard() + .write_text(token) + .map_err(|error| AppError::External { + message: format!("Failed to copy Agent Control token: {error}"), + request_id: None, + }) + }) + .await +} + +/// Deletes a trusted local agent profile. +#[tauri::command] +#[specta::specta] +pub async fn delete_agent_profile( + service: tauri::State<'_, AgentControlService>, + id: String, +) -> Result { + service.delete_profile(&id, api_base_url()).await +} + +/// Applies a user decision to a pending agent approval request. +#[tauri::command] +#[specta::specta] +pub async fn decide_agent_approval( + service: tauri::State<'_, AgentControlService>, + id: String, + approved: bool, +) -> Result { + service.decide_approval(&id, approved, api_base_url()).await +} diff --git a/src-tauri/src/api/ai/mod.rs b/src-tauri/src/api/ai/mod.rs index c44b70c7..e15a9272 100644 --- a/src-tauri/src/api/ai/mod.rs +++ b/src-tauri/src/api/ai/mod.rs @@ -757,6 +757,8 @@ pub fn open_chat_image_location(file_path: String, folder_path: String) -> Resul let requested_folder = PathBuf::from(&folder_path); let (file, open_folder_only) = resolve_image_open_target(&requested_file, &requested_folder)?; let path = image_open_directory(&file, open_folder_only)?; + #[cfg(target_os = "macos")] + let _ = &path; #[cfg(target_os = "windows")] { diff --git a/src-tauri/src/api/mod.rs b/src-tauri/src/api/mod.rs index 7b2bf8f0..b059a574 100644 --- a/src-tauri/src/api/mod.rs +++ b/src-tauri/src/api/mod.rs @@ -1,3 +1,5 @@ +/// Trusted local Agent Control commands +pub mod agent_control; /// AI-related commands (chat, models) pub mod ai; /// Engine lifecycle commands (start, stop, status) diff --git a/src-tauri/src/api/modules/mod.rs b/src-tauri/src/api/modules/mod.rs index 0be85ae7..a7fd6213 100644 --- a/src-tauri/src/api/modules/mod.rs +++ b/src-tauri/src/api/modules/mod.rs @@ -24,7 +24,7 @@ pub async fn get_module_status(module_id: String) -> Result { #[tauri::command] #[specta::specta] -/// Controls a module (start, stop, restart) +/// Controls a module (start, stop, restart, repair) pub async fn control_module( app: AppHandle, request: ControlRequest, diff --git a/src-tauri/src/api/system/config.rs b/src-tauri/src/api/system/config.rs index 6807990c..37504f8e 100644 --- a/src-tauri/src/api/system/config.rs +++ b/src-tauri/src/api/system/config.rs @@ -1,13 +1,26 @@ +use crate::domain::engine::manager::EngineManager; use crate::domain::modules::downloader; use crate::domain::system::config_service::ConfigService; use crate::errors::AppError; -use crate::models::config::AppConfig; +use crate::models::config::{ + ApiProvider, AppConfig, CatalogAppItem, CatalogProviderPolicy, CatalogSnapshot, ModuleItem, +}; +use crate::models::modules::Module; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use tauri::State; + +const SHARED_CLOUD_KEY_PROVIDER_ID: &str = "cloud"; +const SHARED_CLOUD_SECRET_SERVICE: &str = "cloud_api_key"; +const OPENROUTER_KEY_URL: &str = "https://openrouter.ai/settings/keys"; +const CUSTOM_TEXT_PROVIDER_ID: &str = "custom-text"; +const CUSTOM_IMAGE_PROVIDER_ID: &str = "custom-image"; #[tauri::command] #[specta::specta] /// Loads application configuration with module installation status pub async fn get_config( - config_service: tauri::State<'_, std::sync::Arc>, + config_service: State<'_, Arc>, ) -> Result { let mut config = config_service.load_full_config()?; @@ -21,3 +34,574 @@ pub async fn get_config( Ok(config) } + +#[tauri::command] +#[specta::specta] +/// Returns a frontend-ready catalog snapshot with backend-owned installation and provider metadata. +pub async fn get_catalog_snapshot( + config_service: State<'_, Arc>, + engine_manager: State<'_, Arc>, +) -> Result { + let config = get_config(config_service).await?; + let modules = crate::domain::modules::controller::get_all_modules().await; + let engine_defs = engine_manager.list_definitions().await; + Ok(build_catalog_snapshot(config, modules, engine_defs)) +} + +fn build_catalog_snapshot( + config: AppConfig, + installed_modules: Vec, + mut engine_defs: Vec, +) -> CatalogSnapshot { + mark_engine_definitions_installed(&mut engine_defs); + + let installed_modules_by_id = installed_modules + .into_iter() + .map(|module| (module.id.to_ascii_lowercase(), module)) + .collect::>(); + let engine_defs_by_id = engine_defs + .into_iter() + .map(|engine| (engine.id.to_ascii_lowercase(), engine)) + .collect::>(); + let api_providers_by_id = config + .api_providers + .iter() + .cloned() + .map(|provider| (provider.id.to_ascii_lowercase(), provider)) + .collect::>(); + + let mut known_ids = HashSet::new(); + let mut ai = config + .catalog + .ai + .iter() + .map(|item| { + known_ids.insert(item.id.to_ascii_lowercase()); + build_catalog_item( + item, + "ai", + &api_providers_by_id, + &installed_modules_by_id, + &engine_defs_by_id, + ) + }) + .collect::>(); + + append_custom_provider_items(&mut ai, &mut known_ids); + + let mut services = config + .catalog + .services + .iter() + .map(|item| { + known_ids.insert(item.id.to_ascii_lowercase()); + build_catalog_item( + item, + "services", + &api_providers_by_id, + &installed_modules_by_id, + &engine_defs_by_id, + ) + }) + .collect::>(); + + services.extend( + installed_modules_by_id + .values() + .filter(|module| !known_ids.contains(&module.id.to_ascii_lowercase())) + .map(build_discovered_integration_item), + ); + + CatalogSnapshot { + ai, + services, + stars: config.catalog.stars, + } +} + +fn mark_engine_definitions_installed(defs: &mut [crate::domain::engine::types::EngineDefinition]) { + for def in defs { + def.installed = def.managed_externally + || crate::domain::engine::detector::is_engine_installed(&def.id, def.binary.as_deref()); + def.installed_compute_modes = if def.installed && !def.managed_externally { + crate::domain::engine::detector::installed_compute_modes(&def.id) + } else { + Vec::new() + }; + } +} + +fn build_catalog_item( + item: &ModuleItem, + category: &str, + api_providers_by_id: &HashMap, + installed_modules_by_id: &HashMap, + engine_defs_by_id: &HashMap, +) -> CatalogAppItem { + let key = item.id.to_ascii_lowercase(); + let api_provider = api_providers_by_id.get(&key).cloned(); + let installed_module = installed_modules_by_id.get(&key); + let engine = engine_defs_by_id.get(&key); + let capability = primary_capability(&item.capabilities); + let is_api = item.type_name != "local" || api_provider.is_some(); + let installed = if item.coming_soon { + false + } else if item.managed_externally || is_api { + true + } else if let Some(engine) = engine { + engine.installed + } else { + installed_module.is_some() + }; + + CatalogAppItem { + id: item.id.clone(), + name_key: Some(item.name_key.clone()), + desc_key: Some(item.desc_key.clone()), + name: Some(item.name.clone()), + desc: Some(item.desc.clone()), + icon: Some(item.icon.clone()), + preview: installed_module + .and_then(|module| module.preview.clone()) + .or_else(|| item.preview.clone()), + category: category.to_string(), + type_name: if category == "ai" && item.type_name != "local" { + "api".to_string() + } else { + "local".to_string() + }, + capability: capability.clone(), + installed, + installed_compute_modes: engine + .map(|engine| { + engine + .installed_compute_modes + .iter() + .map(|mode| match mode { + crate::domain::engine::types::EngineComputeMode::Gpu => "gpu".to_string(), + crate::domain::engine::types::EngineComputeMode::Cpu => "cpu".to_string(), + }) + .collect() + }) + .unwrap_or_default(), + repo_url: item.repo_url.clone(), + expected_hash: item.expected_hash.clone(), + dl_type: item.dl_type.clone(), + coming_soon: item.coming_soon, + managed_externally: item.managed_externally, + version: item.version.clone(), + config_schema: installed_module + .and_then(|module| module.config_schema.clone()) + .or_else(|| item.config_schema.clone()), + settings_ui: installed_module.and_then(|module| module.settings_ui.clone()), + api_provider_data: api_provider.clone(), + status: installed_module.and_then(|module| module.status.clone()), + provider_policy: Some(build_provider_policy( + &item.id, + category, + &item.type_name, + capability.as_deref(), + api_provider.as_ref(), + )), + } +} + +fn build_discovered_integration_item(module: &Module) -> CatalogAppItem { + CatalogAppItem { + id: module.id.clone(), + name_key: None, + desc_key: None, + name: Some( + module + .preview + .as_ref() + .and_then(|preview| preview.title.clone()) + .unwrap_or_else(|| module.name.clone()), + ), + desc: Some( + module + .preview + .as_ref() + .and_then(|preview| preview.description.clone()) + .unwrap_or_else(|| module.description.clone()), + ), + icon: Some( + module + .preview + .as_ref() + .and_then(|preview| preview.sticker.clone()) + .unwrap_or_else(|| module.icon.clone()), + ), + preview: module.preview.clone(), + category: "services".to_string(), + type_name: "local".to_string(), + capability: Some("text".to_string()), + installed: true, + installed_compute_modes: Vec::new(), + repo_url: None, + expected_hash: None, + dl_type: None, + coming_soon: false, + managed_externally: false, + version: module.version.clone(), + config_schema: module.config_schema.clone(), + settings_ui: module.settings_ui.clone(), + api_provider_data: None, + provider_policy: Some(build_provider_policy( + &module.id, + "services", + "local", + Some("text"), + None, + )), + status: module.status.clone(), + } +} + +fn append_custom_provider_items(ai: &mut Vec, known_ids: &mut HashSet) { + let custom_specs = [ + ( + CUSTOM_TEXT_PROVIDER_ID, + "text", + "Custom", + "ui.launcher.app.custom_text.name", + "Use any text model by pasting its model ID manually.", + "ui.launcher.app.custom_text.desc", + "🔤", + ), + ( + CUSTOM_IMAGE_PROVIDER_ID, + "image", + "Custom", + "ui.launcher.app.custom_image.name", + "Use any image model by pasting its model ID manually.", + "ui.launcher.app.custom_image.desc", + "🪄", + ), + ]; + + for (id, capability, name, name_key, desc, desc_key, icon) in custom_specs { + if !known_ids.insert(id.to_string()) { + continue; + } + + let capability = Some(capability.to_string()); + ai.push(CatalogAppItem { + id: id.to_string(), + name_key: Some(name_key.to_string()), + desc_key: Some(desc_key.to_string()), + name: Some(name.to_string()), + desc: Some(desc.to_string()), + icon: Some(icon.to_string()), + preview: None, + category: "ai".to_string(), + type_name: "api".to_string(), + capability: capability.clone(), + installed: true, + installed_compute_modes: Vec::new(), + repo_url: None, + expected_hash: None, + dl_type: None, + coming_soon: false, + managed_externally: false, + version: "1.0.0".to_string(), + config_schema: None, + settings_ui: None, + api_provider_data: Some(ApiProvider { + id: id.to_string(), + name: name.to_string(), + desc_key: Some(desc_key.to_string()), + description: Some(desc.to_string()), + icon: Some(icon.to_string()), + provider_type: Some(crate::models::config::ProviderType::OpenaiCompatible), + base_url: Some("https://openrouter.ai/api/v1".to_string()), + api_key_env: None, + models: Some(Vec::new()), + capabilities: Some(vec![capability.clone().unwrap_or_default()]), + model_aliases: None, + }), + provider_policy: Some(build_provider_policy( + id, + "ai", + "api", + capability.as_deref(), + None, + )), + status: None, + }); + } +} + +fn primary_capability(capabilities: &[String]) -> Option { + if capabilities.iter().any(|capability| capability == "image") { + return Some("image".to_string()); + } + + if capabilities.iter().any(|capability| capability == "text") { + return Some("text".to_string()); + } + + None +} + +fn build_provider_policy( + id: &str, + category: &str, + type_name: &str, + capability: Option<&str>, + api_provider: Option<&ApiProvider>, +) -> CatalogProviderPolicy { + let is_custom_provider = is_custom_provider(id); + let is_cloud_provider = type_name != "local" || api_provider.is_some() || is_custom_provider; + let image_only = capability == Some("image") || id == CUSTOM_IMAGE_PROVIDER_ID; + let is_clean_app = matches!(id, "axelate" | "axelate-platform"); + let secret_service = provider_secret_service(id, is_cloud_provider); + let uses_custom_provider_key = is_custom_provider; + let key_provider_id = secret_service.as_ref().map(|service| { + if service == SHARED_CLOUD_SECRET_SERVICE { + SHARED_CLOUD_KEY_PROVIDER_ID.to_string() + } else { + id.to_string() + } + }); + let supports_thinking = !is_custom_provider + && api_provider + .and_then(|provider| provider.models.as_ref()) + .is_some_and(|models| { + models.iter().any(|model| { + model + .capabilities + .as_ref() + .is_some_and(|capabilities| capabilities.reasoning) + }) + }); + + CatalogProviderPolicy { + is_cloud_provider, + is_custom_provider, + is_clean_app, + secret_service, + key_provider_id: key_provider_id.clone(), + key_provider_url: key_provider_id + .filter(|provider_id| provider_id == SHARED_CLOUD_KEY_PROVIDER_ID) + .map(|_| OPENROUTER_KEY_URL.to_string()), + uses_custom_provider_key, + show_api_endpoint_selector: id == CUSTOM_TEXT_PROVIDER_ID, + show_custom_model_composer: is_custom_provider, + show_model_stats: !is_custom_provider, + supports_internet_access: category == "ai" + && is_cloud_provider + && !is_clean_app + && !is_custom_provider + && !image_only, + supports_thinking, + image_only, + } +} + +fn provider_secret_service(id: &str, is_cloud_provider: bool) -> Option { + if !is_cloud_provider { + return None; + } + + if is_custom_provider(id) { + return Some(format!("{}_api_key", id.replace('-', "_"))); + } + + Some(SHARED_CLOUD_SECRET_SERVICE.to_string()) +} + +fn is_custom_provider(id: &str) -> bool { + id == CUSTOM_TEXT_PROVIDER_ID || id == CUSTOM_IMAGE_PROVIDER_ID +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::config::{ConfigCatalog, ProviderType}; + use crate::models::modules::ModulePreview; + + fn module_item(id: &str, category_type: &str) -> ModuleItem { + ModuleItem { + id: id.to_string(), + name_key: format!("catalog.{id}.name"), + desc_key: format!("catalog.{id}.desc"), + name: id.to_string(), + desc: format!("{id} description"), + icon: "icon".to_string(), + preview: None, + type_name: category_type.to_string(), + dl_type: None, + capabilities: vec!["text".to_string()], + binary: None, + repo_url: None, + expected_hash: None, + coming_soon: false, + managed_externally: false, + version: "1.0.0".to_string(), + installed: false, + raw_config_schema: None, + config_schema: None, + } + } + + fn app_config(ai: Vec, services: Vec) -> AppConfig { + AppConfig { + version: "1.0.0".to_string(), + api_providers: vec![ApiProvider { + id: "openai".to_string(), + name: "OpenAI".to_string(), + desc_key: None, + description: None, + icon: None, + provider_type: Some(ProviderType::OpenaiCompatible), + base_url: Some("https://openrouter.ai/api/v1".to_string()), + api_key_env: Some("OPENROUTER_API_KEY".to_string()), + models: None, + capabilities: Some(vec!["text".to_string()]), + model_aliases: None, + }], + catalog: ConfigCatalog { + ai, + services, + stars: vec!["openai".to_string()], + }, + } + } + + fn installed_module(id: &str) -> Module { + Module { + id: id.to_string(), + name: id.to_string(), + description: format!("{id} module"), + version: "0.1.0".to_string(), + author: "test".to_string(), + category: "service".to_string(), + icon: "plug".to_string(), + preview: Some(ModulePreview { + title: Some(format!("{id} title")), + description: Some(format!("{id} preview")), + sticker: Some("*".to_string()), + image: None, + i18n: None, + }), + path: format!("C:/tmp/{id}"), + installed: true, + local: true, + enabled: true, + status: Some("stopped".to_string()), + is_deletable: true, + config: HashMap::new(), + config_schema: None, + settings_ui: Some("settings-ui/index.html".to_string()), + } + } + + #[test] + fn catalog_snapshot_marks_api_and_catalog_integrations_from_backend_inputs() { + let mut openai = module_item("openai", "api"); + openai.capabilities = vec!["text".to_string(), "image".to_string()]; + let worker = module_item("worker", "local"); + + let snapshot = build_catalog_snapshot( + app_config(vec![openai], vec![worker]), + vec![installed_module("worker")], + Vec::new(), + ); + + let openai_card = snapshot.ai.iter().find(|item| item.id == "openai"); + assert_eq!(openai_card.map(|item| item.type_name.as_str()), Some("api")); + assert_eq!(openai_card.map(|item| item.installed), Some(true)); + assert_eq!( + openai_card.and_then(|item| item.capability.as_deref()), + Some("image") + ); + assert_eq!( + openai_card.map(|item| item.api_provider_data.is_some()), + Some(true) + ); + assert_eq!( + openai_card.and_then(|item| item.provider_policy.as_ref()), + Some(&CatalogProviderPolicy { + is_cloud_provider: true, + is_custom_provider: false, + is_clean_app: false, + secret_service: Some(SHARED_CLOUD_SECRET_SERVICE.to_string()), + key_provider_id: Some(SHARED_CLOUD_KEY_PROVIDER_ID.to_string()), + key_provider_url: Some(OPENROUTER_KEY_URL.to_string()), + uses_custom_provider_key: false, + show_api_endpoint_selector: false, + show_custom_model_composer: false, + show_model_stats: true, + supports_internet_access: false, + supports_thinking: false, + image_only: true, + }) + ); + + let worker_card = snapshot.services.iter().find(|item| item.id == "worker"); + assert_eq!(worker_card.map(|item| item.installed), Some(true)); + assert_eq!( + worker_card.and_then(|item| item.settings_ui.as_deref()), + Some("settings-ui/index.html") + ); + assert_eq!( + worker_card.and_then(|item| item.status.as_deref()), + Some("stopped") + ); + assert_eq!(snapshot.stars, vec!["openai"]); + } + + #[test] + fn catalog_snapshot_keeps_coming_soon_uninstalled_and_appends_discovered_integrations() { + let mut future = module_item("future-image", "local"); + future.coming_soon = true; + future.capabilities = vec!["image".to_string()]; + + let snapshot = build_catalog_snapshot( + app_config(vec![future], Vec::new()), + vec![installed_module("discovered")], + Vec::new(), + ); + + let future_card = snapshot.ai.iter().find(|item| item.id == "future-image"); + assert_eq!(future_card.map(|item| item.installed), Some(false)); + assert_eq!( + future_card.and_then(|item| item.capability.as_deref()), + Some("image") + ); + + let discovered = snapshot + .services + .iter() + .find(|item| item.id == "discovered"); + assert_eq!(discovered.map(|item| item.installed), Some(true)); + assert_eq!( + discovered.and_then(|item| item.name.as_deref()), + Some("discovered title") + ); + assert_eq!(discovered.and_then(|item| item.icon.as_deref()), Some("*")); + assert_eq!( + discovered.map(|item| item.category.as_str()), + Some("services") + ); + + let custom_text = snapshot + .ai + .iter() + .find(|item| item.id == CUSTOM_TEXT_PROVIDER_ID); + assert_eq!(custom_text.map(|item| item.installed), Some(true)); + assert_eq!( + custom_text + .and_then(|item| item.provider_policy.as_ref()) + .map(|policy| ( + policy.is_custom_provider, + policy.secret_service.as_deref(), + policy.show_api_endpoint_selector, + policy.show_model_stats, + policy.supports_internet_access, + )), + Some((true, Some("custom_text_api_key"), true, false, false)) + ); + } +} diff --git a/src-tauri/src/api/system/console_overview.rs b/src-tauri/src/api/system/console_overview.rs new file mode 100644 index 00000000..8c8bf95a --- /dev/null +++ b/src-tauri/src/api/system/console_overview.rs @@ -0,0 +1,411 @@ +use crate::domain::engine::manager::canonical_engine_id; +use crate::domain::engine::types::EngineDefinition; +use crate::infrastructure::logging::LogEntry; +use crate::models::{SelectedModule, UIState}; +use std::collections::{BTreeMap, BTreeSet}; + +pub(super) struct ConsoleOverviewBuilder; + +pub(super) struct ConsoleLabelFormatter; + +/// Console log view metadata for frontend tabs. +#[derive(Debug, Clone, serde::Serialize, specta::Type)] +pub struct ConsoleLogView { + /// Stable view identifier. + pub id: String, + /// Human-readable label. + pub label: String, +} + +/// Runtime status used by the console overview. +#[derive(Debug, Clone, Copy, serde::Serialize, specta::Type)] +#[serde(rename_all = "lowercase")] +pub enum ConsoleRuntimeStatus { + /// Process is currently running. + Running, + /// Process is starting or switching. + Starting, + /// Process failed or status lookup failed. + Failed, + /// Process is stopped. + Stopped, +} + +/// Console status row for engines or modules. +#[derive(Debug, Clone, serde::Serialize, specta::Type)] +pub struct ConsoleStatusItem { + /// Stable item identifier. + pub id: String, + /// Human-readable label. + pub label: String, + /// Status category discriminator. + pub kind: String, + /// Runtime status. + pub status: ConsoleRuntimeStatus, + /// Additional detail text. + pub detail: String, +} + +/// Aggregated console metadata payload. +#[derive(Debug, Clone, serde::Serialize, specta::Type)] +pub struct ConsoleOverview { + /// Available log views including the default general tab. + pub views: Vec, + /// Runtime status rows for engines and modules. + pub status_items: Vec, +} + +const fn describe_status(status: ConsoleRuntimeStatus) -> &'static str { + match status { + ConsoleRuntimeStatus::Running => "Running", + ConsoleRuntimeStatus::Starting => "Starting…", + ConsoleRuntimeStatus::Failed => "Failed", + ConsoleRuntimeStatus::Stopped => "Stopped", + } +} + +impl ConsoleOverviewBuilder { + pub(super) async fn build( + engine_state: &crate::domain::engine::types::EngineState, + engine_definitions: &[EngineDefinition], + ui_state: &UIState, + logs: &[LogEntry], + ) -> ConsoleOverview { + let registry_engine_labels = Self::collect_registry_engine_labels(engine_definitions); + let module_labels = Self::collect_module_labels(&ui_state.selected_modules); + let module_ids = Self::collect_module_ids(logs, &module_labels); + let mut engine_labels = Self::collect_engine_labels(engine_state); + engine_labels.extend(Self::collect_selected_engine_labels( + &ui_state.selected_modules, + )); + engine_labels.extend(Self::collect_logged_engine_labels( + logs, + ®istry_engine_labels, + )); + let views = Self::build_views(&engine_labels, &module_labels, &module_ids); + let status_items = Self::build_status_items( + engine_state, + ®istry_engine_labels, + &engine_labels, + &module_labels, + &module_ids, + ) + .await; + + ConsoleOverview { + views, + status_items, + } + } + + fn collect_registry_engine_labels( + engine_definitions: &[EngineDefinition], + ) -> BTreeMap { + engine_definitions + .iter() + .map(|definition| { + ( + canonical_engine_id(&definition.id), + definition.name.trim().to_string(), + ) + }) + .filter(|(_, name)| !name.is_empty()) + .collect() + } + + pub(super) fn collect_module_labels( + modules: &std::collections::HashMap, + ) -> BTreeMap { + modules + .iter() + .filter(|(category, module)| category.as_str() == "services" && module.type_ != "api") + .map(|(_, module)| (module.id.clone(), module.name.clone())) + .collect() + } + + pub(super) fn collect_selected_engine_labels( + modules: &std::collections::HashMap, + ) -> BTreeMap { + modules + .iter() + .filter(|(category, module)| { + matches!(category.as_str(), "ai_text" | "ai_image") && module.type_ != "api" + }) + .map(|(_, module)| (canonical_engine_id(&module.id), module.name.clone())) + .collect() + } + + fn collect_module_ids( + logs: &[LogEntry], + module_labels: &BTreeMap, + ) -> BTreeSet { + let mut module_ids: BTreeSet = module_labels.keys().cloned().collect(); + module_ids.extend( + logs.iter() + .filter_map(|entry| entry.module_id.as_ref()) + .filter(|module_id| module_labels.contains_key(*module_id)) + .cloned(), + ); + module_ids + } + + fn collect_engine_labels( + state: &crate::domain::engine::types::EngineState, + ) -> BTreeMap { + let mut labels = BTreeMap::new(); + + if let crate::domain::engine::types::EngineState::Ready { slots } = state { + labels.extend(slots.iter().map(|slot| { + ( + canonical_engine_id(&slot.engine.id), + slot.engine.name.clone(), + ) + })); + } + + labels + } + + fn collect_logged_engine_labels( + logs: &[LogEntry], + registry_engine_labels: &BTreeMap, + ) -> BTreeMap { + logs.iter() + .filter(|entry| entry.module_id.is_none() && !entry.source.starts_with("module:")) + .filter_map(|entry| { + let engine_id = canonical_engine_id(&entry.source); + let label = registry_engine_labels.get(&engine_id)?; + Some((engine_id, label.clone())) + }) + .collect() + } + + fn build_views( + engine_labels: &BTreeMap, + module_labels: &BTreeMap, + module_ids: &BTreeSet, + ) -> Vec { + let mut views = Vec::with_capacity(engine_labels.len() + module_ids.len() + 1); + let mut view_ids = BTreeSet::new(); + let mut view_labels = BTreeSet::new(); + views.push(ConsoleLogView { + id: "general".to_string(), + label: "Platform".to_string(), + }); + view_ids.insert("general".to_string()); + view_labels.insert(Self::normalize_view_label("Platform")); + + for (id, label) in engine_labels { + Self::push_unique_view( + &mut views, + &mut view_ids, + &mut view_labels, + ConsoleLogView { + id: format!("engine:{id}"), + label: label.clone(), + }, + ); + } + + for module_id in module_ids { + Self::push_unique_view( + &mut views, + &mut view_ids, + &mut view_labels, + ConsoleLogView { + id: format!("module:{module_id}"), + label: module_labels + .get(module_id) + .cloned() + .unwrap_or_else(|| ConsoleLabelFormatter::format_module_label(module_id)), + }, + ); + } + + views + } + + fn push_unique_view( + views: &mut Vec, + view_ids: &mut BTreeSet, + view_labels: &mut BTreeSet, + view: ConsoleLogView, + ) { + let normalized_label = Self::normalize_view_label(&view.label); + if view_ids.insert(view.id.clone()) && view_labels.insert(normalized_label) { + views.push(view); + } + } + + fn normalize_view_label(label: &str) -> String { + label + .trim() + .to_ascii_lowercase() + .split_whitespace() + .collect::>() + .join(" ") + } + + async fn build_status_items( + engine_state: &crate::domain::engine::types::EngineState, + registry_engine_labels: &BTreeMap, + engine_labels: &BTreeMap, + module_labels: &BTreeMap, + module_ids: &BTreeSet, + ) -> Vec { + let mut status_items = + Self::build_engine_status_items(engine_state, registry_engine_labels); + let known_status_ids = status_items + .iter() + .map(|item| item.id.clone()) + .collect::>(); + for (engine_id, label) in engine_labels { + let status_id = format!("engine:{engine_id}"); + if !known_status_ids.contains(&status_id) { + status_items.push(ConsoleStatusItem { + id: status_id, + label: label.clone(), + kind: "engine".to_string(), + status: ConsoleRuntimeStatus::Stopped, + detail: describe_status(ConsoleRuntimeStatus::Stopped).to_string(), + }); + } + } + for module_id in module_ids { + status_items.push( + Self::build_module_status_item(module_id, engine_labels, module_labels).await, + ); + } + status_items + } + + async fn build_module_status_item( + module_id: &str, + engine_labels: &BTreeMap, + module_labels: &BTreeMap, + ) -> ConsoleStatusItem { + let status_text = crate::domain::modules::controller::get_module_status(module_id).await; + let status = if status_text == "running" { + ConsoleRuntimeStatus::Running + } else { + ConsoleRuntimeStatus::Stopped + }; + + ConsoleStatusItem { + id: format!("module:{module_id}"), + label: module_labels + .get(module_id) + .cloned() + .or_else(|| engine_labels.get(module_id).cloned()) + .unwrap_or_else(|| ConsoleLabelFormatter::format_module_label(module_id)), + kind: "module".to_string(), + status, + detail: describe_status(status).to_string(), + } + } + + fn build_engine_status_items( + state: &crate::domain::engine::types::EngineState, + registry_engine_labels: &BTreeMap, + ) -> Vec { + use crate::domain::engine::types::EngineState; + + match state { + EngineState::Idle => vec![ConsoleStatusItem { + id: "engine:idle".to_string(), + label: "AI Engines".to_string(), + kind: "engine".to_string(), + status: ConsoleRuntimeStatus::Stopped, + detail: "No active engines".to_string(), + }], + EngineState::Starting { engine_id } => vec![ConsoleStatusItem { + id: format!("engine:{}", canonical_engine_id(engine_id)), + label: Self::engine_label_for_id(engine_id, registry_engine_labels), + kind: "engine".to_string(), + status: ConsoleRuntimeStatus::Starting, + detail: "Starting…".to_string(), + }], + EngineState::Swapping { from, to } => vec![ConsoleStatusItem { + id: format!("engine:{}", canonical_engine_id(to)), + label: Self::engine_label_for_id(to, registry_engine_labels), + kind: "engine".to_string(), + status: ConsoleRuntimeStatus::Starting, + detail: format!("Switching from {from}"), + }], + EngineState::Error { engine_id, message } => vec![ConsoleStatusItem { + id: format!("engine:{}", canonical_engine_id(engine_id)), + label: Self::engine_label_for_id(engine_id, registry_engine_labels), + kind: "engine".to_string(), + status: ConsoleRuntimeStatus::Failed, + detail: message.clone(), + }], + EngineState::Ready { slots } => { + let mut items: BTreeMap = BTreeMap::new(); + for slot in slots { + let id = canonical_engine_id(&slot.engine.id); + let detail = ConsoleLabelFormatter::format_capability(slot.capability); + items + .entry(id.clone()) + .and_modify(|item| { + if !item.detail.split(", ").any(|part| part == detail) { + item.detail.push_str(", "); + item.detail.push_str(&detail); + } + }) + .or_insert_with(|| ConsoleStatusItem { + id: format!("engine:{id}"), + label: slot.engine.name.clone(), + kind: "engine".to_string(), + status: ConsoleRuntimeStatus::Running, + detail, + }); + } + items.into_values().collect() + } + } + } + + fn engine_label_for_id( + engine_id: &str, + registry_engine_labels: &BTreeMap, + ) -> String { + registry_engine_labels + .get(&canonical_engine_id(engine_id)) + .cloned() + .unwrap_or_else(|| ConsoleLabelFormatter::format_module_label(engine_id)) + } +} + +impl ConsoleLabelFormatter { + pub(super) fn format_module_label(module_id: &str) -> String { + module_id + .trim_start_matches("axelate-") + .split('-') + .filter(|part| !part.is_empty()) + .map(Self::format_label_part) + .collect::>() + .join(" ") + } + + pub(super) fn format_capability( + capability: crate::domain::engine::types::Capability, + ) -> String { + match capability { + crate::domain::engine::types::Capability::Text => "text".to_string(), + crate::domain::engine::types::Capability::Image => "image".to_string(), + crate::domain::engine::types::Capability::Vision => "vision".to_string(), + } + } + + fn format_label_part(part: &str) -> String { + let mut chars = part.chars(); + match chars.next() { + Some(first) => { + let mut label = first.to_uppercase().to_string(); + label.push_str(chars.as_str()); + label + } + None => String::new(), + } + } +} diff --git a/src-tauri/src/api/system/log_targets.rs b/src-tauri/src/api/system/log_targets.rs new file mode 100644 index 00000000..98abe8a5 --- /dev/null +++ b/src-tauri/src/api/system/log_targets.rs @@ -0,0 +1,139 @@ +use crate::domain::engine::manager::canonical_engine_id; +use crate::errors::AppError; +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +pub(super) fn resolve_console_log_target(view_id: &str) -> Result { + if let Some(engine_id) = view_id.strip_prefix("engine:") { + let engine_id = canonical_engine_id(engine_id); + validate_console_log_segment(&engine_id, "Engine ID")?; + return Ok(crate::utils::paths::ENGINE_LOGS_DIR.join(engine_id)); + } + + if let Some(module_id) = view_id.strip_prefix("module:") { + crate::domain::modules::downloader::validate_module_id(module_id)?; + return Ok(crate::utils::paths::INTEGRATION_LOGS_DIR.join(module_id)); + } + + if view_id == "general" { + return Ok(crate::utils::paths::LOG_DIR.clone()); + } + + Err(AppError::Validation("invalid console view id".into())) +} + +fn validate_console_log_segment(value: &str, label: &str) -> Result<(), AppError> { + if value.is_empty() { + return Err(AppError::Validation(format!("{label} cannot be empty"))); + } + + if !value + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + { + return Err(AppError::Validation(format!( + "{label} contains invalid characters" + ))); + } + + Ok(()) +} + +pub(super) fn canonical_console_view_id(view_id: &str) -> String { + if let Some(engine_id) = view_id.strip_prefix("engine:") { + return format!("engine:{}", canonical_engine_id(engine_id)); + } + + view_id.trim().to_string() +} + +pub(super) fn clear_console_log_target(view_id: &str, target: &Path) -> Result<(), AppError> { + if !valid_log_root(target)? { + return Ok(()); + } + + if view_id == "general" { + let general_log = target.join("axelate.log"); + if is_regular_log_file(&general_log)? { + clear_log_file(&general_log)?; + } + return Ok(()); + } + + for entry in fs::read_dir(target)? { + let entry = entry?; + let file_type = entry.file_type()?; + if file_type.is_file() && has_log_extension(&entry.path()) { + let path = entry.path(); + clear_log_file(&path)?; + } + } + + Ok(()) +} + +fn valid_log_root(root: &Path) -> Result { + let metadata = match fs::symlink_metadata(root) { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error.into()), + }; + + if metadata.file_type().is_symlink() { + return Err(AppError::Validation( + "console log target cannot be a symlink".into(), + )); + } + + Ok(metadata.is_dir()) +} + +fn is_regular_log_file(path: &Path) -> Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error.into()), + }; + + Ok(metadata.file_type().is_file() && has_log_extension(path)) +} + +fn has_log_extension(path: &Path) -> bool { + path.extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("log")) +} + +fn clear_log_file(path: &Path) -> Result<(), AppError> { + if !path.exists() { + return Ok(()); + } + + fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(path)?; + Ok(()) +} + +pub(super) fn clear_all_console_log_files(root: &Path) -> Result<(), AppError> { + if !valid_log_root(root)? { + return Ok(()); + } + + for entry in fs::read_dir(root)? { + let entry = entry?; + let file_type = entry.file_type()?; + let path = entry.path(); + if file_type.is_dir() { + clear_all_console_log_files(&path)?; + continue; + } + + if file_type.is_file() && has_log_extension(&path) { + clear_log_file(&path)?; + } + } + + Ok(()) +} diff --git a/src-tauri/src/api/system/logs.rs b/src-tauri/src/api/system/logs.rs index 942844bb..98188974 100644 --- a/src-tauri/src/api/system/logs.rs +++ b/src-tauri/src/api/system/logs.rs @@ -1,66 +1,21 @@ -use crate::domain::engine::manager::canonical_engine_id; -use crate::domain::engine::types::EngineDefinition; use crate::errors::AppError; use crate::infrastructure::logging::logger; use crate::infrastructure::logging::{self as logs, LogEntry}; -use crate::models::{SelectedModule, UIState}; -use std::collections::{BTreeMap, BTreeSet}; +use crate::models::UIState; use std::fs; -use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Arc; use tauri::State; -struct ConsoleOverviewBuilder; +use super::log_targets::{ + canonical_console_view_id, clear_all_console_log_files, clear_console_log_target, + resolve_console_log_target, +}; -struct ConsoleLabelFormatter; - -/// Console log view metadata for frontend tabs. -#[derive(Debug, Clone, serde::Serialize, specta::Type)] -pub struct ConsoleLogView { - /// Stable view identifier. - pub id: String, - /// Human-readable label. - pub label: String, -} - -/// Runtime status used by the console overview. -#[derive(Debug, Clone, Copy, serde::Serialize, specta::Type)] -#[serde(rename_all = "lowercase")] -pub enum ConsoleRuntimeStatus { - /// Process is currently running. - Running, - /// Process is starting or switching. - Starting, - /// Process failed or status lookup failed. - Failed, - /// Process is stopped. - Stopped, -} - -/// Console status row for engines or modules. -#[derive(Debug, Clone, serde::Serialize, specta::Type)] -pub struct ConsoleStatusItem { - /// Stable item identifier. - pub id: String, - /// Human-readable label. - pub label: String, - /// Status category discriminator. - pub kind: String, - /// Runtime status. - pub status: ConsoleRuntimeStatus, - /// Additional detail text. - pub detail: String, -} - -/// Aggregated console metadata payload. -#[derive(Debug, Clone, serde::Serialize, specta::Type)] -pub struct ConsoleOverview { - /// Available log views including the default general tab. - pub views: Vec, - /// Runtime status rows for engines and modules. - pub status_items: Vec, -} +use super::console_overview::ConsoleOverviewBuilder; +pub use super::console_overview::{ + ConsoleLogView, ConsoleOverview, ConsoleRuntimeStatus, ConsoleStatusItem, +}; #[tauri::command] #[specta::specta] @@ -194,430 +149,6 @@ fn trace_frontend_log(level: &str, message: &str) { } } -const fn describe_status(status: ConsoleRuntimeStatus) -> &'static str { - match status { - ConsoleRuntimeStatus::Running => "Running", - ConsoleRuntimeStatus::Starting => "Starting…", - ConsoleRuntimeStatus::Failed => "Failed", - ConsoleRuntimeStatus::Stopped => "Stopped", - } -} - -fn resolve_console_log_target(view_id: &str) -> Result { - if let Some(engine_id) = view_id.strip_prefix("engine:") { - let engine_id = canonical_engine_id(engine_id); - validate_console_log_segment(&engine_id, "Engine ID")?; - return Ok(crate::utils::paths::ENGINE_LOGS_DIR.join(engine_id)); - } - - if let Some(module_id) = view_id.strip_prefix("module:") { - crate::domain::modules::downloader::validate_module_id(module_id)?; - return Ok(crate::utils::paths::INTEGRATION_LOGS_DIR.join(module_id)); - } - - Ok(crate::utils::paths::LOG_DIR.clone()) -} - -fn validate_console_log_segment(value: &str, label: &str) -> Result<(), AppError> { - if value.is_empty() { - return Err(AppError::Validation(format!("{label} cannot be empty"))); - } - - if !value - .chars() - .all(|character| character.is_ascii_alphanumeric() || character == '-') - { - return Err(AppError::Validation(format!( - "{label} contains invalid characters" - ))); - } - - Ok(()) -} - -fn canonical_console_view_id(view_id: &str) -> String { - if let Some(engine_id) = view_id.strip_prefix("engine:") { - return format!("engine:{}", canonical_engine_id(engine_id)); - } - - view_id.trim().to_string() -} - -fn clear_console_log_target(view_id: &str, target: &Path) -> Result<(), AppError> { - if view_id == "general" { - clear_log_file(&target.join("axelate.log"))?; - return Ok(()); - } - - if !target.exists() { - return Ok(()); - } - - for entry in fs::read_dir(target)? { - let path = entry?.path(); - if path - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("log")) - { - clear_log_file(&path)?; - } - } - - Ok(()) -} - -fn clear_log_file(path: &Path) -> Result<(), AppError> { - if !path.exists() { - return Ok(()); - } - - fs::OpenOptions::new() - .write(true) - .truncate(true) - .open(path)?; - Ok(()) -} - -fn clear_all_console_log_files(root: &Path) -> Result<(), AppError> { - if !root.exists() { - return Ok(()); - } - - for entry in fs::read_dir(root)? { - let path = entry?.path(); - if path.is_dir() { - clear_all_console_log_files(&path)?; - continue; - } - - if path - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("log")) - { - clear_log_file(&path)?; - } - } - - Ok(()) -} - -impl ConsoleOverviewBuilder { - async fn build( - engine_state: &crate::domain::engine::types::EngineState, - engine_definitions: &[EngineDefinition], - ui_state: &UIState, - logs: &[LogEntry], - ) -> ConsoleOverview { - let registry_engine_labels = Self::collect_registry_engine_labels(engine_definitions); - let module_labels = Self::collect_module_labels(&ui_state.selected_modules); - let module_ids = Self::collect_module_ids(logs, &module_labels); - let mut engine_labels = Self::collect_engine_labels(engine_state); - engine_labels.extend(Self::collect_selected_engine_labels( - &ui_state.selected_modules, - )); - engine_labels.extend(Self::collect_logged_engine_labels( - logs, - ®istry_engine_labels, - )); - let views = Self::build_views(&engine_labels, &module_labels, &module_ids); - let status_items = Self::build_status_items( - engine_state, - ®istry_engine_labels, - &engine_labels, - &module_labels, - &module_ids, - ) - .await; - - ConsoleOverview { - views, - status_items, - } - } - - fn collect_registry_engine_labels( - engine_definitions: &[EngineDefinition], - ) -> BTreeMap { - engine_definitions - .iter() - .map(|definition| { - ( - canonical_engine_id(&definition.id), - definition.name.trim().to_string(), - ) - }) - .filter(|(_, name)| !name.is_empty()) - .collect() - } - - fn collect_module_labels( - modules: &std::collections::HashMap, - ) -> BTreeMap { - modules - .iter() - .filter(|(category, module)| category.as_str() == "services" && module.type_ != "api") - .map(|(_, module)| (module.id.clone(), module.name.clone())) - .collect() - } - - fn collect_selected_engine_labels( - modules: &std::collections::HashMap, - ) -> BTreeMap { - modules - .iter() - .filter(|(category, module)| { - matches!(category.as_str(), "ai_text" | "ai_image") && module.type_ != "api" - }) - .map(|(_, module)| (canonical_engine_id(&module.id), module.name.clone())) - .collect() - } - - fn collect_module_ids( - logs: &[LogEntry], - module_labels: &BTreeMap, - ) -> BTreeSet { - let mut module_ids: BTreeSet = module_labels.keys().cloned().collect(); - module_ids.extend( - logs.iter() - .filter_map(|entry| entry.module_id.as_ref()) - .filter(|module_id| module_labels.contains_key(*module_id)) - .cloned(), - ); - module_ids - } - - fn collect_engine_labels( - state: &crate::domain::engine::types::EngineState, - ) -> BTreeMap { - let mut labels = BTreeMap::new(); - - if let crate::domain::engine::types::EngineState::Ready { slots } = state { - labels.extend(slots.iter().map(|slot| { - ( - canonical_engine_id(&slot.engine.id), - slot.engine.name.clone(), - ) - })); - } - - labels - } - - fn collect_logged_engine_labels( - logs: &[LogEntry], - registry_engine_labels: &BTreeMap, - ) -> BTreeMap { - logs.iter() - .filter(|entry| entry.module_id.is_none() && !entry.source.starts_with("module:")) - .filter_map(|entry| { - let engine_id = canonical_engine_id(&entry.source); - let label = registry_engine_labels.get(&engine_id)?; - Some((engine_id, label.clone())) - }) - .collect() - } - - fn build_views( - engine_labels: &BTreeMap, - module_labels: &BTreeMap, - module_ids: &BTreeSet, - ) -> Vec { - let mut views = Vec::with_capacity(engine_labels.len() + module_ids.len() + 1); - let mut view_ids = BTreeSet::new(); - let mut view_labels = BTreeSet::new(); - views.push(ConsoleLogView { - id: "general".to_string(), - label: "Platform".to_string(), - }); - view_ids.insert("general".to_string()); - view_labels.insert(Self::normalize_view_label("Platform")); - - for (id, label) in engine_labels { - Self::push_unique_view( - &mut views, - &mut view_ids, - &mut view_labels, - ConsoleLogView { - id: format!("engine:{id}"), - label: label.clone(), - }, - ); - } - - for module_id in module_ids { - Self::push_unique_view( - &mut views, - &mut view_ids, - &mut view_labels, - ConsoleLogView { - id: format!("module:{module_id}"), - label: module_labels - .get(module_id) - .cloned() - .unwrap_or_else(|| ConsoleLabelFormatter::format_module_label(module_id)), - }, - ); - } - - views - } - - fn push_unique_view( - views: &mut Vec, - view_ids: &mut BTreeSet, - view_labels: &mut BTreeSet, - view: ConsoleLogView, - ) { - let normalized_label = Self::normalize_view_label(&view.label); - if view_ids.insert(view.id.clone()) && view_labels.insert(normalized_label) { - views.push(view); - } - } - - fn normalize_view_label(label: &str) -> String { - label - .trim() - .to_ascii_lowercase() - .split_whitespace() - .collect::>() - .join(" ") - } - - async fn build_status_items( - engine_state: &crate::domain::engine::types::EngineState, - registry_engine_labels: &BTreeMap, - engine_labels: &BTreeMap, - module_labels: &BTreeMap, - module_ids: &BTreeSet, - ) -> Vec { - let mut status_items = - Self::build_engine_status_items(engine_state, registry_engine_labels); - let known_status_ids = status_items - .iter() - .map(|item| item.id.clone()) - .collect::>(); - for (engine_id, label) in engine_labels { - let status_id = format!("engine:{engine_id}"); - if !known_status_ids.contains(&status_id) { - status_items.push(ConsoleStatusItem { - id: status_id, - label: label.clone(), - kind: "engine".to_string(), - status: ConsoleRuntimeStatus::Stopped, - detail: describe_status(ConsoleRuntimeStatus::Stopped).to_string(), - }); - } - } - for module_id in module_ids { - status_items.push( - Self::build_module_status_item(module_id, engine_labels, module_labels).await, - ); - } - status_items - } - - async fn build_module_status_item( - module_id: &str, - engine_labels: &BTreeMap, - module_labels: &BTreeMap, - ) -> ConsoleStatusItem { - let status_text = crate::domain::modules::controller::get_module_status(module_id).await; - let status = if status_text == "running" { - ConsoleRuntimeStatus::Running - } else { - ConsoleRuntimeStatus::Stopped - }; - - ConsoleStatusItem { - id: format!("module:{module_id}"), - label: module_labels - .get(module_id) - .cloned() - .or_else(|| engine_labels.get(module_id).cloned()) - .unwrap_or_else(|| ConsoleLabelFormatter::format_module_label(module_id)), - kind: "module".to_string(), - status, - detail: describe_status(status).to_string(), - } - } - - fn build_engine_status_items( - state: &crate::domain::engine::types::EngineState, - registry_engine_labels: &BTreeMap, - ) -> Vec { - use crate::domain::engine::types::EngineState; - - match state { - EngineState::Idle => vec![ConsoleStatusItem { - id: "engine:idle".to_string(), - label: "AI Engines".to_string(), - kind: "engine".to_string(), - status: ConsoleRuntimeStatus::Stopped, - detail: "No active engines".to_string(), - }], - EngineState::Starting { engine_id } => vec![ConsoleStatusItem { - id: format!("engine:{}", canonical_engine_id(engine_id)), - label: Self::engine_label_for_id(engine_id, registry_engine_labels), - kind: "engine".to_string(), - status: ConsoleRuntimeStatus::Starting, - detail: "Starting…".to_string(), - }], - EngineState::Swapping { from, to } => vec![ConsoleStatusItem { - id: format!("engine:{}", canonical_engine_id(to)), - label: Self::engine_label_for_id(to, registry_engine_labels), - kind: "engine".to_string(), - status: ConsoleRuntimeStatus::Starting, - detail: format!("Switching from {from}"), - }], - EngineState::Error { engine_id, message } => vec![ConsoleStatusItem { - id: format!("engine:{}", canonical_engine_id(engine_id)), - label: Self::engine_label_for_id(engine_id, registry_engine_labels), - kind: "engine".to_string(), - status: ConsoleRuntimeStatus::Failed, - detail: message.clone(), - }], - EngineState::Ready { slots } => { - let mut items: BTreeMap = BTreeMap::new(); - let mut label_to_id: BTreeMap = BTreeMap::new(); - for slot in slots { - let label_key = Self::normalize_view_label(&slot.engine.name); - let id = label_to_id - .entry(label_key) - .or_insert_with(|| canonical_engine_id(&slot.engine.id)) - .clone(); - let detail = ConsoleLabelFormatter::format_capability(slot.capability); - items - .entry(id.clone()) - .and_modify(|item| { - if !item.detail.split(", ").any(|part| part == detail) { - item.detail.push_str(", "); - item.detail.push_str(&detail); - } - }) - .or_insert_with(|| ConsoleStatusItem { - id: format!("engine:{id}"), - label: slot.engine.name.clone(), - kind: "engine".to_string(), - status: ConsoleRuntimeStatus::Running, - detail, - }); - } - items.into_values().collect() - } - } - } - - fn engine_label_for_id( - engine_id: &str, - registry_engine_labels: &BTreeMap, - ) -> String { - registry_engine_labels - .get(&canonical_engine_id(engine_id)) - .cloned() - .unwrap_or_else(|| ConsoleLabelFormatter::format_module_label(engine_id)) - } -} - fn open_folder(path: &std::path::Path) -> std::io::Result<()> { #[cfg(target_os = "windows")] { @@ -635,47 +166,16 @@ fn open_folder(path: &std::path::Path) -> std::io::Result<()> { } } -impl ConsoleLabelFormatter { - fn format_module_label(module_id: &str) -> String { - module_id - .trim_start_matches("axelate-") - .split('-') - .filter(|part| !part.is_empty()) - .map(Self::format_label_part) - .collect::>() - .join(" ") - } - - fn format_capability(capability: crate::domain::engine::types::Capability) -> String { - match capability { - crate::domain::engine::types::Capability::Text => "text".to_string(), - crate::domain::engine::types::Capability::Image => "image".to_string(), - crate::domain::engine::types::Capability::Vision => "vision".to_string(), - } - } - - fn format_label_part(part: &str) -> String { - let mut chars = part.chars(); - match chars.next() { - Some(first) => { - let mut label = first.to_uppercase().to_string(); - label.push_str(chars.as_str()); - label - } - None => String::new(), - } - } -} - #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] + use super::super::console_overview::{ConsoleLabelFormatter, ConsoleOverviewBuilder}; use super::{ - ConsoleLabelFormatter, ConsoleOverviewBuilder, ConsoleRuntimeStatus, - canonical_console_view_id, canonical_engine_id, clear_all_console_log_files, + ConsoleRuntimeStatus, canonical_console_view_id, clear_all_console_log_files, clear_console_log_target, resolve_console_log_target, }; + use crate::domain::engine::manager::canonical_engine_id; use crate::domain::engine::types::EngineDefinition; use crate::domain::engine::types::{Capability, EngineState, EngineStatus, SlotStatus}; use crate::infrastructure::logging::LogEntry; @@ -799,6 +299,13 @@ mod tests { assert!(error.to_string().contains("invalid characters")); } + #[test] + fn rejects_unknown_console_log_targets() { + let error = resolve_console_log_target("unknown").unwrap_err(); + + assert!(error.to_string().contains("invalid console view id")); + } + #[test] fn clears_general_and_nested_console_logs_only() { let temp = tempfile::tempdir().unwrap(); @@ -836,6 +343,39 @@ mod tests { assert_eq!(fs::read_to_string(text_file).unwrap(), "keep"); } + #[cfg(unix)] + #[test] + fn clear_console_log_files_skips_symlinked_entries() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("root"); + let external = temp.path().join("external.log"); + let linked_log = root.join("linked.log"); + let regular_log = root.join("regular.log"); + fs::create_dir_all(&root).unwrap(); + fs::write(&external, "external").unwrap(); + fs::write(®ular_log, "regular").unwrap(); + std::os::unix::fs::symlink(&external, &linked_log).unwrap(); + + clear_all_console_log_files(&root).unwrap(); + + assert_eq!(fs::read_to_string(external).unwrap(), "external"); + assert_eq!(fs::read_to_string(regular_log).unwrap(), ""); + } + + #[cfg(unix)] + #[test] + fn rejects_symlinked_console_log_roots() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("root"); + let symlink_root = temp.path().join("linked-root"); + fs::create_dir_all(&root).unwrap(); + std::os::unix::fs::symlink(&root, &symlink_root).unwrap(); + + let error = clear_all_console_log_files(&symlink_root).unwrap_err(); + + assert!(error.to_string().contains("cannot be a symlink")); + } + #[tokio::test] async fn console_overview_deduplicates_views_and_reports_engine_states() { let mut ui_state = UIState::default(); @@ -894,6 +434,51 @@ mod tests { assert_eq!(engine_status.detail, "image, vision"); } + #[tokio::test] + async fn console_overview_deduplicates_running_engines_by_id_not_label() { + let shared_name = "Local Engine"; + let state = EngineState::Ready { + slots: vec![ + SlotStatus { + capability: Capability::Text, + engine: EngineStatus { + id: "engine-a".to_string(), + name: shared_name.to_string(), + capabilities: vec![Capability::Text], + endpoint: "http://127.0.0.1:8001".to_string(), + healthy: true, + }, + }, + SlotStatus { + capability: Capability::Image, + engine: EngineStatus { + id: "engine-b".to_string(), + name: shared_name.to_string(), + capabilities: vec![Capability::Image], + endpoint: "http://127.0.0.1:8002".to_string(), + healthy: true, + }, + }, + ], + }; + + let overview = ConsoleOverviewBuilder::build( + &state, + &Vec::new(), + &UIState::default(), + &Vec::::new(), + ) + .await; + let status_ids = overview + .status_items + .iter() + .map(|item| item.id.as_str()) + .collect::>(); + + assert!(status_ids.contains(&"engine:engine-a")); + assert!(status_ids.contains(&"engine:engine-b")); + } + #[tokio::test] async fn console_overview_names_logged_engines_from_registry_definitions() { let logs = vec![engine_log_entry("custom_engine")]; diff --git a/src-tauri/src/api/system/mod.rs b/src-tauri/src/api/system/mod.rs index 8f1a9f1c..73321239 100644 --- a/src-tauri/src/api/system/mod.rs +++ b/src-tauri/src/api/system/mod.rs @@ -2,8 +2,10 @@ pub mod bootstrap; /// Configuration management commands pub mod config; +mod console_overview; /// Health check commands pub mod health; +mod log_targets; /// Logging commands pub mod logs; diff --git a/src-tauri/src/app/window.rs b/src-tauri/src/app/window.rs index 94cf53a6..3829b0d1 100644 --- a/src-tauri/src/app/window.rs +++ b/src-tauri/src/app/window.rs @@ -126,11 +126,15 @@ pub fn create_main_window(app: &tauri::AppHandle) -> Option, + /// Non-secret token prefix for recognition in the UI. + pub token_prefix: String, + /// Creation timestamp in RFC3339 UTC. + pub created_at: String, + /// Last successful API authentication timestamp in RFC3339 UTC. + pub last_seen_at: Option, + /// Whether the profile has been revoked. + pub revoked: bool, +} + +/// Agent profile creation/rotation response. Raw bearer tokens stay backend-owned. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentProfileTokenResponse { + /// Public profile metadata. + pub profile: AgentProfile, +} + +/// Agent action audit entry. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentAuditEntry { + /// Stable audit entry id. + pub id: String, + /// Agent profile id or launcher-env for development tokens. + pub actor_id: String, + /// Agent display name or development token label. + pub actor_name: String, + /// Action name such as module.start. + pub action: String, + /// Target resource id. + pub target: String, + /// Result label such as success, denied, or pending-approval. + pub result: String, + /// Timestamp in RFC3339 UTC. + pub created_at: String, +} + +/// Approval state for risky agent requests. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Type)] +#[serde(rename_all = "kebab-case")] +pub enum AgentApprovalStatus { + /// Waiting for a user decision. + Pending, + /// User approved the request. + Approved, + /// User denied the request. + Denied, +} + +/// Risky action request that must not mutate launcher state until approved. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentApprovalRequest { + /// Stable approval request id. + pub id: String, + /// Agent profile id. + pub agent_id: String, + /// Agent display name. + pub agent_name: String, + /// Requested action name. + pub action: String, + /// Target resource id or description. + pub target: String, + /// Human-readable dry-run or diff summary. + pub diff: String, + /// Risk label such as high or dangerous. + pub risk: String, + /// Current decision state. + pub status: AgentApprovalStatus, + /// Creation timestamp in RFC3339 UTC. + pub created_at: String, + /// Decision timestamp in RFC3339 UTC. + pub decided_at: Option, +} + +/// Full public Agent Control state for Settings UI. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentControlState { + /// Whether trusted local agent profiles are accepted by the local API. + pub enabled: bool, + /// Local API base URL. + pub api_base_url: String, + /// Known agent profiles. + pub profiles: Vec, + /// Recent audit entries. + pub audit: Vec, + /// Recent approval requests. + pub approvals: Vec, +} + +/// Authenticated agent identity from a bearer token. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthorizedAgent { + /// Profile id. + pub id: String, + /// Profile name. + pub name: String, + /// Granted scopes. + pub scopes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StoredAgentProfile { + id: String, + name: String, + scopes: Vec, + token_hash: String, + token_prefix: String, + created_at: String, + last_seen_at: Option, + revoked: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AgentControlStore { + enabled: bool, + profiles: Vec, + audit: Vec, + approvals: Vec, +} + +/// Backend-owned Agent Control persistence and authorization service. +#[derive(Debug, Clone)] +pub struct AgentControlService { + json_store: JsonStore, + lock: Arc>, + pending_tokens: Arc>>, +} + +impl AgentControlService { + /// Creates a new service. + pub fn new(json_store: JsonStore) -> Self { + Self { + json_store, + lock: Arc::new(tokio::sync::Mutex::new(())), + pending_tokens: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + } + } + + /// Returns redacted Agent Control state for UI. + pub async fn state(&self, api_base_url: String) -> Result { + let _guard = self.lock.lock().await; + let store = self.load_store_locked().await?; + Ok(public_state(store, api_base_url)) + } + + /// Enables or disables trusted local agent profiles. + pub async fn set_enabled( + &self, + enabled: bool, + api_base_url: String, + ) -> Result { + let _guard = self.lock.lock().await; + let mut store = self.load_store_locked().await?; + store.enabled = enabled; + self.save_store_locked(&store).await?; + Ok(public_state(store, api_base_url)) + } + + /// Creates a trusted local agent profile and returns its one-time token. + pub async fn create_profile( + &self, + name: Option, + scopes: Option>, + ) -> Result { + let _guard = self.lock.lock().await; + let mut store = self.load_store_locked().await?; + let token = generate_agent_token(); + let now = now_rfc3339(); + let profile = StoredAgentProfile { + id: uuid::Uuid::new_v4().to_string(), + name: normalize_profile_name(name), + scopes: normalize_scopes(scopes), + token_hash: hash_token(&token), + token_prefix: token_prefix(&token), + created_at: now, + last_seen_at: None, + revoked: false, + }; + let public_profile = public_profile(&profile); + store.profiles.push(profile); + store.enabled = true; + self.save_store_locked(&store).await?; + self.store_pending_token(public_profile.id.clone(), token) + .await; + + Ok(AgentProfileTokenResponse { + profile: public_profile, + }) + } + + /// Rotates a profile token and returns the new one-time token. + pub async fn rotate_profile(&self, id: &str) -> Result { + let _guard = self.lock.lock().await; + let mut store = self.load_store_locked().await?; + let token = generate_agent_token(); + let Some(profile) = store.profiles.iter_mut().find(|profile| profile.id == id) else { + return Err(AppError::NotFound(format!("Agent profile {id} not found"))); + }; + if profile.revoked { + return Err(AppError::Validation(format!( + "Agent profile {id} is revoked; create a new profile instead" + ))); + } + profile.token_hash = hash_token(&token); + profile.token_prefix = token_prefix(&token); + profile.last_seen_at = None; + let public_profile = public_profile(profile); + let profile_id = public_profile.id.clone(); + self.save_store_locked(&store).await?; + self.store_pending_token(profile_id, token).await; + + Ok(AgentProfileTokenResponse { + profile: public_profile, + }) + } + + /// Revokes a profile token. + pub async fn revoke_profile( + &self, + id: &str, + api_base_url: String, + ) -> Result { + let _guard = self.lock.lock().await; + let mut store = self.load_store_locked().await?; + let Some(profile) = store.profiles.iter_mut().find(|profile| profile.id == id) else { + return Err(AppError::NotFound(format!("Agent profile {id} not found"))); + }; + profile.revoked = true; + self.pending_tokens.lock().await.remove(id); + self.save_store_locked(&store).await?; + Ok(public_state(store, api_base_url)) + } + + /// Deletes a trusted local agent profile and removes its pending approvals. + pub async fn delete_profile( + &self, + id: &str, + api_base_url: String, + ) -> Result { + let _guard = self.lock.lock().await; + let mut store = self.load_store_locked().await?; + let before = store.profiles.len(); + store.profiles.retain(|profile| profile.id != id); + if store.profiles.len() == before { + return Err(AppError::NotFound(format!("Agent profile {id} not found"))); + } + store.approvals.retain(|approval| { + approval.agent_id != id || approval.status != AgentApprovalStatus::Pending + }); + self.pending_tokens.lock().await.remove(id); + self.save_store_locked(&store).await?; + Ok(public_state(store, api_base_url)) + } + + /// Takes the one-time plaintext token for backend-mediated copy flows. + pub async fn take_pending_token(&self, id: &str) -> Result { + let Some(token) = self.pending_tokens.lock().await.remove(id) else { + return Err(AppError::Validation( + "No one-time token is available for this profile; rotate it to create a new token" + .to_string(), + )); + }; + Ok(token) + } + + /// Copies the one-time plaintext token and consumes it only after copy succeeds. + pub async fn copy_pending_token_with(&self, id: &str, copy: F) -> Result<(), AppError> + where + F: FnOnce(&str) -> Result<(), AppError>, + { + let mut pending_tokens = self.pending_tokens.lock().await; + let Some(token) = pending_tokens.get(id) else { + return Err(AppError::Validation( + "No one-time token is available for this profile; rotate it to create a new token" + .to_string(), + )); + }; + copy(token)?; + pending_tokens.remove(id); + Ok(()) + } + + /// Authenticates a bearer token against enabled, non-revoked profiles. + pub async fn authorize_token(&self, token: &str) -> Option { + let _guard = self.lock.lock().await; + let mut store = self.load_store_locked().await.ok()?; + if !store.enabled { + return None; + } + + let hash = hash_token(token); + let profile = store + .profiles + .iter_mut() + .find(|profile| !profile.revoked && profile.token_hash == hash)?; + profile.last_seen_at = Some(now_rfc3339()); + let agent = AuthorizedAgent { + id: profile.id.clone(), + name: profile.name.clone(), + scopes: profile.scopes.clone(), + }; + let _ = self.save_store_locked(&store).await; + Some(agent) + } + + /// Records an agent audit entry. + pub async fn record_audit( + &self, + actor_id: String, + actor_name: String, + action: String, + target: String, + result: String, + ) -> Result<(), AppError> { + let _guard = self.lock.lock().await; + let mut store = self.load_store_locked().await?; + store.audit.insert( + 0, + AgentAuditEntry { + id: uuid::Uuid::new_v4().to_string(), + actor_id, + actor_name, + action, + target, + result, + created_at: now_rfc3339(), + }, + ); + store.audit.truncate(MAX_AUDIT_ENTRIES); + self.save_store_locked(&store).await + } + + /// Creates a pending approval request without mutating launcher state. + pub async fn create_approval_request( + &self, + agent: &AuthorizedAgent, + action: String, + target: String, + diff: String, + risk: String, + ) -> Result { + let _guard = self.lock.lock().await; + let mut store = self.load_store_locked().await?; + let request = AgentApprovalRequest { + id: uuid::Uuid::new_v4().to_string(), + agent_id: agent.id.clone(), + agent_name: agent.name.clone(), + action, + target, + diff, + risk, + status: AgentApprovalStatus::Pending, + created_at: now_rfc3339(), + decided_at: None, + }; + store.approvals.insert(0, request.clone()); + store.approvals.truncate(MAX_APPROVAL_REQUESTS); + self.save_store_locked(&store).await?; + Ok(request) + } + + /// Applies a user decision to a pending approval request. + pub async fn decide_approval( + &self, + id: &str, + approved: bool, + api_base_url: String, + ) -> Result { + let _guard = self.lock.lock().await; + let mut store = self.load_store_locked().await?; + let Some(request) = store.approvals.iter_mut().find(|request| request.id == id) else { + return Err(AppError::NotFound(format!("Agent approval {id} not found"))); + }; + if request.status != AgentApprovalStatus::Pending { + return Err(AppError::Validation(format!( + "Agent approval {id} has already been decided" + ))); + } + request.status = if approved { + AgentApprovalStatus::Approved + } else { + AgentApprovalStatus::Denied + }; + request.decided_at = Some(now_rfc3339()); + self.save_store_locked(&store).await?; + Ok(public_state(store, api_base_url)) + } + + async fn load_store_locked(&self) -> Result { + self.json_store.load_async(&FILE_AGENT_CONTROL).await + } + + async fn save_store_locked(&self, store: &AgentControlStore) -> Result<(), AppError> { + self.json_store.save_async(&FILE_AGENT_CONTROL, store).await + } + + async fn store_pending_token(&self, id: String, token: String) { + self.pending_tokens.lock().await.insert(id, token); + } + + #[cfg(test)] + async fn claim_pending_token_for_test(&self, id: &str) -> Result { + self.take_pending_token(id).await + } +} + +/// Returns the default Trusted Local scopes. +pub fn trusted_local_scopes() -> Vec { + vec![ + AgentScope::Observe, + AgentScope::Operate, + AgentScope::Configure, + AgentScope::DraftCreate, + ] +} + +fn normalize_scopes(scopes: Option>) -> Vec { + let mut scopes = scopes.unwrap_or_else(trusted_local_scopes); + scopes.sort_by_key(|scope| scope_rank(*scope)); + scopes.dedup(); + if scopes.is_empty() { + return trusted_local_scopes(); + } + if scopes.contains(&AgentScope::FullAccess) { + return vec![AgentScope::FullAccess]; + } + scopes +} + +const fn scope_rank(scope: AgentScope) -> u8 { + match scope { + AgentScope::Observe => 0, + AgentScope::Operate => 1, + AgentScope::Configure => 2, + AgentScope::DraftCreate => 3, + AgentScope::FullAccess => 4, + } +} + +fn normalize_profile_name(name: Option) -> String { + name.map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "Trusted Local".to_string()) +} + +fn generate_agent_token() -> String { + format!( + "axl_agent_{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ) +} + +fn hash_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) +} + +fn token_prefix(token: &str) -> String { + token.chars().take(TOKEN_PREFIX_LEN).collect() +} + +fn public_state(store: AgentControlStore, api_base_url: String) -> AgentControlState { + AgentControlState { + enabled: store.enabled, + api_base_url, + profiles: store.profiles.iter().map(public_profile).collect(), + audit: store.audit, + approvals: store.approvals, + } +} + +fn public_profile(profile: &StoredAgentProfile) -> AgentProfile { + AgentProfile { + id: profile.id.clone(), + name: profile.name.clone(), + scopes: profile.scopes.clone(), + token_prefix: profile.token_prefix.clone(), + created_at: profile.created_at.clone(), + last_seen_at: profile.last_seen_at.clone(), + revoked: profile.revoked, + } +} + +fn now_rfc3339() -> String { + Utc::now().to_rfc3339() +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::{AgentControlService, AgentScope}; + use crate::errors::AppError; + use crate::infrastructure::filesystem::local_file_service::LocalFileService; + use crate::infrastructure::persistence::json_store::JsonStore; + use crate::utils::paths::FILE_AGENT_CONTROL; + use std::sync::Arc; + + static TEST_LOCK: std::sync::LazyLock> = + std::sync::LazyLock::new(|| tokio::sync::Mutex::new(())); + + fn service() -> AgentControlService { + AgentControlService::new(JsonStore::new(Arc::new(LocalFileService::new()))) + } + + async fn reset_store() { + let _ = tokio::fs::remove_file(&*FILE_AGENT_CONTROL).await; + } + + #[tokio::test] + async fn created_profile_returns_token_once_and_authorizes_when_enabled() { + let _guard = TEST_LOCK.lock().await; + reset_store().await; + let service = service(); + let response = service + .create_profile(Some("Codex".to_string()), None) + .await + .expect("profile"); + + assert_eq!(response.profile.name, "Codex"); + assert!(response.profile.scopes.contains(&AgentScope::Observe)); + let token = service + .claim_pending_token_for_test(&response.profile.id) + .await + .expect("pending token"); + assert!(token.starts_with("axl_agent_")); + + let authorized = service.authorize_token(&token).await.expect("authorized"); + assert_eq!(authorized.name, "Codex"); + } + + #[tokio::test] + async fn take_pending_token_consumes_token() { + let _guard = TEST_LOCK.lock().await; + reset_store().await; + let service = service(); + let response = service.create_profile(None, None).await.expect("profile"); + + let first = service + .take_pending_token(&response.profile.id) + .await + .expect("pending token"); + assert!(first.starts_with("axl_agent_")); + assert!( + service + .take_pending_token(&response.profile.id) + .await + .is_err() + ); + } + + #[tokio::test] + async fn copy_pending_token_consumes_only_after_success() { + let _guard = TEST_LOCK.lock().await; + reset_store().await; + let service = service(); + let response = service.create_profile(None, None).await.expect("profile"); + let mut copied = String::new(); + + let failed = service + .copy_pending_token_with(&response.profile.id, |_| { + Err(AppError::External { + message: "clipboard unavailable".to_string(), + request_id: None, + }) + }) + .await; + assert!(failed.is_err()); + + service + .copy_pending_token_with(&response.profile.id, |token| { + copied = token.to_string(); + Ok(()) + }) + .await + .expect("copy succeeds"); + assert!(copied.starts_with("axl_agent_")); + assert!( + service + .take_pending_token(&response.profile.id) + .await + .is_err() + ); + } + + #[tokio::test] + async fn rotated_token_replaces_previous_pending_token() { + let _guard = TEST_LOCK.lock().await; + reset_store().await; + let service = service(); + let response = service.create_profile(None, None).await.expect("profile"); + let rotated = service + .rotate_profile(&response.profile.id) + .await + .expect("rotated profile"); + + assert_eq!(rotated.profile.id, response.profile.id); + let token = service + .take_pending_token(&response.profile.id) + .await + .expect("pending token"); + assert!(service.authorize_token(&token).await.is_some()); + } + + #[tokio::test] + async fn revoked_profile_cannot_authorize() { + let _guard = TEST_LOCK.lock().await; + reset_store().await; + let service = service(); + let response = service.create_profile(None, None).await.expect("profile"); + + service + .revoke_profile(&response.profile.id, "http://127.0.0.1:3000".to_string()) + .await + .expect("revoke"); + + assert!( + service + .take_pending_token(&response.profile.id) + .await + .is_err() + ); + } + + #[tokio::test] + async fn revoked_profile_cannot_be_rotated_back_to_active() { + let _guard = TEST_LOCK.lock().await; + reset_store().await; + let service = service(); + let response = service.create_profile(None, None).await.expect("profile"); + + service + .revoke_profile(&response.profile.id, "http://127.0.0.1:3000".to_string()) + .await + .expect("revoke"); + + assert!(service.rotate_profile(&response.profile.id).await.is_err()); + } + + #[tokio::test] + async fn deleted_profile_is_removed_and_cannot_authorize() { + let _guard = TEST_LOCK.lock().await; + reset_store().await; + let service = service(); + let response = service.create_profile(None, None).await.expect("profile"); + + let state = service + .delete_profile(&response.profile.id, "http://127.0.0.1:3000".to_string()) + .await + .expect("delete"); + + assert!(state.profiles.is_empty()); + assert!( + service + .take_pending_token(&response.profile.id) + .await + .is_err() + ); + } + + #[tokio::test] + async fn approval_decision_updates_status() { + let _guard = TEST_LOCK.lock().await; + reset_store().await; + let service = service(); + let response = service.create_profile(None, None).await.expect("profile"); + let token = service + .claim_pending_token_for_test(&response.profile.id) + .await + .expect("pending token"); + let agent = service.authorize_token(&token).await.expect("agent"); + let approval = service + .create_approval_request( + &agent, + "package.install".to_string(), + "demo".to_string(), + "Install demo".to_string(), + "dangerous".to_string(), + ) + .await + .expect("approval"); + + let state = service + .decide_approval(&approval.id, false, "http://127.0.0.1:3000".to_string()) + .await + .expect("decision"); + + assert_eq!( + state.approvals.first().map(|item| &item.status), + Some(&super::AgentApprovalStatus::Denied) + ); + } + + #[tokio::test] + async fn full_access_scope_replaces_narrow_scopes() { + let _guard = TEST_LOCK.lock().await; + reset_store().await; + let service = service(); + let response = service + .create_profile( + Some("Full Access".to_string()), + Some(vec![AgentScope::Observe, AgentScope::FullAccess]), + ) + .await + .expect("profile"); + + assert_eq!(response.profile.scopes, vec![AgentScope::FullAccess]); + let token = service + .claim_pending_token_for_test(&response.profile.id) + .await + .expect("pending token"); + let authorized = service.authorize_token(&token).await.expect("authorized"); + assert_eq!(authorized.scopes, vec![AgentScope::FullAccess]); + } + + #[tokio::test] + async fn deleting_profile_keeps_decided_approval_history() { + let _guard = TEST_LOCK.lock().await; + reset_store().await; + let service = service(); + let response = service.create_profile(None, None).await.expect("profile"); + let token = service + .claim_pending_token_for_test(&response.profile.id) + .await + .expect("pending token"); + let agent = service.authorize_token(&token).await.expect("agent"); + let pending = service + .create_approval_request( + &agent, + "package.install".to_string(), + "pending-demo".to_string(), + "Install pending demo".to_string(), + "dangerous".to_string(), + ) + .await + .expect("pending approval"); + let decided = service + .create_approval_request( + &agent, + "package.delete".to_string(), + "decided-demo".to_string(), + "Delete decided demo".to_string(), + "dangerous".to_string(), + ) + .await + .expect("decided approval"); + + service + .decide_approval(&decided.id, true, "http://127.0.0.1:3000".to_string()) + .await + .expect("decision"); + let state = service + .delete_profile(&response.profile.id, "http://127.0.0.1:3000".to_string()) + .await + .expect("delete"); + + assert!( + state + .approvals + .iter() + .all(|approval| approval.id != pending.id) + ); + assert!( + state + .approvals + .iter() + .any(|approval| approval.id == decided.id + && approval.status == super::AgentApprovalStatus::Approved) + ); + } + + #[tokio::test] + async fn decided_approval_cannot_be_changed() { + let _guard = TEST_LOCK.lock().await; + reset_store().await; + let service = service(); + let response = service.create_profile(None, None).await.expect("profile"); + let token = service + .claim_pending_token_for_test(&response.profile.id) + .await + .expect("pending token"); + let agent = service.authorize_token(&token).await.expect("agent"); + let approval = service + .create_approval_request( + &agent, + "package.install".to_string(), + "demo".to_string(), + "Install demo".to_string(), + "dangerous".to_string(), + ) + .await + .expect("approval"); + + service + .decide_approval(&approval.id, false, "http://127.0.0.1:3000".to_string()) + .await + .expect("first decision"); + + assert!( + service + .decide_approval(&approval.id, true, "http://127.0.0.1:3000".to_string()) + .await + .is_err() + ); + } +} diff --git a/src-tauri/src/domain/ai/ai_dispatch.rs b/src-tauri/src/domain/ai/ai_dispatch.rs index 7671d8af..275987e0 100644 --- a/src-tauri/src/domain/ai/ai_dispatch.rs +++ b/src-tauri/src/domain/ai/ai_dispatch.rs @@ -1,3 +1,4 @@ +use super::ai_provider_resolution::{clamp_max_tokens, resolve_cloud_provider_request}; use super::session::ChatSessionManager; use super::types::{ChatMessage, ChatRequest, ChatResponse}; use crate::domain::engine::config::{build_default_engine_config, merge_user_engine_config}; @@ -24,12 +25,6 @@ struct LocalEngineResolution { messages_context: Vec, } -struct CloudProviderResolution { - base_url: String, - effective_model: String, - model_max_tokens: Option, -} - pub(super) fn normalize_session_id(value: Option<&str>) -> Option<&str> { value .map(str::trim) @@ -89,6 +84,7 @@ pub(super) async fn prepare_chat_dispatch( resolve_cloud_provider_request( request, config_service, + DEFAULT_CLOUD_BASE_URL, cloud_base_url_override, &mut base_url, &mut effective_model, @@ -342,160 +338,11 @@ async fn prepend_local_system_prompt( Ok(()) } -fn resolve_cloud_provider_request( - request: &ChatRequest, - config_service: &crate::domain::system::config_service::ConfigService, - base_url_override: Option<&str>, - base_url: &mut String, - effective_model: &mut String, - model_max_tokens: &mut Option, -) { - if let Ok(config) = config_service.load_full_config() - && let Some(provider) = config - .api_providers - .iter() - .find(|provider| provider.id == request.provider) - { - let custom_models = config_service.load_custom_models().ok(); - let resolution = resolve_cloud_provider_values( - &request.provider, - &request.model, - DEFAULT_CLOUD_BASE_URL, - base_url_override, - provider, - custom_models.as_ref(), - ); - base_url.clone_from(&resolution.base_url); - effective_model.clone_from(&resolution.effective_model); - *model_max_tokens = resolution.model_max_tokens; - } -} - -fn resolve_cloud_provider_values( - provider_id: &str, - request_model: &str, - default_base_url: &str, - base_url_override: Option<&str>, - provider: &crate::models::config::ApiProvider, - custom_models: Option<&crate::models::custom_models::CustomModelConfig>, -) -> CloudProviderResolution { - let base_url = base_url_override.map_or_else( - || { - provider - .base_url - .clone() - .unwrap_or_else(|| default_base_url.to_string()) - }, - str::to_string, - ); - let mut effective_model = request_model.to_string(); - let mut model_max_tokens = None; - - if let Some(target) = provider - .model_aliases - .as_ref() - .and_then(|aliases| aliases.get(request_model)) - { - tracing::info!("Resolved model alias: {request_model} -> {target}"); - effective_model.clone_from(target); - } - - if let Some(models) = &provider.models - && let Some(definition) = models.iter().find(|model| model.id == effective_model) - { - model_max_tokens = definition.max_output_tokens; - if let Some(api_model) = definition - .api_models - .as_ref() - .and_then(|models| models.text.as_ref()) - { - tracing::info!("Resolved API model ID: {effective_model} -> {api_model}"); - effective_model = api_model.clone(); - } - } - - if let Some(custom_models) = custom_models - && let Some(custom) = custom_models - .models - .iter() - .find(|model| model.id == effective_model && model.provider_id == provider_id) - { - tracing::info!( - "Resolved Custom Model: {} -> {}", - effective_model, - custom.base_model_id - ); - effective_model = custom.base_model_id.clone(); - } - - CloudProviderResolution { - base_url, - effective_model, - model_max_tokens, - } -} - -fn clamp_max_tokens(request_limit: Option, model_limit: Option) -> Option { - match (request_limit, model_limit) { - (Some(request_limit), Some(model_limit)) => Some(std::cmp::min(request_limit, model_limit)), - (None, Some(model_limit)) => Some(model_limit), - (request_limit, None) => request_limit, - } -} - #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] - use super::{ - clamp_max_tokens, normalize_session_id, resolve_cloud_provider_values, - resolve_local_text_model_id, - }; - use crate::models::config::{ - AiModel, ApiModelConfig, ApiProvider, ModelStats, ModelTier, ProviderType, - }; - use crate::models::custom_models::{CustomModel, CustomModelConfig}; - use std::collections::HashMap; - - fn provider() -> ApiProvider { - ApiProvider { - id: "gpt".to_string(), - name: "GPT".to_string(), - desc_key: None, - description: None, - icon: None, - provider_type: Some(ProviderType::Openai), - base_url: Some("https://api.example.test/v1".to_string()), - api_key_env: None, - models: Some(vec![AiModel { - id: "catalog-model".to_string(), - desc_key: String::new(), - name: "Catalog Model".to_string(), - desc: String::new(), - tier: ModelTier::Strong, - model_size: None, - release_date: None, - context_window: Some(128_000), - max_output_tokens: Some(16_384), - pricing: None, - stats: ModelStats { - speed: 8, - logic: 9, - creative: 7, - }, - capabilities: None, - api_models: Some(ApiModelConfig { - text: Some("provider-text-model".to_string()), - image: None, - }), - }]), - capabilities: Some(vec!["text".to_string()]), - model_aliases: Some(HashMap::from([( - "ui-model".to_string(), - "catalog-model".to_string(), - )])), - } - } + use super::{normalize_session_id, resolve_local_text_model_id}; #[test] fn normalize_session_id_rejects_blank_values() { @@ -530,89 +377,4 @@ mod tests { assert_eq!(resolve_local_text_model_id("default", None), "default"); assert_eq!(resolve_local_text_model_id(" ", None), "default"); } - - #[test] - fn clamp_max_tokens_respects_model_limit() { - assert_eq!(clamp_max_tokens(Some(4_000), Some(2_000)), Some(2_000)); - assert_eq!(clamp_max_tokens(Some(1_000), Some(2_000)), Some(1_000)); - assert_eq!(clamp_max_tokens(None, Some(2_000)), Some(2_000)); - assert_eq!(clamp_max_tokens(Some(1_000), None), Some(1_000)); - assert_eq!(clamp_max_tokens(None, None), None); - } - - #[test] - fn resolve_cloud_provider_values_applies_alias_api_model_and_limit() { - let resolution = resolve_cloud_provider_values( - "gpt", - "ui-model", - "https://fallback.test/v1", - None, - &provider(), - None, - ); - - assert_eq!(resolution.base_url, "https://api.example.test/v1"); - assert_eq!(resolution.effective_model, "provider-text-model"); - assert_eq!(resolution.model_max_tokens, Some(16_384)); - } - - #[test] - fn resolve_cloud_provider_values_keeps_default_base_url_without_provider_url() { - let mut provider = provider(); - provider.base_url = None; - - let resolution = resolve_cloud_provider_values( - "gpt", - "raw-model", - "https://fallback.test/v1", - None, - &provider, - None, - ); - - assert_eq!(resolution.base_url, "https://fallback.test/v1"); - assert_eq!(resolution.effective_model, "raw-model"); - assert_eq!(resolution.model_max_tokens, None); - } - - #[test] - fn resolve_cloud_provider_values_prefers_explicit_base_url_override() { - let resolution = resolve_cloud_provider_values( - "gpt", - "ui-model", - "https://fallback.test/v1", - Some("https://api.openai.com/v1"), - &provider(), - None, - ); - - assert_eq!(resolution.base_url, "https://api.openai.com/v1"); - assert_eq!(resolution.effective_model, "provider-text-model"); - assert_eq!(resolution.model_max_tokens, Some(16_384)); - } - - #[test] - fn resolve_cloud_provider_values_applies_custom_model_after_catalog_mapping() { - let custom_models = CustomModelConfig { - models: vec![CustomModel { - id: "provider-text-model".to_string(), - name: "Custom".to_string(), - provider_id: "gpt".to_string(), - base_model_id: "ft:gpt:custom".to_string(), - created_at: 1.0, - }], - }; - - let resolution = resolve_cloud_provider_values( - "gpt", - "ui-model", - "https://fallback.test/v1", - None, - &provider(), - Some(&custom_models), - ); - - assert_eq!(resolution.effective_model, "ft:gpt:custom"); - assert_eq!(resolution.model_max_tokens, Some(16_384)); - } } diff --git a/src-tauri/src/domain/ai/ai_provider_resolution.rs b/src-tauri/src/domain/ai/ai_provider_resolution.rs new file mode 100644 index 00000000..142ba209 --- /dev/null +++ b/src-tauri/src/domain/ai/ai_provider_resolution.rs @@ -0,0 +1,252 @@ +//! Cloud AI provider request resolution. +//! +//! Keeps provider catalog aliases, API model ids, custom model overrides, and +//! provider token caps separate from local engine dispatch. + +struct CloudProviderResolution { + base_url: String, + effective_model: String, + model_max_tokens: Option, +} + +pub(super) fn resolve_cloud_provider_request( + request: &super::types::ChatRequest, + config_service: &crate::domain::system::config_service::ConfigService, + default_base_url: &str, + base_url_override: Option<&str>, + base_url: &mut String, + effective_model: &mut String, + model_max_tokens: &mut Option, +) { + if let Ok(config) = config_service.load_full_config() + && let Some(provider) = config + .api_providers + .iter() + .find(|provider| provider.id == request.provider) + { + let custom_models = config_service.load_custom_models().ok(); + let resolution = resolve_cloud_provider_values( + &request.provider, + &request.model, + default_base_url, + base_url_override, + provider, + custom_models.as_ref(), + ); + base_url.clone_from(&resolution.base_url); + effective_model.clone_from(&resolution.effective_model); + *model_max_tokens = resolution.model_max_tokens; + } +} + +fn resolve_cloud_provider_values( + provider_id: &str, + request_model: &str, + default_base_url: &str, + base_url_override: Option<&str>, + provider: &crate::models::config::ApiProvider, + custom_models: Option<&crate::models::custom_models::CustomModelConfig>, +) -> CloudProviderResolution { + let base_url = base_url_override.map_or_else( + || { + provider + .base_url + .clone() + .unwrap_or_else(|| default_base_url.to_string()) + }, + str::to_string, + ); + let mut effective_model = request_model.to_string(); + let mut model_max_tokens = None; + + if let Some(target) = provider + .model_aliases + .as_ref() + .and_then(|aliases| aliases.get(request_model)) + { + tracing::info!("Resolved model alias: {request_model} -> {target}"); + effective_model.clone_from(target); + } + + if let Some(models) = &provider.models + && let Some(definition) = models.iter().find(|model| model.id == effective_model) + { + model_max_tokens = definition.max_output_tokens; + if let Some(api_model) = definition + .api_models + .as_ref() + .and_then(|models| models.text.as_ref()) + { + tracing::info!("Resolved API model ID: {effective_model} -> {api_model}"); + effective_model = api_model.clone(); + } + } + + if let Some(custom_models) = custom_models + && let Some(custom) = custom_models + .models + .iter() + .find(|model| model.id == effective_model && model.provider_id == provider_id) + { + tracing::info!( + "Resolved Custom Model: {} -> {}", + effective_model, + custom.base_model_id + ); + effective_model = custom.base_model_id.clone(); + } + + CloudProviderResolution { + base_url, + effective_model, + model_max_tokens, + } +} + +pub(super) fn clamp_max_tokens( + request_limit: Option, + model_limit: Option, +) -> Option { + match (request_limit, model_limit) { + (Some(request_limit), Some(model_limit)) => Some(std::cmp::min(request_limit, model_limit)), + (None, Some(model_limit)) => Some(model_limit), + (request_limit, None) => request_limit, + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::{clamp_max_tokens, resolve_cloud_provider_values}; + use crate::models::config::{ + AiModel, ApiModelConfig, ApiProvider, ModelStats, ModelTier, ProviderType, + }; + use crate::models::custom_models::{CustomModel, CustomModelConfig}; + use std::collections::HashMap; + + fn provider() -> ApiProvider { + ApiProvider { + id: "gpt".to_string(), + name: "GPT".to_string(), + desc_key: None, + description: None, + icon: None, + provider_type: Some(ProviderType::Openai), + base_url: Some("https://api.example.test/v1".to_string()), + api_key_env: None, + models: Some(vec![AiModel { + id: "catalog-model".to_string(), + desc_key: String::new(), + name: "Catalog Model".to_string(), + desc: String::new(), + tier: ModelTier::Strong, + model_size: None, + release_date: None, + context_window: Some(128_000), + max_output_tokens: Some(16_384), + pricing: None, + stats: ModelStats { + speed: 8, + logic: 9, + creative: 7, + }, + capabilities: None, + api_models: Some(ApiModelConfig { + text: Some("provider-text-model".to_string()), + image: None, + }), + }]), + capabilities: Some(vec!["text".to_string()]), + model_aliases: Some(HashMap::from([( + "ui-model".to_string(), + "catalog-model".to_string(), + )])), + } + } + + #[test] + fn clamp_max_tokens_respects_model_limit() { + assert_eq!(clamp_max_tokens(Some(4_000), Some(2_000)), Some(2_000)); + assert_eq!(clamp_max_tokens(Some(1_000), Some(2_000)), Some(1_000)); + assert_eq!(clamp_max_tokens(None, Some(2_000)), Some(2_000)); + assert_eq!(clamp_max_tokens(Some(1_000), None), Some(1_000)); + assert_eq!(clamp_max_tokens(None, None), None); + } + + #[test] + fn resolve_cloud_provider_values_applies_alias_api_model_and_limit() { + let resolution = resolve_cloud_provider_values( + "gpt", + "ui-model", + "https://fallback.test/v1", + None, + &provider(), + None, + ); + + assert_eq!(resolution.base_url, "https://api.example.test/v1"); + assert_eq!(resolution.effective_model, "provider-text-model"); + assert_eq!(resolution.model_max_tokens, Some(16_384)); + } + + #[test] + fn resolve_cloud_provider_values_keeps_default_base_url_without_provider_url() { + let mut provider = provider(); + provider.base_url = None; + + let resolution = resolve_cloud_provider_values( + "gpt", + "raw-model", + "https://fallback.test/v1", + None, + &provider, + None, + ); + + assert_eq!(resolution.base_url, "https://fallback.test/v1"); + assert_eq!(resolution.effective_model, "raw-model"); + assert_eq!(resolution.model_max_tokens, None); + } + + #[test] + fn resolve_cloud_provider_values_prefers_explicit_base_url_override() { + let resolution = resolve_cloud_provider_values( + "gpt", + "ui-model", + "https://fallback.test/v1", + Some("https://api.openai.com/v1"), + &provider(), + None, + ); + + assert_eq!(resolution.base_url, "https://api.openai.com/v1"); + assert_eq!(resolution.effective_model, "provider-text-model"); + assert_eq!(resolution.model_max_tokens, Some(16_384)); + } + + #[test] + fn resolve_cloud_provider_values_applies_custom_model_after_catalog_mapping() { + let custom_models = CustomModelConfig { + models: vec![CustomModel { + id: "provider-text-model".to_string(), + name: "Custom".to_string(), + provider_id: "gpt".to_string(), + base_model_id: "ft:gpt:custom".to_string(), + created_at: 1.0, + }], + }; + + let resolution = resolve_cloud_provider_values( + "gpt", + "ui-model", + "https://fallback.test/v1", + None, + &provider(), + Some(&custom_models), + ); + + assert_eq!(resolution.effective_model, "ft:gpt:custom"); + assert_eq!(resolution.model_max_tokens, Some(16_384)); + } +} diff --git a/src-tauri/src/domain/ai/ai_service.rs b/src-tauri/src/domain/ai/ai_service.rs index f791a519..0fa91c7b 100644 --- a/src-tauri/src/domain/ai/ai_service.rs +++ b/src-tauri/src/domain/ai/ai_service.rs @@ -11,6 +11,7 @@ use super::ai_dispatch::{ LocalEngineAccess, PreparedChatDispatch, normalize_session_id, persist_successful_response, prepare_chat_dispatch, }; +pub use super::ai_validation::validate_api_key; use super::session::ChatSessionManager; use super::streaming::{AiProvider, OpenAiCompatibleProvider, StreamEvent, StreamSink}; pub use super::types::{ @@ -370,126 +371,91 @@ fn timeout_error(request_id: String, timeout: std::time::Duration) -> crate::err } } -// ================================================================================== -// Helpers -// ================================================================================== - -/// Builds the outbound validation request without leaking secrets into the URL. -fn build_validation_request( - client: &reqwest::Client, - provider: &str, - key: &str, - base_url: Option<&str>, -) -> Result { - let request = if provider == "gemini" && key.starts_with("AIza") { - client - .get("https://generativelanguage.googleapis.com/v1beta/models") - .header("x-goog-api-key", key) - } else { - let base_url = base_url - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("https://openrouter.ai/api/v1") - .trim_end_matches('/'); - let models_url = format!("{base_url}/models"); - - // OpenAI-compatible providers expose model listing behind the same - // base URL used for chat completions. - client - .get(models_url) - .header("Authorization", format!("Bearer {key}")) - }; +/// Counts tokens in text using tiktoken when the model maps to a known OpenAI tokenizer. +pub fn count_tokens(text: &str, model: Option<&str>) -> Result { + use tiktoken_rs::cl100k_base; - request - .build() - .map_err(|e| crate::errors::AppError::External { - request_id: None, - message: e.to_string(), - }) -} + if let Some(model_name) = model { + if let Some(count) = count_with_known_tiktoken_model(text, model_name) { + return Ok(count); + } -/// Validates an API key against OpenRouter (or generic OpenAI endpoint). -pub async fn validate_api_key( - provider: String, - key: String, - base_url: Option, -) -> Result { - let key = key.trim().to_string(); - if key.is_empty() - || key.chars().any(char::is_whitespace) - || key.contains("://") - || key.contains('/') - || key.contains('?') - || key.contains('&') - { - return Ok(false); + if should_use_portable_token_estimate(model_name) { + return Ok(estimate_portable_token_count(text)); + } } - // Gemini native keys must start with AIza (unless routed via OpenAI-compatible proxy) - if provider == "gemini" && key.starts_with("AIza") { - // Validate directly against Google AI Studio - } else if key.len() < 8 { - // Any reasonable API key should be at least 8 chars - return Ok(false); - } + let bpe = cl100k_base().map_err(|e| format!("Failed to load cl100k_base tokenizer: {e}"))?; - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .map_err(|e| crate::errors::AppError::External { - request_id: None, - message: e.to_string(), - })?; - - let request = build_validation_request(&client, &provider, &key, base_url.as_deref())?; - - // Explicitly drop key after building request - std::mem::drop(key); - - let res = client - .execute(request) - .await - .map_err(|e| crate::errors::AppError::External { - request_id: None, - message: e.to_string(), - })?; - - if !res.status().is_success() { - return Ok(false); - } + Ok(bpe.encode_with_special_tokens(text).len()) +} - let body = res.json::().await.map_err(|e| { - tracing::error!("[Validation] Failed to parse response JSON: {e}"); - crate::errors::AppError::External { - request_id: None, - message: "Malformed API response during validation".to_string(), - } - })?; +fn count_with_known_tiktoken_model(text: &str, model_name: &str) -> Option { + use tiktoken_rs::bpe_for_model; - if let Some(data) = body.get("data").and_then(|d| d.as_array()) { - return Ok(!data.is_empty()); + if let Ok(bpe) = bpe_for_model(model_name) { + return Some(bpe.encode_with_special_tokens(text).len()); } - if body.get("models").is_some() { - return Ok(true); + let (_, model_id) = model_name.rsplit_once('/')?; + if model_id == model_name { + return None; } - Ok(false) + bpe_for_model(model_id) + .ok() + .map(|bpe| bpe.encode_with_special_tokens(text).len()) } -/// Counts tokens in text using tiktoken -pub fn count_tokens(text: &str, model: Option<&str>) -> Result { - use tiktoken_rs::{bpe_for_model, cl100k_base}; +fn should_use_portable_token_estimate(model_name: &str) -> bool { + let normalized = model_name.to_ascii_lowercase(); + [ + "llama", + "mistral", + "mixtral", + "qwen", + "deepseek", + "gemma", + "phi", + "yi-", + "codellama", + "starcoder", + "local", + "gguf", + "ollama", + "llamacpp", + "llama.cpp", + "anthropic/", + "claude", + "google/", + "gemini", + "x-ai/", + "grok", + ] + .iter() + .any(|marker| normalized.contains(marker)) +} - if let Some(model_name) = model - && let Ok(bpe) = bpe_for_model(model_name) - { - return Ok(bpe.encode_with_special_tokens(text).len()); +fn estimate_portable_token_count(text: &str) -> usize { + let trimmed = text.trim(); + if trimmed.is_empty() { + return 0; } - let bpe = cl100k_base().map_err(|e| format!("Failed to load cl100k_base tokenizer: {e}"))?; + let char_count = trimmed.chars().count(); + let non_ascii_count = trimmed + .chars() + .filter(|character| !character.is_ascii()) + .count(); + let word_count = trimmed.split_whitespace().count(); + let word_estimate = word_count + word_count.div_ceil(3); + let char_estimate = if non_ascii_count.saturating_mul(2) >= char_count { + char_count + } else { + char_count.div_ceil(3) + }; - Ok(bpe.encode_with_special_tokens(text).len()) + word_estimate.max(char_estimate).max(1) } /// Dispatches an image generation request to the appropriate provider (local engine). @@ -562,15 +528,44 @@ mod tests { #[test] fn test_count_tokens_with_model_fallback() { - // Unknown model should fall back to cl100k_base without error + // Unknown model should fall back without error let count = count_tokens( "Testing an unknown model tokenizer", Some("unknown-model-xyz"), ) - .expect("Should fall back to cl100k_base"); + .expect("Should fall back to a portable estimate"); assert!(count > 0); } + #[test] + fn count_tokens_resolves_openrouter_openai_model_ids() { + let direct = count_tokens("Hello world", Some("gpt-4.1")).expect("direct model count"); + let namespaced = + count_tokens("Hello world", Some("openai/gpt-4.1")).expect("namespaced model count"); + + assert_eq!(direct, namespaced); + } + + #[test] + fn count_tokens_uses_portable_estimate_for_local_models() { + let count = count_tokens( + "The quick brown fox jumps over the lazy dog", + Some("meta-llama/llama-3.1-8b-instruct"), + ) + .expect("local model count"); + + assert_eq!(count, 15); + } + + #[test] + fn portable_token_estimate_handles_cjk_without_whitespace() { + let text = "这是一个没有空格的中文句子"; + let count = + count_tokens(text, Some("llamacpp/local-model")).expect("portable CJK estimate"); + + assert_eq!(count, text.chars().count()); + } + #[test] fn conflicting_local_capability_is_text_image_exclusive() { assert_eq!( @@ -609,91 +604,6 @@ mod tests { assert!(count <= 15, "Should not wildly over-count 9 words"); } - #[tokio::test] - async fn test_validate_api_key_rejects_obvious_non_keys() { - assert!( - !validate_api_key("openrouter".to_string(), String::new(), None) - .await - .expect("empty key should not error") - ); - assert!( - !validate_api_key( - "openrouter".to_string(), - "https://reddit.com/r/not-a-key".to_string(), - None - ) - .await - .expect("url-like key should not error") - ); - assert!( - !validate_api_key("openrouter".to_string(), "not a real key".to_string(), None) - .await - .expect("whitespace key should not error") - ); - } - - #[test] - fn test_build_validation_request_keeps_gemini_key_out_of_url() { - let client = reqwest::Client::new(); - let request = build_validation_request(&client, "gemini", "AIza-test-key", None) - .expect("gemini request should build"); - - assert_eq!( - request.url().as_str(), - "https://generativelanguage.googleapis.com/v1beta/models" - ); - assert_eq!( - request - .headers() - .get("x-goog-api-key") - .expect("gemini header should exist"), - "AIza-test-key" - ); - } - - #[test] - fn test_build_validation_request_uses_bearer_for_openrouter_keys() { - let client = reqwest::Client::new(); - let request = build_validation_request(&client, "openrouter", "sk-or-test", None) - .expect("openrouter request should build"); - - assert_eq!( - request.url().as_str(), - "https://openrouter.ai/api/v1/models" - ); - assert_eq!( - request - .headers() - .get("Authorization") - .expect("authorization header should exist"), - "Bearer sk-or-test" - ); - } - - #[test] - fn test_build_validation_request_uses_configured_openai_compatible_base_url() { - let client = reqwest::Client::new(); - let request = build_validation_request( - &client, - "groq", - "gsk-test", - Some("https://api.groq.com/openai/v1/"), - ) - .expect("groq request should build"); - - assert_eq!( - request.url().as_str(), - "https://api.groq.com/openai/v1/models" - ); - assert_eq!( - request - .headers() - .get("Authorization") - .expect("authorization header should exist"), - "Bearer gsk-test" - ); - } - #[test] fn test_resolve_image_setting_prefers_settings_key() { let mut extra_settings = HashMap::new(); diff --git a/src-tauri/src/domain/ai/ai_validation.rs b/src-tauri/src/domain/ai/ai_validation.rs new file mode 100644 index 00000000..8c0bd6a1 --- /dev/null +++ b/src-tauri/src/domain/ai/ai_validation.rs @@ -0,0 +1,284 @@ +//! API key validation helpers for cloud AI providers. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +/// Builds the outbound validation request without leaking secrets into the URL. +fn build_validation_request( + client: &reqwest::Client, + provider: &str, + key: &str, + base_url: Option<&str>, +) -> Result { + let request = if provider == "gemini" && key.starts_with("AIza") { + client + .get("https://generativelanguage.googleapis.com/v1beta/models") + .header("x-goog-api-key", key) + } else { + let base_url = base_url + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("https://openrouter.ai/api/v1") + .trim_end_matches('/'); + validate_openai_compatible_base_url(base_url)?; + let models_url = format!("{base_url}/models"); + + // OpenAI-compatible providers expose model listing behind the same + // base URL used for chat completions. + client + .get(models_url) + .header("Authorization", format!("Bearer {key}")) + }; + + request + .build() + .map_err(|e| crate::errors::AppError::External { + request_id: None, + message: e.to_string(), + }) +} + +fn validate_openai_compatible_base_url(base_url: &str) -> Result<(), crate::errors::AppError> { + let parsed = reqwest::Url::parse(base_url).map_err(|_| { + crate::errors::AppError::Validation("Unsupported validation base URL".to_string()) + })?; + + if parsed.scheme() != "https" || parsed.host_str().is_none() { + return Err(crate::errors::AppError::Validation( + "Unsupported validation base URL".to_string(), + )); + } + + let Some(host) = parsed.host_str() else { + return Err(crate::errors::AppError::Validation( + "Unsupported validation base URL".to_string(), + )); + }; + + let normalized_host = host.trim_end_matches('.').to_ascii_lowercase(); + if normalized_host == "localhost" { + return Err(crate::errors::AppError::Validation( + "Unsupported validation base URL".to_string(), + )); + } + + let normalized_ip_host = normalized_host + .trim_start_matches('[') + .trim_end_matches(']'); + if let Ok(ip) = normalized_ip_host.parse::() + && is_restricted_ip(ip) + { + return Err(crate::errors::AppError::Validation( + "Unsupported validation base URL".to_string(), + )); + } + + Ok(()) +} + +fn is_restricted_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => is_restricted_ipv4(ip), + IpAddr::V6(ip) => is_restricted_ipv6(ip), + } +} + +const fn is_restricted_ipv4(ip: Ipv4Addr) -> bool { + ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.is_broadcast() + || ip.is_unspecified() +} + +fn is_restricted_ipv6(ip: Ipv6Addr) -> bool { + ip.is_loopback() + || ip.is_unspecified() + || ip.is_unique_local() + || ip.is_unicast_link_local() + || ip.to_ipv4_mapped().is_some_and(is_restricted_ipv4) +} + +/// Validates an API key against OpenRouter (or generic OpenAI endpoint). +pub async fn validate_api_key( + provider: String, + key: String, + base_url: Option, +) -> Result { + let key = key.trim().to_string(); + if key.is_empty() + || key.chars().any(char::is_whitespace) + || key.contains("://") + || key.contains('/') + || key.contains('?') + || key.contains('&') + { + return Ok(false); + } + + // Gemini native keys must start with AIza (unless routed via OpenAI-compatible proxy). + if provider == "gemini" && key.starts_with("AIza") { + // Validate directly against Google AI Studio. + } else if key.len() < 8 { + // Any reasonable API key should be at least 8 chars. + return Ok(false); + } + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| crate::errors::AppError::External { + request_id: None, + message: e.to_string(), + })?; + + let request = build_validation_request(&client, &provider, &key, base_url.as_deref())?; + + // Explicitly drop key after building request. + std::mem::drop(key); + + let res = client + .execute(request) + .await + .map_err(|e| crate::errors::AppError::External { + request_id: None, + message: e.to_string(), + })?; + + if !res.status().is_success() { + return Ok(false); + } + + let body = res.json::().await.map_err(|e| { + tracing::error!("[Validation] Failed to parse response JSON: {e}"); + crate::errors::AppError::External { + request_id: None, + message: "Malformed API response during validation".to_string(), + } + })?; + + if let Some(data) = body.get("data").and_then(|d| d.as_array()) { + return Ok(!data.is_empty()); + } + + if body.get("models").is_some() { + return Ok(true); + } + + Ok(false) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::{build_validation_request, validate_api_key}; + + #[tokio::test] + async fn validate_api_key_rejects_obvious_non_keys() { + assert!( + !validate_api_key("openrouter".to_string(), String::new(), None) + .await + .expect("empty key should not error") + ); + assert!( + !validate_api_key( + "openrouter".to_string(), + "https://reddit.com/r/not-a-key".to_string(), + None + ) + .await + .expect("url-like key should not error") + ); + assert!( + !validate_api_key("openrouter".to_string(), "not a real key".to_string(), None) + .await + .expect("whitespace key should not error") + ); + } + + #[test] + fn build_validation_request_keeps_gemini_key_out_of_url() { + let client = reqwest::Client::new(); + let request = build_validation_request(&client, "gemini", "AIza-test-key", None) + .expect("gemini request should build"); + + assert_eq!( + request.url().as_str(), + "https://generativelanguage.googleapis.com/v1beta/models" + ); + assert_eq!( + request + .headers() + .get("x-goog-api-key") + .expect("gemini header should exist"), + "AIza-test-key" + ); + } + + #[test] + fn build_validation_request_uses_bearer_for_openrouter_keys() { + let client = reqwest::Client::new(); + let request = build_validation_request(&client, "openrouter", "sk-or-test", None) + .expect("openrouter request should build"); + + assert_eq!( + request.url().as_str(), + "https://openrouter.ai/api/v1/models" + ); + assert_eq!( + request + .headers() + .get("Authorization") + .expect("authorization header should exist"), + "Bearer sk-or-test" + ); + } + + #[test] + fn build_validation_request_uses_configured_openai_compatible_base_url() { + let client = reqwest::Client::new(); + let request = build_validation_request( + &client, + "groq", + "gsk-test", + Some("https://api.groq.com/openai/v1/"), + ) + .expect("groq request should build"); + + assert_eq!( + request.url().as_str(), + "https://api.groq.com/openai/v1/models" + ); + assert_eq!( + request + .headers() + .get("Authorization") + .expect("authorization header should exist"), + "Bearer gsk-test" + ); + } + + #[test] + fn build_validation_request_rejects_unsafe_base_urls() { + let client = reqwest::Client::new(); + for base_url in [ + "http://api.groq.com/openai/v1", + "https://localhost/v1", + "https://127.0.0.1/v1", + "https://[::ffff:127.0.0.1]/v1", + "https://[::ffff:10.0.0.1]/v1", + "https://10.0.0.2/v1", + "file:///tmp/models", + "not-a-url", + ] { + let error = + build_validation_request(&client, "custom-text", "sk-test-key", Some(base_url)) + .expect_err("unsafe validation URL should be rejected"); + assert!( + error + .to_string() + .contains("Unsupported validation base URL") + ); + } + } +} diff --git a/src-tauri/src/domain/ai/mod.rs b/src-tauri/src/domain/ai/mod.rs index 6e5b2a3c..da29e5e1 100644 --- a/src-tauri/src/domain/ai/mod.rs +++ b/src-tauri/src/domain/ai/mod.rs @@ -1,6 +1,8 @@ mod ai_dispatch; +mod ai_provider_resolution; /// AI service implementation pub mod ai_service; +mod ai_validation; /// Custom model management service pub mod custom_model_service; mod image_cloud; @@ -23,6 +25,7 @@ mod session_context; mod session_persistence; /// AI streaming abstractions and provider implementations pub mod streaming; +mod streaming_chunks; /// AI Data Transfer Objects (DTOs) pub mod types; // Re-export public surface so existing callers need no changes diff --git a/src-tauri/src/domain/ai/provider_payload.rs b/src-tauri/src/domain/ai/provider_payload.rs index 6424ac50..e3288e9a 100644 --- a/src-tauri/src/domain/ai/provider_payload.rs +++ b/src-tauri/src/domain/ai/provider_payload.rs @@ -177,20 +177,38 @@ pub(super) fn should_attach_web_search(req: &ChatRequest) -> bool { pub(super) fn extract_message_text(content: &serde_json::Value) -> String { match content { serde_json::Value::String(text) => text.clone(), - serde_json::Value::Array(parts) => parts - .iter() - .filter_map(|part| { - (part.get("type")?.as_str()? == "text") - .then(|| part.get("text")?.as_str()) - .flatten() - .map(ToOwned::to_owned) - }) - .collect::>() - .join("\n"), + serde_json::Value::Array(parts) => { + let mut text = String::with_capacity(multimodal_text_capacity_hint(parts)); + for part in parts { + let Some(value) = multimodal_text_part(part) else { + continue; + }; + if !text.is_empty() { + text.push('\n'); + } + text.push_str(value); + } + text + } _ => String::new(), } } +fn multimodal_text_capacity_hint(parts: &[serde_json::Value]) -> usize { + let text_bytes = parts + .iter() + .filter_map(multimodal_text_part) + .map(str::len) + .sum::(); + text_bytes.saturating_add(parts.len().saturating_sub(1)) +} + +fn multimodal_text_part(part: &serde_json::Value) -> Option<&str> { + (part.get("type")?.as_str()? == "text") + .then(|| part.get("text")?.as_str()) + .flatten() +} + pub(super) fn build_web_search_tool(options: &WebSearchOptions) -> serde_json::Value { let mut parameters = serde_json::Map::new(); parameters.insert( diff --git a/src-tauri/src/domain/ai/session.rs b/src-tauri/src/domain/ai/session.rs index 6a06e3a4..fb4f6ef6 100644 --- a/src-tauri/src/domain/ai/session.rs +++ b/src-tauri/src/domain/ai/session.rs @@ -9,32 +9,19 @@ use std::sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, }; +use std::time::Duration; use tokio::sync::Notify; use super::session_context::{ - build_summary_lines, estimate_message_tokens, estimate_messages_tokens, extract_message_text, - find_history_overlap, group_turn_ranges, merge_summary, + build_local_context_messages, extract_message_text, find_history_overlap, }; use super::types::{ChatMessage, ChatReply, ChatSession}; -const LOCAL_CONTEXT_RESERVE_TOKENS: usize = 1024; -const LOCAL_RECENT_TURNS: usize = 3; -const LOCAL_SUMMARY_BUDGET_NUMERATOR: usize = 28; -const LOCAL_SUMMARY_BUDGET_DENOMINATOR: usize = 100; -const LOCAL_MIN_SUMMARY_TOKENS: usize = 160; - use super::session_persistence::SessionPersistence; -struct LocalContextBudget { - available_tokens: usize, - summary_tokens: usize, -} - -struct LocalContextState { - turn_ranges: Vec<(usize, usize)>, - recent_start_index: usize, - persisted_summary_count: usize, -} +const SAVE_DEBOUNCE_DELAY: Duration = Duration::from_secs(5); +const SAVE_RETRY_INITIAL_DELAY: Duration = Duration::from_secs(5); +const SAVE_RETRY_MAX_DELAY: Duration = Duration::from_mins(5); /// Manages persistence and retrieval of chat sessions. /// @@ -105,6 +92,7 @@ impl ChatSessionManager { tauri::async_runtime::spawn(async move { tracing::debug!("Background chat session saver started."); + let mut save_failure_count = 0u32; loop { save_notify.notified().await; if !persistence_available.load(Ordering::Relaxed) { @@ -112,7 +100,7 @@ impl ChatSessionManager { continue; } - tokio::time::sleep(std::time::Duration::from_secs(5)).await; + tokio::time::sleep(SAVE_DEBOUNCE_DELAY).await; if dirty.swap(false, Ordering::AcqRel) { let save_lock = Arc::clone(&save_lock); let sessions = Arc::clone(&sessions); @@ -123,17 +111,30 @@ impl ChatSessionManager { .await { Ok(Ok(())) => { + save_failure_count = 0; tracing::debug!("Chat history saved to disk."); } Ok(Err(error)) => { + save_failure_count = save_failure_count.saturating_add(1); dirty.store(true, Ordering::Release); + let retry_delay = save_retry_delay(save_failure_count); + tracing::error!( + "Failed to save chat history: {error}; retrying in {}s", + retry_delay.as_secs() + ); + tokio::time::sleep(retry_delay).await; save_notify.notify_one(); - tracing::error!("Failed to save chat history: {}", error); } Err(error) => { + save_failure_count = save_failure_count.saturating_add(1); dirty.store(true, Ordering::Release); + let retry_delay = save_retry_delay(save_failure_count); + tracing::error!( + "Saver task join error: {error}; retrying in {}s", + retry_delay.as_secs() + ); + tokio::time::sleep(retry_delay).await; save_notify.notify_one(); - tracing::error!("Saver task join error: {}", error); } } } @@ -314,10 +315,8 @@ impl ChatSessionManager { return Vec::new(); } - let budget = LocalContextBudget::new(context_size); - let state = LocalContextState::from_session(&session); - let summary_changed = state.refresh_summary(&mut session, budget.summary_tokens, model); - let context = state.build_context(&session, budget.available_tokens, model); + let (context, summary_changed) = + build_local_context_messages(&mut session, context_size, model); if summary_changed { drop(session); self.mark_dirty(); @@ -335,6 +334,13 @@ impl ChatSessionManager { } } +fn save_retry_delay(failure_count: u32) -> Duration { + let exponent = failure_count.saturating_sub(1).min(6); + SAVE_RETRY_INITIAL_DELAY + .saturating_mul(2u32.saturating_pow(exponent)) + .min(SAVE_RETRY_MAX_DELAY) +} + impl ChatSessionManager { fn flush_sessions_locked( save_lock: &Mutex<()>, @@ -354,153 +360,6 @@ impl ChatSessionManager { } } -impl LocalContextBudget { - fn new(context_size: usize) -> Self { - let normalized_context_size = context_size.max(4096); - let available_tokens = normalized_context_size - .saturating_sub(LOCAL_CONTEXT_RESERVE_TOKENS) - .max(512); - let summary_tokens = available_tokens.saturating_mul(LOCAL_SUMMARY_BUDGET_NUMERATOR) - / LOCAL_SUMMARY_BUDGET_DENOMINATOR; - - Self { - available_tokens, - summary_tokens: summary_tokens.max(LOCAL_MIN_SUMMARY_TOKENS), - } - } -} - -impl LocalContextState { - fn from_session(session: &ChatSession) -> Self { - let turn_ranges = group_turn_ranges(&session.history); - let recent_start_index = turn_ranges - .len() - .checked_sub(LOCAL_RECENT_TURNS) - .and_then(|index| turn_ranges.get(index)) - .map_or(0, |(start, _)| *start); - let persisted_summary_count = - usize::try_from(session.summary_message_count).unwrap_or(usize::MAX); - - Self { - turn_ranges, - recent_start_index, - persisted_summary_count, - } - } - - fn refresh_summary( - &self, - session: &mut ChatSession, - summary_budget: usize, - model: &str, - ) -> bool { - if self.recent_start_index < self.persisted_summary_count { - session.summary = None; - session.summary_message_count = 0; - return true; - } - - if self.recent_start_index <= self.persisted_summary_count { - return false; - } - - let Some(new_summary_slice) = session - .history - .get(self.persisted_summary_count..self.recent_start_index) - else { - return false; - }; - - let summary_lines = build_summary_lines(new_summary_slice); - if summary_lines.is_empty() { - return false; - } - - session.summary = merge_summary( - session.summary.as_deref(), - &summary_lines, - summary_budget, - model, - ); - session.summary_message_count = u32::try_from(self.recent_start_index).unwrap_or(u32::MAX); - true - } - - fn build_context( - &self, - session: &ChatSession, - available_budget: usize, - model: &str, - ) -> Vec { - let (mut context, used_tokens) = - Self::build_summary_message(session.summary.clone(), available_budget, model); - context.extend(self.collect_recent_turns(session, available_budget, used_tokens, model)); - context - } - - fn build_summary_message( - summary: Option, - available_budget: usize, - model: &str, - ) -> (Vec, usize) { - let Some(summary_content) = summary else { - return (Vec::new(), 0); - }; - - let hidden_summary = format!( - "Internal conversation summary for continuity. Use it only as hidden context. Do not quote, reveal, translate, or mention it unless the user explicitly asks. Reply directly to the latest user message in the user's language.\n\nSummary:\n{summary_content}" - ); - - let summary_message = ChatMessage { - id: uuid::Uuid::new_v4().to_string(), - role: "system".to_string(), - content: serde_json::Value::String(hidden_summary), - thought_signature: None, - }; - let summary_tokens = estimate_message_tokens(&summary_message, model); - if summary_tokens > available_budget { - return (Vec::new(), 0); - } - - (vec![summary_message], summary_tokens) - } - - fn collect_recent_turns( - &self, - session: &ChatSession, - available_budget: usize, - initial_tokens: usize, - model: &str, - ) -> Vec { - let recent_turn_ranges = self - .turn_ranges - .len() - .checked_sub(LOCAL_RECENT_TURNS) - .and_then(|start| self.turn_ranges.get(start..)) - .unwrap_or(&self.turn_ranges); - - let mut used_tokens = initial_tokens; - let mut kept_recent: Vec = Vec::new(); - - for (start, end) in recent_turn_ranges.iter().rev() { - let Some(turn) = session.history.get(*start..*end) else { - continue; - }; - let turn_tokens = estimate_messages_tokens(turn, model); - if used_tokens + turn_tokens > available_budget { - continue; - } - - let mut turn_messages = turn.to_vec(); - turn_messages.append(&mut kept_recent); - kept_recent = turn_messages; - used_tokens += turn_tokens; - } - - kept_recent - } -} - impl Default for ChatSessionManager { fn default() -> Self { Self::new() @@ -531,6 +390,16 @@ mod tests { assert!(ts > 0.0, "Timestamp should be a positive UNIX epoch value"); } + #[test] + fn save_retry_delay_uses_capped_exponential_backoff() { + assert_eq!(save_retry_delay(0), Duration::from_secs(5)); + assert_eq!(save_retry_delay(1), Duration::from_secs(5)); + assert_eq!(save_retry_delay(2), Duration::from_secs(10)); + assert_eq!(save_retry_delay(3), Duration::from_secs(20)); + assert_eq!(save_retry_delay(7), Duration::from_mins(5)); + assert_eq!(save_retry_delay(u32::MAX), Duration::from_mins(5)); + } + #[test] fn test_merge_request_messages_in_memory() { let manager = test_manager(); diff --git a/src-tauri/src/domain/ai/session_context.rs b/src-tauri/src/domain/ai/session_context.rs index 25d7a51f..3cd25010 100644 --- a/src-tauri/src/domain/ai/session_context.rs +++ b/src-tauri/src/domain/ai/session_context.rs @@ -1,6 +1,16 @@ use std::fmt::Write as _; -use super::types::ChatMessage; +use super::types::{ChatMessage, ChatSession}; + +const LOCAL_CONTEXT_RESERVE_TOKENS: usize = 1024; +const LOCAL_RECENT_TURNS: usize = 3; +const LOCAL_SUMMARY_BUDGET_NUMERATOR: usize = 28; +const LOCAL_SUMMARY_BUDGET_DENOMINATOR: usize = 100; +const LOCAL_MIN_SUMMARY_TOKENS: usize = 160; +const LOCAL_MEDIUM_CONTEXT_TOKENS: usize = 32_768; +const LOCAL_LARGE_CONTEXT_TOKENS: usize = 131_072; +const LOCAL_MAX_CONTEXT_RESERVE_TOKENS: usize = 8_192; +const LOCAL_MAX_MIN_SUMMARY_TOKENS: usize = 2_048; const LEGACY_SUMMARY_PREFIXES: [&str; 5] = [ "Conversation recap from earlier turns:\n", @@ -10,26 +20,51 @@ const LEGACY_SUMMARY_PREFIXES: [&str; 5] = [ "Контекст:", ]; +struct LocalContextBudget { + available_tokens: usize, + summary_tokens: usize, + recent_turns: usize, +} + +struct LocalContextState { + turn_ranges: Vec<(usize, usize)>, + persisted_summary_count: usize, +} + +pub(super) fn build_local_context_messages( + session: &mut ChatSession, + context_size: usize, + model: &str, +) -> (Vec, bool) { + let budget = LocalContextBudget::new(context_size); + let state = LocalContextState::from_session(session); + let summary_cutoff_index = state.recent_context_start_index( + session, + budget.available_tokens, + budget.summary_tokens, + model, + budget.recent_turns, + ); + let summary_changed = + state.refresh_summary(session, budget.summary_tokens, model, summary_cutoff_index); + let context = state.build_context(session, &budget, model); + + (context, summary_changed) +} + pub(super) fn extract_message_text(content: &serde_json::Value) -> Option { match content { serde_json::Value::String(text) => Some(text.clone()), serde_json::Value::Array(parts) => { - let text = parts - .iter() - .filter_map(|part| { - let object = part.as_object()?; - if object.get("type").and_then(serde_json::Value::as_str) != Some("text") { - return None; - } - object - .get("text") - .and_then(serde_json::Value::as_str) - .map(ToOwned::to_owned) - }) - .collect::>() - .join("\n") - .trim() - .to_string(); + let mut text = String::with_capacity(multimodal_text_capacity_hint(parts)); + + for part in parts { + if let Some(value) = multimodal_text_part(part) { + push_joined_text(&mut text, value, '\n'); + } + } + + let text = text.trim().to_string(); if text.is_empty() { None } else { Some(text) } } @@ -177,6 +212,240 @@ pub(super) fn merge_summary( None } +impl LocalContextBudget { + fn new(context_size: usize) -> Self { + let normalized_context_size = context_size.max(4096); + let reserve_tokens = scaled_context_reserve_tokens(normalized_context_size); + let available_tokens = normalized_context_size + .saturating_sub(reserve_tokens) + .max(512); + let summary_tokens = available_tokens + .saturating_mul(summary_budget_percent(normalized_context_size)) + / LOCAL_SUMMARY_BUDGET_DENOMINATOR; + let min_summary_tokens = scaled_min_summary_tokens(normalized_context_size); + + Self { + available_tokens, + summary_tokens: summary_tokens.max(min_summary_tokens), + recent_turns: recent_turn_count(normalized_context_size), + } + } +} + +fn scaled_context_reserve_tokens(context_size: usize) -> usize { + let scaled_reserve = context_size / 16; + scaled_reserve.clamp( + LOCAL_CONTEXT_RESERVE_TOKENS, + LOCAL_MAX_CONTEXT_RESERVE_TOKENS, + ) +} + +const fn summary_budget_percent(context_size: usize) -> usize { + if context_size >= LOCAL_LARGE_CONTEXT_TOKENS { + 36 + } else if context_size >= LOCAL_MEDIUM_CONTEXT_TOKENS { + 32 + } else { + LOCAL_SUMMARY_BUDGET_NUMERATOR + } +} + +fn scaled_min_summary_tokens(context_size: usize) -> usize { + let scaled_minimum = context_size / 64; + scaled_minimum.clamp(LOCAL_MIN_SUMMARY_TOKENS, LOCAL_MAX_MIN_SUMMARY_TOKENS) +} + +const fn recent_turn_count(context_size: usize) -> usize { + if context_size >= LOCAL_LARGE_CONTEXT_TOKENS { + 8 + } else if context_size >= LOCAL_MEDIUM_CONTEXT_TOKENS { + 5 + } else { + LOCAL_RECENT_TURNS + } +} + +impl LocalContextState { + fn from_session(session: &ChatSession) -> Self { + let turn_ranges = group_turn_ranges(&session.history); + let persisted_summary_count = + usize::try_from(session.summary_message_count).unwrap_or(usize::MAX); + + Self { + turn_ranges, + persisted_summary_count, + } + } + + fn refresh_summary( + &self, + session: &mut ChatSession, + summary_budget: usize, + model: &str, + summary_cutoff_index: usize, + ) -> bool { + if summary_cutoff_index < self.persisted_summary_count { + session.summary = None; + session.summary_message_count = 0; + return true; + } + + if summary_cutoff_index <= self.persisted_summary_count { + return false; + } + + let Some(new_summary_slice) = session + .history + .get(self.persisted_summary_count..summary_cutoff_index) + else { + return false; + }; + + let summary_lines = build_summary_lines(new_summary_slice); + if summary_lines.is_empty() { + return false; + } + + let merged_summary = merge_summary( + session.summary.as_deref(), + &summary_lines, + summary_budget, + model, + ); + let Some(merged_summary) = merged_summary else { + return false; + }; + + session.summary = Some(merged_summary); + session.summary_message_count = u32::try_from(summary_cutoff_index).unwrap_or(u32::MAX); + true + } + + fn build_context( + &self, + session: &ChatSession, + budget: &LocalContextBudget, + model: &str, + ) -> Vec { + let (mut context, used_tokens) = + Self::build_summary_message(session.summary.clone(), budget.available_tokens, model); + context.extend(self.collect_recent_turns( + session, + budget.available_tokens, + used_tokens, + model, + budget.recent_turns, + )); + context + } + + fn build_summary_message( + summary: Option, + available_budget: usize, + model: &str, + ) -> (Vec, usize) { + let Some(summary_content) = summary else { + return (Vec::new(), 0); + }; + + let hidden_summary = format!( + "Internal conversation summary for continuity. Use it only as hidden context. Do not quote, reveal, translate, or mention it unless the user explicitly asks. Reply directly to the latest user message in the user's language.\n\nSummary:\n{summary_content}" + ); + + let summary_message = ChatMessage { + id: uuid::Uuid::new_v4().to_string(), + role: "system".to_string(), + content: serde_json::Value::String(hidden_summary), + thought_signature: None, + }; + let summary_tokens = estimate_message_tokens(&summary_message, model); + if summary_tokens > available_budget { + return (Vec::new(), 0); + } + + (vec![summary_message], summary_tokens) + } + + fn collect_recent_turns( + &self, + session: &ChatSession, + available_budget: usize, + initial_tokens: usize, + model: &str, + recent_turns: usize, + ) -> Vec { + let recent_turn_ranges = self + .turn_ranges + .len() + .checked_sub(recent_turns) + .and_then(|start| self.turn_ranges.get(start..)) + .unwrap_or(&self.turn_ranges); + + let mut used_tokens = initial_tokens; + let mut kept_recent: Vec = Vec::new(); + + for (start, end) in recent_turn_ranges.iter().rev() { + let Some(turn) = session.history.get(*start..*end) else { + continue; + }; + let turn_tokens = estimate_messages_tokens(turn, model); + if used_tokens + turn_tokens > available_budget { + break; + } + + let mut turn_messages = turn.to_vec(); + turn_messages.append(&mut kept_recent); + kept_recent = turn_messages; + used_tokens += turn_tokens; + } + + kept_recent + } + + fn recent_context_start_index( + &self, + session: &ChatSession, + available_budget: usize, + initial_tokens: usize, + model: &str, + recent_turns: usize, + ) -> usize { + let recent_turn_ranges = self + .turn_ranges + .len() + .checked_sub(recent_turns) + .and_then(|start| self.turn_ranges.get(start..)) + .unwrap_or(&self.turn_ranges); + + let mut used_tokens = initial_tokens; + let mut first_kept_index = session.history.len(); + + for (start, end) in recent_turn_ranges.iter().rev() { + let Some(turn) = session.history.get(*start..*end) else { + continue; + }; + let turn_tokens = estimate_messages_tokens(turn, model); + if used_tokens + turn_tokens > available_budget { + break; + } + + first_kept_index = *start; + used_tokens += turn_tokens; + } + + first_kept_index + } + + #[cfg(test)] + fn planned_recent_start_index(&self, recent_turns: usize) -> usize { + self.turn_ranges + .len() + .checked_sub(recent_turns) + .and_then(|index| self.turn_ranges.get(index)) + .map_or(0, |(start, _)| *start) + } +} + fn normalize_summary_lines(summary: &str) -> Vec { let stripped = LEGACY_SUMMARY_PREFIXES .iter() @@ -208,18 +477,49 @@ fn count_text_tokens(text: &str, model: &str) -> usize { }) } +fn multimodal_text_capacity_hint(parts: &[serde_json::Value]) -> usize { + let text_bytes = parts + .iter() + .filter_map(multimodal_text_part) + .map(str::len) + .sum::(); + text_bytes.saturating_add(parts.len().saturating_sub(1)) +} + +fn multimodal_text_part(part: &serde_json::Value) -> Option<&str> { + let object = part.as_object()?; + (object.get("type").and_then(serde_json::Value::as_str) == Some("text")) + .then(|| object.get("text").and_then(serde_json::Value::as_str)) + .flatten() +} + +fn push_joined_text(target: &mut String, value: &str, separator: char) { + if !target.is_empty() { + target.push(separator); + } + target.push_str(value); +} + +fn collapse_whitespace(text: &str) -> String { + let mut normalized = String::with_capacity(text.len()); + for part in text.split_whitespace() { + push_joined_text(&mut normalized, part, ' '); + } + normalized +} + fn summarize_content(content: &serde_json::Value) -> String { let text = match content { serde_json::Value::String(value) => value.clone(), serde_json::Value::Array(parts) => { - let mut text_parts = Vec::new(); + let mut merged = String::with_capacity(multimodal_text_capacity_hint(parts)); let mut image_count = 0usize; for part in parts { if let Some(part_type) = part.get("type").and_then(serde_json::Value::as_str) { if part_type == "text" { if let Some(value) = part.get("text").and_then(serde_json::Value::as_str) { - text_parts.push(value.to_string()); + push_joined_text(&mut merged, value, ' '); } } else if part_type == "image_url" { image_count += 1; @@ -227,7 +527,6 @@ fn summarize_content(content: &serde_json::Value) -> String { } } - let mut merged = text_parts.join(" "); if image_count > 0 { if !merged.is_empty() { merged.push(' '); @@ -243,7 +542,7 @@ fn summarize_content(content: &serde_json::Value) -> String { other => other.to_string(), }; - let normalized = text.split_whitespace().collect::>().join(" "); + let normalized = collapse_whitespace(&text); if normalized.len() <= 96 { normalized } else { @@ -251,3 +550,169 @@ fn summarize_content(content: &serde_json::Value) -> String { format!("{}...", truncated.trim_end()) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn message(role: &str, text: &str) -> ChatMessage { + ChatMessage { + id: uuid::Uuid::new_v4().to_string(), + role: role.to_string(), + content: serde_json::Value::String(text.to_string()), + thought_signature: None, + } + } + + fn session_with_turns(turn_count: usize) -> ChatSession { + let mut history = Vec::new(); + for index in 0..turn_count { + history.push(message("user", &format!("user {index}"))); + history.push(message("assistant", &format!("assistant {index}"))); + } + + ChatSession { + history, + summary: None, + summary_message_count: 0, + last_updated: 0.0, + } + } + + #[test] + fn local_context_budget_keeps_small_context_conservative() { + let budget = LocalContextBudget::new(4096); + + assert_eq!(budget.available_tokens, 3072); + assert_eq!(budget.summary_tokens, 860); + assert_eq!(budget.recent_turns, LOCAL_RECENT_TURNS); + } + + #[test] + fn local_context_budget_scales_for_large_contexts() { + let small = LocalContextBudget::new(4096); + let large = LocalContextBudget::new(131_072); + + assert_eq!(large.available_tokens, 122_880); + assert_eq!(large.recent_turns, 8); + assert!(large.summary_tokens > small.summary_tokens); + assert!(large.available_tokens > small.available_tokens); + } + + #[test] + fn local_context_summary_boundary_uses_scaled_recent_turns() { + let session = session_with_turns(10); + + let state = LocalContextState::from_session(&session); + + assert_eq!( + state.planned_recent_start_index(LocalContextBudget::new(4096).recent_turns), + 14 + ); + assert_eq!( + state.planned_recent_start_index(LocalContextBudget::new(131_072).recent_turns), + 4 + ); + } + + #[test] + fn refresh_summary_keeps_existing_summary_when_merge_does_not_fit() { + let mut session = session_with_turns(5); + session.summary = Some("existing summary".to_string()); + session.summary_message_count = 0; + let state = LocalContextState::from_session(&session); + + let changed = state.refresh_summary( + &mut session, + 0, + "local-model", + state.planned_recent_start_index(LOCAL_RECENT_TURNS), + ); + + assert!(!changed); + assert_eq!(session.summary.as_deref(), Some("existing summary")); + assert_eq!(session.summary_message_count, 0); + } + + #[test] + fn collect_recent_turns_stops_at_first_over_budget_turn() { + let session = ChatSession { + history: vec![ + message("user", "older user"), + message("assistant", "older assistant"), + message("user", &"large ".repeat(400)), + message("assistant", &"large ".repeat(400)), + message("user", "latest user"), + message("assistant", "latest assistant"), + ], + summary: None, + summary_message_count: 0, + last_updated: 0.0, + }; + let state = LocalContextState::from_session(&session); + + let kept = state.collect_recent_turns(&session, 80, 0, "local-model", LOCAL_RECENT_TURNS); + + let kept_text = kept + .iter() + .filter_map(|message| message.content.as_str()) + .collect::>(); + assert_eq!(kept_text, vec!["latest user", "latest assistant"]); + } + + #[test] + fn recent_context_start_index_tracks_first_turn_that_fits() { + let session = ChatSession { + history: vec![ + message("user", "older user"), + message("assistant", "older assistant"), + message("user", &"large ".repeat(400)), + message("assistant", &"large ".repeat(400)), + message("user", "latest user"), + message("assistant", "latest assistant"), + ], + summary: None, + summary_message_count: 0, + last_updated: 0.0, + }; + let state = LocalContextState::from_session(&session); + + let start = + state.recent_context_start_index(&session, 80, 0, "local-model", LOCAL_RECENT_TURNS); + + assert_eq!(start, 4); + } + + #[test] + fn extract_message_text_joins_multimodal_text_without_media() { + let content = json!([ + { "type": "text", "text": "first" }, + { "type": "image_url", "image_url": { "url": "data:image/png;base64,abc" } }, + { "type": "text", "text": "second" } + ]); + + assert_eq!( + extract_message_text(&content).as_deref(), + Some("first\nsecond") + ); + } + + #[test] + fn extract_message_text_returns_none_for_media_only_content() { + let content = json!([{ "type": "image_url", "image_url": { "url": "image.png" } }]); + + assert_eq!(extract_message_text(&content), None); + } + + #[test] + fn summarize_content_collapses_multimodal_text_and_counts_images() { + let content = json!([ + { "type": "text", "text": "hello\nworld" }, + { "type": "image_url", "image_url": { "url": "image-1.png" } }, + { "type": "image_url", "image_url": { "url": "image-2.png" } } + ]); + + assert_eq!(summarize_content(&content), "hello world 2 images"); + } +} diff --git a/src-tauri/src/domain/ai/streaming.rs b/src-tauri/src/domain/ai/streaming.rs index 31735a75..286467dd 100644 --- a/src-tauri/src/domain/ai/streaming.rs +++ b/src-tauri/src/domain/ai/streaming.rs @@ -13,6 +13,9 @@ use tokio::sync::mpsc; use super::provider_http; use super::provider_payload; use super::provider_response; +use super::streaming_chunks::{ + StreamChunkResult, StreamingAccumulator, process_stream_chunk, process_trailing_stream_buffer, +}; use super::types::{ChatReply, ChatRequest, ChatResponse, TokenUsage}; // ================================================================================== @@ -115,48 +118,6 @@ struct RequestExecution { payload: serde_json::Map, } -struct StreamingAccumulator { - full_content: String, - buffer: String, - final_usage: Option, - saw_terminal_chunk: bool, - chunks_emitted: u32, - started_at: std::time::Instant, - first_chunk_after: Option, -} - -impl StreamingAccumulator { - fn new() -> Self { - Self { - full_content: String::new(), - buffer: String::new(), - final_usage: None, - saw_terminal_chunk: false, - chunks_emitted: 0, - started_at: std::time::Instant::now(), - first_chunk_after: None, - } - } - - fn record_chat_chunk(&mut self, content: &str) { - if content.is_empty() { - return; - } - - if self.first_chunk_after.is_none() { - self.first_chunk_after = Some(self.started_at.elapsed()); - } - self.chunks_emitted = self.chunks_emitted.saturating_add(1); - self.full_content.push_str(content); - } -} - -enum StreamChunkResult { - Continue, - Done, - Error(String), -} - impl OpenAiCompatibleProvider { /// Creates a new OpenAI-compatible provider with the specified base URL. pub fn new(base_url: &str) -> Self { @@ -436,218 +397,17 @@ impl AiProvider for OpenAiCompatibleProvider { } } -fn process_stream_chunk( - chunk: &[u8], - message_id: &str, - sink: &dyn StreamSink, - state: &mut StreamingAccumulator, -) -> StreamChunkResult { - let chunk_str = String::from_utf8_lossy(chunk); - - if state.buffer.len() + chunk_str.len() > 1_024_024 { - tracing::error!("[AI] Stream buffer overflow protection triggered. Clearing buffer."); - state.buffer.clear(); - } - - state.buffer.push_str(&chunk_str); - - while let Some(pos) = state.buffer.find('\n') { - let line = state.buffer[..pos].trim().to_string(); - state.buffer.drain(..=pos); - - match process_stream_line(&line, message_id, sink, state) { - StreamChunkResult::Continue => {} - result => return result, - } - } - - StreamChunkResult::Continue -} - -fn process_trailing_stream_buffer( - message_id: &str, - sink: &dyn StreamSink, - state: &mut StreamingAccumulator, -) -> StreamChunkResult { - let line = state.buffer.trim().to_string(); - state.buffer.clear(); - - if line.is_empty() { - return StreamChunkResult::Continue; - } - - process_stream_line(&line, message_id, sink, state) -} - -fn process_stream_line( - line: &str, - message_id: &str, - sink: &dyn StreamSink, - state: &mut StreamingAccumulator, -) -> StreamChunkResult { - if line.is_empty() || line.starts_with(':') || line.starts_with("event:") { - return StreamChunkResult::Continue; - } - - let Some(data) = line.strip_prefix("data:").map(str::trim) else { - return StreamChunkResult::Continue; - }; - - if data == "[DONE]" { - return StreamChunkResult::Done; - } - - handle_stream_json_line(data, message_id, sink, state) -} - -fn handle_stream_json_line( - data: &str, - message_id: &str, - sink: &dyn StreamSink, - state: &mut StreamingAccumulator, -) -> StreamChunkResult { - let json = serde_json::from_str::(data).map_err(|error| { - tracing::debug!("[AI] Failed to parse stream JSON chunk: {error}"); - format!("AI stream returned malformed JSON chunk: {error}") - }); - let Ok(json) = json else { - return StreamChunkResult::Error("AI stream returned malformed JSON chunk".to_string()); - }; - - if let Some(message) = provider_response::extract_stream_error_message(&json) { - return StreamChunkResult::Error(message); - } - - if let Some(usage) = provider_response::extract_token_usage(&json) { - state.final_usage = Some(usage); - } - - let choice = json - .get("choices") - .and_then(|choices| choices.as_array()) - .and_then(|choices| choices.first()); - - if let Some(choice) = choice { - if let Some(message) = choice - .get("error") - .and_then(provider_response::extract_error_message) - { - return StreamChunkResult::Error(message); - } - - if let Some(finish_reason) = choice.get("finish_reason").and_then(|value| value.as_str()) { - if finish_reason.eq_ignore_ascii_case("error") { - return StreamChunkResult::Error( - provider_response::extract_error_message(choice) - .unwrap_or_else(|| "AI provider reported a streaming error".to_string()), - ); - } - - if !finish_reason.trim().is_empty() { - state.saw_terminal_chunk = true; - } - } - } - - if json - .get("stop") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) - || json - .get("done") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) - { - state.saw_terminal_chunk = true; - } - - let delta = choice.and_then(|value| value.get("delta")); - - if let Some(reasoning) = delta - .and_then(|d| d.get("reasoning_content")) - .and_then(provider_response::extract_stream_text) - .or_else(|| { - delta - .and_then(|d| d.get("reasoning")) - .and_then(provider_response::extract_stream_text) - }) - { - sink.emit(StreamEvent::ThoughtChunk { - message_id: message_id.to_string(), - content: reasoning, - }); - } - - let content = delta - .and_then(|d| d.get("content")) - .and_then(provider_response::extract_stream_text) - .or_else(|| { - choice - .and_then(|value| value.get("text")) - .and_then(provider_response::extract_stream_text) - .or_else(|| { - choice - .and_then(|value| value.get("content")) - .and_then(provider_response::extract_stream_text) - }) - }) - .or_else(|| { - json.get("content") - .and_then(provider_response::extract_stream_text) - }) - .or_else(|| { - json.get("message") - .and_then(|message| message.get("content")) - .and_then(provider_response::extract_stream_text) - }) - .or_else(|| { - json.get("response") - .and_then(provider_response::extract_stream_text) - }) - .or_else(|| { - json.get("message") - .and_then(|message| message.get("content")) - .and_then(provider_response::extract_stream_text) - }) - .or_else(|| { - json.get("token") - .and_then(|token| token.get("text")) - .and_then(provider_response::extract_stream_text) - }); - - if let Some(content) = content { - state.record_chat_chunk(&content); - sink.emit(StreamEvent::ChatChunk { - message_id: message_id.to_string(), - content, - }); - } - - StreamChunkResult::Continue -} - #[cfg(test)] mod tests { #![allow(clippy::expect_used, clippy::indexing_slicing)] - use super::{StreamChunkResult, StreamingAccumulator, is_local_base_url, process_stream_chunk}; + use super::is_local_base_url; + use crate::domain::ai::WebSearchOptions; use crate::domain::ai::{ChatMessage, ChatRequest}; - use crate::domain::ai::{StreamEvent, StreamSink, WebSearchOptions}; use crate::domain::ai::{provider_http, provider_payload}; use reqwest::StatusCode; use serde_json::json; - #[derive(Default)] - struct TestSink { - events: std::sync::Mutex>, - } - - impl StreamSink for TestSink { - fn emit(&self, event: StreamEvent) { - self.events.lock().expect("sink mutex").push(event); - } - } - fn sample_request() -> ChatRequest { ChatRequest { provider: "gpt".to_string(), @@ -804,121 +564,4 @@ mod tests { assert!(payload.get("session_id").is_none()); assert!(payload.get("reasoning").is_none()); } - - #[test] - fn process_stream_chunk_surfaces_provider_errors() { - let sink = TestSink::default(); - let mut state = StreamingAccumulator::new(); - let chunk = b"data: {\"error\":{\"message\":\"rate limited\"}}\n\n".as_slice(); - - let result = process_stream_chunk(chunk, "msg-1", &sink, &mut state); - - assert!(matches!(result, StreamChunkResult::Error(message) if message == "rate limited")); - } - - #[test] - fn process_stream_chunk_collects_content_and_terminal_reason() { - let sink = TestSink::default(); - let mut state = StreamingAccumulator::new(); - let chunk = b"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n"; - - let result = process_stream_chunk(chunk, "msg-1", &sink, &mut state); - - assert!(matches!(result, StreamChunkResult::Continue)); - assert_eq!(state.full_content, "hello"); - assert!(state.saw_terminal_chunk); - assert_eq!( - state.final_usage.as_ref().map(|usage| usage.total_tokens), - Some(3) - ); - - let events = sink.events.lock().expect("sink events"); - assert!(matches!( - events.first(), - Some(StreamEvent::ChatChunk { content, .. }) if content == "hello" - )); - } - - #[test] - fn process_stream_chunk_normalizes_ollama_usage() { - let sink = TestSink::default(); - let mut state = StreamingAccumulator::new(); - let chunk = b"data: {\"message\":{\"content\":\"hello\"},\"done\":true,\"prompt_eval_count\":7,\"eval_count\":11}\n\n"; - - let result = process_stream_chunk(chunk, "msg-1", &sink, &mut state); - - assert!(matches!(result, StreamChunkResult::Continue)); - assert_eq!(state.full_content, "hello"); - assert!(state.saw_terminal_chunk); - let usage = state.final_usage.expect("usage"); - assert_eq!(usage.prompt_tokens, 7); - assert_eq!(usage.completion_tokens, 11); - assert_eq!(usage.total_tokens, 18); - - let events = sink.events.lock().expect("sink events"); - assert!(matches!( - events.first(), - Some(StreamEvent::ChatChunk { content, .. }) if content == "hello" - )); - } - - #[test] - fn process_stream_chunk_supports_ollama_message_content() { - let sink = TestSink::default(); - let mut state = StreamingAccumulator::new(); - let chunk = b"data: {\"message\":{\"content\":\"hello from ollama\"},\"done\":true}\n\n"; - - let result = process_stream_chunk(chunk, "msg-1", &sink, &mut state); - - assert!(matches!(result, StreamChunkResult::Continue)); - assert_eq!(state.full_content, "hello from ollama"); - assert!(state.saw_terminal_chunk); - - let events = sink.events.lock().expect("sink events"); - assert!(matches!( - events.first(), - Some(StreamEvent::ChatChunk { content, .. }) if content == "hello from ollama" - )); - } - - #[test] - fn process_stream_chunk_normalizes_llama_cpp_timings_usage() { - let sink = TestSink::default(); - let mut state = StreamingAccumulator::new(); - let chunk = - b"data: {\"content\":\"done\",\"stop\":true,\"timings\":{\"prompt_n\":5,\"predicted_n\":13}}\n\n"; - - let result = process_stream_chunk(chunk, "msg-1", &sink, &mut state); - - assert!(matches!(result, StreamChunkResult::Continue)); - let usage = state.final_usage.expect("usage"); - assert_eq!(usage.prompt_tokens, 5); - assert_eq!(usage.completion_tokens, 13); - assert_eq!(usage.total_tokens, 18); - } - - #[test] - fn process_stream_chunk_supports_llama_style_top_level_content() { - let sink = TestSink::default(); - let mut state = StreamingAccumulator::new(); - let chunk = b"data: {\"content\":\"hello\",\"stop\":false}\n\ndata: {\"content\":\" world\",\"stop\":true}\n\n"; - - let result = process_stream_chunk(chunk, "msg-1", &sink, &mut state); - - assert!(matches!(result, StreamChunkResult::Continue)); - assert_eq!(state.full_content, "hello world"); - assert!(state.saw_terminal_chunk); - assert_eq!(state.chunks_emitted, 2); - - let events = sink.events.lock().expect("sink events"); - assert_eq!(events.len(), 2); - assert!(matches!( - events.first(), - Some(StreamEvent::ChatChunk { content, .. }) if content == "hello" - )); - assert!(matches!( - events.get(1), - Some(StreamEvent::ChatChunk { content, .. }) if content == " world" - )); - } } diff --git a/src-tauri/src/domain/ai/streaming_chunks.rs b/src-tauri/src/domain/ai/streaming_chunks.rs new file mode 100644 index 00000000..84a24d58 --- /dev/null +++ b/src-tauri/src/domain/ai/streaming_chunks.rs @@ -0,0 +1,382 @@ +use super::provider_response; +use super::streaming::{StreamEvent, StreamSink}; +use super::types::TokenUsage; + +pub(super) struct StreamingAccumulator { + pub(super) full_content: String, + pub(super) buffer: String, + pub(super) final_usage: Option, + pub(super) saw_terminal_chunk: bool, + pub(super) chunks_emitted: u32, + pub(super) started_at: std::time::Instant, + pub(super) first_chunk_after: Option, +} + +impl StreamingAccumulator { + pub(super) fn new() -> Self { + Self { + full_content: String::new(), + buffer: String::new(), + final_usage: None, + saw_terminal_chunk: false, + chunks_emitted: 0, + started_at: std::time::Instant::now(), + first_chunk_after: None, + } + } + + fn record_chat_chunk(&mut self, content: &str) { + if content.is_empty() { + return; + } + + if self.first_chunk_after.is_none() { + self.first_chunk_after = Some(self.started_at.elapsed()); + } + self.chunks_emitted = self.chunks_emitted.saturating_add(1); + self.full_content.push_str(content); + } +} + +pub(super) enum StreamChunkResult { + Continue, + Done, + Error(String), +} + +pub(super) fn process_stream_chunk( + chunk: &[u8], + message_id: &str, + sink: &dyn StreamSink, + state: &mut StreamingAccumulator, +) -> StreamChunkResult { + let chunk_str = String::from_utf8_lossy(chunk); + + if state.buffer.len() + chunk_str.len() > 1_024_024 { + tracing::error!("[AI] Stream buffer overflow protection triggered. Clearing buffer."); + state.buffer.clear(); + } + + state.buffer.push_str(&chunk_str); + + while let Some(pos) = state.buffer.find('\n') { + let line = state.buffer[..pos].trim().to_string(); + state.buffer.drain(..=pos); + + match process_stream_line(&line, message_id, sink, state) { + StreamChunkResult::Continue => {} + result => return result, + } + } + + StreamChunkResult::Continue +} + +pub(super) fn process_trailing_stream_buffer( + message_id: &str, + sink: &dyn StreamSink, + state: &mut StreamingAccumulator, +) -> StreamChunkResult { + let line = state.buffer.trim().to_string(); + state.buffer.clear(); + + if line.is_empty() { + return StreamChunkResult::Continue; + } + + process_stream_line(&line, message_id, sink, state) +} + +fn process_stream_line( + line: &str, + message_id: &str, + sink: &dyn StreamSink, + state: &mut StreamingAccumulator, +) -> StreamChunkResult { + if line.is_empty() || line.starts_with(':') || line.starts_with("event:") { + return StreamChunkResult::Continue; + } + + let Some(data) = line.strip_prefix("data:").map(str::trim) else { + return StreamChunkResult::Continue; + }; + + if data == "[DONE]" { + return StreamChunkResult::Done; + } + + handle_stream_json_line(data, message_id, sink, state) +} + +fn handle_stream_json_line( + data: &str, + message_id: &str, + sink: &dyn StreamSink, + state: &mut StreamingAccumulator, +) -> StreamChunkResult { + let json = serde_json::from_str::(data).map_err(|error| { + tracing::debug!("[AI] Failed to parse stream JSON chunk: {error}"); + format!("AI stream returned malformed JSON chunk: {error}") + }); + let Ok(json) = json else { + return StreamChunkResult::Error("AI stream returned malformed JSON chunk".to_string()); + }; + + if let Some(message) = provider_response::extract_stream_error_message(&json) { + return StreamChunkResult::Error(message); + } + + if let Some(usage) = provider_response::extract_token_usage(&json) { + state.final_usage = Some(usage); + } + + let choice = json + .get("choices") + .and_then(|choices| choices.as_array()) + .and_then(|choices| choices.first()); + + if let Some(choice) = choice { + if let Some(message) = choice + .get("error") + .and_then(provider_response::extract_error_message) + { + return StreamChunkResult::Error(message); + } + + if let Some(finish_reason) = choice.get("finish_reason").and_then(|value| value.as_str()) { + if finish_reason.eq_ignore_ascii_case("error") { + return StreamChunkResult::Error( + provider_response::extract_error_message(choice) + .unwrap_or_else(|| "AI provider reported a streaming error".to_string()), + ); + } + + if !finish_reason.trim().is_empty() { + state.saw_terminal_chunk = true; + } + } + } + + if json + .get("stop") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + || json + .get("done") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + state.saw_terminal_chunk = true; + } + + let delta = choice.and_then(|value| value.get("delta")); + + if let Some(reasoning) = delta + .and_then(|d| d.get("reasoning_content")) + .and_then(provider_response::extract_stream_text) + .or_else(|| { + delta + .and_then(|d| d.get("reasoning")) + .and_then(provider_response::extract_stream_text) + }) + { + sink.emit(StreamEvent::ThoughtChunk { + message_id: message_id.to_string(), + content: reasoning, + }); + } + + let content = delta + .and_then(|d| d.get("content")) + .and_then(provider_response::extract_stream_text) + .or_else(|| { + choice + .and_then(|value| value.get("text")) + .and_then(provider_response::extract_stream_text) + .or_else(|| { + choice + .and_then(|value| value.get("content")) + .and_then(provider_response::extract_stream_text) + }) + }) + .or_else(|| { + json.get("content") + .and_then(provider_response::extract_stream_text) + }) + .or_else(|| { + json.get("message") + .and_then(|message| message.get("content")) + .and_then(provider_response::extract_stream_text) + }) + .or_else(|| { + json.get("response") + .and_then(provider_response::extract_stream_text) + }) + .or_else(|| { + json.get("delta") + .and_then(|delta| delta.get("content")) + .and_then(provider_response::extract_stream_text) + }) + .or_else(|| { + json.get("token") + .and_then(|token| token.get("text")) + .and_then(provider_response::extract_stream_text) + }); + + if let Some(content) = content { + state.record_chat_chunk(&content); + sink.emit(StreamEvent::ChatChunk { + message_id: message_id.to_string(), + content, + }); + } + + StreamChunkResult::Continue +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::{StreamChunkResult, StreamingAccumulator, process_stream_chunk}; + use crate::domain::ai::{StreamEvent, StreamSink}; + + #[derive(Default)] + struct TestSink { + events: std::sync::Mutex>, + } + + impl StreamSink for TestSink { + fn emit(&self, event: StreamEvent) { + self.events.lock().expect("sink mutex").push(event); + } + } + + #[test] + fn process_stream_chunk_surfaces_provider_errors() { + let sink = TestSink::default(); + let mut state = StreamingAccumulator::new(); + let chunk = b"data: {\"error\":{\"message\":\"rate limited\"}}\n\n".as_slice(); + + let result = process_stream_chunk(chunk, "msg-1", &sink, &mut state); + + assert!(matches!(result, StreamChunkResult::Error(message) if message == "rate limited")); + } + + #[test] + fn process_stream_chunk_collects_content_and_terminal_reason() { + let sink = TestSink::default(); + let mut state = StreamingAccumulator::new(); + let chunk = b"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n"; + + let result = process_stream_chunk(chunk, "msg-1", &sink, &mut state); + + assert!(matches!(result, StreamChunkResult::Continue)); + assert_eq!(state.full_content, "hello"); + assert!(state.saw_terminal_chunk); + assert_eq!( + state.final_usage.as_ref().map(|usage| usage.total_tokens), + Some(3) + ); + + let events = sink.events.lock().expect("sink events"); + assert!(matches!( + events.first(), + Some(StreamEvent::ChatChunk { content, .. }) if content == "hello" + )); + } + + #[test] + fn process_stream_chunk_normalizes_ollama_usage() { + let sink = TestSink::default(); + let mut state = StreamingAccumulator::new(); + let chunk = b"data: {\"message\":{\"content\":\"hello\"},\"done\":true,\"prompt_eval_count\":7,\"eval_count\":11}\n\n"; + + let result = process_stream_chunk(chunk, "msg-1", &sink, &mut state); + + assert!(matches!(result, StreamChunkResult::Continue)); + assert_eq!(state.full_content, "hello"); + assert!(state.saw_terminal_chunk); + let usage = state.final_usage.expect("usage"); + assert_eq!(usage.prompt_tokens, 7); + assert_eq!(usage.completion_tokens, 11); + assert_eq!(usage.total_tokens, 18); + + let events = sink.events.lock().expect("sink events"); + assert!(matches!( + events.first(), + Some(StreamEvent::ChatChunk { content, .. }) if content == "hello" + )); + } + + #[test] + fn process_stream_chunk_supports_ollama_message_content() { + let sink = TestSink::default(); + let mut state = StreamingAccumulator::new(); + let chunk = b"data: {\"message\":{\"content\":\"hello from ollama\"},\"done\":true}\n\n"; + + let result = process_stream_chunk(chunk, "msg-1", &sink, &mut state); + + assert!(matches!(result, StreamChunkResult::Continue)); + assert_eq!(state.full_content, "hello from ollama"); + assert!(state.saw_terminal_chunk); + + let events = sink.events.lock().expect("sink events"); + assert!(matches!( + events.first(), + Some(StreamEvent::ChatChunk { content, .. }) if content == "hello from ollama" + )); + } + + #[test] + fn process_stream_chunk_normalizes_llama_cpp_timings_usage() { + let sink = TestSink::default(); + let mut state = StreamingAccumulator::new(); + let chunk = b"data: {\"content\":\"done\",\"stop\":true,\"timings\":{\"prompt_n\":5,\"predicted_n\":13}}\n\n"; + + let result = process_stream_chunk(chunk, "msg-1", &sink, &mut state); + + assert!(matches!(result, StreamChunkResult::Continue)); + let usage = state.final_usage.expect("usage"); + assert_eq!(usage.prompt_tokens, 5); + assert_eq!(usage.completion_tokens, 13); + assert_eq!(usage.total_tokens, 18); + } + + #[test] + fn process_stream_chunk_supports_llama_style_top_level_content() { + let sink = TestSink::default(); + let mut state = StreamingAccumulator::new(); + let chunk = b"data: {\"content\":\"hello\",\"stop\":false}\n\ndata: {\"content\":\" world\",\"stop\":true}\n\n"; + + let result = process_stream_chunk(chunk, "msg-1", &sink, &mut state); + + assert!(matches!(result, StreamChunkResult::Continue)); + assert_eq!(state.full_content, "hello world"); + assert!(state.saw_terminal_chunk); + assert_eq!(state.chunks_emitted, 2); + + let events = sink.events.lock().expect("sink events"); + assert_eq!(events.len(), 2); + assert!(matches!( + events.first(), + Some(StreamEvent::ChatChunk { content, .. }) if content == "hello" + )); + assert!(matches!( + events.get(1), + Some(StreamEvent::ChatChunk { content, .. }) if content == " world" + )); + } + + #[test] + fn process_stream_chunk_supports_top_level_delta_content() { + let sink = TestSink::default(); + let mut state = StreamingAccumulator::new(); + let chunk = b"data: {\"delta\":{\"content\":\"delta text\"}}\n\n"; + + let result = process_stream_chunk(chunk, "msg-1", &sink, &mut state); + + assert!(matches!(result, StreamChunkResult::Continue)); + assert_eq!(state.full_content, "delta text"); + } +} diff --git a/src-tauri/src/domain/engine/engine_ids.rs b/src-tauri/src/domain/engine/engine_ids.rs new file mode 100644 index 00000000..0460d489 --- /dev/null +++ b/src-tauri/src/domain/engine/engine_ids.rs @@ -0,0 +1,32 @@ +/// Returns the normalized engine registry id. +pub fn canonical_engine_id(engine_id: &str) -> String { + let normalized = engine_id + .trim() + .to_ascii_lowercase() + .replace([' ', '.', '_'], "-"); + + let mut normalized = normalized; + while normalized.contains("--") { + normalized = normalized.replace("--", "-"); + } + + normalized +} + +pub(super) fn canonical_engine_log_id(engine_id: &str) -> String { + canonical_engine_id(engine_id) +} + +#[cfg(test)] +mod tests { + use super::canonical_engine_id; + + #[test] + fn canonical_engine_id_normalizes_without_remapping_cpp_engines() { + assert_eq!(canonical_engine_id(" sdcpp "), "sdcpp"); + assert_eq!(canonical_engine_id("llama cpp"), "llama-cpp"); + assert_eq!(canonical_engine_id("llama_cpp"), "llama-cpp"); + assert_eq!(canonical_engine_id("llama.cpp"), "llama-cpp"); + assert_eq!(canonical_engine_id("sd.cpp"), "sd-cpp"); + } +} diff --git a/src-tauri/src/domain/engine/manager.rs b/src-tauri/src/domain/engine/manager.rs index e528ef6c..80ab8b8b 100644 --- a/src-tauri/src/domain/engine/manager.rs +++ b/src-tauri/src/domain/engine/manager.rs @@ -18,6 +18,7 @@ use tracing::{error, info, warn}; use crate::errors::AppError; use super::engine_args::{build_engine_args, sdcpp_preview_enabled}; +use super::engine_ids::canonical_engine_log_id; use super::engine_runtime::{ diagnose_engine_start_failure, find_available_local_port, is_endpoint_healthy, spawn_log_reader, wait_for_health, @@ -28,6 +29,7 @@ use super::types::{ }; pub use super::engine_args::resolve_sdcpp_preview_path; +pub use super::engine_ids::canonical_engine_id; #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x0800_0000; @@ -691,25 +693,6 @@ impl EngineManager { } } -/// Returns the normalized engine registry id. -pub fn canonical_engine_id(engine_id: &str) -> String { - let normalized = engine_id - .trim() - .to_ascii_lowercase() - .replace([' ', '.', '_'], "-"); - - let mut normalized = normalized; - while normalized.contains("--") { - normalized = normalized.replace("--", "-"); - } - - normalized -} - -fn canonical_engine_log_id(engine_id: &str) -> String { - canonical_engine_id(engine_id) -} - #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::panic)] @@ -941,13 +924,4 @@ mod tests { assert!(sdcpp_preview_enabled(&extra_args)); assert!(resolve_sdcpp_preview_path(&extra_args).is_none()); } - - #[test] - fn canonical_engine_id_normalizes_without_remapping_cpp_engines() { - assert_eq!(canonical_engine_id(" sdcpp "), "sdcpp"); - assert_eq!(canonical_engine_id("llama cpp"), "llama-cpp"); - assert_eq!(canonical_engine_id("llama_cpp"), "llama-cpp"); - assert_eq!(canonical_engine_id("llama.cpp"), "llama-cpp"); - assert_eq!(canonical_engine_id("sd.cpp"), "sd-cpp"); - } } diff --git a/src-tauri/src/domain/engine/mod.rs b/src-tauri/src/domain/engine/mod.rs index d50a6763..79dae841 100644 --- a/src-tauri/src/domain/engine/mod.rs +++ b/src-tauri/src/domain/engine/mod.rs @@ -8,6 +8,7 @@ pub mod config; /// Engine binary detection (installed check + path resolution) pub mod detector; mod engine_args; +mod engine_ids; mod engine_profile; mod engine_runtime; /// Engine event emission trait diff --git a/src-tauri/src/domain/integration_api/auth.rs b/src-tauri/src/domain/integration_api/auth.rs index f0f71a5e..258bffd0 100644 --- a/src-tauri/src/domain/integration_api/auth.rs +++ b/src-tauri/src/domain/integration_api/auth.rs @@ -48,17 +48,35 @@ pub(super) fn is_loopback_peer(peer_addr: Option) -> bool peer_addr.is_some_and(|addr| addr.ip().is_loopback()) } -pub(super) fn is_authorized(headers: &HashMap) -> bool { - authorize_request(headers).is_some() -} - pub(super) fn authorize_request(headers: &HashMap) -> Option { headers .get("authorization") .and_then(|value| authorized_bearer_client(value)) } +pub(super) async fn authorize_request_with_agent_profiles( + headers: &HashMap, + agent_control: &crate::domain::agent_control::AgentControlService, +) -> Option { + if let Some(client) = authorize_request(headers) { + return Some(client); + } + + let token = headers + .get("authorization") + .and_then(|value| bearer_token(value))?; + agent_control + .authorize_token(token) + .await + .map(AuthorizedClient::Agent) +} + fn authorized_bearer_client(value: &str) -> Option { + let token = bearer_token(value)?; + authorized_token_client(token) +} + +fn bearer_token(value: &str) -> Option<&str> { let mut parts = value.split_whitespace(); let scheme = parts.next()?; let token = parts.next()?; @@ -66,11 +84,11 @@ fn authorized_bearer_client(value: &str) -> Option { return None; } - authorized_token_client(token) + Some(token) } fn authorized_token_client(token: &str) -> Option { - if token == super::api_token() { + if token == super::api_token() || is_configured_agent_api_token(token) { return Some(AuthorizedClient::Launcher); } @@ -86,3 +104,19 @@ fn authorized_token_client(token: &str) -> Option { .filter(|expected| expected == token) .map(|_| AuthorizedClient::Module(module_id.to_string())) } + +fn is_configured_agent_api_token(token: &str) -> bool { + let Ok(configured) = std::env::var("AXELATE_AGENT_API_TOKEN") else { + return false; + }; + + agent_api_token_matches(token, Some(configured.as_str())) +} + +pub(super) fn agent_api_token_matches(token: &str, configured: Option<&str>) -> bool { + let Some(configured) = configured.map(str::trim).filter(|value| value.len() >= 32) else { + return false; + }; + + token == configured +} diff --git a/src-tauri/src/domain/integration_api/mod.rs b/src-tauri/src/domain/integration_api/mod.rs index cd1394a7..925f450e 100644 --- a/src-tauri/src/domain/integration_api/mod.rs +++ b/src-tauri/src/domain/integration_api/mod.rs @@ -8,6 +8,7 @@ mod http; mod routing; mod types; +use crate::domain::agent_control::AgentControlService; use crate::domain::ai::ChatSessionManager; use crate::domain::ai::ImageGenerationState; use crate::domain::engine::manager::EngineManager; @@ -140,6 +141,7 @@ pub struct LauncherHttpApiContext { image_generation_state: Arc, settings_service: SettingsService, ui_state_service: UiStateService, + agent_control_service: AgentControlService, } impl std::fmt::Debug for LauncherHttpApiContext { @@ -156,6 +158,7 @@ impl std::fmt::Debug for LauncherHttpApiContext { ) .field("settings_service", &"") .field("ui_state_service", &"") + .field("agent_control_service", &"") .finish() } } @@ -171,6 +174,7 @@ impl LauncherHttpApiContext { image_generation_state: Arc, settings_service: SettingsService, ui_state_service: UiStateService, + agent_control_service: AgentControlService, ) -> Self { Self { app, @@ -180,6 +184,7 @@ impl LauncherHttpApiContext { image_generation_state, settings_service, ui_state_service, + agent_control_service, } } } @@ -297,10 +302,6 @@ fn preflight_http_request( return None; } - if !auth::is_authorized(&request.headers) { - return Some(json_error(401, "Missing or invalid launcher API token")); - } - None } diff --git a/src-tauri/src/domain/integration_api/routing.rs b/src-tauri/src/domain/integration_api/routing.rs index 8a4ab97d..809de05e 100644 --- a/src-tauri/src/domain/integration_api/routing.rs +++ b/src-tauri/src/domain/integration_api/routing.rs @@ -1,5 +1,6 @@ //! Route dispatch and request handlers for the local integration API. +use crate::domain::agent_control::{AgentApprovalRequest, AgentScope}; use crate::domain::ai::ai_service; use crate::domain::ai::types::{ ChatMessage, ChatRequest, ImageGenerationRequest, WebSearchOptions, @@ -7,25 +8,43 @@ use crate::domain::ai::types::{ use crate::domain::modules::controller::{self as module_controller, ModuleAction}; use crate::domain::modules::paths as module_paths; use crate::errors::AppError; -use crate::models::{AiModel, ApiProvider, ModelTier, ModuleItem, ProviderType, SelectedModule}; +use crate::infrastructure::logging::LogEntry; +use crate::models::{ + AiModel, ApiProvider, ModelTier, Module, ModuleItem, ProviderType, SelectedModule, +}; use serde_json::json; +use sha2::Digest; use std::collections::HashMap; use std::net::SocketAddr; +use std::path::{Path, PathBuf}; use tauri::Emitter; -use super::auth::{authorize_request, is_loopback_peer}; +use super::auth::{authorize_request_with_agent_profiles, is_loopback_peer}; use super::http::{json_error, json_response, parse_json_body, request_path, status_for_app_error}; use super::types::{ - AuthorizedClient, HttpRequest, HttpResponse, ImageApiResponse, IntegrationImageRequest, - IntegrationModuleStageRequest, IntegrationTextRequest, ModuleContextApiResponse, - ModuleStageChangedEvent, TextApiResponse, + AgentLauncherStateResponse, AgentLogsResponse, AgentModelSummary, AgentModuleSummary, + AgentOpenPageEvent, AgentProviderSummary, AuthorizedClient, HttpRequest, HttpResponse, + ImageApiResponse, IntegrationAgentApprovalRequest, IntegrationDraftCreateRequest, + IntegrationDraftCreateResponse, IntegrationImageRequest, IntegrationModuleStageRequest, + IntegrationOpenPageRequest, IntegrationSelectModuleRequest, IntegrationTextRequest, + ModuleContextApiResponse, ModuleStageChangedEvent, TextApiResponse, }; use super::{LauncherHttpApiContext, SDK_API_VERSION, api_base_url}; +const AGENT_LOGS_DEFAULT_LIMIT: usize = 200; +const AGENT_LOGS_MAX_LIMIT: usize = 1000; const CUSTOM_TEXT_PROVIDER_ID: &str = "custom-text"; const CUSTOM_IMAGE_PROVIDER_ID: &str = "custom-image"; const CUSTOM_TEXT_BACKEND_PROVIDER_ID: &str = "gpt"; const CUSTOM_IMAGE_BACKEND_PROVIDER_ID: &str = "gpt-image"; +const INTEGRATION_DRAFTS_DIR_NAME: &str = "IntegrationDrafts"; + +#[derive(Debug, Clone, PartialEq)] +pub(super) struct AgentLogsQuery { + pub view_id: Option, + pub since: f64, + pub limit: usize, +} pub(super) async fn dispatch_http_request( request: HttpRequest, @@ -41,13 +60,26 @@ pub(super) async fn dispatch_http_request( return json_response(200, json!({ "ok": true, "service": "axelate-launcher" })); } - let Some(client) = authorize_request(&request.headers) else { + let Some(client) = + authorize_request_with_agent_profiles(&request.headers, &context.agent_control_service) + .await + else { return json_error(401, "Missing or invalid launcher API token"); }; - match route_authorized_request(path, &request, context, &client).await { + match route_authorized_request(path, &request, context.clone(), &client).await { Ok(response) => response, - Err(error) => json_error(status_for_app_error(&error), &error.to_string()), + Err(error) => { + record_agent_audit( + &context, + &client, + audit_action_from_request(&request.method, path), + path.to_string(), + audit_result_for_error(&error).to_string(), + ) + .await; + json_error(status_for_app_error(&error), &error.to_string()) + } } } @@ -64,7 +96,80 @@ async fn route_authorized_request( .collect::>(); match (request.method.as_str(), segments.as_slice()) { + ("GET", ["v1", "agent", "state"]) => { + ensure_launcher_client(client)?; + ensure_agent_scope(client, AgentScope::Observe)?; + handle_agent_state_request(&context).await + } + ("GET", ["v1", "agent", "capabilities"]) => { + ensure_launcher_client(client)?; + ensure_agent_scope(client, AgentScope::Observe)?; + Ok(handle_agent_capabilities_request(client)) + } + ("GET", ["v1", "agent", "logs"]) => { + ensure_launcher_client(client)?; + ensure_agent_scope(client, AgentScope::Observe)?; + handle_agent_logs_request(request) + } + ("GET", ["v1", "agent", "approvals"]) => { + ensure_launcher_client(client)?; + ensure_agent_scope(client, AgentScope::Observe)?; + handle_agent_approvals_request(&context, client).await + } + ("POST", ["v1", "agent", "approval-requests"]) => { + let agent = ensure_profile_agent(client)?; + handle_agent_approval_request(request, &context, agent).await + } + ("POST", ["v1", "launcher", "open-page"]) => { + ensure_agent_scope(client, AgentScope::Operate)?; + ensure_launcher_client(client)?; + let response = handle_open_page_request(request, &context).await?; + record_agent_audit( + &context, + client, + "launcher.open-page".to_string(), + response.page_id.clone(), + "success".to_string(), + ) + .await; + Ok(json_response( + 200, + json!({ "ok": true, "pageId": response.page_id }), + )) + } + ("POST", ["v1", "launcher", "select-module"]) => { + ensure_agent_scope(client, AgentScope::Operate)?; + ensure_launcher_client(client)?; + let response = handle_select_module_request(request, &context).await?; + record_agent_audit( + &context, + client, + "launcher.select-module".to_string(), + format!("{}:{}", response.category, response.module.id), + "success".to_string(), + ) + .await; + Ok(json_response( + 200, + json!({ "ok": true, "category": response.category, "module": response.module }), + )) + } + ("POST", ["v1", "integration-drafts"]) => { + ensure_agent_scope(client, AgentScope::DraftCreate)?; + ensure_launcher_client(client)?; + let response = handle_create_integration_draft_request(request).await?; + record_agent_audit( + &context, + client, + "integration-draft.create".to_string(), + response.id.clone(), + "success".to_string(), + ) + .await; + Ok(json_response(201, json!(response))) + } ("GET", ["v1", "modules"]) => { + ensure_agent_scope(client, AgentScope::Observe)?; let modules = modules_visible_to_client(module_controller::get_all_modules().await, client); Ok(json_response( @@ -74,6 +179,7 @@ async fn route_authorized_request( } ("GET", ["v1", "modules", module_id, "status"]) => { ensure_module_route_owner(client, module_id)?; + ensure_agent_scope(client, AgentScope::Observe)?; crate::domain::modules::downloader::validate_module_id(module_id)?; let status = module_controller::get_module_status(module_id).await; Ok(json_response( @@ -83,40 +189,912 @@ async fn route_authorized_request( } ("GET", ["v1", "modules", module_id, "context"]) => { ensure_module_route_owner(client, module_id)?; + ensure_agent_scope(client, AgentScope::Observe)?; handle_module_context_request(module_id) } ("GET", ["v1", "modules", module_id, "settings"]) => { ensure_module_route_owner(client, module_id)?; + ensure_agent_scope(client, AgentScope::Configure)?; handle_get_module_settings_request(&context, module_id).await } ("PUT", ["v1", "modules", module_id, "settings"]) => { ensure_module_route_owner(client, module_id)?; - handle_put_module_settings_request(request, &context, module_id).await + ensure_agent_scope(client, AgentScope::Configure)?; + let response = handle_put_module_settings_request(request, &context, module_id)?; + record_agent_audit( + &context, + client, + "module.settings.put".to_string(), + module_id.to_string(), + "success".to_string(), + ) + .await; + Ok(response) } ("PATCH", ["v1", "modules", module_id, "settings"]) => { ensure_module_route_owner(client, module_id)?; - handle_patch_module_settings_request(request, &context, module_id).await + ensure_agent_scope(client, AgentScope::Configure)?; + let response = + handle_patch_module_settings_request(request, &context, module_id).await?; + record_agent_audit( + &context, + client, + "module.settings.patch".to_string(), + module_id.to_string(), + "success".to_string(), + ) + .await; + Ok(response) } ("POST", ["v1", "modules", module_id, "stage"]) => { ensure_module_route_owner(client, module_id)?; + ensure_agent_scope(client, AgentScope::Configure)?; handle_module_stage_request(request, &context, module_id) } ("POST", ["v1", "modules", module_id, action]) => { ensure_module_route_owner(client, module_id)?; + ensure_agent_scope(client, AgentScope::Operate)?; crate::domain::modules::downloader::validate_module_id(module_id)?; let action = parse_module_action(action)?; - let response = module_controller::control(context.app, module_id, action).await?; + let response = + module_controller::control(context.app.clone(), module_id, action).await?; + record_agent_audit( + &context, + client, + format!("module.{}", module_action_name(action)), + module_id.to_string(), + if response.success { + "success" + } else { + "failed" + } + .to_string(), + ) + .await; + if response.success + && matches!( + action, + ModuleAction::Start | ModuleAction::Restart | ModuleAction::Repair + ) + { + if let Err(error) = sync_launcher_selected_module(&context, client, module_id).await + { + tracing::warn!( + module_id, + "Failed to sync launcher selected module after successful action: {error}" + ); + } + } Ok(json_response( 200, json!({ "ok": response.success, "response": response }), )) } - ("POST", ["v1", "ai", "text"]) => handle_text_request(request, context, client).await, - ("POST", ["v1", "ai", "image"]) => handle_image_request(request, context, client).await, + ("POST", ["v1", "ai", "text"]) => { + ensure_agent_scope(client, AgentScope::Operate)?; + handle_text_request(request, context, client).await + } + ("POST", ["v1", "ai", "image"]) => { + ensure_agent_scope(client, AgentScope::Operate)?; + handle_image_request(request, context, client).await + } _ => Ok(json_error(404, "Unknown launcher API route")), } } +async fn handle_create_integration_draft_request( + request: &HttpRequest, +) -> Result { + let payload: IntegrationDraftCreateRequest = parse_json_body(request)?; + let name = payload.name.trim(); + if name.is_empty() { + return Err(AppError::Validation( + "Integration draft name is required".to_string(), + )); + } + + let id = match payload + .id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(id) => id.to_string(), + None => draft_id_from_name(name), + }; + crate::domain::modules::downloader::validate_module_id(&id)?; + + let runtime_kind = payload + .runtime_kind + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("python") + .to_ascii_lowercase(); + validate_draft_runtime_kind(&runtime_kind)?; + + let entry = payload + .entry + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map_or_else(|| default_draft_entry(&runtime_kind), ToOwned::to_owned); + validate_relative_draft_path(&entry)?; + + let drafts_root = integration_drafts_dir(); + let draft_dir = drafts_root.join(&id); + if draft_dir.exists() { + return Err(AppError::Validation(format!( + "Integration draft {id} already exists" + ))); + } + + tokio::fs::create_dir_all(&draft_dir) + .await + .map_err(|error| AppError::Io(format!("Failed to create draft directory: {error}")))?; + let entry_path = draft_dir.join(&entry); + if let Some(parent) = entry_path.parent() { + tokio::fs::create_dir_all(parent).await.map_err(|error| { + AppError::Io(format!("Failed to create draft entry directory: {error}")) + })?; + } + + let manifest_path = draft_dir.join("axelate-module.toml"); + tokio::fs::write( + &manifest_path, + draft_manifest_text( + &id, + name, + payload.description.as_deref().unwrap_or_default(), + &runtime_kind, + &entry, + ), + ) + .await + .map_err(|error| AppError::Io(format!("Failed to write draft manifest: {error}")))?; + tokio::fs::write(&entry_path, draft_entry_text(&runtime_kind)) + .await + .map_err(|error| AppError::Io(format!("Failed to write draft entry: {error}")))?; + tokio::fs::write( + draft_dir.join("README.md"), + format!("# {name}\n\nDraft integration created by Agent Control.\n"), + ) + .await + .map_err(|error| AppError::Io(format!("Failed to write draft README: {error}")))?; + + Ok(IntegrationDraftCreateResponse { + ok: true, + id, + draft_dir: draft_dir.display().to_string(), + manifest_path: manifest_path.display().to_string(), + entry_path: entry_path.display().to_string(), + }) +} + +pub(super) fn handle_agent_logs_request(request: &HttpRequest) -> Result { + let query = parse_agent_logs_query(&request.path)?; + let logs = match query.view_id.as_deref() { + Some(view_id) => { + crate::api::system::logs::get_console_logs(view_id.to_string(), query.since)? + } + None => Vec::new(), + }; + let skip = logs.len().saturating_sub(query.limit); + let logs = logs + .into_iter() + .skip(skip) + .map(sanitize_agent_log_entry) + .collect::>(); + + Ok(json_response( + 200, + json!(AgentLogsResponse { + ok: true, + api_version: SDK_API_VERSION, + view_id: query.view_id, + since: query.since, + limit: query.limit, + logs, + }), + )) +} + +fn integration_drafts_dir() -> PathBuf { + crate::utils::paths::RUNTIME_DIR.join(INTEGRATION_DRAFTS_DIR_NAME) +} + +pub(super) fn draft_id_from_name(name: &str) -> String { + let mut id = String::with_capacity(name.len()); + let mut last_was_dash = false; + for character in name.chars().flat_map(char::to_lowercase) { + if character.is_ascii_alphanumeric() { + id.push(character); + last_was_dash = false; + } else if !last_was_dash { + id.push('-'); + last_was_dash = true; + } + } + let id = id.trim_matches('-'); + if id.is_empty() { + format!( + "draft-{}", + &hex::encode(sha2::Sha256::digest(name.as_bytes()))[..12] + ) + } else { + id.to_string() + } +} + +pub(super) fn validate_draft_runtime_kind(kind: &str) -> Result<(), AppError> { + match kind { + "python" | "node" | "bun" => Ok(()), + _ => Err(AppError::Validation(format!( + "Unsupported draft runtime kind: {kind}" + ))), + } +} + +pub(super) fn validate_relative_draft_path(path: &str) -> Result<(), AppError> { + if path.trim().is_empty() { + return Err(AppError::Validation( + "Draft entry path cannot be empty".to_string(), + )); + } + let path = Path::new(path); + if path.is_absolute() + || path.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir + | std::path::Component::Prefix(_) + | std::path::Component::RootDir + ) + }) + { + return Err(AppError::Validation( + "Draft entry path must stay inside the draft directory".to_string(), + )); + } + Ok(()) +} + +pub(super) fn default_draft_entry(runtime_kind: &str) -> String { + match runtime_kind { + "node" => "src/main.js", + "bun" => "src/main.ts", + _ => "src/main.py", + } + .to_string() +} + +pub(super) fn draft_manifest_text( + id: &str, + name: &str, + description: &str, + runtime_kind: &str, + entry: &str, +) -> String { + format!( + r#"api_version = "1" +id = "{}" +name = "{}" +version = "0.1.0" +description = "{}" +type = "service" + +[runtime] +kind = "{}" +entry = "{}" +"#, + escape_toml_string(id), + escape_toml_string(name), + escape_toml_string(description), + escape_toml_string(runtime_kind), + escape_toml_string(entry), + ) +} + +fn draft_entry_text(runtime_kind: &str) -> &'static str { + match runtime_kind { + "node" | "bun" => "console.log('Axelate draft integration started');\n", + _ => "print('Axelate draft integration started')\n", + } +} + +pub(super) fn escape_toml_string(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") +} + +pub(super) fn sanitize_agent_log_entry(mut entry: LogEntry) -> LogEntry { + entry.source = redact_sensitive_log_text(&entry.source); + entry.level = redact_sensitive_log_text(&entry.level); + entry.message = redact_sensitive_log_text(&entry.message); + entry.display_time = entry.display_time.as_deref().map(redact_sensitive_log_text); + entry.normalized_level = entry + .normalized_level + .as_deref() + .map(redact_sensitive_log_text); + entry.scope = entry.scope.as_deref().map(redact_sensitive_log_text); + entry.summary_message = entry + .summary_message + .as_deref() + .map(redact_sensitive_log_text); + entry.source_label = entry.source_label.as_deref().map(redact_sensitive_log_text); + entry.source_class = entry.source_class.as_deref().map(redact_sensitive_log_text); + entry.page = entry.page.as_deref().map(redact_sensitive_log_text); + entry.action = entry.action.as_deref().map(redact_sensitive_log_text); + entry.expected = entry.expected.as_deref().map(redact_sensitive_log_text); + entry +} + +pub(super) fn redact_sensitive_log_text(input: &str) -> String { + let with_assignments = redact_sensitive_assignments(input); + redact_bearer_tokens(&with_assignments) +} + +fn redact_sensitive_assignments(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let bytes = input.as_bytes(); + let mut index = 0; + + while index < bytes.len() { + let Some(byte) = bytes.get(index).copied() else { + break; + }; + if !is_key_char(byte) { + if let Some(character) = input[index..].chars().next() { + output.push(character); + index += character.len_utf8(); + } else { + break; + } + continue; + } + + let key_start = index; + while bytes.get(index).copied().is_some_and(is_key_char) { + index += 1; + } + let key_end = index; + let mut cursor = skip_ascii_spaces(bytes, index); + let Some(delimiter) = bytes + .get(cursor) + .copied() + .filter(|byte| matches!(byte, b':' | b'=')) + else { + output.push_str(&input[key_start..key_end]); + continue; + }; + + cursor += 1; + cursor = skip_ascii_spaces(bytes, cursor); + + if !is_sensitive_key(&input[key_start..key_end]) { + output.push_str(&input[key_start..cursor]); + index = cursor; + continue; + } + + output.push_str(&input[key_start..key_end]); + output.push_str(&input[key_end..cursor]); + + let quote = bytes + .get(cursor) + .copied() + .filter(|byte| matches!(byte, b'"' | b'\'')); + if quote.is_some() { + if let Some(byte) = bytes.get(cursor).copied() { + output.push(char::from(byte)); + } + cursor += 1; + } + + output.push_str("[REDACTED]"); + cursor = skip_sensitive_value(bytes, cursor, quote, delimiter); + if let Some(quote_byte) = quote + && bytes.get(cursor).is_some_and(|byte| *byte == quote_byte) + { + output.push(char::from(quote_byte)); + cursor += 1; + } + index = cursor; + } + + output +} + +fn redact_bearer_tokens(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let lower = input.to_ascii_lowercase(); + let mut index = 0; + + while let Some(relative) = lower[index..].find("bearer ") { + let marker_start = index + relative; + let token_start = marker_start + "bearer ".len(); + output.push_str(&input[index..token_start]); + + let token_end = input + .as_bytes() + .get(token_start..) + .unwrap_or_default() + .iter() + .position(|byte| is_bearer_token_delimiter(*byte)) + .map_or(input.len(), |position| token_start + position); + + if token_end > token_start { + output.push_str("[REDACTED]"); + } + index = token_end; + } + + output.push_str(&input[index..]); + output +} + +const fn is_key_char(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') +} + +fn skip_ascii_spaces(bytes: &[u8], mut index: usize) -> usize { + while bytes.get(index).is_some_and(u8::is_ascii_whitespace) { + index += 1; + } + index +} + +fn skip_sensitive_value(bytes: &[u8], mut index: usize, quote: Option, delimiter: u8) -> usize { + if quote.is_none() + && starts_with_ascii_case_insensitive(bytes.get(index..).unwrap_or_default(), b"bearer ") + { + index += "bearer".len(); + index = skip_ascii_spaces(bytes, index); + while bytes + .get(index) + .copied() + .is_some_and(|byte| !is_bearer_token_delimiter(byte)) + { + index += 1; + } + return index; + } + + while index < bytes.len() { + let Some(byte) = bytes.get(index).copied() else { + break; + }; + if quote.is_some_and(|quote| byte == quote) { + break; + } + if quote.is_none() + && (byte.is_ascii_whitespace() + || matches!(byte, b',' | b'}' | b']') + || (delimiter == b'=' && byte == b'&')) + { + break; + } + index += 1; + } + index +} + +fn starts_with_ascii_case_insensitive(value: &[u8], expected: &[u8]) -> bool { + value.len() >= expected.len() + && value + .iter() + .zip(expected.iter()) + .all(|(left, right)| left.eq_ignore_ascii_case(right)) +} + +fn is_sensitive_key(key: &str) -> bool { + let normalized = key + .chars() + .filter(char::is_ascii_alphanumeric) + .flat_map(char::to_lowercase) + .collect::(); + matches!( + normalized.as_str(), + "apikey" | "authorization" | "auth" | "password" | "secret" | "token" | "key" + ) || normalized.ends_with("apikey") + || normalized.ends_with("key") + || normalized.ends_with("token") + || normalized.ends_with("secret") + || normalized.ends_with("password") +} + +const fn is_bearer_token_delimiter(byte: u8) -> bool { + byte.is_ascii_whitespace() || matches!(byte, b'"' | b'\'' | b',' | b'}' | b']' | b')') +} + +async fn handle_agent_approvals_request( + context: &LauncherHttpApiContext, + client: &AuthorizedClient, +) -> Result { + let state = context + .agent_control_service + .state(api_base_url().to_string()) + .await?; + let approvals = approvals_visible_to_client(state.approvals, client); + Ok(json_response( + 200, + json!({ "ok": true, "approvals": approvals }), + )) +} + +pub(super) fn approvals_visible_to_client( + approvals: Vec, + client: &AuthorizedClient, +) -> Vec { + match client { + AuthorizedClient::Launcher => approvals, + AuthorizedClient::Agent(agent) => approvals + .into_iter() + .filter(|approval| approval.agent_id == agent.id) + .collect(), + AuthorizedClient::Module(_) => Vec::new(), + } +} + +async fn handle_agent_approval_request( + request: &HttpRequest, + context: &LauncherHttpApiContext, + agent: &crate::domain::agent_control::AuthorizedAgent, +) -> Result { + let payload: IntegrationAgentApprovalRequest = parse_json_body(request)?; + let action = normalize_approval_field("action", payload.action.trim(), 96)?; + let target = normalize_approval_field("target", payload.target.trim(), 160)?; + let diff = normalize_approval_field("diff", payload.diff.trim(), 4_000)?; + let risk = normalize_approval_risk(payload.risk.trim())?; + if !is_dangerous_approval_action(&action) && risk != "dangerous" { + return Ok(json_error( + 400, + "Approval requests are reserved for dangerous or high-risk actions", + )); + } + + let approval = context + .agent_control_service + .create_approval_request(agent, action, target, diff, risk) + .await?; + if let Err(error) = context.app.emit( + "agent-control:state-changed", + json!({ + "reason": "approval-request-created", + "approvalId": approval.id, + }), + ) { + tracing::warn!("Failed to emit Agent Control state change: {error}"); + } + record_agent_audit( + context, + &AuthorizedClient::Agent(agent.clone()), + "approval.request".to_string(), + approval.target.clone(), + "pending-approval".to_string(), + ) + .await; + + Ok(json_response( + 202, + json!(AgentApprovalCreatedResponse { ok: true, approval }), + )) +} + +fn handle_agent_capabilities_request(client: &AuthorizedClient) -> HttpResponse { + json_response( + 200, + json!({ + "ok": true, + "apiVersion": SDK_API_VERSION, + "auth": { + "actorId": client.actor_id(), + "actorName": client.actor_name(), + "scopes": granted_agent_scopes(client), + }, + "endpoints": { + "observe": [ + "GET /v1/health", + "GET /v1/agent/capabilities", + "GET /v1/agent/state", + "GET /v1/agent/logs?viewId=", + "GET /v1/modules", + "GET /v1/modules/:id/status", + "GET /v1/modules/:id/context" + ], + "operate": [ + "POST /v1/launcher/open-page", + "POST /v1/launcher/select-module", + "POST /v1/modules/:id/start", + "POST /v1/modules/:id/stop", + "POST /v1/modules/:id/restart", + "POST /v1/ai/text", + "POST /v1/ai/image" + ], + "configure": [ + "GET /v1/modules/:id/settings", + "PATCH /v1/modules/:id/settings", + "POST /v1/modules/:id/stage" + ], + "draftCreate": [ + "POST /v1/integration-drafts" + ], + "approval": [ + "GET /v1/agent/approvals", + "POST /v1/agent/approval-requests" + ] + }, + "safety": { + "loopbackOnly": true, + "secretsRedacted": true, + "rawLogsBlocked": true, + "fullSettingsReplacementBlocked": true, + "dangerousActionsRequireApproval": [ + "install", + "delete", + "uninstall", + "update", + "secret", + "raw-log", + "filesystem", + "network-permission" + ] + } + }), + ) +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentApprovalCreatedResponse { + ok: bool, + approval: AgentApprovalRequest, +} + +#[derive(Debug)] +struct AgentOpenPageResponse { + page_id: String, +} + +#[derive(Debug)] +struct AgentSelectModuleResponse { + category: String, + module: SelectedModule, +} + +async fn handle_open_page_request( + request: &HttpRequest, + context: &LauncherHttpApiContext, +) -> Result { + let payload: IntegrationOpenPageRequest = parse_json_body(request)?; + let page_id = payload.page_id.trim(); + validate_agent_page_id(page_id)?; + + let mut ui_state = context.ui_state_service.get_ui_state().await?; + ui_state.last_page = Some(page_id.to_string()); + context.ui_state_service.save_ui_state(&ui_state).await?; + + if let Err(error) = context.app.emit( + "agent-control:open-page", + AgentOpenPageEvent { + page_id: page_id.to_string(), + source: "agent-control", + }, + ) { + tracing::warn!("Failed to emit Agent Control page open request: {error}"); + } + + Ok(AgentOpenPageResponse { + page_id: page_id.to_string(), + }) +} + +async fn handle_select_module_request( + request: &HttpRequest, + context: &LauncherHttpApiContext, +) -> Result { + let payload: IntegrationSelectModuleRequest = parse_json_body(request)?; + let category = payload.category.trim(); + let module_id = payload.module_id.trim(); + validate_agent_selection_category(category)?; + crate::domain::modules::downloader::validate_module_id(module_id)?; + + let module = match resolve_selectable_module(&context.config_service, module_id) { + Ok(module) => module, + Err(_) => resolve_runtime_selected_module(module_id).await?, + }; + validate_selected_module_category(&context.config_service, category, module_id, &module) + .await?; + let mut ui_state = context.ui_state_service.get_ui_state().await?; + ui_state + .selected_modules + .insert(category.to_string(), module.clone()); + context.ui_state_service.save_ui_state(&ui_state).await?; + + if let Err(error) = context.app.emit( + "ui-state:selected-module-changed", + json!({ + "category": category, + "module": module, + "source": "agent-control", + }), + ) { + tracing::warn!("Failed to emit Agent Control selected module change: {error}"); + } + + Ok(AgentSelectModuleResponse { + category: category.to_string(), + module, + }) +} + +pub(super) fn parse_agent_logs_query(path: &str) -> Result { + let mut result = AgentLogsQuery { + view_id: None, + since: 0.0, + limit: AGENT_LOGS_DEFAULT_LIMIT, + }; + let query = path.split_once('?').map_or("", |(_, query)| query); + + for pair in query.split('&').filter(|pair| !pair.trim().is_empty()) { + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + match key.trim() { + "viewId" | "view_id" => { + let decoded = percent_decode_query_value(value.trim())?; + result.view_id = Some(decoded).filter(|value| !value.is_empty()); + } + "since" => { + result.since = parse_non_negative_f64("since", value)?; + } + "limit" => { + result.limit = parse_agent_logs_limit(value)?; + } + _ => {} + } + } + + Ok(result) +} + +fn percent_decode_query_value(value: &str) -> Result { + let mut output = Vec::with_capacity(value.len()); + let bytes = value.as_bytes(); + let mut index = 0; + while index < bytes.len() { + let Some(byte) = bytes.get(index).copied() else { + break; + }; + match byte { + b'+' => { + output.push(b' '); + index += 1; + } + b'%' => { + let Some(hex) = value.get(index + 1..index + 3) else { + return Err(AppError::Validation( + "Query parameter contains incomplete percent encoding".to_string(), + )); + }; + let byte = u8::from_str_radix(hex, 16).map_err(|_| { + AppError::Validation( + "Query parameter contains invalid percent encoding".to_string(), + ) + })?; + output.push(byte); + index += 3; + } + _ => { + if let Some(character) = value[index..].chars().next() { + let mut buffer = [0; 4]; + output.extend_from_slice(character.encode_utf8(&mut buffer).as_bytes()); + index += character.len_utf8(); + } else { + break; + } + } + } + } + String::from_utf8(output).map_err(|_| { + AppError::Validation("Query parameter is not valid UTF-8 after decoding".to_string()) + }) +} + +fn parse_non_negative_f64(name: &str, value: &str) -> Result { + let parsed = value + .trim() + .parse::() + .map_err(|error| AppError::Validation(format!("Invalid {name}: {error}")))?; + if parsed.is_finite() && parsed >= 0.0 { + Ok(parsed) + } else { + Err(AppError::Validation(format!( + "Invalid {name}: expected a non-negative finite number" + ))) + } +} + +fn parse_agent_logs_limit(value: &str) -> Result { + let parsed = value + .trim() + .parse::() + .map_err(|error| AppError::Validation(format!("Invalid limit: {error}")))?; + if parsed == 0 { + return Err(AppError::Validation( + "Invalid limit: expected a positive number".to_string(), + )); + } + + Ok(parsed.min(AGENT_LOGS_MAX_LIMIT)) +} + +async fn handle_agent_state_request( + context: &LauncherHttpApiContext, +) -> Result { + let config = context.config_service.load_full_config()?; + let ui_state = context.ui_state_service.get_ui_state().await?; + let modules = module_controller::get_all_modules() + .await + .into_iter() + .map(agent_module_summary) + .collect::>(); + let providers = config + .api_providers + .iter() + .map(agent_provider_summary) + .collect::>(); + let engine_state = context.engine_manager.state().await; + + Ok(json_response( + 200, + json!(AgentLauncherStateResponse { + ok: true, + api_version: SDK_API_VERSION, + selected_modules: ui_state.selected_modules, + modules, + providers, + engine_state, + }), + )) +} + +fn agent_module_summary(module: crate::models::Module) -> AgentModuleSummary { + AgentModuleSummary { + id: module.id, + name: module.name, + category: module.category, + installed: module.installed, + enabled: module.enabled, + status: module.status, + } +} + +pub(super) fn agent_provider_summary(provider: &ApiProvider) -> AgentProviderSummary { + AgentProviderSummary { + id: provider.id.clone(), + name: provider.name.clone(), + provider_type: provider + .provider_type + .as_ref() + .and_then(|value| serde_json::to_value(value).ok()) + .and_then(|value| value.as_str().map(ToOwned::to_owned)), + capabilities: provider.capabilities.clone().unwrap_or_default(), + models: provider + .models + .as_deref() + .unwrap_or_default() + .iter() + .map(|model| AgentModelSummary { + id: model.id.clone(), + name: model.name.clone(), + capabilities: model.capabilities.clone(), + }) + .collect(), + } +} + pub(super) fn modules_visible_to_client( mut modules: Vec, client: &AuthorizedClient, @@ -153,10 +1131,12 @@ async fn handle_get_module_settings_request( module_id: &str, ) -> Result { ensure_installed_module_id(module_id)?; - let settings = context - .settings_service - .get_module_settings(module_id) - .await?; + let settings = sanitize_agent_module_settings( + context + .settings_service + .get_module_settings(module_id) + .await?, + ); Ok(json_response( 200, @@ -164,21 +1144,91 @@ async fn handle_get_module_settings_request( )) } -async fn handle_put_module_settings_request( - request: &HttpRequest, - context: &LauncherHttpApiContext, - module_id: &str, -) -> Result { - ensure_installed_module_id(module_id)?; - let settings: HashMap = parse_json_body(request)?; - context - .settings_service - .save_module_settings(module_id, &settings) - .await?; +pub(super) fn sanitize_agent_module_settings( + settings: HashMap, +) -> HashMap { + settings + .into_iter() + .map(|(key, value)| { + let value = if is_sensitive_key(&key) { + serde_json::Value::String("[REDACTED]".to_string()) + } else { + sanitize_agent_setting_value(value) + }; + (key, value) + }) + .collect() +} - Ok(json_response( - 200, - json!({ "ok": true, "moduleId": module_id, "settings": settings }), +fn sanitize_agent_setting_value(value: serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::Object(map) => serde_json::Value::Object( + map.into_iter() + .map(|(key, value)| { + let value = if is_sensitive_key(&key) { + serde_json::Value::String("[REDACTED]".to_string()) + } else { + sanitize_agent_setting_value(value) + }; + (key, value) + }) + .collect(), + ), + serde_json::Value::Array(values) => serde_json::Value::Array( + values + .into_iter() + .map(sanitize_agent_setting_value) + .collect(), + ), + value => value, + } +} + +pub(super) fn ensure_agent_settings_update_is_safe( + settings: &HashMap, +) -> Result<(), AppError> { + for (key, value) in settings { + ensure_agent_setting_key_is_safe(key)?; + ensure_agent_setting_value_is_safe(value)?; + } + Ok(()) +} + +fn ensure_agent_setting_value_is_safe(value: &serde_json::Value) -> Result<(), AppError> { + match value { + serde_json::Value::Object(map) => { + for (key, value) in map { + ensure_agent_setting_key_is_safe(key)?; + ensure_agent_setting_value_is_safe(value)?; + } + } + serde_json::Value::Array(values) => { + for value in values { + ensure_agent_setting_value_is_safe(value)?; + } + } + _ => {} + } + Ok(()) +} + +fn ensure_agent_setting_key_is_safe(key: &str) -> Result<(), AppError> { + if is_sensitive_key(key) { + return Err(AppError::PermissionDenied(format!( + "Agent API cannot read or change sensitive module setting: {key}" + ))); + } + Ok(()) +} + +fn handle_put_module_settings_request( + _request: &HttpRequest, + _context: &LauncherHttpApiContext, + _module_id: &str, +) -> Result { + Err(AppError::PermissionDenied( + "Full module settings replacement is not allowed through Agent API; use PATCH for safe settings" + .to_string(), )) } @@ -189,6 +1239,7 @@ async fn handle_patch_module_settings_request( ) -> Result { ensure_installed_module_id(module_id)?; let updates: HashMap = parse_json_body(request)?; + ensure_agent_settings_update_is_safe(&updates)?; let mut settings = context .settings_service .get_module_settings(module_id) @@ -201,7 +1252,11 @@ async fn handle_patch_module_settings_request( Ok(json_response( 200, - json!({ "ok": true, "moduleId": module_id, "settings": settings }), + json!({ + "ok": true, + "moduleId": module_id, + "settings": sanitize_agent_module_settings(settings), + }), )) } @@ -253,7 +1308,7 @@ pub(super) fn ensure_module_route_owner( module_id: &str, ) -> Result<(), AppError> { match client { - AuthorizedClient::Launcher => Ok(()), + AuthorizedClient::Launcher | AuthorizedClient::Agent(_) => Ok(()), AuthorizedClient::Module(owner_id) if owner_id == module_id => Ok(()), AuthorizedClient::Module(_) => Err(AppError::PermissionDenied( "Integration token cannot access another integration".to_string(), @@ -261,6 +1316,50 @@ pub(super) fn ensure_module_route_owner( } } +pub(super) fn ensure_launcher_client(client: &AuthorizedClient) -> Result<(), AppError> { + match client { + AuthorizedClient::Launcher | AuthorizedClient::Agent(_) => Ok(()), + AuthorizedClient::Module(_) => Err(AppError::PermissionDenied( + "Integration token cannot access launcher-wide agent state".to_string(), + )), + } +} + +fn ensure_agent_scope(client: &AuthorizedClient, scope: AgentScope) -> Result<(), AppError> { + match client { + AuthorizedClient::Launcher | AuthorizedClient::Module(_) => Ok(()), + AuthorizedClient::Agent(agent) + if agent.scopes.contains(&scope) || agent.scopes.contains(&AgentScope::FullAccess) => + { + Ok(()) + } + AuthorizedClient::Agent(_) => Err(AppError::PermissionDenied(format!( + "Agent token is missing required scope: {scope:?}" + ))), + } +} + +fn granted_agent_scopes(client: &AuthorizedClient) -> Vec { + match client { + AuthorizedClient::Agent(agent) => agent.scopes.clone(), + AuthorizedClient::Launcher => vec![AgentScope::FullAccess], + AuthorizedClient::Module(_) => Vec::new(), + } +} + +fn ensure_profile_agent( + client: &AuthorizedClient, +) -> Result<&crate::domain::agent_control::AuthorizedAgent, AppError> { + match client { + AuthorizedClient::Agent(agent) => Ok(agent), + AuthorizedClient::Launcher | AuthorizedClient::Module(_) => { + Err(AppError::PermissionDenied( + "Approval requests require an agent profile token".to_string(), + )) + } + } +} + fn handle_module_stage_request( request: &HttpRequest, context: &LauncherHttpApiContext, @@ -306,12 +1405,130 @@ pub(super) fn parse_module_action(action: &str) -> Result Ok(ModuleAction::Start), "stop" => Ok(ModuleAction::Stop), "restart" => Ok(ModuleAction::Restart), + "repair" => Ok(ModuleAction::Repair), _ => Err(AppError::Validation(format!( "Unsupported module action: {action}" ))), } } +const fn module_action_name(action: ModuleAction) -> &'static str { + match action { + ModuleAction::Start => "start", + ModuleAction::Stop => "stop", + ModuleAction::Restart => "restart", + ModuleAction::Repair => "repair", + ModuleAction::Install => "install", + ModuleAction::Uninstall => "uninstall", + ModuleAction::Update => "update", + } +} + +pub(super) fn normalize_approval_field( + field: &str, + value: &str, + max_len: usize, +) -> Result { + if value.is_empty() { + return Err(AppError::Validation(format!( + "Agent approval {field} cannot be empty" + ))); + } + if value.len() > max_len { + return Err(AppError::Validation(format!( + "Agent approval {field} is too long" + ))); + } + Ok(value.to_string()) +} + +pub(super) fn normalize_approval_risk(value: &str) -> Result { + let normalized = value.trim().to_ascii_lowercase(); + match normalized.as_str() { + "high" | "dangerous" => Ok(normalized), + "medium" | "low" | "" => Err(AppError::Validation( + "Agent approval risk must be high or dangerous".to_string(), + )), + _ => Err(AppError::Validation(format!( + "Unsupported agent approval risk: {value}" + ))), + } +} + +pub(super) fn is_dangerous_approval_action(action: &str) -> bool { + let normalized = action.to_ascii_lowercase(); + [ + "install", + "delete", + "remove", + "uninstall", + "update", + "upgrade", + "secret", + "token", + "raw-log", + "raw_logs", + "filesystem", + "file-system", + "network-permission", + "permission", + ] + .iter() + .any(|marker| normalized.contains(marker)) +} + +pub(super) fn audit_action_from_request(method: &str, path: &str) -> String { + let normalized_path = path + .split('?') + .next() + .unwrap_or(path) + .trim_matches('/') + .replace('/', "."); + format!("http.{}.{}", method.to_ascii_lowercase(), normalized_path) +} + +const fn audit_result_for_error(error: &AppError) -> &'static str { + match error { + AppError::PermissionDenied(_) | AppError::FrontendSecretForbidden(_) => "denied", + AppError::Validation(_) => "rejected", + AppError::NotFound(_) => "not-found", + AppError::Io(_) + | AppError::Serialization(_) + | AppError::Config(_) + | AppError::External { .. } + | AppError::Internal { .. } => "failed", + } +} + +async fn record_agent_audit( + context: &LauncherHttpApiContext, + client: &AuthorizedClient, + action: String, + target: String, + result: String, +) { + if !matches!( + client, + AuthorizedClient::Launcher | AuthorizedClient::Agent(_) + ) { + return; + } + + if let Err(error) = context + .agent_control_service + .record_audit( + client.actor_id(), + client.actor_name(), + action, + target, + result, + ) + .await + { + tracing::warn!("Failed to record agent audit entry: {error}"); + } +} + async fn handle_text_request( request: &HttpRequest, context: LauncherHttpApiContext, @@ -494,6 +1711,131 @@ fn resolve_selected_provider_module( .ok_or_else(|| AppError::Validation(format!("Unknown AI provider: {provider_id}"))) } +fn resolve_selectable_module( + config_service: &crate::domain::system::config_service::ConfigService, + module_id: &str, +) -> Result { + resolve_selected_provider_module(config_service, module_id) +} + +async fn resolve_runtime_selected_module(module_id: &str) -> Result { + module_controller::get_all_modules() + .await + .into_iter() + .find(|module| module.id == module_id) + .map(|module| selected_module_from_runtime_module(&module)) + .ok_or_else(|| AppError::Validation(format!("Unknown selectable module: {module_id}"))) +} + +fn validate_agent_page_id(page_id: &str) -> Result<(), AppError> { + if page_id.is_empty() { + return Err(AppError::Validation("Page id cannot be empty".to_string())); + } + if !page_id + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-' || character == '_') + { + return Err(AppError::Validation( + "Page id contains invalid characters".to_string(), + )); + } + Ok(()) +} + +fn validate_agent_selection_category(category: &str) -> Result<(), AppError> { + match category { + "ai_text" | "ai_image" | "services" => Ok(()), + _ => Err(AppError::Validation(format!( + "Unsupported selected module category: {category}" + ))), + } +} + +async fn validate_selected_module_category( + config_service: &crate::domain::system::config_service::ConfigService, + category: &str, + module_id: &str, + module: &SelectedModule, +) -> Result<(), AppError> { + let expected = + selection_category_for_selected_module(config_service, module_id, module).await?; + if expected == category { + return Ok(()); + } + + Err(AppError::Validation(format!( + "Module {module_id} belongs to {expected}, not {category}" + ))) +} + +async fn selection_category_for_selected_module( + config_service: &crate::domain::system::config_service::ConfigService, + module_id: &str, + module: &SelectedModule, +) -> Result<&'static str, AppError> { + let config = config_service.load_full_config()?; + if let Some(item) = config + .catalog + .services + .iter() + .find(|item| item.id == module_id) + { + return Ok(selection_category_for_catalog_item(item)); + } + if let Some(item) = config.catalog.ai.iter().find(|item| item.id == module_id) { + return Ok(selection_category_for_catalog_item(item)); + } + if let Some(provider) = config + .api_providers + .iter() + .find(|provider| provider.id == module_id) + { + return Ok(selection_category_for_provider(provider)); + } + + if module_id == CUSTOM_TEXT_PROVIDER_ID { + return Ok("ai_text"); + } + if module_id == CUSTOM_IMAGE_PROVIDER_ID { + return Ok("ai_image"); + } + + module_controller::get_all_modules() + .await + .into_iter() + .find(|candidate| candidate.id == module.id) + .map(|runtime_module| selection_category_for_runtime_module(&runtime_module)) + .ok_or_else(|| AppError::Validation(format!("Unknown selectable module: {module_id}"))) +} + +fn selection_category_for_catalog_item(item: &ModuleItem) -> &'static str { + if item.type_name.trim().eq_ignore_ascii_case("service") { + return "services"; + } + selection_category_for_capabilities(&item.capabilities) +} + +fn selection_category_for_provider(provider: &ApiProvider) -> &'static str { + provider + .capabilities + .as_deref() + .map_or("ai_text", selection_category_for_capabilities) +} + +pub(super) fn selection_category_for_capabilities(capabilities: &[String]) -> &'static str { + let has_image = capabilities + .iter() + .any(|capability| capability.trim().eq_ignore_ascii_case("image")); + let has_text = capabilities + .iter() + .any(|capability| capability.trim().eq_ignore_ascii_case("text")); + if has_image && !has_text { + "ai_image" + } else { + "ai_text" + } +} + pub(super) fn selected_module_from_catalog_item(module: &ModuleItem) -> SelectedModule { SelectedModule { id: module.id.clone(), @@ -506,6 +1848,18 @@ pub(super) fn selected_module_from_catalog_item(module: &ModuleItem) -> Selected } } +pub(super) fn selected_module_from_runtime_module(module: &Module) -> SelectedModule { + SelectedModule { + id: module.id.clone(), + name: module.name.clone(), + name_key: None, + icon: module.icon.clone(), + type_: "local".to_string(), + desc_key: None, + desc: module.description.clone(), + } +} + pub(super) fn selected_module_from_api_provider(provider: &ApiProvider) -> SelectedModule { let type_ = match provider.provider_type { Some(ProviderType::Local) => "local", @@ -523,6 +1877,14 @@ pub(super) fn selected_module_from_api_provider(provider: &ApiProvider) -> Selec } } +pub(super) fn selection_category_for_runtime_module(module: &Module) -> &'static str { + if module.category.trim().eq_ignore_ascii_case("ai") { + "ai_text" + } else { + "services" + } +} + pub(super) fn backend_provider_id(provider_id: &str) -> &str { match provider_id { CUSTOM_TEXT_PROVIDER_ID => CUSTOM_TEXT_BACKEND_PROVIDER_ID, @@ -531,6 +1893,46 @@ pub(super) fn backend_provider_id(provider_id: &str) -> &str { } } +async fn sync_launcher_selected_module( + context: &LauncherHttpApiContext, + client: &AuthorizedClient, + module_id: &str, +) -> Result<(), AppError> { + if matches!(client, AuthorizedClient::Module(_)) { + return Ok(()); + } + + let Some(module) = module_controller::get_all_modules() + .await + .into_iter() + .find(|module| module.id == module_id) + else { + tracing::warn!("Started module {module_id} but could not find it for UI selection sync"); + return Ok(()); + }; + + let category = selection_category_for_runtime_module(&module); + let selected_module = selected_module_from_runtime_module(&module); + let mut state = context.ui_state_service.get_ui_state().await?; + state + .selected_modules + .insert(category.to_string(), selected_module.clone()); + context.ui_state_service.save_ui_state(&state).await?; + + if let Err(error) = context.app.emit( + "ui-state:selected-module-changed", + json!({ + "category": category, + "module": selected_module, + "source": "integration-api" + }), + ) { + tracing::warn!("Failed to emit selected module change for {module_id}: {error}"); + } + + Ok(()) +} + fn is_custom_provider_id(provider_id: &str) -> bool { matches!( provider_id, @@ -560,7 +1962,7 @@ pub(super) fn resolve_session_id( match client { AuthorizedClient::Module(module_id) => Some(format!("integration:{module_id}")), - AuthorizedClient::Launcher => None, + AuthorizedClient::Launcher | AuthorizedClient::Agent(_) => None, } } diff --git a/src-tauri/src/domain/integration_api/tests.rs b/src-tauri/src/domain/integration_api/tests.rs index 5ffca848..288b8f2a 100644 --- a/src-tauri/src/domain/integration_api/tests.rs +++ b/src-tauri/src/domain/integration_api/tests.rs @@ -5,14 +5,24 @@ use super::http::{ find_header_end, json_error, json_response, parse_header_line, parse_header_lines, parse_json_body, read_http_request, status_for_app_error, status_text, }; +use super::preflight_http_request; use super::routing::{ - backend_provider_id, ensure_module_route_owner, merge_json_settings, model_api_id, - modules_visible_to_client, parse_module_action, resolve_session_id, - selected_module_from_api_provider, selected_module_from_catalog_item, tier_rank, + agent_provider_summary, approvals_visible_to_client, audit_action_from_request, + backend_provider_id, default_draft_entry, draft_id_from_name, draft_manifest_text, + ensure_launcher_client, ensure_module_route_owner, escape_toml_string, + handle_agent_logs_request, is_dangerous_approval_action, merge_json_settings, model_api_id, + modules_visible_to_client, normalize_approval_field, normalize_approval_risk, + parse_agent_logs_query, parse_module_action, redact_sensitive_log_text, resolve_session_id, + sanitize_agent_log_entry, sanitize_agent_module_settings, selected_module_from_api_provider, + selected_module_from_catalog_item, selected_module_from_runtime_module, + selection_category_for_capabilities, selection_category_for_runtime_module, tier_rank, + validate_draft_runtime_kind, validate_relative_draft_path, }; use super::types::{AuthorizedClient, IntegrationTextRequest, ModuleContextApiResponse}; +use crate::domain::agent_control::{AgentApprovalRequest, AgentApprovalStatus, AgentScope}; use crate::domain::modules::controller::ModuleAction; use crate::errors::AppError; +use crate::infrastructure::logging::LogEntry; use crate::models::{ AiModel, ApiModelConfig, ModelStats, ModelTier, Module, ModuleItem, ProviderType, SelectedModule, @@ -82,13 +92,23 @@ fn authorization_accepts_bearer_token() { "authorization".to_string(), format!("Bearer {}", super::api_token()), ); - assert!(auth::is_authorized(&headers)); + assert!(auth::authorize_request(&headers).is_some()); headers.insert( "authorization".to_string(), format!("bearer {}", super::api_token()), ); - assert!(auth::is_authorized(&headers)); + assert!(auth::authorize_request(&headers).is_some()); +} + +#[test] +fn authorization_accepts_explicit_agent_token() { + let token = "agent-token-123456789012345678901234567890"; + + assert!(auth::agent_api_token_matches(token, Some(token))); + assert!(!auth::agent_api_token_matches("wrong-token", Some(token))); + assert!(!auth::agent_api_token_matches("short", Some("short"))); + assert!(!auth::agent_api_token_matches(token, None)); } #[test] @@ -99,7 +119,7 @@ fn authorization_rejects_old_header_token() { super::api_token().to_string(), ); - assert!(!auth::is_authorized(&headers)); + assert!(auth::authorize_request(&headers).is_none()); } #[test] @@ -128,6 +148,120 @@ fn authorization_maps_module_tokens_to_module_owner() { )); } +#[test] +fn launcher_wide_agent_state_requires_launcher_client() { + assert!(ensure_launcher_client(&AuthorizedClient::Launcher).is_ok()); + assert!(matches!( + ensure_launcher_client(&AuthorizedClient::Module("sample-module".to_string())), + Err(AppError::PermissionDenied(_)) + )); +} + +#[test] +fn agent_logs_query_defaults_and_clamps_limit() { + let defaults = parse_agent_logs_query("/v1/agent/logs").expect("defaults"); + assert_eq!(defaults.view_id, None); + assert!(defaults.since.abs() < f64::EPSILON); + assert_eq!(defaults.limit, 200); + + let parsed = parse_agent_logs_query("/v1/agent/logs?viewId=engine:sdcpp&since=12.5&limit=5000") + .expect("query"); + assert_eq!(parsed.view_id.as_deref(), Some("engine:sdcpp")); + assert!((parsed.since - 12.5).abs() < f64::EPSILON); + assert_eq!(parsed.limit, 1000); +} + +#[test] +fn agent_logs_query_decodes_view_id() { + let parsed = + parse_agent_logs_query("/v1/agent/logs?viewId=engine%3Allamacpp&since=1").expect("query"); + + assert_eq!(parsed.view_id.as_deref(), Some("engine:llamacpp")); + assert!((parsed.since - 1.0).abs() < f64::EPSILON); +} + +#[test] +fn agent_logs_query_rejects_invalid_since_and_limit() { + assert!(parse_agent_logs_query("/v1/agent/logs?since=-1").is_err()); + assert!(parse_agent_logs_query("/v1/agent/logs?since=inf").is_err()); + assert!(parse_agent_logs_query("/v1/agent/logs?limit=0").is_err()); +} + +#[test] +fn agent_logs_without_view_id_return_empty_list() { + let request = super::types::HttpRequest { + method: "GET".to_string(), + path: "/v1/agent/logs?limit=5".to_string(), + headers: HashMap::new(), + body: Vec::new(), + }; + + let response = handle_agent_logs_request(&request).expect("logs response"); + + assert_eq!(response.status, 200); + assert_eq!(response.body.get("logs"), Some(&serde_json::json!([]))); + assert_eq!(response.body.get("viewId"), Some(&serde_json::Value::Null)); +} + +#[test] +fn agent_logs_redact_sensitive_values() { + let redacted = redact_sensitive_log_text( + "Authorization: Bearer axl_agent_secret token=abc api_key=\"sk-test\" access_key=ak private_key=pk secret_key=sk openaiKey=o anthropicKey=a ssh_key=ssh safe=ok url=/x?api_key=query&ok=1", + ); + + assert!(!redacted.contains("axl_agent_secret")); + assert!(!redacted.contains("token=abc")); + assert!(!redacted.contains("sk-test")); + assert!(!redacted.contains("access_key=ak")); + assert!(!redacted.contains("private_key=pk")); + assert!(!redacted.contains("secret_key=sk")); + assert!(!redacted.contains("openaiKey=o")); + assert!(!redacted.contains("anthropicKey=a")); + assert!(!redacted.contains("ssh_key=ssh")); + assert!(!redacted.contains("api_key=query")); + assert!(redacted.contains("Authorization: [REDACTED]")); + assert!(redacted.contains("token=[REDACTED]")); + assert!(redacted.contains("api_key=\"[REDACTED]\"")); + assert!(redacted.contains("access_key=[REDACTED]")); + assert!(redacted.contains("private_key=[REDACTED]")); + assert!(redacted.contains("secret_key=[REDACTED]")); + assert!(redacted.contains("openaiKey=[REDACTED]")); + assert!(redacted.contains("anthropicKey=[REDACTED]")); + assert!(redacted.contains("ssh_key=[REDACTED]")); + assert!(redacted.contains("safe=ok")); + assert!(redacted.contains("api_key=[REDACTED]&ok=1")); +} + +#[test] +fn agent_log_entry_sanitizes_string_fields() { + let entry = LogEntry { + timestamp: 1.0, + source: "module:demo".to_string(), + level: "info".to_string(), + message: "apiKey=secret-value".to_string(), + module_id: Some("demo".to_string()), + display_time: None, + normalized_level: None, + scope: None, + summary_message: Some("Bearer bearer-secret".to_string()), + source_label: None, + source_class: None, + page: None, + action: None, + expected: Some("password: hunter2".to_string()), + }; + + let sanitized = sanitize_agent_log_entry(entry); + + assert_eq!(sanitized.message, "apiKey=[REDACTED]"); + assert_eq!( + sanitized.summary_message.as_deref(), + Some("Bearer [REDACTED]") + ); + assert_eq!(sanitized.expected.as_deref(), Some("password: [REDACTED]")); + assert_eq!(sanitized.module_id.as_deref(), Some("demo")); +} + #[test] fn issuing_new_module_token_invalidates_previous_token() { let old_token = auth::issue_module_api_token("rotating-module").expect("old token"); @@ -171,13 +305,30 @@ fn authorization_rejects_malformed_bearer_values() { "authorization".to_string(), format!("Bearer {} extra", super::api_token()), ); - assert!(!auth::is_authorized(&headers)); + assert!(auth::authorize_request(&headers).is_none()); headers.insert( "authorization".to_string(), format!("Token {}", super::api_token()), ); - assert!(!auth::is_authorized(&headers)); + assert!(auth::authorize_request(&headers).is_none()); +} + +#[test] +fn preflight_keeps_profile_tokens_for_dispatch_authorization() { + let request = super::types::HttpRequest { + method: "GET".to_string(), + path: "/v1/agent/state".to_string(), + headers: HashMap::from([( + "authorization".to_string(), + "Bearer axl_agent_profile_token".to_string(), + )]), + body: Vec::new(), + }; + + assert!( + preflight_http_request(&request, Some("127.0.0.1:3000".parse().expect("peer"))).is_none() + ); } #[test] @@ -279,6 +430,98 @@ fn patch_settings_merge_nested_objects_without_dropping_existing_keys() { assert_eq!(settings.get("theme"), Some(&serde_json::json!("light"))); } +#[test] +fn agent_module_settings_redact_sensitive_keys_recursively() { + let settings = HashMap::from([ + ("bot_token".to_string(), serde_json::json!("secret-token")), + ("openaiKey".to_string(), serde_json::json!("openai-secret")), + ( + "anthropicKey".to_string(), + serde_json::json!("anthropic-secret"), + ), + ("module_language".to_string(), serde_json::json!("auto")), + ( + "nested".to_string(), + serde_json::json!({ + "api_key": "secret-key", + "access_key": "access-secret", + "private_key": "private-secret", + "secret_key": "nested-secret", + "ssh_key": "ssh-secret", + "safe": true, + }), + ), + ]); + + let sanitized = sanitize_agent_module_settings(settings); + + assert_eq!( + sanitized.get("bot_token"), + Some(&serde_json::json!("[REDACTED]")) + ); + assert_eq!( + sanitized.get("openaiKey"), + Some(&serde_json::json!("[REDACTED]")) + ); + assert_eq!( + sanitized.get("anthropicKey"), + Some(&serde_json::json!("[REDACTED]")) + ); + assert_eq!( + sanitized.get("module_language"), + Some(&serde_json::json!("auto")) + ); + assert_eq!( + sanitized + .get("nested") + .and_then(|nested| nested.get("api_key")), + Some(&serde_json::json!("[REDACTED]")) + ); + assert_eq!( + sanitized + .get("nested") + .and_then(|nested| nested.get("access_key")), + Some(&serde_json::json!("[REDACTED]")) + ); + assert_eq!( + sanitized + .get("nested") + .and_then(|nested| nested.get("private_key")), + Some(&serde_json::json!("[REDACTED]")) + ); + assert_eq!( + sanitized + .get("nested") + .and_then(|nested| nested.get("secret_key")), + Some(&serde_json::json!("[REDACTED]")) + ); + assert_eq!( + sanitized + .get("nested") + .and_then(|nested| nested.get("ssh_key")), + Some(&serde_json::json!("[REDACTED]")) + ); + assert_eq!( + sanitized + .get("nested") + .and_then(|nested| nested.get("safe")), + Some(&serde_json::json!(true)) + ); +} + +#[test] +fn agent_module_settings_updates_reject_sensitive_keys() { + let updates = HashMap::from([( + "nested".to_string(), + serde_json::json!({ "openaiKey": "new-secret" }), + )]); + + let error = super::routing::ensure_agent_settings_update_is_safe(&updates) + .expect_err("secret updates must be rejected"); + + assert!(matches!(error, AppError::PermissionDenied(_))); +} + #[test] fn module_context_response_uses_public_camel_case_contract() { let response = serde_json::to_value(ModuleContextApiResponse { @@ -378,12 +621,54 @@ fn module_action_parser_accepts_integration_routes_only() { parse_module_action("restart").expect("restart"), ModuleAction::Restart ); + assert_eq!( + parse_module_action("repair").expect("repair"), + ModuleAction::Repair + ); assert!(matches!( parse_module_action("install"), Err(AppError::Validation(_)) )); } +#[test] +fn integration_draft_helpers_validate_safe_contract() { + assert_eq!(draft_id_from_name("My Draft Tool"), "my-draft-tool"); + assert!(validate_draft_runtime_kind("python").is_ok()); + assert!(validate_draft_runtime_kind("node").is_ok()); + assert!(validate_draft_runtime_kind("bun").is_ok()); + assert!(validate_draft_runtime_kind("binary").is_err()); + assert_eq!(default_draft_entry("node"), "src/main.js"); + assert_eq!(default_draft_entry("bun"), "src/main.ts"); + assert_eq!(default_draft_entry("python"), "src/main.py"); + + assert!(validate_relative_draft_path("src/main.py").is_ok()); + assert!(validate_relative_draft_path("../outside.py").is_err()); + assert!(validate_relative_draft_path("/outside.py").is_err()); + #[cfg(windows)] + assert!(validate_relative_draft_path("C:/outside.py").is_err()); + #[cfg(windows)] + assert!(validate_relative_draft_path(r"C:temp\file.py").is_err()); +} + +#[test] +fn integration_draft_manifest_escapes_toml_strings() { + assert_eq!(escape_toml_string("a\"b\nc"), "a\\\"b\\nc"); + let manifest = draft_manifest_text( + "demo", + "Demo \"Tool\"", + "Line one\nLine two", + "python", + "src/main.py", + ); + + assert!(manifest.contains("id = \"demo\"")); + assert!(manifest.contains("name = \"Demo \\\"Tool\\\"\"")); + assert!(manifest.contains("description = \"Line one\\nLine two\"")); + assert!(manifest.contains("kind = \"python\"")); + assert!(manifest.contains("entry = \"src/main.py\"")); +} + #[test] fn loopback_guard_rejects_missing_or_remote_peers() { assert!(auth::is_loopback_peer(Some( @@ -415,6 +700,84 @@ fn json_response_helpers_preserve_status_and_error_shape() { ); } +#[test] +fn agent_approval_helpers_accept_only_risky_actions() { + assert_eq!( + normalize_approval_field("action", "install module", 96).expect("action"), + "install module" + ); + assert!(normalize_approval_field("target", "", 96).is_err()); + assert!(normalize_approval_field("diff", "x".repeat(97).as_str(), 96).is_err()); + assert_eq!( + normalize_approval_risk("Dangerous").expect("dangerous"), + "dangerous" + ); + assert_eq!(normalize_approval_risk("HIGH").expect("high"), "high"); + assert!(normalize_approval_risk("medium").is_err()); + assert!(is_dangerous_approval_action("install package")); + assert!(is_dangerous_approval_action("read raw-log file")); + assert!(!is_dangerous_approval_action("open page")); +} + +#[test] +fn agent_approvals_are_scoped_to_requesting_agent() { + let approvals = vec![ + AgentApprovalRequest { + id: "approval-a".to_string(), + agent_id: "agent-a".to_string(), + agent_name: "Agent A".to_string(), + action: "package.install".to_string(), + target: "demo-a".to_string(), + diff: "Install A".to_string(), + risk: "dangerous".to_string(), + status: AgentApprovalStatus::Pending, + created_at: "2026-05-23T00:00:00Z".to_string(), + decided_at: None, + }, + AgentApprovalRequest { + id: "approval-b".to_string(), + agent_id: "agent-b".to_string(), + agent_name: "Agent B".to_string(), + action: "package.install".to_string(), + target: "demo-b".to_string(), + diff: "Install B".to_string(), + risk: "dangerous".to_string(), + status: AgentApprovalStatus::Pending, + created_at: "2026-05-23T00:00:01Z".to_string(), + decided_at: None, + }, + ]; + let agent = AuthorizedClient::Agent(crate::domain::agent_control::AuthorizedAgent { + id: "agent-a".to_string(), + name: "Agent A".to_string(), + scopes: vec![AgentScope::Observe], + }); + + let visible = approvals_visible_to_client(approvals.clone(), &agent); + + assert_eq!(visible.len(), 1); + assert_eq!( + visible.first().map(|approval| approval.id.as_str()), + Some("approval-a") + ); + assert_eq!( + approvals_visible_to_client(approvals, &AuthorizedClient::Launcher).len(), + 2 + ); +} + +#[test] +fn failed_agent_requests_get_stable_audit_action_names() { + assert_eq!( + audit_action_from_request("PATCH", "/v1/modules/demo/settings?x=1"), + "http.patch.v1.modules.demo.settings" + ); + assert_eq!( + audit_action_from_request("GET", "/v1/agent/capabilities"), + "http.get.v1.agent.capabilities" + ); +} + #[test] fn selected_module_from_catalog_preserves_localized_metadata() { let module = ModuleItem { @@ -514,6 +877,107 @@ fn selected_module_from_api_provider_maps_provider_type() { assert_eq!(selected_local.type_, "local"); } +#[test] +fn runtime_module_selection_maps_service_modules_to_services_card() { + let module = Module { + id: "telegram-parser".to_string(), + name: "Telegram Parser".to_string(), + description: "Reads exports".to_string(), + version: "1.0.0".to_string(), + author: "Axelate".to_string(), + category: "service".to_string(), + icon: "box".to_string(), + preview: None, + path: String::new(), + installed: true, + local: true, + enabled: false, + status: Some("running".to_string()), + is_deletable: true, + config: HashMap::new(), + config_schema: None, + settings_ui: None, + }; + + let selected = selected_module_from_runtime_module(&module); + + assert_eq!(selection_category_for_runtime_module(&module), "services"); + assert_eq!(selected.id, "telegram-parser"); + assert_eq!(selected.name, "Telegram Parser"); + assert_eq!(selected.type_, "local"); + assert_eq!(selected.desc, "Reads exports"); +} + +#[test] +fn runtime_module_selection_maps_ai_modules_to_text_slot() { + let module = Module { + id: "local-agent".to_string(), + name: "Local Agent".to_string(), + description: "AI module".to_string(), + version: "1.0.0".to_string(), + author: "Axelate".to_string(), + category: "AI".to_string(), + icon: "cpu".to_string(), + preview: None, + path: String::new(), + installed: true, + local: true, + enabled: false, + status: Some("running".to_string()), + is_deletable: true, + config: HashMap::new(), + config_schema: None, + settings_ui: None, + }; + + assert_eq!(selection_category_for_runtime_module(&module), "ai_text"); +} + +#[test] +fn selection_category_uses_image_slot_for_image_only_capabilities() { + assert_eq!( + selection_category_for_capabilities(&["image".to_string()]), + "ai_image" + ); + assert_eq!( + selection_category_for_capabilities(&["text".to_string(), "image".to_string()]), + "ai_text" + ); +} + +#[test] +fn agent_provider_summary_does_not_expose_secret_or_endpoint_fields() { + let provider = crate::models::ApiProvider { + id: "custom-text".to_string(), + name: "Custom Text".to_string(), + desc_key: None, + description: Some("Custom provider".to_string()), + icon: Some("AI".to_string()), + provider_type: Some(ProviderType::OpenaiCompatible), + base_url: Some("https://api.example.test/v1".to_string()), + api_key_env: Some("CUSTOM_TEXT_API_KEY".to_string()), + models: Some(vec![model_with_api_ids()]), + capabilities: Some(vec!["text".to_string()]), + model_aliases: None, + }; + + let summary = serde_json::to_value(agent_provider_summary(&provider)).expect("summary"); + + assert_eq!( + summary.get("id").and_then(serde_json::Value::as_str), + Some("custom-text") + ); + assert!(summary.get("baseUrl").is_none()); + assert!(summary.get("apiKeyEnv").is_none()); + assert_eq!( + summary + .get("models") + .and_then(serde_json::Value::as_array) + .map(Vec::len), + Some(1) + ); +} + #[test] fn maps_app_errors_to_http_status_codes() { assert_eq!( diff --git a/src-tauri/src/domain/integration_api/types.rs b/src-tauri/src/domain/integration_api/types.rs index d8a84aa4..08525b5d 100644 --- a/src-tauri/src/domain/integration_api/types.rs +++ b/src-tauri/src/domain/integration_api/types.rs @@ -1,8 +1,12 @@ //! DTOs and internal types for the local launcher HTTP API. +use crate::domain::agent_control::AuthorizedAgent; use crate::domain::ai::types::{ ChatMessage, ChatResponse, ImageGenerationResponse, WebSearchOptions, }; +use crate::domain::engine::types::EngineState; +use crate::infrastructure::logging::LogEntry; +use crate::models::{ModelCapabilities, SelectedModule}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::net::{SocketAddr, TcpStream}; @@ -40,9 +44,28 @@ pub(super) type HttpWorkerReceiver = Arc String { + match self { + Self::Launcher => "launcher-env".to_string(), + Self::Agent(agent) => agent.id.clone(), + Self::Module(module_id) => format!("module:{module_id}"), + } + } + + pub(super) fn actor_name(&self) -> String { + match self { + Self::Launcher => "Launcher token".to_string(), + Self::Agent(agent) => agent.name.clone(), + Self::Module(module_id) => format!("Module {module_id}"), + } + } +} + // ── Integration request DTOs ───────────────────────────────────────────────── #[derive(Debug, Deserialize)] @@ -89,6 +112,38 @@ pub(super) struct IntegrationModuleStageRequest { pub progress: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct IntegrationAgentApprovalRequest { + pub action: String, + pub target: String, + pub diff: String, + pub risk: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct IntegrationOpenPageRequest { + pub page_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct IntegrationSelectModuleRequest { + pub category: String, + pub module_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct IntegrationDraftCreateRequest { + pub id: Option, + pub name: String, + pub runtime_kind: Option, + pub entry: Option, + pub description: Option, +} + // ── Integration response DTOs ──────────────────────────────────────────────── #[derive(Debug, Serialize)] @@ -109,6 +164,16 @@ pub(super) struct ImageApiResponse { pub response: ImageGenerationResponse, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct IntegrationDraftCreateResponse { + pub ok: bool, + pub id: String, + pub draft_dir: String, + pub manifest_path: String, + pub entry_path: String, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(super) struct ModuleContextApiResponse { @@ -122,6 +187,57 @@ pub(super) struct ModuleContextApiResponse { pub http_api_base: String, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct AgentLauncherStateResponse { + pub ok: bool, + pub api_version: &'static str, + pub selected_modules: HashMap, + pub modules: Vec, + pub providers: Vec, + pub engine_state: EngineState, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct AgentLogsResponse { + pub ok: bool, + pub api_version: &'static str, + pub view_id: Option, + pub since: f64, + pub limit: usize, + pub logs: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct AgentModuleSummary { + pub id: String, + pub name: String, + pub category: String, + pub installed: bool, + pub enabled: bool, + pub status: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct AgentProviderSummary { + pub id: String, + pub name: String, + pub provider_type: Option, + pub capabilities: Vec, + pub models: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct AgentModelSummary { + pub id: String, + pub name: String, + pub capabilities: Option, +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(super) struct ModuleStageChangedEvent { @@ -132,3 +248,10 @@ pub(super) struct ModuleStageChangedEvent { pub progress: Option, pub source: &'static str, } + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct AgentOpenPageEvent { + pub page_id: String, + pub source: &'static str, +} diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs index 93b95709..1dadb0ee 100644 --- a/src-tauri/src/domain/mod.rs +++ b/src-tauri/src/domain/mod.rs @@ -1,3 +1,5 @@ +/// Trusted local Agent Control profiles and approval policy. +pub mod agent_control; /// AI domain logic pub mod ai; /// Engine lifecycle and queue management diff --git a/src-tauri/src/domain/modules/controller/lifecycle.rs b/src-tauri/src/domain/modules/controller/lifecycle.rs index cb772e9e..50504966 100644 --- a/src-tauri/src/domain/modules/controller/lifecycle.rs +++ b/src-tauri/src/domain/modules/controller/lifecycle.rs @@ -363,7 +363,9 @@ impl<'a> LifecycleExecutor<'a> { { let pid = child.id().unwrap_or(0); if pid > 0 { + #[allow(unsafe_code)] unsafe { + // SAFETY: SIGTERM is sent to the registered child PID before falling back to tokio kill. libc::kill(pid as libc::pid_t, libc::SIGTERM); } } diff --git a/src-tauri/src/domain/modules/controller/mod.rs b/src-tauri/src/domain/modules/controller/mod.rs index e259b8ca..786f6192 100644 --- a/src-tauri/src/domain/modules/controller/mod.rs +++ b/src-tauri/src/domain/modules/controller/mod.rs @@ -93,6 +93,8 @@ pub enum ModuleAction { Stop, /// Stop and then start the module Restart, + /// Rebuild managed runtime state and start the module + Repair, /// Run installation hooks Install, /// Cleanly remove module files @@ -108,6 +110,7 @@ impl FromStr for ModuleAction { "start" => Ok(Self::Start), "stop" => Ok(Self::Stop), "restart" => Ok(Self::Restart), + "repair" => Ok(Self::Repair), "install" => Ok(Self::Install), "uninstall" => Ok(Self::Uninstall), "update" => Ok(Self::Update), @@ -403,27 +406,23 @@ pub async fn control( tracing::info!("Restarting module: {module_id}"); executor.stop(&manifest).await?; - // Wait for it to actually die (up to 5s) with survival check - let mut terminated = false; - for attempt in 0..20 { - if !controller.is_running(module_id, &module_path).await { - terminated = true; - tracing::info!( - "Module {module_id} terminated after {attempt} attempts during restart" - ); - break; - } - tokio::time::sleep(std::time::Duration::from_millis(250)).await; - } + wait_for_module_stop(&controller, module_id, &module_path, "restart").await?; - if !terminated { - return Err(AppError::Internal { - request_id: None, - message: format!("Module {module_id} failed to terminate during restart"), - }); + executor.start(&manifest).await + } + ModuleAction::Repair => { + tracing::info!("Repairing module: {module_id}"); + executor.stop(&manifest).await?; + wait_for_module_stop(&controller, module_id, &module_path, "repair").await?; + + if script_runtime::supports_manifest(&manifest) { + script_runtime::repair_environment(module_id, &manifest).await?; } - executor.start(&manifest).await + executor.start(&manifest).await.map(|mut response| { + response.message = format!("Module {module_id} repaired. {}", response.message); + response + }) } _ => Ok(ControlResponse { success: false, @@ -433,6 +432,33 @@ pub async fn control( } } +async fn wait_for_module_stop( + controller: &Controller, + module_id: &str, + module_path: &Path, + action: &str, +) -> Result<(), AppError> { + for attempt in 0..20 { + if !controller.is_running(module_id, module_path).await { + tracing::info!( + "Module {module_id} terminated after {attempt} attempts during {action}" + ); + return Ok(()); + } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + + if !controller.is_running(module_id, module_path).await { + tracing::info!("Module {module_id} terminated after final wait during {action}"); + return Ok(()); + } + + Err(AppError::Internal { + request_id: None, + message: format!("Module {module_id} failed to terminate during {action}"), + }) +} + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] @@ -455,6 +481,10 @@ mod tests { ModuleAction::from_str("Restart").unwrap(), ModuleAction::Restart ); + assert_eq!( + ModuleAction::from_str("repair").unwrap(), + ModuleAction::Repair + ); assert_eq!( ModuleAction::from_str("install").unwrap(), ModuleAction::Install diff --git a/src-tauri/src/domain/modules/controller/process.rs b/src-tauri/src/domain/modules/controller/process.rs index 4cdf1ef1..5e3c0239 100644 --- a/src-tauri/src/domain/modules/controller/process.rs +++ b/src-tauri/src/domain/modules/controller/process.rs @@ -39,7 +39,9 @@ pub fn is_running(pid: usize) -> bool { // On Unix, kill(pid, 0) is the standard way to check if a process exists. // If it returns 0, the process exists. // If it returns -1 and errno is EPERM, the process exists but we can't signal it. + #[allow(unsafe_code)] unsafe { + // SAFETY: kill(pid, 0) only checks signal permission/existence and does not send a signal. let res = libc::kill(pid as libc::pid_t, 0); if res == 0 { return true; @@ -123,7 +125,9 @@ pub fn kill_orphan(pid: usize) -> Result { #[cfg(not(target_os = "windows"))] { + #[allow(unsafe_code)] unsafe { + // SAFETY: PID is rechecked above and SIGKILL is the intended fallback for orphan cleanup. if libc::kill(pid as libc::pid_t, libc::SIGKILL) == 0 { Ok(format!("Successfully killed orphan PID {pid}")) } else { diff --git a/src-tauri/src/domain/modules/controller/script_runtime.rs b/src-tauri/src/domain/modules/controller/script_runtime.rs index 6d1ee93a..2ceaef8d 100644 --- a/src-tauri/src/domain/modules/controller/script_runtime.rs +++ b/src-tauri/src/domain/modules/controller/script_runtime.rs @@ -92,6 +92,42 @@ pub async fn spawn_process( } } +/// Removes launcher-managed dependency/runtime environments for one module. +pub async fn repair_environment( + module_id: &str, + manifest: &ModuleManifest, +) -> Result<(), AppError> { + match manifest.runtime.kind { + ModuleRuntimeKind::Python => { + let runtime_root = python_runtime_root(); + let python_version = resolve_python_version(manifest); + remove_managed_env(&venv_dir(&runtime_root, module_id, &python_version)).await + } + ModuleRuntimeKind::Node => { + let runtime_root = node_runtime_root(); + let version = resolve_runtime_version(manifest, "system"); + remove_managed_env(&js_env_dir(&runtime_root, module_id, &version)).await + } + ModuleRuntimeKind::Bun => { + let runtime_root = bun_runtime_root(); + let version = resolve_runtime_version(manifest, "system"); + remove_managed_env(&js_env_dir(&runtime_root, module_id, &version)).await + } + ModuleRuntimeKind::Binary => Ok(()), + } +} + +async fn remove_managed_env(path: &Path) -> Result<(), AppError> { + match tokio::fs::remove_dir_all(path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(AppError::Io(format!( + "Failed to remove managed runtime environment {}: {error}", + path.display() + ))), + } +} + async fn spawn_python_process( module_id: &str, module_path: &Path, @@ -154,10 +190,8 @@ async fn spawn_node_process( .map_err(|e| AppError::Io(format!("Failed to create Node runtime root: {e}")))?; let node_executable = find_node_executable().await?; - let npm_executable = find_program("npm").await?; let env_dir = js_env_dir(&runtime_root, module_id, &version); ensure_js_dependencies_installed(JsDependencyInstall { - package_manager: &npm_executable, runtime_root: &runtime_root, env_dir: &env_dir, module_path, @@ -192,7 +226,6 @@ async fn spawn_bun_process( let bun_executable = find_program("bun").await?; let env_dir = js_env_dir(&runtime_root, module_id, &version); ensure_js_dependencies_installed(JsDependencyInstall { - package_manager: &bun_executable, runtime_root: &runtime_root, env_dir: &env_dir, module_path, @@ -578,7 +611,6 @@ async fn ensure_requirements_installed( } struct JsDependencyInstall<'a> { - package_manager: &'a OsString, runtime_root: &'a Path, env_dir: &'a Path, module_path: &'a Path, @@ -590,7 +622,6 @@ struct JsDependencyInstall<'a> { async fn ensure_js_dependencies_installed(args: JsDependencyInstall<'_>) -> Result<(), AppError> { let JsDependencyInstall { - package_manager, runtime_root, env_dir, module_path, @@ -634,13 +665,8 @@ async fn ensure_js_dependencies_installed(args: JsDependencyInstall<'_>) -> Resu )) })?; - let manager = manifest - .runtime - .package_manager - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(default_package_manager); + let manager = resolve_js_package_manager(manifest, default_package_manager)?; + let package_manager = find_program(manager).await?; let mut command = Command::new(package_manager); match manager { @@ -678,6 +704,26 @@ async fn ensure_js_dependencies_installed(args: JsDependencyInstall<'_>) -> Resu Ok(()) } +fn resolve_js_package_manager<'a>( + manifest: &'a ModuleManifest, + default_package_manager: &'a str, +) -> Result<&'a str, AppError> { + let manager = manifest + .runtime + .package_manager + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(default_package_manager); + + match manager { + "npm" | "bun" => Ok(manager), + other => Err(AppError::Validation(format!( + "Unsupported package manager '{other}'" + ))), + } +} + fn compute_sha256(path: &Path) -> Result { let content = fs::read(path).map_err(|e| { AppError::Io(format!( @@ -865,4 +911,42 @@ mod tests { venv_dir(runtime_root, "sample-integration", "3.12") ); } + + #[test] + fn resolve_js_package_manager_defaults_and_respects_overrides() { + let mut node_manifest = manifest_with_runtime(ModuleRuntimeKind::Node, "src/main.js"); + assert_eq!( + resolve_js_package_manager(&node_manifest, "npm").expect("default npm"), + "npm" + ); + + node_manifest.runtime.package_manager = Some("bun".to_string()); + assert_eq!( + resolve_js_package_manager(&node_manifest, "npm").expect("bun override"), + "bun" + ); + + let mut bun_manifest = manifest_with_runtime(ModuleRuntimeKind::Bun, "src/main.ts"); + assert_eq!( + resolve_js_package_manager(&bun_manifest, "bun").expect("default bun"), + "bun" + ); + + bun_manifest.runtime.package_manager = Some("npm".to_string()); + assert_eq!( + resolve_js_package_manager(&bun_manifest, "bun").expect("npm override"), + "npm" + ); + } + + #[test] + fn resolve_js_package_manager_rejects_unsupported_values() { + let mut manifest = manifest_with_runtime(ModuleRuntimeKind::Node, "src/main.js"); + manifest.runtime.package_manager = Some("pnpm".to_string()); + + assert!(matches!( + resolve_js_package_manager(&manifest, "npm"), + Err(AppError::Validation(_)) + )); + } } diff --git a/src-tauri/src/domain/modules/github_release_targets.rs b/src-tauri/src/domain/modules/github_release_targets.rs new file mode 100644 index 00000000..28e85b69 --- /dev/null +++ b/src-tauri/src/domain/modules/github_release_targets.rs @@ -0,0 +1,171 @@ +use crate::domain::system::hardware_probe::AcceleratorClass; + +use super::github_releases::{HardwareProfile, ReleaseAsset, ReleaseComputeTarget}; + +pub(super) const fn recommended_release_target( + cpu: Option<&super::github_releases::ReleaseDownloadVariant>, + gpu: Option<&super::github_releases::ReleaseDownloadVariant>, + hardware: HardwareProfile, +) -> ReleaseComputeTarget { + if gpu.is_some() && has_real_gpu_accelerator(hardware) { + return ReleaseComputeTarget::Gpu; + } + if cpu.is_some() { + return ReleaseComputeTarget::Cpu; + } + if gpu.is_some() { + return ReleaseComputeTarget::Gpu; + } + ReleaseComputeTarget::Cpu +} + +pub(super) fn release_assets_match_target( + assets: &[ReleaseAsset], + target: ReleaseComputeTarget, +) -> bool { + match target { + ReleaseComputeTarget::Auto => true, + ReleaseComputeTarget::Both => { + release_assets_match_target(assets, ReleaseComputeTarget::Gpu) + && release_assets_match_target(assets, ReleaseComputeTarget::Cpu) + } + ReleaseComputeTarget::Gpu => assets + .iter() + .filter(|asset| !is_runtime_asset_name(&asset.name)) + .any(|asset| is_gpu_asset_name(&asset.name)), + ReleaseComputeTarget::Cpu => assets + .iter() + .filter(|asset| !is_runtime_asset_name(&asset.name)) + .any(|asset| is_cpu_asset_name(&asset.name)), + } +} + +pub(super) const fn hardware_for_target( + hardware: HardwareProfile, + target: ReleaseComputeTarget, +) -> HardwareProfile { + match target { + ReleaseComputeTarget::Auto | ReleaseComputeTarget::Both => hardware, + ReleaseComputeTarget::Gpu => { + if matches!( + hardware.accelerator, + AcceleratorClass::CpuOnly | AcceleratorClass::Unknown + ) { + HardwareProfile { + accelerator: AcceleratorClass::GenericGpu, + cpu_tier: hardware.cpu_tier, + cuda_driver_major: None, + cuda_driver_minor: None, + } + } else { + hardware + } + } + ReleaseComputeTarget::Cpu => HardwareProfile { + accelerator: AcceleratorClass::CpuOnly, + cpu_tier: hardware.cpu_tier, + cuda_driver_major: None, + cuda_driver_minor: None, + }, + } +} + +fn is_runtime_asset_name(name: &str) -> bool { + name.to_ascii_lowercase().starts_with("cudart-") +} + +pub(super) fn is_gpu_asset_name(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + is_gpu_asset_name_lower(&lower) +} + +pub(super) fn is_gpu_asset_name_lower(lower: &str) -> bool { + release_asset_tokens(lower).any(|token| { + token == "cuda" + || token.starts_with("cuda12") + || token.starts_with("cuda13") + || token.starts_with("cu12") + || token.starts_with("cu13") + || token == "metal" + || token == "vulkan" + || token == "hip" + || token == "rocm" + || token == "sycl" + || token == "openvino" + || token == "nvidia" + || token == "amd" + || token == "radeon" + }) +} + +pub(super) fn is_cpu_asset_name(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + let mut has_cpu_token = false; + let mut has_os_or_arch_token = false; + let mut has_unknown_accelerator_token = false; + for token in release_asset_tokens(&lower) { + if token == "cpu" + || token == "avx" + || token == "avx2" + || token == "avx512" + || token == "noavx" + { + has_cpu_token = true; + } + if matches!( + token, + "linux" + | "windows" + | "win" + | "darwin" + | "macos" + | "osx" + | "x86" + | "x86_64" + | "x64" + | "amd64" + | "arm64" + | "aarch64" + ) { + has_os_or_arch_token = true; + } + if token == "metal" || token == "npu" || token == "xpu" || token.starts_with("rtx") { + has_unknown_accelerator_token = true; + } + } + + (has_cpu_token || (has_os_or_arch_token && is_packaged_binary_name(&lower))) + && !has_unknown_accelerator_token + && !is_gpu_asset_name_lower(&lower) +} + +fn is_packaged_binary_name(lower: &str) -> bool { + let path = std::path::Path::new(lower); + let extension = path.extension().and_then(std::ffi::OsStr::to_str); + if matches!( + extension, + Some("zip" | "tgz" | "7z" | "exe" | "msi" | "dmg" | "appimage") + ) { + return true; + } + + extension == Some("gz") + && path + .file_stem() + .and_then(std::ffi::OsStr::to_str) + .and_then(|stem| std::path::Path::new(stem).extension()) + .and_then(std::ffi::OsStr::to_str) + == Some("tar") +} + +fn release_asset_tokens(name: &str) -> impl Iterator { + name.split(|character: char| !character.is_ascii_alphanumeric()) + .filter(|token| !token.is_empty()) +} + +const fn has_real_gpu_accelerator(hardware: HardwareProfile) -> bool { + !matches!( + hardware.accelerator, + AcceleratorClass::CpuOnly | AcceleratorClass::Unknown + ) +} diff --git a/src-tauri/src/domain/modules/github_releases.rs b/src-tauri/src/domain/modules/github_releases.rs index 3ca7616b..34aec966 100644 --- a/src-tauri/src/domain/modules/github_releases.rs +++ b/src-tauri/src/domain/modules/github_releases.rs @@ -8,6 +8,9 @@ use specta::Type; use super::github_release_selection::{ current_platform, detect_hardware_profile, select_release_assets, }; +use super::github_release_targets::{ + hardware_for_target, recommended_release_target, release_assets_match_target, +}; /// A single downloadable asset selected from a GitHub release. #[derive(Clone, Debug)] @@ -440,155 +443,6 @@ fn release_download_variant( }) } -const fn recommended_release_target( - cpu: Option<&ReleaseDownloadVariant>, - gpu: Option<&ReleaseDownloadVariant>, - hardware: HardwareProfile, -) -> ReleaseComputeTarget { - if gpu.is_some() && has_real_gpu_accelerator(hardware) { - return ReleaseComputeTarget::Gpu; - } - if cpu.is_some() { - return ReleaseComputeTarget::Cpu; - } - if gpu.is_some() { - return ReleaseComputeTarget::Gpu; - } - ReleaseComputeTarget::Cpu -} - -fn release_assets_match_target(assets: &[ReleaseAsset], target: ReleaseComputeTarget) -> bool { - match target { - ReleaseComputeTarget::Auto => true, - ReleaseComputeTarget::Both => { - release_assets_match_target(assets, ReleaseComputeTarget::Gpu) - && release_assets_match_target(assets, ReleaseComputeTarget::Cpu) - } - ReleaseComputeTarget::Gpu => assets - .iter() - .filter(|asset| !is_runtime_asset_name(&asset.name)) - .any(|asset| is_gpu_asset_name(&asset.name)), - ReleaseComputeTarget::Cpu => assets - .iter() - .filter(|asset| !is_runtime_asset_name(&asset.name)) - .any(|asset| is_cpu_asset_name(&asset.name)), - } -} - -fn is_runtime_asset_name(name: &str) -> bool { - name.to_ascii_lowercase().starts_with("cudart-") -} - -fn is_gpu_asset_name(name: &str) -> bool { - let lower = name.to_ascii_lowercase(); - is_gpu_asset_name_lower(&lower) -} - -fn is_gpu_asset_name_lower(lower: &str) -> bool { - release_asset_tokens(lower).any(|token| { - token == "cuda" - || token.starts_with("cuda12") - || token.starts_with("cuda13") - || token.starts_with("cu12") - || token.starts_with("cu13") - || token == "metal" - || token == "vulkan" - || token == "hip" - || token == "rocm" - || token == "sycl" - || token == "openvino" - || token == "nvidia" - || token == "amd" - || token == "radeon" - }) -} - -fn is_cpu_asset_name(name: &str) -> bool { - let lower = name.to_ascii_lowercase(); - let mut has_cpu_token = false; - let mut has_os_or_arch_token = false; - let mut has_unknown_accelerator_token = false; - for token in release_asset_tokens(&lower) { - if token == "cpu" - || token == "avx" - || token == "avx2" - || token == "avx512" - || token == "noavx" - { - has_cpu_token = true; - } - if matches!( - token, - "linux" - | "windows" - | "win" - | "darwin" - | "macos" - | "osx" - | "x86" - | "x86_64" - | "x64" - | "amd64" - | "arm64" - | "aarch64" - ) { - has_os_or_arch_token = true; - } - if token == "metal" || token == "npu" || token == "xpu" || token.starts_with("rtx") { - has_unknown_accelerator_token = true; - } - } - - (has_cpu_token || has_os_or_arch_token) - && !has_unknown_accelerator_token - && !is_gpu_asset_name_lower(&lower) -} - -fn release_asset_tokens(name: &str) -> impl Iterator { - name.split(|character: char| !character.is_ascii_alphanumeric()) - .filter(|token| !token.is_empty()) -} - -const fn has_real_gpu_accelerator(hardware: HardwareProfile) -> bool { - !matches!( - hardware.accelerator, - crate::domain::system::hardware_probe::AcceleratorClass::CpuOnly - | crate::domain::system::hardware_probe::AcceleratorClass::Unknown - ) -} - -const fn hardware_for_target( - hardware: HardwareProfile, - target: ReleaseComputeTarget, -) -> HardwareProfile { - match target { - ReleaseComputeTarget::Auto | ReleaseComputeTarget::Both => hardware, - ReleaseComputeTarget::Gpu => { - if matches!( - hardware.accelerator, - crate::domain::system::hardware_probe::AcceleratorClass::CpuOnly - | crate::domain::system::hardware_probe::AcceleratorClass::Unknown - ) { - HardwareProfile { - accelerator: - crate::domain::system::hardware_probe::AcceleratorClass::GenericGpu, - cpu_tier: hardware.cpu_tier, - cuda_driver_major: None, - cuda_driver_minor: None, - } - } else { - hardware - } - } - ReleaseComputeTarget::Cpu => HardwareProfile { - accelerator: crate::domain::system::hardware_probe::AcceleratorClass::CpuOnly, - cpu_tier: hardware.cpu_tier, - cuda_driver_major: None, - cuda_driver_minor: None, - }, - } -} - fn select_assets_for_target( module_id: &str, platform: Platform, @@ -697,6 +551,9 @@ fn invalid_repo_url(repo_url: &str) -> AppError { mod tests { use super::*; use crate::domain::modules::github_release_selection::{base_main_score, cpu_feature_score}; + use crate::domain::modules::github_release_targets::{ + is_cpu_asset_name, is_gpu_asset_name, is_gpu_asset_name_lower, + }; use crate::domain::system::hardware_probe::{AcceleratorClass, CpuInstructionTier}; #[test] @@ -746,6 +603,13 @@ mod tests { assert!(is_gpu_asset_name("llama-b8981-bin-win-hip-radeon-x64.zip")); } + #[test] + fn os_arch_tokens_without_binary_extension_are_not_cpu_assets() { + assert!(!is_cpu_asset_name("llama-windows-x64.sha256")); + assert!(!is_cpu_asset_name("llama-linux-amd64.txt")); + assert!(is_cpu_asset_name("llama-linux-amd64.tar.gz")); + } + #[test] fn unknown_accelerator_tokens_are_not_classified_as_cpu() { assert!(!is_cpu_asset_name("llama-b8981-bin-win-metal-x64.zip")); diff --git a/src-tauri/src/domain/modules/mod.rs b/src-tauri/src/domain/modules/mod.rs index 387575d5..2cba9d24 100644 --- a/src-tauri/src/domain/modules/mod.rs +++ b/src-tauri/src/domain/modules/mod.rs @@ -9,6 +9,7 @@ mod downloader_service; mod downloader_support; mod downloader_transfer; mod github_release_selection; +mod github_release_targets; /// Open-Source engine GitHub releases parsing pub mod github_releases; /// Filesystem watcher for externally changed integrations. diff --git a/src-tauri/src/domain/system/hardware_probe.rs b/src-tauri/src/domain/system/hardware_probe.rs index 7a340c41..26f177ab 100644 --- a/src-tauri/src/domain/system/hardware_probe.rs +++ b/src-tauri/src/domain/system/hardware_probe.rs @@ -187,18 +187,11 @@ fn default_probe() -> GpuInfo { } } +#[cfg(target_os = "windows")] async fn probe_windows_gpu_names() -> Option> { - #[cfg(target_os = "windows")] - { - tokio::task::spawn_blocking(query_windows_gpu_names_wmi) - .await - .ok()? - } - - #[cfg(not(target_os = "windows"))] - { - None - } + tokio::task::spawn_blocking(query_windows_gpu_names_wmi) + .await + .ok()? } #[cfg(target_os = "windows")] @@ -257,7 +250,7 @@ async fn probe_linux_lspci_names() -> Option> { #[cfg(target_os = "linux")] async fn probe_linux_drm_names() -> Option> { let mut entries = tokio::fs::read_dir("/sys/class/drm").await.ok()?; - let mut names = Vec::new(); + let mut vendor_ids = Vec::new(); while let Ok(Some(entry)) = entries.next_entry().await { let filename = entry.file_name().to_string_lossy().to_string(); @@ -267,13 +260,11 @@ async fn probe_linux_drm_names() -> Option> { let vendor_path = entry.path().join("device").join("vendor"); if let Ok(vendor_id) = tokio::fs::read_to_string(vendor_path).await { - if let Some(name) = linux_vendor_name(vendor_id.trim()) { - names.push(name.to_string()); - } + vendor_ids.push(vendor_id); } } - normalize_names(names) + parse_linux_vendor_ids(&vendor_ids.join("\n")) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/src-tauri/src/infrastructure/logging/logger.rs b/src-tauri/src/infrastructure/logging/logger.rs index aa83989f..95a85e4b 100644 --- a/src-tauri/src/infrastructure/logging/logger.rs +++ b/src-tauri/src/infrastructure/logging/logger.rs @@ -476,9 +476,7 @@ fn resolve_module_id(source: &str, message: &str) -> Option { } fn extract_module_id_from_source(source: &str) -> Option { - source - .strip_prefix("module:") - .map(std::string::ToString::to_string) + source.strip_prefix("module:").and_then(sanitize_module_id) } fn resolve_module_id_from_text(message: &str) -> Option { @@ -540,32 +538,28 @@ fn sanitize_module_id(raw: &str) -> Option { return None; } - Some(module_id.to_string()) + Some(module_id.to_ascii_lowercase()) } fn infer_runtime_log_source(namespace: RuntimeLogNamespace, runtime_id: &str) -> String { match namespace { RuntimeLogNamespace::Engine => normalize_engine_id(runtime_id), - RuntimeLogNamespace::Module => format!("module:{runtime_id}"), + RuntimeLogNamespace::Module => sanitize_module_id(runtime_id).map_or_else( + || format!("module:{}", runtime_id.to_ascii_lowercase()), + |module_id| format!("module:{module_id}"), + ), } } +#[allow(clippy::cast_precision_loss)] fn parse_log_timestamp(line: &str) -> Option { let timestamp_text = line.get(..19)?; chrono::NaiveDateTime::parse_from_str(timestamp_text, "%Y-%m-%d %H:%M:%S") .ok() - .and_then(|timestamp| { - let timestamp = chrono::Local - .from_local_datetime(×tamp) - .single() - .or_else(|| chrono::Local.from_local_datetime(×tamp).earliest())?; - let seconds = timestamp.timestamp().to_string().parse::().ok()?; - let milliseconds = timestamp - .timestamp_subsec_millis() - .to_string() - .parse::() - .ok()?; - Some(seconds + milliseconds / 1000.0) + .map(|timestamp| { + let timestamp = chrono::Utc.from_utc_datetime(×tamp); + let seconds = timestamp.timestamp(); + seconds as f64 + f64::from(timestamp.timestamp_subsec_millis()) / 1000.0 }) } @@ -607,7 +601,10 @@ fn is_entry_in_console_view(entry: &LogEntry, view_id: &str) -> bool { } if let Some(module_id) = view_id.strip_prefix("module:") { - return entry.module_id.as_deref() == Some(module_id) + let Some(module_id) = sanitize_module_id(module_id) else { + return false; + }; + return entry.module_id.as_deref() == Some(module_id.as_str()) || entry.source == format!("module:{module_id}"); } @@ -851,13 +848,13 @@ impl ConsoleLogParser { #[cfg(test)] mod tests { - use super::{RuntimeLogNamespace, parse_runtime_log_line}; + use super::{RuntimeLogNamespace, parse_log_timestamp, parse_runtime_log_line}; #[test] fn module_runtime_log_line_uses_module_source_namespace() -> Result<(), String> { let entry = parse_runtime_log_line( RuntimeLogNamespace::Module, - "sample-integration", + "Sample-Integration", "2026-04-24 07:00:00 [INFO] Integration started", 0, 0.0, @@ -901,4 +898,13 @@ mod tests { assert_eq!(entry.source, "llama-cpp"); Ok(()) } + + #[test] + fn runtime_log_timestamp_is_interpreted_as_utc() -> Result<(), String> { + let timestamp = parse_log_timestamp("2026-04-24 07:00:00 [INFO] model loaded") + .ok_or_else(|| "runtime timestamp".to_string())?; + + assert!((timestamp - 1_777_014_000.0).abs() < f64::EPSILON); + Ok(()) + } } diff --git a/src-tauri/src/infrastructure/system/startup.rs b/src-tauri/src/infrastructure/system/startup.rs index 59418d15..598d1290 100644 --- a/src-tauri/src/infrastructure/system/startup.rs +++ b/src-tauri/src/infrastructure/system/startup.rs @@ -49,16 +49,6 @@ fn detect_blocking_requirement() -> Option { } } - #[cfg(target_os = "macos")] - { - return None; - } - - #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] - { - return None; - } - None } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f496dcfa..29380009 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -45,7 +45,7 @@ mod tests; // Re-export API modules to match the flat structure expected by collect_commands! use api::{ - ai, engine, + agent_control, ai, engine, modules::{self, downloader}, secure, settings::{self, theme, translations, ui_state, window_settings}, @@ -98,7 +98,15 @@ pub fn create_specta_builder() -> Builder { .semantic_types(semantic_types) .commands(collect_commands![ health::get_health, + agent_control::get_agent_control_state, + agent_control::set_agent_control_enabled, + agent_control::create_agent_profile, + agent_control::rotate_agent_profile, + agent_control::copy_agent_profile_token, + agent_control::delete_agent_profile, + agent_control::decide_agent_approval, config::get_config, + config::get_catalog_snapshot, settings::get_settings, settings::save_settings, settings::save_setting, @@ -249,8 +257,11 @@ fn setup_dependencies(app: &tauri::App) -> Result<(), Box let settings_service = SettingsService::new(json_store.clone()); let ui_state_service = UiStateService::new(json_store.clone()); let window_settings_service = WindowSettingsService::new(json_store.clone()); + let agent_control_service = + crate::domain::agent_control::AgentControlService::new(json_store.clone()); let settings_service_for_api = settings_service.clone(); let ui_state_service_for_api = ui_state_service.clone(); + let agent_control_service_for_api = agent_control_service.clone(); let config_repo = crate::infrastructure::config::config_repository::FileConfigRepository::new( app.handle().clone(), @@ -264,6 +275,7 @@ fn setup_dependencies(app: &tauri::App) -> Result<(), Box app.manage(settings_service); app.manage(ui_state_service); app.manage(window_settings_service); + app.manage(agent_control_service); app.manage(std::sync::Arc::clone(&config_service)); app.manage(crate::domain::modules::downloader::DownloaderService::new()); app.manage(crate::domain::modules::settings_ui_protocol::ModuleSettingsSessionStore::default()); @@ -311,6 +323,7 @@ fn setup_dependencies(app: &tauri::App) -> Result<(), Box std::sync::Arc::clone(&image_generation_state), settings_service_for_api, ui_state_service_for_api, + agent_control_service_for_api, ), )?; tracing::debug!( diff --git a/src-tauri/src/models/config.rs b/src-tauri/src/models/config.rs index d642ab4d..61c2eb01 100644 --- a/src-tauri/src/models/config.rs +++ b/src-tauri/src/models/config.rs @@ -217,6 +217,110 @@ pub struct ModuleItem { pub config_schema: Option>, } +/// Frontend-ready catalog application item. +#[derive(Debug, Serialize, Deserialize, Clone, Type)] +#[serde(rename_all = "camelCase")] +pub struct CatalogAppItem { + /// Unique item identifier. + pub id: String, + /// Localization key for name. + pub name_key: Option, + /// Localization key for description. + pub desc_key: Option, + /// Display name. + pub name: Option, + /// Description text. + pub desc: Option, + /// Icon/emoji. + pub icon: Option, + /// Optional module-owned card preview metadata. + #[serde(default)] + pub preview: Option, + /// Catalog category. + pub category: String, + /// Runtime type used by the launcher UI. + #[serde(rename = "type")] + pub type_name: String, + /// Primary AI output capability. + pub capability: Option, + /// Whether item files/runtime are currently present. + pub installed: bool, + /// Installed compute modes for local engines. + #[serde(default)] + pub installed_compute_modes: Vec, + /// Download repository URL. + pub repo_url: Option, + /// Expected integrity hash. + pub expected_hash: Option, + /// Download strategy. + pub dl_type: Option, + /// Placeholder marker. + pub coming_soon: bool, + /// Whether runtime is managed outside Axelate. + pub managed_externally: bool, + /// Semantic version. + pub version: String, + /// Configuration schema. + #[serde(default)] + pub config_schema: Option>, + /// Optional module-owned settings UI entry. + #[serde(default)] + pub settings_ui: Option, + /// API provider metadata for provider cards. + #[serde(default)] + pub api_provider_data: Option, + /// Backend-owned UI/runtime policy for this catalog item. + #[serde(default)] + pub provider_policy: Option, + /// Current runtime status for integrations. + #[serde(default)] + pub status: Option, +} + +/// Frontend rendering/runtime policy derived from backend catalog/provider metadata. +#[derive(Debug, Serialize, Deserialize, Clone, Type, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CatalogProviderPolicy { + /// Whether the card is a cloud/API provider. + pub is_cloud_provider: bool, + /// Whether the card is a user-defined OpenAI-compatible provider slot. + pub is_custom_provider: bool, + /// Whether the card should render as a no-settings module. + pub is_clean_app: bool, + /// Secure-storage service name used for this provider key. + pub secret_service: Option, + /// Logical key provider used by the settings UI. + pub key_provider_id: Option, + /// URL opened when the user clicks the API key label. + pub key_provider_url: Option, + /// Whether the key field uses a custom-provider label and storage slot. + pub uses_custom_provider_key: bool, + /// Whether the API endpoint selector should be visible. + pub show_api_endpoint_selector: bool, + /// Whether custom manual model IDs can be managed in the UI. + pub show_custom_model_composer: bool, + /// Whether model comparison stats should be shown. + pub show_model_stats: bool, + /// Whether the internet access toggle should be shown. + pub supports_internet_access: bool, + /// Whether reasoning controls should be shown for built-in models. + pub supports_thinking: bool, + /// Whether this provider/card is image-only. + pub image_only: bool, +} + +/// Frontend-ready catalog snapshot assembled by the backend. +#[derive(Debug, Serialize, Deserialize, Clone, Type)] +#[serde(rename_all = "camelCase")] +pub struct CatalogSnapshot { + /// AI provider and engine cards. + pub ai: Vec, + /// Service/integration cards. + pub services: Vec, + /// Starred/favorite item ids. + pub stars: Vec, +} + /// AI model configurations grouped by provider pub type ConfigModels = HashMap>; diff --git a/src-tauri/src/models/modules.rs b/src-tauri/src/models/modules.rs index f16ad5b8..258f930a 100644 --- a/src-tauri/src/models/modules.rs +++ b/src-tauri/src/models/modules.rs @@ -27,7 +27,7 @@ pub struct ModulePreview { pub struct ControlRequest { /// Module identifier (optional for global actions) pub module_id: Option, - /// Control action ("start", "stop", "restart") + /// Control action ("start", "stop", "restart", "repair") pub action: String, } diff --git a/src-tauri/src/utils/memory.rs b/src-tauri/src/utils/memory.rs index 4d7391b7..423c5ef4 100644 --- a/src-tauri/src/utils/memory.rs +++ b/src-tauri/src/utils/memory.rs @@ -4,6 +4,7 @@ * @description Utilities for managing process memory footprint */ #[cfg(target_os = "windows")] +/// Requests the operating system to trim the launcher process working set. pub fn trim_memory() { use windows_sys::Win32::System::Threading::{GetCurrentProcess, SetProcessWorkingSetSize}; @@ -17,6 +18,7 @@ pub fn trim_memory() { } #[cfg(not(target_os = "windows"))] +/// Requests a memory trim when the current platform supports it. pub fn trim_memory() { // No-op for other OSs } diff --git a/src-tauri/src/utils/paths.rs b/src-tauri/src/utils/paths.rs index e3cdf401..2eadf167 100644 --- a/src-tauri/src/utils/paths.rs +++ b/src-tauri/src/utils/paths.rs @@ -1,7 +1,9 @@ use crate::errors::AppError; +use std::cmp::Ordering; use std::fs; use std::path::{Path, PathBuf}; use std::sync::LazyLock; +use std::time::SystemTime; #[cfg(not(test))] const APPDATA_DIR_NAME: &str = "AxelateData"; @@ -182,6 +184,10 @@ pub static FILE_ENGINE_CONFIG: LazyLock = /// Path to UI state file (`AxelateData/User/UI/ui_state.json`) pub static FILE_UI_STATE: LazyLock = LazyLock::new(|| UI_DIR.join("ui_state.json")); +/// Path to Agent Control profiles and audit state (`AxelateData/User/Configs/agent_control.json`) +pub static FILE_AGENT_CONTROL: LazyLock = + LazyLock::new(|| CONFIG_DIR.join("agent_control.json")); + /// Directory for Chat history (`AxelateData/User/Chat`) pub static CHAT_DIR: LazyLock = LazyLock::new(|| USER_ROOT.join("Chat")); @@ -245,11 +251,12 @@ fn cleanup_old_logs() -> Result<(), AppError> { return Ok(()); } - // Sort by modification time (oldest first) + // Sort by modification time (oldest first). Files with unreadable metadata stay last so + // cleanup does not delete them ahead of logs whose age is known. log_files.sort_by(|a, b| { let time_a = a.metadata().and_then(|m| m.modified()).ok(); let time_b = b.metadata().and_then(|m| m.modified()).ok(); - time_a.cmp(&time_b) + compare_log_modified_times(time_a, time_b) }); // Remove oldest files @@ -266,11 +273,25 @@ fn cleanup_old_logs() -> Result<(), AppError> { Ok(()) } +fn compare_log_modified_times(left: Option, right: Option) -> Ordering { + match (left, right) { + (Some(left), Some(right)) => left.cmp(&right), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + } +} + #[cfg(test)] mod tests { #![allow(clippy::expect_used)] - use super::{RESOURCES_DIR_NAME, development_resource_dir_candidates, manifest_dir}; + use super::{ + RESOURCES_DIR_NAME, compare_log_modified_times, development_resource_dir_candidates, + manifest_dir, + }; + use std::cmp::Ordering; + use std::time::{Duration, SystemTime}; #[test] fn development_resource_dir_candidates_are_manifest_relative() { @@ -295,4 +316,25 @@ mod tests { .join(RESOURCES_DIR_NAME) ); } + + #[test] + fn log_cleanup_orders_unreadable_metadata_after_known_times() { + let old = SystemTime::UNIX_EPOCH + Duration::from_secs(1); + let new = SystemTime::UNIX_EPOCH + Duration::from_secs(2); + + assert_eq!( + compare_log_modified_times(Some(old), Some(new)), + Ordering::Less + ); + assert_eq!( + compare_log_modified_times(Some(new), Some(old)), + Ordering::Greater + ); + assert_eq!(compare_log_modified_times(Some(old), None), Ordering::Less); + assert_eq!( + compare_log_modified_times(None, Some(old)), + Ordering::Greater + ); + assert_eq!(compare_log_modified_times(None, None), Ordering::Equal); + } } diff --git a/src/app/CoreLifecycleController.test.ts b/src/app/CoreLifecycleController.test.ts index 5b0cd611..34316a88 100644 --- a/src/app/CoreLifecycleController.test.ts +++ b/src/app/CoreLifecycleController.test.ts @@ -40,7 +40,7 @@ function createDeps(isDestroyed: () => boolean): CoreLifecycleDeps { i18nUI: {}, catalog: {}, navigation: {}, - navigationUI: {}, + navigationUI: { showPage: vi.fn().mockResolvedValue(undefined) }, chatController: { init: vi.fn(), destroy: vi.fn() }, bridge: {}, eventHandler: {}, @@ -133,4 +133,63 @@ describe('CoreLifecycleController', () => { expect(unlisten).toHaveBeenCalledTimes(1); expect(deps.bootstrap.tracer.info).not.toHaveBeenCalledWith('[Core] Ready.'); }); + + it('applies backend selected module events to state and dashboard card', async () => { + type SelectedModulePayload = { category: string; module: { id: string; name: string } }; + const selectedModuleHandlers: Array<(payload: SelectedModulePayload) => void> = []; + const deps = createDeps(() => false); + vi.mocked(deps.backendSelection.tauriProvider.listen).mockImplementationOnce( + (_event, callback) => { + selectedModuleHandlers.push(callback as (payload: SelectedModulePayload) => void); + return Promise.resolve(vi.fn()); + }, + ); + const controller = new CoreLifecycleController(deps); + + await controller.runInit(); + const selectedModuleHandler = selectedModuleHandlers.at(0); + expect(selectedModuleHandler).toBeDefined(); + selectedModuleHandler?.({ + category: 'services', + module: { id: 'telegram-parser', name: 'Telegram Parser' }, + }); + + expect(deps.backendSelection.stateStore.updateNestedState).toHaveBeenCalledWith( + 'selected_modules', + 'services', + { id: 'telegram-parser', name: 'Telegram Parser' }, + false, + ); + expect(deps.backendSelection.appUI.updateModuleCard).toHaveBeenCalledWith('services', { + id: 'telegram-parser', + name: 'Telegram Parser', + }); + }); + + it('ignores malformed agent open-page events', async () => { + const agentOpenPageHandlers: Array<(payload: unknown) => void> = []; + const deps = createDeps(() => false); + vi.mocked(deps.backendSelection.tauriProvider.listen).mockImplementation( + (event, callback) => { + if (event === 'agent-control:open-page') { + agentOpenPageHandlers.push(callback as (payload: unknown) => void); + } + return Promise.resolve(vi.fn()); + }, + ); + const controller = new CoreLifecycleController(deps); + + await controller.runInit(); + const handler = agentOpenPageHandlers.at(0); + expect(handler).toBeDefined(); + expect(() => handler?.({ pageId: null })).not.toThrow(); + handler?.({ pageId: ' console ' }); + + expect(deps.bootstrap.navigationUI.showPage).toHaveBeenCalledWith( + 'console', + null, + false, + false, + ); + }); }); diff --git a/src/app/CoreLifecycleController.ts b/src/app/CoreLifecycleController.ts index f997aaf0..992a74b7 100644 --- a/src/app/CoreLifecycleController.ts +++ b/src/app/CoreLifecycleController.ts @@ -45,6 +45,11 @@ type SelectedModuleChangedPayload = { source?: string; }; +type AgentOpenPagePayload = { + pageId: string; + source?: string; +}; + export type CoreBootstrapDeps = { aiBridge: AIBridge; tauriProvider: TauriProvider; @@ -130,6 +135,7 @@ export type CoreLifecycleDeps = { export class CoreLifecycleController { private _deferredChatInitTimer: ReturnType | null = null; private _selectedModuleChangedUnlisten: (() => void) | null = null; + private _agentOpenPageUnlisten: (() => void) | null = null; private _activeGlobalShortcutKeydown: ((e: KeyboardEvent) => void) | null = null; constructor(private readonly _deps: CoreLifecycleDeps) {} @@ -178,6 +184,10 @@ export class CoreLifecycleController { if (this._deps.state.isDestroyed()) { return; } + await this._listenForAgentOpenPageRequests(); + if (this._deps.state.isDestroyed()) { + return; + } this._deps.bootstrap.tracer.info('[Core] Ready.'); } @@ -196,6 +206,15 @@ export class CoreLifecycleController { ); } this._selectedModuleChangedUnlisten = null; + try { + this._agentOpenPageUnlisten?.(); + } catch (error) { + this._deps.bootstrap.tracer.warn( + '[Core] Failed to remove Agent Control open-page listener during destroy:', + error, + ); + } + this._agentOpenPageUnlisten = null; try { await destroyCoreResources({ deferredChatInitTimer: this._deferredChatInitTimer, @@ -268,4 +287,40 @@ export class CoreLifecycleController { ); this._deps.backendSelection.appUI.updateModuleCard(payload.category, payload.module); } + + private async _listenForAgentOpenPageRequests(): Promise { + const tauriProvider = this._deps.bootstrap.tauriProvider; + if (!tauriProvider.isTauri() || this._agentOpenPageUnlisten !== null) { + return; + } + + const unlisten = await tauriProvider.listen( + 'agent-control:open-page', + (payload) => { + void this._applyAgentOpenPageRequest(payload).catch((error: unknown) => { + this._deps.bootstrap.tracer.warn( + '[Core] Failed to apply Agent Control open-page request:', + error, + ); + }); + }, + ); + if (this._deps.state.isDestroyed()) { + unlisten(); + return; + } + this._agentOpenPageUnlisten = unlisten; + } + + private async _applyAgentOpenPageRequest(payload: AgentOpenPagePayload): Promise { + const rawPageId = (payload as { pageId?: unknown }).pageId; + if (typeof rawPageId !== 'string') { + return; + } + const pageId = rawPageId.trim(); + if (pageId === '') { + return; + } + await this._deps.bootstrap.navigationUI.showPage(pageId, null, false, false); + } } diff --git a/src/app/CoreServiceFactory.ts b/src/app/CoreServiceFactory.ts index 8ba15659..bae80c27 100644 --- a/src/app/CoreServiceFactory.ts +++ b/src/app/CoreServiceFactory.ts @@ -60,7 +60,9 @@ export function createCoreServiceBundle(tracer: LoggerService): CoreServiceBundl const navigation = new NavigationService(tracer); const soundService = new SoundService(tracer); const monitoringService = new MonitoringService(tauriProvider, tracer); - const consoleLogService = new ConsoleLogService(tauriProvider, tracer); + const consoleLogService = new ConsoleLogService(tauriProvider, tracer, (key, fallback) => + i18n.t(key, fallback), + ); const settingsService = new SettingsService(tauriProvider, tracer); return { diff --git a/src/app/CoreStateRestore.test.ts b/src/app/CoreStateRestore.test.ts index 48f289bc..5a421f5a 100644 --- a/src/app/CoreStateRestore.test.ts +++ b/src/app/CoreStateRestore.test.ts @@ -45,8 +45,14 @@ describe('CoreStateRestore', () => { expect(updateModuleCard).toHaveBeenNthCalledWith(4, 'ai_text', textApp); }); - it('should restore custom AI providers that only exist in the frontend catalog augmentation', () => { + it('should restore custom AI providers from the backend catalog snapshot', () => { const updateModuleCard = vi.fn(); + const customTextApp = { + id: CUSTOM_TEXT_PROVIDER_ID, + name: 'Custom', + type: 'api', + capability: 'text', + }; restoreSelectedModules({ tracer: { @@ -58,20 +64,43 @@ describe('CoreStateRestore', () => { }), } as never, catalog: { - getAppById: () => undefined, - getCatalog: () => ({ - ai: [{ id: 'gpt', name: 'GPT', type: 'api', capability: 'text' }], - services: [], + getAppById: (appId: string) => + appId === CUSTOM_TEXT_PROVIDER_ID ? customTextApp : undefined, + } as never, + appUI: { + updateModuleCard, + } as never, + }); + + expect(updateModuleCard).toHaveBeenCalledWith('ai_text', customTextApp); + }); + + it('should preserve persisted AI selections that are not in the catalog snapshot', () => { + const updateModuleCard = vi.fn(); + const selectedTextApp = { + id: 'external-ai-provider', + name: 'External Provider', + type: 'api', + capability: 'text', + }; + + restoreSelectedModules({ + tracer: { + warn: vi.fn(), + }, + moduleSettings: { + getSelectedModules: () => ({ + ai_text: selectedTextApp, }), } as never, + catalog: { + getAppById: () => undefined, + } as never, appUI: { updateModuleCard, } as never, }); - expect(updateModuleCard).toHaveBeenCalledWith( - 'ai_text', - expect.objectContaining({ id: CUSTOM_TEXT_PROVIDER_ID }), - ); + expect(updateModuleCard).toHaveBeenCalledWith('ai_text', selectedTextApp); }); }); diff --git a/src/app/CoreStateRestore.ts b/src/app/CoreStateRestore.ts index 407803e6..e6ef86e5 100644 --- a/src/app/CoreStateRestore.ts +++ b/src/app/CoreStateRestore.ts @@ -3,7 +3,6 @@ import type { CatalogService } from '@/shared/services/CatalogService'; import type { ModuleSettingsService } from '@/shared/services/modules/ModuleSettingsService'; import type { AppUI } from '@/shared/shell/AppUI'; import type { IApp } from '@/shared/types/coreTypes'; -import { appendCustomProviderApps } from '@/shared/utils/customProviderSupport'; type RestoreLogger = Pick; @@ -86,13 +85,13 @@ function resolveRestoredApp( return catalogApp; } - if (!category.startsWith('ai')) { - return null; + if (isAiCategory(category) && typeof selectedModule.name === 'string') { + return selectedModule as IApp; } - return ( - appendCustomProviderApps(catalog.getCatalog().ai).find( - (app) => app.id === selectedModule.id, - ) ?? null - ); + return null; +} + +function isAiCategory(category: string): boolean { + return category === 'ai_text' || category === 'ai_image'; } diff --git a/src/assets/fonts/Cubic_11.zh-subset.woff2 b/src/assets/fonts/Cubic_11.zh-subset.woff2 index ee4bcdf4..64a24490 100644 Binary files a/src/assets/fonts/Cubic_11.zh-subset.woff2 and b/src/assets/fonts/Cubic_11.zh-subset.woff2 differ diff --git a/src/features/ai/services/AIBridge.test.ts b/src/features/ai/services/AIBridge.test.ts index 53393691..fabc338d 100644 --- a/src/features/ai/services/AIBridge.test.ts +++ b/src/features/ai/services/AIBridge.test.ts @@ -1,4 +1,4 @@ -/** +/** * AIBridge Unit Tests — Full Coverage */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -17,6 +17,37 @@ const mockListen = vi.fn().mockResolvedValue(() => { }); const mockEmit = vi.fn(); +const cloudProviderPolicy = { + isCloudProvider: true, + isCustomProvider: false, + isCleanApp: false, + secretService: 'cloud_api_key', + keyProviderId: 'cloud', + keyProviderUrl: 'https://openrouter.ai/settings/keys', + usesCustomProviderKey: false, + showApiEndpointSelector: false, + showCustomModelComposer: false, + showModelStats: true, + supportsInternetAccess: true, + supportsThinking: true, + imageOnly: false, +}; + +const localTextProviderPolicy = { + ...cloudProviderPolicy, + isCloudProvider: false, + secretService: null, + keyProviderId: null, + keyProviderUrl: null, + supportsInternetAccess: false, + supportsThinking: false, +}; + +const localImageProviderPolicy = { + ...localTextProviderPolicy, + imageOnly: true, +}; + // Mock Core dependency const mockCore = { tauriProvider: { @@ -65,12 +96,12 @@ const mockCore = { catalog: { getCatalog: vi.fn().mockReturnValue({ ai: [ - { id: 'gpt', capability: 'text' }, - { id: 'gemini', capability: 'text' }, - { id: 'llamacpp', capability: 'text' }, - { id: 'sdcpp', capability: 'image' }, - { id: 'gpt-image', capability: 'image' }, - { id: 'seedream-image', capability: 'image' }, + { id: 'gpt', capability: 'text', providerPolicy: cloudProviderPolicy }, + { id: 'gemini', capability: 'text', providerPolicy: cloudProviderPolicy }, + { id: 'llamacpp', capability: 'text', providerPolicy: localTextProviderPolicy }, + { id: 'sdcpp', capability: 'image', providerPolicy: localImageProviderPolicy }, + { id: 'gpt-image', capability: 'image', providerPolicy: cloudProviderPolicy }, + { id: 'seedream-image', capability: 'image', providerPolicy: cloudProviderPolicy }, ], services: [], }), @@ -153,7 +184,7 @@ describe('AIBridge', () => { localStorage.clear(); aiBridge = new AIBridge(mockTracer); // eslint-disable-next-line @typescript-eslint/no-explicit-any - aiBridge.setCore(mockCore as any); + aiBridge.setContext(mockCore as any); // Mock session ID for init mockInvoke.mockResolvedValueOnce('test-session-123'); @@ -180,7 +211,7 @@ describe('AIBridge', () => { it('should abort initialization when core dependency is missing', async () => { const bridge2 = new AIBridge(mockTracer); - await bridge2.init(); + await expect(bridge2.init()).rejects.toThrow('context dependency is missing'); // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((bridge2 as any)._initialized).toBe(false); @@ -194,13 +225,13 @@ describe('AIBridge', () => { it('should clean up transport state when initialization fails', async () => { const bridge2 = new AIBridge(mockTracer); // eslint-disable-next-line @typescript-eslint/no-explicit-any - bridge2.setCore(mockCore as any); + bridge2.setContext(mockCore as any); // eslint-disable-next-line @typescript-eslint/no-explicit-any const transportDestroySpy = vi.spyOn((bridge2 as any)._transport, 'destroy'); // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn((bridge2 as any)._transport, 'init').mockRejectedValue(new Error('boom')); - await bridge2.init(); + await expect(bridge2.init()).rejects.toThrow('boom'); expect(transportDestroySpy).toHaveBeenCalled(); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -210,7 +241,7 @@ describe('AIBridge', () => { it('should broadcast chunks and thoughts via transport callbacks', async () => { const bridge2 = new AIBridge(mockTracer); // eslint-disable-next-line @typescript-eslint/no-explicit-any - bridge2.setCore(mockCore as any); + bridge2.setContext(mockCore as any); mockInvoke.mockResolvedValueOnce('session-id'); let chunkCallback: ((payload: string) => void) | undefined; @@ -808,7 +839,7 @@ describe('AIBridge', () => { const bridge2 = new AIBridge(mockTracer); // eslint-disable-next-line @typescript-eslint/no-explicit-any - bridge2.setCore(mockCore as any); + bridge2.setContext(mockCore as any); mockInvoke.mockResolvedValueOnce('session-id'); await bridge2.init(); // should not throw when IPC streaming is unavailable @@ -816,17 +847,17 @@ describe('AIBridge', () => { mockCore.tauriProvider.isTauri.mockReturnValue(true); }); - it('should handle IPC initialization failure gracefully (line 86)', async () => { + it('should surface IPC initialization failure', async () => { // Make onStream throw to trigger the catch block const bridge2 = new AIBridge(mockTracer); // eslint-disable-next-line @typescript-eslint/no-explicit-any - bridge2.setCore(mockCore as any); + bridge2.setContext(mockCore as any); // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn((bridge2 as any)._transport, 'onStream').mockImplementation(() => { throw new Error('IPC broken'); }); mockInvoke.mockResolvedValueOnce('session-id'); - await expect(bridge2.init()).resolves.not.toThrow(); // error is caught internally + await expect(bridge2.init()).rejects.toThrow('IPC broken'); }); }); @@ -945,13 +976,12 @@ describe('AIBridge', () => { // ---------------------------------------------------------- additional branch coverage describe('Additional branch coverage', () => { - it('should handle setCore when _transport is not AIChatTransport (Line 39)', () => { + it('should ignore transports without a context setter', () => { const tempBridge = new AIBridge(mockTracer); // eslint-disable-next-line @typescript-eslint/no-explicit-any - (tempBridge as any)._transport = { setCore: vi.fn() }; + (tempBridge as any)._transport = {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any - tempBridge.setCore(mockCore as any); - // Should not throw and Should not call setCore on the plain object since it fails instanceof + tempBridge.setContext(mockCore as any); }); it('should handle DEV false branch (Lines 61-72)', async () => { @@ -961,7 +991,7 @@ describe('AIBridge', () => { const tempBridge = new AIBridge(mockTracer); // eslint-disable-next-line @typescript-eslint/no-explicit-any - tempBridge.setCore(mockCore as any); + tempBridge.setContext(mockCore as any); mockInvoke.mockResolvedValueOnce('session'); await tempBridge.init(); @@ -969,9 +999,9 @@ describe('AIBridge', () => { (import.meta.env as any).DEV = orgDev; }); - it('should reject sendMessage when _core is null and no model can be resolved', async () => { + it('should reject sendMessage when context is null and no model can be resolved', async () => { const tempBridge = new AIBridge(mockTracer); - // Do NOT call setCore here to leave _core as null + // Do NOT call setContext here to leave the bridge context as null // Bypass API key checks logic just to test missing core/model resolution. // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -993,7 +1023,7 @@ describe('AIBridge', () => { const result = await tempBridge.sendMessage('test message'); expect(result.ok).toBe(false); - expect(result.error).toBe('No AI model selected'); + expect(result.error).toBe('AI bridge is not ready'); }); it('should handle an empty error string in backend mismatch logic (Line 218)', async () => { diff --git a/src/features/ai/services/AIBridge.ts b/src/features/ai/services/AIBridge.ts index 4aa8c4e1..d7935c5e 100644 --- a/src/features/ai/services/AIBridge.ts +++ b/src/features/ai/services/AIBridge.ts @@ -18,6 +18,7 @@ import type { AIBridgeContext } from './AIBridgeContext'; import { AIBridgeRuntime } from './AIBridgeRuntime'; import { AIBridgeInactivityController } from './AIBridgeInactivityController'; import { AIBridgeMessageController } from './AIBridgeMessageController'; +import { AI_BRIDGE_INACTIVITY_TIMEOUT_MS } from './AIBridgeConfig'; export type { MessageSource, MessageHandler, IChunkHandler } from '../types/aiTypes'; @@ -33,7 +34,6 @@ export class AIBridge implements IAIBridge { private readonly _unlisteners: (() => void)[] = []; private readonly _localContextWindows = new Map(); private _initialized = false; - private readonly INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes private readonly _events = new AIBridgeEvents(); private readonly _transport: IChatTransport; private readonly _manager: AIProviderManager; @@ -51,7 +51,7 @@ export class AIBridge implements IAIBridge { this._engineStatus = new EngineStatusService(this._tracer); this._runtime = new AIBridgeRuntime(this._tracer); this._inactivityController = new AIBridgeInactivityController( - this.INACTIVITY_TIMEOUT_MS, + AI_BRIDGE_INACTIVITY_TIMEOUT_MS, this._tracer, () => { this.stopProvider(); @@ -88,10 +88,6 @@ export class AIBridge implements IAIBridge { this._engineStatus.setContext(context); } - public setCore(context: AIBridgeContext): void { - this.setContext(context); - } - /** * Initializes the bridge singleton and registries. */ @@ -102,8 +98,11 @@ export class AIBridge implements IAIBridge { } if (this._context === null) { - this._tracer.error('[AIBridge] Initialization aborted: Core dependency is missing'); - return; + const error = new Error( + '[AIBridge] Initialization aborted: context dependency is missing', + ); + this._tracer.error(error.message); + throw error; } const context = this._context; @@ -132,6 +131,7 @@ export class AIBridge implements IAIBridge { } catch (error: unknown) { this._tracer.error('[AIBridge] Critical IPC initialization failure:', error); this._cleanupTransportState(); + throw error; } } diff --git a/src/features/ai/services/AIBridgeConfig.ts b/src/features/ai/services/AIBridgeConfig.ts new file mode 100644 index 00000000..b99af100 --- /dev/null +++ b/src/features/ai/services/AIBridgeConfig.ts @@ -0,0 +1 @@ +export const AI_BRIDGE_INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; diff --git a/src/features/ai/services/AIBridgeMessageController.test.ts b/src/features/ai/services/AIBridgeMessageController.test.ts index 0bc23a74..3d4c73ee 100644 --- a/src/features/ai/services/AIBridgeMessageController.test.ts +++ b/src/features/ai/services/AIBridgeMessageController.test.ts @@ -7,12 +7,50 @@ import { CUSTOM_TEXT_PROVIDER_ID, } from '@/shared/utils/customProviderSupport'; +const customTextPolicy = { + isCloudProvider: true, + isCustomProvider: true, + isCleanApp: false, + secretService: 'custom_text_api_key', + keyProviderId: CUSTOM_TEXT_PROVIDER_ID, + keyProviderUrl: null, + usesCustomProviderKey: true, + showApiEndpointSelector: true, + showCustomModelComposer: true, + showModelStats: false, + supportsInternetAccess: false, + supportsThinking: false, + imageOnly: false, +}; + +const customImagePolicy = { + ...customTextPolicy, + secretService: 'custom_image_api_key', + keyProviderId: CUSTOM_IMAGE_PROVIDER_ID, + showApiEndpointSelector: false, + showCustomModelComposer: false, + imageOnly: true, +}; + +const localTextPolicy = { + ...customTextPolicy, + isCloudProvider: false, + isCustomProvider: false, + secretService: null, + keyProviderId: null, + keyProviderUrl: null, +}; + function createProviderPolicy(): AIBridgeProviderPolicy { return new AIBridgeProviderPolicy(() => ({ ai: [ - { id: CUSTOM_TEXT_PROVIDER_ID, capability: 'text' }, - { id: CUSTOM_IMAGE_PROVIDER_ID, capability: 'image' }, - { id: 'llamacpp', capability: 'text' }, + { id: CUSTOM_TEXT_PROVIDER_ID, capability: 'text', providerPolicy: customTextPolicy }, + { + id: CUSTOM_IMAGE_PROVIDER_ID, + capability: 'image', + providerPolicy: customImagePolicy, + }, + { id: 'llamacpp', capability: 'text', providerPolicy: localTextPolicy }, ], })); } @@ -161,6 +199,29 @@ function createImageController() { } describe('AIBridgeMessageController custom providers', () => { + it('routes built-in cloud text providers through their OpenRouter catalog base URL', async () => { + const { controller, transport, manager, context } = createTextController(); + manager.activeProviderId = 'gpt'; + manager.model = 'openai/gpt-5.5'; + manager.getProviderBaseUrl.mockReturnValue('https://openrouter.ai/api/v1'); + context.aiSettings.getInternetAccessEnabled.mockReturnValue(true); + + await controller.sendMessage('What is the latest release today?', 'chat', [], []); + + expect(context.aiSettings.getThinkingLevel).toHaveBeenCalledWith('gpt'); + expect(context.aiSettings.getInternetAccessEnabled).toHaveBeenCalledWith('gpt'); + expect(manager.getProviderBaseUrl).toHaveBeenCalledWith('gpt'); + expect(transport.send).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'gpt', + model: 'openai/gpt-5.5', + cloud_api_base_url: 'https://openrouter.ai/api/v1', + thinking_level: 'high', + web_search: { enabled: true }, + }), + ); + }); + it('routes custom text providers through the custom backend slot without changing model ids', async () => { const { controller, transport } = createTextController(); @@ -170,29 +231,22 @@ describe('AIBridgeMessageController custom providers', () => { expect.objectContaining({ provider: CUSTOM_TEXT_PROVIDER_ID, model: 'deepseek/deepseek-r1-0528', - thinking_level: 'high', }), ); }); - it('uses custom text provider settings for thinking and internet access', async () => { + it('does not send OpenRouter-only request options for custom text providers', async () => { const { controller, transport, context } = createTextController(); context.aiSettings.getThinkingLevel.mockReturnValue('off'); context.aiSettings.getInternetAccessEnabled.mockReturnValue(true); await controller.sendMessage('What is the latest OpenAI news today?', 'chat', [], []); - expect(context.aiSettings.getThinkingLevel).toHaveBeenCalledWith(CUSTOM_TEXT_PROVIDER_ID); - expect(context.aiSettings.getInternetAccessEnabled).toHaveBeenCalledWith( - CUSTOM_TEXT_PROVIDER_ID, - ); - expect(transport.send).toHaveBeenCalledWith( - expect.objectContaining({ - provider: CUSTOM_TEXT_PROVIDER_ID, - thinking_level: 'none', - web_search: { enabled: true }, - }), - ); + expect(context.aiSettings.getThinkingLevel).not.toHaveBeenCalled(); + expect(context.aiSettings.getInternetAccessEnabled).not.toHaveBeenCalled(); + const request = transport.send.mock.calls[0]?.[0] as Record; + expect(request).not.toHaveProperty('thinking_level'); + expect(request).not.toHaveProperty('web_search'); }); it('passes provider base URLs to OpenAI-compatible text requests', async () => { @@ -429,6 +483,15 @@ describe('AIBridgeMessageController custom providers', () => { expect(transport.sendSilent).toHaveBeenCalledOnce(); }); + it('does not send OpenRouter-only reasoning options during custom silent prompt preparation', async () => { + const { controller, transport } = createTextController(); + + await controller.prepareImagePrompt('rewrite image prompt'); + + const request = transport.sendSilent.mock.calls[0]?.[0] as Record; + expect(request).not.toHaveProperty('thinking_level'); + }); + it('passes provider base URLs to silent prompt preparation requests', async () => { const { controller, transport, manager } = createTextController(); manager.getProviderBaseUrl.mockReturnValue('https://api.groq.com/openai/v1'); diff --git a/src/features/ai/services/AIBridgeMessageController.ts b/src/features/ai/services/AIBridgeMessageController.ts index bdd96b38..54552758 100644 --- a/src/features/ai/services/AIBridgeMessageController.ts +++ b/src/features/ai/services/AIBridgeMessageController.ts @@ -50,6 +50,10 @@ export class AIBridgeMessageController { return this._handleMissingProvider(source); } + if (this._deps.getContext() === null) { + return this._handleMissingContext(); + } + await this._deps.manager.refreshActiveApiKey(); if (this._deps.manager.apiKey === null && this._deps.manager.isActive() === false) { @@ -103,7 +107,7 @@ export class AIBridgeMessageController { const requestOptions = this._deps.providerPolicy.buildRequestOptions({ hasApiKey: this._deps.manager.apiKey !== null, maxOutputTokens: Math.min(this._deps.manager.maxOutputTokens ?? 320, 420), - thinkingLevel: 'off', + thinkingLevel: this._usesOpenRouterRequestOptions(providerId) ? 'off' : undefined, webSearchEnabled: false, }); const request = constructChatRequest( @@ -152,6 +156,11 @@ export class AIBridgeMessageController { }; } + private _handleMissingContext(): IBridgeResponse { + const msg = this._deps.translate('ui.ai.bridge_not_ready', 'AI bridge is not ready'); + return { ok: false, error: msg }; + } + private async _sendImageMessage( providerId: string, text: string, @@ -238,11 +247,16 @@ export class AIBridgeMessageController { return this._handleMissingModel(); } const cloudApiBaseUrl = this._deps.manager.getProviderBaseUrl(providerId); + const usesOpenRouterRequestOptions = this._usesOpenRouterRequestOptions(providerId); const requestOptions = this._deps.providerPolicy.buildRequestOptions({ hasApiKey: this._deps.manager.apiKey !== null, maxOutputTokens: this._deps.manager.maxOutputTokens, - thinkingLevel: context?.aiSettings.getThinkingLevel(providerId), - webSearchEnabled: context?.aiSettings.getInternetAccessEnabled(providerId), + thinkingLevel: usesOpenRouterRequestOptions + ? context?.aiSettings.getThinkingLevel(providerId) + : undefined, + webSearchEnabled: + usesOpenRouterRequestOptions && + context?.aiSettings.getInternetAccessEnabled(providerId), }); const request = constructChatRequest(requestHistory, newMessage, requestAttachments, { providerId: backendProviderId, @@ -310,6 +324,10 @@ export class AIBridgeMessageController { return this._deps.providerPolicy.isLocalTextProvider(providerId) ? 'default' : null; } + private _usesOpenRouterRequestOptions(providerId: string): boolean { + return providerId !== CUSTOM_TEXT_PROVIDER_ID; + } + private _withModelContext( response: IBridgeResponse, providerId: string, diff --git a/src/features/ai/services/AIBridgeProviderPolicy.test.ts b/src/features/ai/services/AIBridgeProviderPolicy.test.ts index 3625ef6c..22662625 100644 --- a/src/features/ai/services/AIBridgeProviderPolicy.test.ts +++ b/src/features/ai/services/AIBridgeProviderPolicy.test.ts @@ -5,15 +5,42 @@ import { CUSTOM_TEXT_PROVIDER_ID, } from '@/shared/utils/customProviderSupport'; +const cloudPolicy = { + isCloudProvider: true, + isCustomProvider: false, + isCleanApp: false, + secretService: 'cloud_api_key', + keyProviderId: 'cloud', + keyProviderUrl: 'https://openrouter.ai/settings/keys', + usesCustomProviderKey: false, + showApiEndpointSelector: false, + showCustomModelComposer: false, + showModelStats: true, + supportsInternetAccess: true, + supportsThinking: true, + imageOnly: false, +}; + +const localPolicy = { + ...cloudPolicy, + isCloudProvider: false, + secretService: null, + keyProviderId: null, + keyProviderUrl: null, + supportsInternetAccess: false, + supportsThinking: false, +}; + describe('AIBridgeProviderPolicy', () => { const policy = new AIBridgeProviderPolicy(() => ({ ai: [ - { id: 'llamacpp', capability: 'text' }, - { id: 'sdcpp', capability: 'image' }, - { id: 'comfyui', capability: 'image' }, - { id: 'seedream-image', capability: 'image' }, - { id: CUSTOM_IMAGE_PROVIDER_ID, capability: 'image' }, - { id: CUSTOM_TEXT_PROVIDER_ID, capability: 'text' }, + { id: 'llamacpp', capability: 'text', providerPolicy: localPolicy }, + { id: 'sdcpp', capability: 'image', providerPolicy: localPolicy }, + { id: 'comfyui', capability: 'image', providerPolicy: localPolicy }, + { id: 'gemini', capability: 'text', providerPolicy: cloudPolicy }, + { id: 'seedream-image', capability: 'image', providerPolicy: cloudPolicy }, + { id: CUSTOM_IMAGE_PROVIDER_ID, capability: 'image', providerPolicy: cloudPolicy }, + { id: CUSTOM_TEXT_PROVIDER_ID, capability: 'text', providerPolicy: cloudPolicy }, ], })); @@ -23,6 +50,7 @@ describe('AIBridgeProviderPolicy', () => { expect(policy.isCloudProvider(CUSTOM_TEXT_PROVIDER_ID)).toBe(true); expect(policy.isCloudProvider(CUSTOM_IMAGE_PROVIDER_ID)).toBe(true); expect(policy.isCloudProvider('llamacpp')).toBe(false); + expect(policy.isCloudProvider('unknown-provider')).toBe(false); expect(policy.isImageProvider('comfyui')).toBe(true); expect(policy.isImageProvider('seedream-image')).toBe(true); expect(policy.isImageProvider(CUSTOM_IMAGE_PROVIDER_ID)).toBe(true); @@ -33,13 +61,14 @@ describe('AIBridgeProviderPolicy', () => { expect(policy.isLocalTextProvider('llamacpp')).toBe(true); expect(policy.isLocalTextProvider('sdcpp')).toBe(false); expect(policy.isLocalTextProvider(CUSTOM_TEXT_PROVIDER_ID)).toBe(false); + expect(policy.isLocalTextProvider('unknown-provider')).toBe(false); }); it('should prefer catalog capabilities for provider output type', () => { const catalogPolicy = new AIBridgeProviderPolicy(() => ({ ai: [ - { id: 'local-image-engine', capability: 'image' }, - { id: 'local-text-engine', capability: 'text' }, + { id: 'local-image-engine', capability: 'image', providerPolicy: localPolicy }, + { id: 'local-text-engine', capability: 'text', providerPolicy: localPolicy }, ], })); diff --git a/src/features/ai/services/AIBridgeProviderPolicy.ts b/src/features/ai/services/AIBridgeProviderPolicy.ts index ea768b0d..769c4e1a 100644 --- a/src/features/ai/services/AIBridgeProviderPolicy.ts +++ b/src/features/ai/services/AIBridgeProviderPolicy.ts @@ -1,4 +1,3 @@ -import { isCloudProviderId } from '@/shared/utils/providerSupport'; import type { IApp } from '@/shared/types/coreTypes'; type ThinkingLevel = 'off' | 'low' | 'medium' | 'high'; @@ -23,7 +22,8 @@ export class AIBridgeProviderPolicy { public constructor(private readonly _getCatalog?: ProviderCatalogGetter) {} public isCloudProvider(providerId: string): boolean { - return isCloudProviderId(providerId); + const policy = this._catalogProvider(providerId)?.providerPolicy; + return policy?.isCloudProvider === true; } public isImageProvider(providerId: string): boolean { @@ -31,11 +31,19 @@ export class AIBridgeProviderPolicy { } public isManagedLocalImageEngine(providerId: string): boolean { - return !this.isCloudProvider(providerId) && this.isImageProvider(providerId); + return ( + this._catalogProvider(providerId) !== null && + !this.isCloudProvider(providerId) && + this.isImageProvider(providerId) + ); } public isLocalTextProvider(providerId: string): boolean { - return !this.isCloudProvider(providerId) && !this.isImageProvider(providerId); + return ( + this._catalogProvider(providerId) !== null && + !this.isCloudProvider(providerId) && + !this.isImageProvider(providerId) + ); } public buildRequestOptions(input: RequestOptionInput): AIBridgeRequestOptions { @@ -67,20 +75,24 @@ export class AIBridgeProviderPolicy { } private _catalogCapability(providerId: string): IApp['capability'] | null { + return this._catalogProvider(providerId)?.capability ?? null; + } + + private _catalogProvider(providerId: string): Partial | null { const catalog = this._getCatalog?.(); const ai = catalog?.ai; if (!Array.isArray(ai)) { return null; } - const provider = ai.find((entry): entry is Partial => { - return ( - typeof entry === 'object' && - entry !== null && - (entry as Partial).id === providerId - ); - }); - const capability = provider?.capability; - return capability === 'image' || capability === 'text' ? capability : null; + return ( + ai.find((entry): entry is Partial => { + return ( + typeof entry === 'object' && + entry !== null && + (entry as Partial).id === providerId + ); + }) ?? null + ); } } diff --git a/src/features/ai/services/AIChatTransport.test.ts b/src/features/ai/services/AIChatTransport.test.ts index 2582c1eb..aa215205 100644 --- a/src/features/ai/services/AIChatTransport.test.ts +++ b/src/features/ai/services/AIChatTransport.test.ts @@ -31,10 +31,21 @@ function makeRequest(overrides: Partial = {}): IChatRequest { model: 'gemini-pro', messages: [{ role: 'user', content: 'Hello' }], api_key: null, + cloud_api_base_url: 'https://openrouter.ai/api/v1', ...overrides, }; } +function makeLocalRequest(overrides: Partial = {}): IChatRequest { + const request = makeRequest({ + provider: 'llamacpp', + model: 'model.gguf', + ...overrides, + }); + delete request.cloud_api_base_url; + return request; +} + describe('AIChatTransport', () => { let transport: AIChatTransport; let mockCore: ReturnType; @@ -50,7 +61,7 @@ describe('AIChatTransport', () => { }; transport = new AIChatTransport(tracer); mockCore = createMockCore(); - transport.setCore(mockCore as unknown as Parameters[0]); + transport.setContext(mockCore as unknown as Parameters[0]); }); afterEach(() => { @@ -182,9 +193,7 @@ describe('AIChatTransport', () => { : Promise.resolve(true), ); - const sendPromise = transport.send( - makeRequest({ provider: 'llamacpp', model: 'model.gguf' }), - ); + const sendPromise = transport.send(makeLocalRequest()); vi.advanceTimersByTime(90_001); await Promise.resolve(); @@ -197,6 +206,76 @@ describe('AIChatTransport', () => { await expect(sendPromise).resolves.toEqual({ ok: true, text: 'local done' }); }); + it('should keep self-hosted local endpoints alive past the cloud timeout', async () => { + let resolveInvoke: ( + response: Awaited>, + ) => void = () => { + throw new Error('invoke promise was not started'); + }; + mockCore.tauriProvider.invoke.mockImplementation((command: string) => + command === 'send_chat_message' + ? new Promise((resolve) => { + resolveInvoke = resolve; + }) + : Promise.resolve(true), + ); + + const sendPromise = transport.send( + makeRequest({ + provider: 'custom-text', + cloud_api_base_url: 'http://127.0.0.1:8080/v1', + }), + ); + vi.advanceTimersByTime(90_001); + await Promise.resolve(); + + expect(mockCore.tauriProvider.invoke).not.toHaveBeenCalledWith( + 'cancel_chat_generation', + expect.anything(), + ); + + resolveInvoke({ ok: true, reply: { text: 'local endpoint done' } }); + await expect(sendPromise).resolves.toEqual({ + ok: true, + text: 'local endpoint done', + }); + }); + + it('should classify bracketed IPv6 loopback endpoints as local', async () => { + let resolveInvoke: ( + response: Awaited>, + ) => void = () => { + throw new Error('invoke promise was not started'); + }; + mockCore.tauriProvider.invoke.mockImplementation((command: string) => + command === 'send_chat_message' + ? new Promise((resolve) => { + resolveInvoke = resolve; + }) + : Promise.resolve(true), + ); + + const sendPromise = transport.send( + makeRequest({ + provider: 'custom-text', + cloud_api_base_url: 'http://[::1]:8080/v1', + }), + ); + vi.advanceTimersByTime(90_001); + await Promise.resolve(); + + expect(mockCore.tauriProvider.invoke).not.toHaveBeenCalledWith( + 'cancel_chat_generation', + expect.anything(), + ); + + resolveInvoke({ ok: true, reply: { text: 'ipv6 local endpoint done' } }); + await expect(sendPromise).resolves.toEqual({ + ok: true, + text: 'ipv6 local endpoint done', + }); + }); + it('should extract message from plain error objects', async () => { mockCore.tauriProvider.invoke.mockRejectedValue({ message: 'ipc object failed' }); @@ -353,9 +432,7 @@ describe('AIChatTransport', () => { : Promise.resolve(true), ); - const sendPromise = transport.sendSilent( - makeRequest({ provider: 'llamacpp', model: 'model.gguf' }), - ); + const sendPromise = transport.sendSilent(makeLocalRequest()); vi.advanceTimersByTime(90_001); await Promise.resolve(); diff --git a/src/features/ai/services/AIChatTransport.ts b/src/features/ai/services/AIChatTransport.ts index 77867135..e7888efb 100644 --- a/src/features/ai/services/AIChatTransport.ts +++ b/src/features/ai/services/AIChatTransport.ts @@ -9,7 +9,6 @@ import type { import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { StreamChunkPayload } from '@/shared/types/bindings'; import type { AITransportContext } from './AIBridgeContext'; -import { isCloudProviderId } from '@/shared/utils/providerSupport'; type AIChatTransportLogger = Pick; const STALE_REQUEST_CANCEL_TIMEOUT_MS = 750; @@ -63,10 +62,6 @@ export class AIChatTransport implements IChatTransport { this._context = context; } - public setCore(context: AITransportContext): void { - this.setContext(context); - } - public async init(): Promise { if (this._context?.tauriProvider.isTauri() === true) { // Setup global listener for streaming chunks if needed here, @@ -437,9 +432,56 @@ export class AIChatTransport implements IChatTransport { } private _chatRequestTimeoutMs(request: IChatRequest): number { - return isCloudProviderId(request.provider) - ? CLOUD_CHAT_REQUEST_TIMEOUT_MS - : LOCAL_CHAT_REQUEST_TIMEOUT_MS; + const baseUrl = request.cloud_api_base_url?.trim() ?? ''; + if (baseUrl.length === 0 || this._isLocalChatEndpoint(request)) { + return LOCAL_CHAT_REQUEST_TIMEOUT_MS; + } + + return CLOUD_CHAT_REQUEST_TIMEOUT_MS; + } + + private _isLocalChatEndpoint(request: IChatRequest): boolean { + const provider = request.provider.trim().toLowerCase(); + if (['llamacpp', 'llama-cpp', 'ollama', 'sdcpp', 'comfyui'].includes(provider)) { + return true; + } + + const baseUrl = request.cloud_api_base_url?.trim(); + if (baseUrl === undefined || baseUrl === '') { + return true; + } + + try { + const hostname = new URL(baseUrl).hostname.toLowerCase(); + return this._isLocalHostname(hostname); + } catch { + return false; + } + } + + private _isLocalHostname(hostname: string): boolean { + const normalizedHostname = hostname.replace(/^\[(.*)\]$/u, '$1'); + if ( + normalizedHostname === 'localhost' || + normalizedHostname === '::1' || + normalizedHostname === '0.0.0.0' || + normalizedHostname.endsWith('.local') || + normalizedHostname.startsWith('127.') + ) { + return true; + } + + if (normalizedHostname.startsWith('10.') || normalizedHostname.startsWith('192.168.')) { + return true; + } + + const match = /^172\.(\d+)\./u.exec(normalizedHostname); + if (match?.[1] === undefined) { + return false; + } + + const secondOctet = Number.parseInt(match[1], 10); + return secondOctet >= 16 && secondOctet <= 31; } public destroy(): void { diff --git a/src/features/ai/services/AIProviderManager.test.ts b/src/features/ai/services/AIProviderManager.test.ts index 7b5b0d10..553aff63 100644 --- a/src/features/ai/services/AIProviderManager.test.ts +++ b/src/features/ai/services/AIProviderManager.test.ts @@ -8,6 +8,46 @@ import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import { CUSTOM_TEXT_PROVIDER_ID } from '@/shared/utils/customProviderSupport'; import type { AIProviderManagerContext } from './AIBridgeContext'; +const cloudProviderPolicy = { + isCloudProvider: true, + isCustomProvider: false, + isCleanApp: false, + secretService: 'cloud_api_key', + keyProviderId: 'cloud', + keyProviderUrl: 'https://openrouter.ai/settings/keys', + usesCustomProviderKey: false, + showApiEndpointSelector: false, + showCustomModelComposer: false, + showModelStats: true, + supportsInternetAccess: true, + supportsThinking: true, + imageOnly: false, +}; + +const customTextProviderPolicy = { + ...cloudProviderPolicy, + isCustomProvider: true, + secretService: 'custom_text_api_key', + keyProviderId: CUSTOM_TEXT_PROVIDER_ID, + keyProviderUrl: null, + usesCustomProviderKey: true, + showApiEndpointSelector: true, + showCustomModelComposer: true, + showModelStats: false, + supportsInternetAccess: false, + supportsThinking: false, +}; + +const localProviderPolicy = { + ...cloudProviderPolicy, + isCloudProvider: false, + secretService: null, + keyProviderId: null, + keyProviderUrl: null, + supportsInternetAccess: false, + supportsThinking: false, +}; + // Mock catalogHelpers used internally vi.mock('@/features/ai/utils/catalogHelpers', () => ({ getModelData: vi.fn(() => null), @@ -31,7 +71,19 @@ function createMockCore( hasSecureKey: vi.fn(hasKeyFn), }, catalog: { - getCatalog: vi.fn().mockReturnValue({ ai: [] }), + getCatalog: vi.fn().mockReturnValue({ + ai: [ + { id: 'gpt', capability: 'text', providerPolicy: cloudProviderPolicy }, + { id: 'gemini', capability: 'text', providerPolicy: cloudProviderPolicy }, + { id: 'local', capability: 'text', providerPolicy: localProviderPolicy }, + { id: 'llamacpp', capability: 'text', providerPolicy: localProviderPolicy }, + { + id: CUSTOM_TEXT_PROVIDER_ID, + capability: 'text', + providerPolicy: customTextProviderPolicy, + }, + ], + }), }, aiSettings: { setSelectedAIModel: vi.fn(), @@ -66,7 +118,7 @@ describe('AIProviderManager', () => { describe('init', () => { it('should generate and save a new session ID if none exists', async () => { const mockCore = createMockCore(() => Promise.resolve(null)); - manager.setCore(mockCore); + manager.setContext(mockCore); await manager.init(); @@ -79,7 +131,7 @@ describe('AIProviderManager', () => { it('should restore existing session ID without saving', async () => { const mockCore = createMockCore(() => Promise.resolve('existing-session-abc')); - manager.setCore(mockCore); + manager.setContext(mockCore); await manager.init(); @@ -89,7 +141,7 @@ describe('AIProviderManager', () => { it('should generate session ID when secure storage is empty', async () => { const mockCore = createMockCore(() => Promise.resolve(null)); - manager.setCore(mockCore); + manager.setContext(mockCore); await manager.init(); @@ -102,7 +154,7 @@ describe('AIProviderManager', () => { it('should generate session ID when secure read fails', async () => { const mockCore = createMockCore(() => Promise.reject(new Error('secure read failed'))); - manager.setCore(mockCore); + manager.setContext(mockCore); await manager.init(); @@ -123,7 +175,7 @@ describe('AIProviderManager', () => { vi.mocked(mockCore.tauriProvider.saveSecureKey ?? vi.fn()).mockRejectedValueOnce( new Error('secure unavailable'), ); - manager.setCore(mockCore); + manager.setContext(mockCore); await expect(manager.init()).resolves.toBeUndefined(); @@ -143,7 +195,7 @@ describe('AIProviderManager', () => { describe('startProvider', () => { it('should return true immediately if same provider already active', async () => { const mockCore = createMockCore(() => Promise.resolve('sk-key')); - manager.setCore(mockCore); + manager.setContext(mockCore); await manager.startProvider('gemini'); const result = await manager.startProvider('gemini'); @@ -157,7 +209,7 @@ describe('AIProviderManager', () => { () => Promise.resolve(hasKey ? 'sk-key' : null), () => Promise.resolve(hasKey), ); - manager.setCore(mockCore); + manager.setContext(mockCore); await manager.startProvider('gemini'); hasKey = false; @@ -171,7 +223,7 @@ describe('AIProviderManager', () => { it('should stop previous provider when switching', async () => { const mockCore = createMockCore(() => Promise.resolve('sk-key')); - manager.setCore(mockCore); + manager.setContext(mockCore); await manager.startProvider('gemini'); await manager.startProvider('gpt'); @@ -181,7 +233,7 @@ describe('AIProviderManager', () => { it('should return false if API key is empty for non-local provider', async () => { const mockCore = createMockCore(() => Promise.resolve('')); - manager.setCore(mockCore); + manager.setContext(mockCore); const result = await manager.startProvider('gemini'); expect(result).toBe(false); @@ -191,7 +243,7 @@ describe('AIProviderManager', () => { it('should succeed for local provider without a key', async () => { const mockCore = createMockCore(() => Promise.resolve('')); - manager.setCore(mockCore); + manager.setContext(mockCore); const result = await manager.startProvider('local'); expect(result).toBe(true); @@ -201,7 +253,7 @@ describe('AIProviderManager', () => { const mockCore = createMockCore(() => Promise.reject(new Error('Secure storage crash')), ); - manager.setCore(mockCore); + manager.setContext(mockCore); const result = await manager.startProvider('gemini'); expect(result).toBe(false); @@ -209,7 +261,7 @@ describe('AIProviderManager', () => { it('should persist the resolved model via aiSettings', async () => { const mockCore = createMockCore(() => Promise.resolve('sk-test')); - manager.setCore(mockCore); + manager.setContext(mockCore); await manager.startProvider('gemini'); @@ -224,7 +276,7 @@ describe('AIProviderManager', () => { describe('stopProvider', () => { it('should clear state when active', async () => { const mockCore = createMockCore(() => Promise.resolve('sk-key')); - manager.setCore(mockCore); + manager.setContext(mockCore); await manager.startProvider('gemini'); manager.stopProvider(); @@ -248,7 +300,7 @@ describe('AIProviderManager', () => { it('should return true for local engine without key', async () => { const mockCore = createMockCore(() => Promise.resolve('')); - manager.setCore(mockCore); + manager.setContext(mockCore); await manager.startProvider('llamacpp'); expect(manager.isActive()).toBe(true); @@ -256,7 +308,7 @@ describe('AIProviderManager', () => { it('should treat custom providers as cloud providers requiring their own key', async () => { const mockCore = createMockCore(() => Promise.resolve('sk-key')); - manager.setCore(mockCore); + manager.setContext(mockCore); const result = await manager.startProvider(CUSTOM_TEXT_PROVIDER_ID); @@ -274,7 +326,7 @@ describe('AIProviderManager', () => { () => Promise.resolve('original-key'), () => Promise.resolve(hasKey), ); - manager.setCore(mockCore); + manager.setContext(mockCore); await manager.startProvider('gemini'); hasKey = false; @@ -294,7 +346,7 @@ describe('AIProviderManager', () => { describe('_saveSecureVal (via init)', () => { it('should save session ID when core is present and no session exists', async () => { const mockCore = createMockCore(() => Promise.resolve(null)); - manager.setCore(mockCore); + manager.setContext(mockCore); await manager.init(); @@ -330,7 +382,7 @@ describe('AIProviderManager', () => { { id: 'gemini', name: 'Google Gemini' }, ], }); - manager.setCore(mockCore); + manager.setContext(mockCore); expect(manager.getProviderDisplayName('gpt')).toBe('OpenAI GPT'); expect(manager.getProviderDisplayName('gemini')).toBe('Google Gemini'); @@ -360,7 +412,7 @@ describe('AIProviderManager', () => { }, ], }); - manager.setCore(mockCore); + manager.setContext(mockCore); expect(manager.getProviderBaseUrl('gpt')).toBe('https://openrouter.ai/api/v1'); expect(manager.getProviderBaseUrl(CUSTOM_TEXT_PROVIDER_ID)).toBe( @@ -374,7 +426,7 @@ describe('AIProviderManager', () => { it('should use persisted model from aiSettings when available (L143)', async () => { const mockCore = createMockCore(() => Promise.resolve('sk-key')); vi.mocked(mockCore.aiSettings.getSelectedAIModel).mockReturnValue('custom-model'); - manager.setCore(mockCore); + manager.setContext(mockCore); await manager.startProvider('gemini'); @@ -389,27 +441,23 @@ describe('AIProviderManager', () => { vi.mocked(mockCore.aiSettings.getSelectedAIModel).mockReturnValue( null as unknown as string, ); - manager.setCore(mockCore); + manager.setContext(mockCore); await manager.startProvider('gemini'); expect(manager.model).toBe('catalog-best-model'); }); - it('should return null from _getPersistedModel when core is not set (L143 true branch)', async () => { - // No setCore() called — _core is null → _getPersistedModel returns null - // startProvider('local') resolves with fallback model from _getDefaultModel - // 'local' provider: _resolveApiKey returns '' (no core), isLocal=true → proceeds + it('should fail closed when core is not set', async () => { const result = await manager.startProvider('local'); - expect(result).toBe(true); - // Model comes from _getDefaultModel since _getPersistedModel returned null - expect(manager.model).toBe('default'); + expect(result).toBe(false); + expect(manager.model).toBe(''); }); it('should ignore empty persisted local models and fall back to a non-empty default', async () => { const mockCore = createMockCore(() => Promise.resolve('')); vi.mocked(mockCore.aiSettings.getSelectedAIModel).mockReturnValue(''); - manager.setCore(mockCore); + manager.setContext(mockCore); const result = await manager.startProvider('llamacpp'); @@ -417,16 +465,17 @@ describe('AIProviderManager', () => { expect(manager.model).toBe('default'); }); - it('should not invent a cloud model when catalog and persisted settings are empty', async () => { + it('should deny providers without backend policy', async () => { const mockCore = createMockCore(() => Promise.resolve('sk-key')); + vi.mocked(mockCore.catalog.getCatalog).mockReturnValue({ ai: [] }); vi.mocked(mockCore.aiSettings.getSelectedAIModel).mockReturnValue(''); - manager.setCore(mockCore); + manager.setContext(mockCore); const result = await manager.startProvider('gemini'); - expect(result).toBe(true); + expect(result).toBe(false); expect(manager.model).toBe(''); - expect(mockCore.aiSettings.setSelectedAIModel).toHaveBeenCalledWith('gemini', ''); + expect(mockCore.aiSettings.setSelectedAIModel).not.toHaveBeenCalled(); }); it('should reflect model changes from settings without restarting the provider', async () => { @@ -435,7 +484,7 @@ describe('AIProviderManager', () => { vi.mocked(mockCore.aiSettings.getSelectedAIModel).mockImplementation( () => selectedModel, ); - manager.setCore(mockCore); + manager.setContext(mockCore); await manager.startProvider('gemini'); expect(manager.model).toBe('gemini-3.1-pro'); diff --git a/src/features/ai/services/AIProviderManager.ts b/src/features/ai/services/AIProviderManager.ts index 8cc9c2f6..7d064dbd 100644 --- a/src/features/ai/services/AIProviderManager.ts +++ b/src/features/ai/services/AIProviderManager.ts @@ -5,9 +5,7 @@ import type { AIProviderManagerContext } from './AIBridgeContext'; import { CUSTOM_TEXT_PROVIDER_ID, getCustomProviderDisplayName, - isCustomProviderId, } from '@/shared/utils/customProviderSupport'; -import { isCloudProviderId, resolveProviderSecretService } from '@/shared/utils/providerSupport'; type AIProviderManagerLogger = Pick; @@ -25,10 +23,6 @@ export class AIProviderManager { this._context = context; } - public setCore(context: AIProviderManagerContext): void { - this.setContext(context); - } - public async init(): Promise { // Initialize Session ID from secure storage. const secureSid = await this._getSecureVal('ai_session_id').catch((error: unknown) => { @@ -184,10 +178,13 @@ export class AIProviderManager { // --- Private Helpers --- private async _resolveHasApiKey(providerId: string): Promise { - const secretService = resolveProviderSecretService(providerId); + const secretService = this._getCatalogProvider(providerId)?.providerPolicy?.secretService; if (secretService === null) { return false; } + if (secretService === undefined) { + return false; + } return await this._hasSecureVal(secretService); } @@ -195,14 +192,17 @@ export class AIProviderManager { /** * Returns true if the provider ID represents a local engine * (not a cloud API provider requiring an API key). - * Any ID that doesn't match a known cloud provider prefix is treated as local. */ private _isLocalProvider(providerId: string): boolean { - if (isCustomProviderId(providerId)) { - return false; + const policy = this._getCatalogProvider(providerId)?.providerPolicy; + if (policy !== null && policy !== undefined) { + return !policy.isCloudProvider; } - return !isCloudProviderId(providerId); + this._tracer.error( + `[AIProviderManager] Missing provider policy for "${providerId}", denying startup`, + ); + return false; } private _getPersistedModel(providerId: string): string | null { @@ -247,6 +247,10 @@ export class AIProviderManager { return Array.isArray(aiCatalog) ? (aiCatalog as IAICatalogApp[]) : []; } + private _getCatalogProvider(providerId: string): IAICatalogApp | null { + return this._getAiCatalogApps().find((provider) => provider.id === providerId) ?? null; + } + private async _getSecureVal(key: string): Promise { if (this._context?.tauriProvider.getSecureKey) { return await this._context.tauriProvider.getSecureKey(key); diff --git a/src/features/ai/services/EngineStatusService.test.ts b/src/features/ai/services/EngineStatusService.test.ts index 108e092c..ad1f9ae1 100644 --- a/src/features/ai/services/EngineStatusService.test.ts +++ b/src/features/ai/services/EngineStatusService.test.ts @@ -38,7 +38,7 @@ describe('EngineStatusService', () => { error: vi.fn(), }; service = new EngineStatusService(tracer); - service.setCore(core); + service.setContext(core); }); it('does not initialize outside tauri', () => { @@ -48,7 +48,7 @@ describe('EngineStatusService', () => { listen: vi.fn(), }, } as unknown as EngineStatusContext; - service.setCore(webCore); + service.setContext(webCore); service.init(); expect(webCore.tauriProvider.listen).not.toHaveBeenCalled(); }); @@ -247,7 +247,7 @@ describe('EngineStatusService', () => { listen: vi.fn(), }, } as unknown as EngineStatusContext; - service.setCore(webCore); + service.setContext(webCore); const noop = ( service as unknown as { _listen: (event: string, handler: (payload: unknown) => void) => () => void; @@ -258,7 +258,7 @@ describe('EngineStatusService', () => { const deferred: { resolve?: (fn: () => void) => void; reject?: (err: unknown) => void } = {}; - service.setCore(core); + service.setContext(core); vi.mocked(core.tauriProvider.listen).mockImplementation( () => new Promise<() => void>((resolve, reject) => { diff --git a/src/features/ai/services/EngineStatusService.ts b/src/features/ai/services/EngineStatusService.ts index a270316b..d4692a93 100644 --- a/src/features/ai/services/EngineStatusService.ts +++ b/src/features/ai/services/EngineStatusService.ts @@ -62,10 +62,6 @@ export class EngineStatusService { this._context = context; } - public setCore(context: EngineStatusContext): void { - this.setContext(context); - } - public init(): void { if (this._initialized) { return; diff --git a/src/features/ai/types/aiTypes.ts b/src/features/ai/types/aiTypes.ts index 6efdab1c..864b59db 100644 --- a/src/features/ai/types/aiTypes.ts +++ b/src/features/ai/types/aiTypes.ts @@ -3,6 +3,8 @@ * @description Domain-specific type definitions and contracts for the AI module infrastructure. */ +import type { CatalogProviderPolicy } from '@/shared/types/bindings'; + // ============================================================================ // Communication Contracts // ============================================================================ @@ -246,6 +248,7 @@ export interface IAICatalogApp { name?: string; type?: 'api' | 'local'; apiProviderData?: IAIProviderData; + providerPolicy?: CatalogProviderPolicy | null; } /** diff --git a/src/features/ai/ui/AISettingsContentRenderer.ts b/src/features/ai/ui/AISettingsContentRenderer.ts index ecf78cbf..b8cf5e1e 100644 --- a/src/features/ai/ui/AISettingsContentRenderer.ts +++ b/src/features/ai/ui/AISettingsContentRenderer.ts @@ -23,6 +23,7 @@ type AISettingsRenderContext = { savedModel: string; apiBaseUrl: string; showApiEndpointSelector: boolean; + usesCustomProviderKey: boolean; showModelStats: boolean; showCustomModelComposer: boolean; translate: TranslateFunc; @@ -105,7 +106,7 @@ export class AISettingsContentRenderer { } private _buildMarkup(context: AISettingsRenderContext): string { - if (context.viewPolicy.isCleanApp(context.appId)) { + if (context.viewPolicy.isCleanApp(context.app)) { return this._buildCleanAppMarkup(context); } @@ -128,15 +129,26 @@ export class AISettingsContentRenderer { private _buildProviderMarkup(context: AISettingsRenderContext): string { const { appId, savedModel, translate, viewPolicy } = context; const models = sortModelsByPrice(context.models); - const apiKeyLabel = context.showApiEndpointSelector + const apiKeyLabel = context.usesCustomProviderKey ? translate('ui.settings.api_key_label_custom', 'Custom provider API key') : translate('ui.settings.api_key_label_openrouter', 'OpenRouter API key'); - const apiKeyNote = context.showApiEndpointSelector + const apiKeyNote = context.usesCustomProviderKey ? translate('ui.settings.keys_encrypted_custom', 'Uses a custom provider key.') : translate( 'ui.settings.keys_encrypted_openrouter', 'Built-in cloud cards use OpenRouter.', ); + const apiKeyLinkTitle = translate( + 'ui.settings.manage_openrouter_keys_title', + 'Manage your OpenRouter API keys', + ); + const apiKeyTitle = context.usesCustomProviderKey + ? `${apiKeyLabel}` + : ` + + ${apiKeyLabel} + + `; return `
@@ -144,11 +156,7 @@ export class AISettingsContentRenderer {
-

🔑 - - ${apiKeyLabel} - -

+

🔑 ${apiKeyTitle}

@@ -184,7 +192,7 @@ export class AISettingsContentRenderer { translate, context.supportsThinking, context.thinkingLevel, - context.viewPolicy.shouldForceThinkingVisibility(appId), + context.viewPolicy.shouldForceThinkingVisibility(context.app), )} ${context.supportsInternetAccess ? renderInternetAccessSection(appId, translate, context.internetAccessEnabled) : ''} diff --git a/src/features/ai/ui/AISettingsKeyController.test.ts b/src/features/ai/ui/AISettingsKeyController.test.ts index c6725114..f9a02302 100644 --- a/src/features/ai/ui/AISettingsKeyController.test.ts +++ b/src/features/ai/ui/AISettingsKeyController.test.ts @@ -48,11 +48,11 @@ describe('AISettingsKeyController', () => { settingsService.getSecureKeyMeta.mockResolvedValue({ exists: true, length: 8 }); settingsService.getSecureKey.mockResolvedValue('secret-1'); - await controller.hydrateStoredMask(input, 'cloud'); + await controller.hydrateStoredMask(input, 'cloud_api_key'); expect(input.value).toBe('••••••••'); expect(input.dataset['storedMasked']).toBe('true'); - await controller.toggleVisibility(input, button, 'cloud'); + await controller.toggleVisibility(input, button, 'cloud_api_key'); expect(input.value).toBe('secret-1'); expect(input.dataset['storedRevealed']).toBe('true'); expect(button.innerHTML).toContain(''); @@ -69,14 +69,20 @@ describe('AISettingsKeyController', () => { settingsService.validateApiKey.mockResolvedValue(true); settingsService.saveSecureKey.mockResolvedValue(undefined); - await controller.checkKey(input, button, 'cloud', 'https://api.openai.com/v1'); + await controller.checkKey( + input, + button, + 'cloud_api_key', + 'cloud', + 'https://api.openai.com/v1', + ); expect(settingsService.validateApiKey).toHaveBeenCalledWith( 'cloud', 'typed-key', 'https://api.openai.com/v1', ); - expect(settingsService.saveSecureKey).toHaveBeenCalledWith('cloud', 'typed-key'); + expect(settingsService.saveSecureKey).toHaveBeenCalledWith('cloud_api_key', 'typed-key'); expect(input.dataset['storedMasked']).toBe('true'); expect(button.disabled).toBe(false); expect(button.innerHTML).toBe('Check'); @@ -114,7 +120,7 @@ describe('AISettingsKeyController', () => { input.value = 'typed-key'; input.dataset['keyDirty'] = 'true'; - await controllerWithDisappearingSettings.checkKey(input, button, 'cloud'); + await controllerWithDisappearingSettings.checkKey(input, button, 'cloud_api_key', 'cloud'); expect(validateOnlyService.validateApiKey).toHaveBeenCalledWith( 'cloud', @@ -140,9 +146,9 @@ describe('AISettingsKeyController', () => { input.value = ''; settingsService.removeSecureKey.mockResolvedValue(undefined); - await controller.checkKey(input, button, 'cloud'); + await controller.checkKey(input, button, 'cloud_api_key', 'cloud'); - expect(settingsService.removeSecureKey).toHaveBeenCalledWith('cloud'); + expect(settingsService.removeSecureKey).toHaveBeenCalledWith('cloud_api_key'); expect(settingsService.saveSecureKey).not.toHaveBeenCalled(); expect(input.dataset['storedMasked']).toBeUndefined(); expect(input.value).toBe(''); @@ -155,10 +161,10 @@ describe('AISettingsKeyController', () => { input.value = ''; settingsService.removeSecureKey.mockResolvedValue(undefined); - const removed = await controller.removeClearedStoredKey(input, 'cloud'); + const removed = await controller.removeClearedStoredKey(input, 'cloud_api_key'); expect(removed).toBe(true); - expect(settingsService.removeSecureKey).toHaveBeenCalledWith('cloud'); + expect(settingsService.removeSecureKey).toHaveBeenCalledWith('cloud_api_key'); expect(input.dataset['storedMasked']).toBeUndefined(); expect(input.dataset['storedRevealed']).toBeUndefined(); expect(input.dataset['keyDirty']).toBeUndefined(); @@ -175,7 +181,7 @@ describe('AISettingsKeyController', () => { input.value = ''; settingsService.removeSecureKey.mockRejectedValue(new Error('secure storage failed')); - const removed = await controller.removeClearedStoredKey(input, 'cloud'); + const removed = await controller.removeClearedStoredKey(input, 'cloud_api_key'); expect(removed).toBe(false); expect(input.dataset['storedMasked']).toBe('true'); @@ -208,7 +214,10 @@ describe('AISettingsKeyController', () => { tracer, }); - const removed = await controllerWithoutSettings.removeClearedStoredKey(input, 'cloud'); + const removed = await controllerWithoutSettings.removeClearedStoredKey( + input, + 'cloud_api_key', + ); expect(removed).toBe(false); expect(input.dataset['storedMasked']).toBe('true'); @@ -240,7 +249,7 @@ describe('AISettingsKeyController', () => { tracer, }); - await controllerWithoutSettings.checkKey(input, button, 'cloud'); + await controllerWithoutSettings.checkKey(input, button, 'cloud_api_key', 'cloud'); expect(input.dataset['keyDirty']).toBe('true'); expect(showToast).toHaveBeenCalledWith( diff --git a/src/features/ai/ui/AISettingsKeyController.ts b/src/features/ai/ui/AISettingsKeyController.ts index fa31a33b..264f0c49 100644 --- a/src/features/ai/ui/AISettingsKeyController.ts +++ b/src/features/ai/ui/AISettingsKeyController.ts @@ -24,8 +24,8 @@ type KeyControllerOptions = { export class AISettingsKeyController { public constructor(private readonly _options: KeyControllerOptions) {} - public async hydrateStoredMask(input: KeyInput, providerId: string): Promise { - const meta = await this._options.getSettingsService()?.getSecureKeyMeta(providerId); + public async hydrateStoredMask(input: KeyInput, secretService: string): Promise { + const meta = await this._options.getSettingsService()?.getSecureKeyMeta(secretService); if (meta?.exists === true) { this.applyStoredKeyMask(input, meta.length); } @@ -42,7 +42,7 @@ export class AISettingsKeyController { target.dataset['keyDirty'] = 'true'; } - public async removeClearedStoredKey(input: KeyInput, providerId: string): Promise { + public async removeClearedStoredKey(input: KeyInput, secretService: string): Promise { if (input.value.trim() !== '') { return false; } @@ -54,7 +54,7 @@ export class AISettingsKeyController { input.dataset['keyRemoveInFlight'] = 'true'; try { const settingsService = this._requireSettingsService(); - await settingsService.removeSecureKey(providerId); + await settingsService.removeSecureKey(secretService); this.clearStoredKeyMask(input); this._showToast( this._options.getTranslator()('ui.settings.key_removed', 'API key removed'), @@ -92,7 +92,7 @@ export class AISettingsKeyController { public async toggleVisibility( input: KeyInput | null, button: KeyButton | null, - providerId: string, + secretService: string, ): Promise { if (input === null || button === null) { return; @@ -103,7 +103,7 @@ export class AISettingsKeyController { input.dataset['storedRevealed'] !== 'true' ) { const settingsService = this._options.getSettingsService(); - const revealedKey = await settingsService?.getSecureKey(providerId); + const revealedKey = await settingsService?.getSecureKey(secretService); if (revealedKey === undefined || revealedKey === null || revealedKey === '') { this._showToast( this._options.getTranslator()( @@ -130,7 +130,8 @@ export class AISettingsKeyController { public async checkKey( input: KeyInput | null, button: KeyButton | null, - providerId: string, + secretService: string, + validationProviderId: string, validationBaseUrl?: string | undefined, ): Promise { if (input === null || button === null) { @@ -160,23 +161,23 @@ export class AISettingsKeyController { let isValid = false; if (shouldRemoveStoredKey) { - await this._requireSettingsService().removeSecureKey(providerId); + await this._requireSettingsService().removeSecureKey(secretService); this.clearStoredKeyMask(input); this.updateButtonState(button, 'success', this._options.icons.check); this._showToast(t('ui.settings.key_removed', 'API key removed'), 'success'); return; } else if (shouldValidateTypedKey) { - isValid = await this._validateKey(providerId, key, validationBaseUrl); + isValid = await this._validateKey(validationProviderId, key, validationBaseUrl); } else if (shouldValidateStoredKey) { isValid = await this._requireSettingsService().validateStoredApiKey( - providerId, + validationProviderId, validationBaseUrl, ); } if (isValid) { if (shouldValidateTypedKey && key !== '') { - await this._requireSettingsService().saveSecureKey(providerId, key); + await this._requireSettingsService().saveSecureKey(secretService, key); this.applyStoredKeyMask(input, key.length); } this.updateButtonState(button, 'success', this._options.icons.check); diff --git a/src/features/ai/ui/AISettingsRenderer.test.ts b/src/features/ai/ui/AISettingsRenderer.test.ts index ffbc5a6a..38ffaa52 100644 --- a/src/features/ai/ui/AISettingsRenderer.test.ts +++ b/src/features/ai/ui/AISettingsRenderer.test.ts @@ -7,7 +7,10 @@ vi.mock('dompurify', () => ({ })); import { aiSettingsRenderer } from './AISettingsRenderer'; -import { CUSTOM_TEXT_PROVIDER_ID } from '@/shared/utils/customProviderSupport'; +import { + CUSTOM_IMAGE_PROVIDER_ID, + CUSTOM_TEXT_PROVIDER_ID, +} from '@/shared/utils/customProviderSupport'; describe('AISettingsRenderer', () => { let customModelsState: Array<{ @@ -83,6 +86,56 @@ describe('AISettingsRenderer', () => { }, ]; + const openRouterPolicy = { + isCloudProvider: true, + isCustomProvider: false, + isCleanApp: false, + secretService: 'cloud_api_key', + keyProviderId: 'cloud', + keyProviderUrl: 'https://openrouter.ai/settings/keys', + usesCustomProviderKey: false, + showApiEndpointSelector: false, + showCustomModelComposer: false, + showModelStats: true, + supportsInternetAccess: true, + supportsThinking: true, + imageOnly: false, + }; + + const cleanAppPolicy = { + ...openRouterPolicy, + isCloudProvider: false, + isCleanApp: true, + secretService: null, + keyProviderId: null, + keyProviderUrl: null, + supportsInternetAccess: false, + supportsThinking: false, + }; + + const customTextPolicy = { + ...openRouterPolicy, + isCustomProvider: true, + secretService: 'custom_text_api_key', + keyProviderId: CUSTOM_TEXT_PROVIDER_ID, + keyProviderUrl: null, + usesCustomProviderKey: true, + showApiEndpointSelector: true, + showCustomModelComposer: true, + showModelStats: false, + supportsInternetAccess: false, + supportsThinking: false, + }; + + const customImagePolicy = { + ...customTextPolicy, + secretService: 'custom_image_api_key', + keyProviderId: CUSTOM_IMAGE_PROVIDER_ID, + showApiEndpointSelector: false, + showCustomModelComposer: false, + imageOnly: true, + }; + beforeEach(async () => { document.body.innerHTML = `
`; customModelsState = []; @@ -149,6 +202,7 @@ describe('AISettingsRenderer', () => { await aiSettingsRenderer.render(container, { id: 'axelate', name: 'Axelate', + providerPolicy: cleanAppPolicy, } as never); expect(container.textContent).toContain('Axelate Settings'); @@ -162,6 +216,7 @@ describe('AISettingsRenderer', () => { id: 'gpt', name: 'GPT', apiProviderData: { models }, + providerPolicy: openRouterPolicy, } as never); const input = container.querySelector('#gpt-api-key-input') as HTMLInputElement; @@ -181,6 +236,9 @@ describe('AISettingsRenderer', () => { expect(container.querySelectorAll('.ai-model-card')).toHaveLength(2); expect(container.textContent).toContain('Ctx: 128K'); expect(container.querySelector('.ai-api-endpoint-card')).toBeNull(); + expect(link.getAttribute('title')).toBe( + 'ui.settings.manage_openrouter_keys_title:Manage your OpenRouter API keys', + ); input.dispatchEvent(new FocusEvent('focus', { bubbles: true })); input.value = 'new-secret'; @@ -203,6 +261,25 @@ describe('AISettingsRenderer', () => { expect(i18nUI.applyTranslations).toHaveBeenCalled(); }); + it('does not open unsafe provider key URLs', async () => { + const container = document.getElementById('root') as HTMLElement; + + await aiSettingsRenderer.render(container, { + id: 'gpt', + name: 'GPT', + apiProviderData: { models }, + providerPolicy: { + ...openRouterPolicy, + keyProviderUrl: 'file:///C:/Users/FORLE/secrets.txt', + }, + } as never); + + const link = container.querySelector('#gpt-api-link') as HTMLElement; + link.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(tauri.openUrl).not.toHaveBeenCalled(); + }); + it('renders API endpoint presets only for custom text providers', async () => { const container = document.getElementById('root') as HTMLElement; @@ -210,6 +287,7 @@ describe('AISettingsRenderer', () => { id: CUSTOM_TEXT_PROVIDER_ID, name: 'Custom', apiProviderData: { models: [] }, + providerPolicy: customTextPolicy, } as never); const openAiEndpointCard = container.querySelector( @@ -226,7 +304,63 @@ describe('AISettingsRenderer', () => { 'ui.settings.api_endpoint_saved:API endpoint saved', 'success', ); - expect(settingsService.getSecureKeyMeta).toHaveBeenCalledWith(CUSTOM_TEXT_PROVIDER_ID); + expect(settingsService.getSecureKeyMeta).toHaveBeenCalledWith('custom_text_api_key'); + }); + + it('labels built-in providers as OpenRouter and custom text as a separate provider', async () => { + const container = document.getElementById('root') as HTMLElement; + + await aiSettingsRenderer.render(container, { + id: 'gpt', + name: 'OpenAI', + apiProviderData: { models }, + providerPolicy: openRouterPolicy, + } as never); + + expect(container.textContent).toContain('OpenRouter API key'); + expect(container.textContent).toContain('Built-in cloud cards use OpenRouter.'); + expect(container.textContent).not.toContain('Custom provider API key'); + expect(container.querySelector('.ai-api-endpoint-card')).toBeNull(); + + await aiSettingsRenderer.render(container, { + id: CUSTOM_TEXT_PROVIDER_ID, + name: 'Custom', + apiProviderData: { models: [] }, + providerPolicy: customTextPolicy, + } as never); + + expect(container.textContent).toContain('Custom provider API key'); + expect(container.textContent).toContain('Uses a custom provider key.'); + expect(container.textContent).not.toContain('Built-in cloud cards use OpenRouter.'); + expect(container.querySelector(`#${CUSTOM_TEXT_PROVIDER_ID}-api-link`)).toBeNull(); + expect( + container.querySelector('.ai-api-endpoint-card[data-provider="openrouter"]'), + ).not.toBeNull(); + expect( + container.querySelector('.ai-api-endpoint-card[data-provider="openai"]'), + ).not.toBeNull(); + expect( + container.querySelector('.ai-api-endpoint-card[data-provider="custom"]'), + ).not.toBeNull(); + }); + + it('labels custom image providers as custom without showing text endpoint controls', async () => { + const container = document.getElementById('root') as HTMLElement; + + await aiSettingsRenderer.render(container, { + id: CUSTOM_IMAGE_PROVIDER_ID, + name: 'Custom', + capability: 'image', + apiProviderData: { models: [] }, + providerPolicy: customImagePolicy, + } as never); + + expect(container.textContent).toContain('Custom provider API key'); + expect(container.textContent).toContain('Uses a custom provider key.'); + expect(container.textContent).not.toContain('Built-in cloud cards use OpenRouter.'); + expect(container.querySelector(`#${CUSTOM_IMAGE_PROVIDER_ID}-api-link`)).toBeNull(); + expect(container.querySelector('.ai-api-endpoint-card')).toBeNull(); + expect(settingsService.getSecureKeyMeta).toHaveBeenCalledWith('custom_image_api_key'); }); it('validates custom provider keys against the selected API endpoint', async () => { @@ -238,6 +372,7 @@ describe('AISettingsRenderer', () => { id: CUSTOM_TEXT_PROVIDER_ID, name: 'Custom', apiProviderData: { models: [] }, + providerPolicy: customTextPolicy, } as never); const input = document.getElementById( @@ -254,7 +389,7 @@ describe('AISettingsRenderer', () => { 'https://api.groq.com/openai/v1', ); expect(settingsService.saveSecureKey).toHaveBeenCalledWith( - CUSTOM_TEXT_PROVIDER_ID, + 'custom_text_api_key', 'gsk-valid-key', ); }); @@ -265,13 +400,14 @@ describe('AISettingsRenderer', () => { id: 'gpt', name: 'GPT', apiProviderData: { models }, + providerPolicy: openRouterPolicy, } as never); const input = document.getElementById('gpt-api-key-input') as HTMLInputElement; expect(input.type).toBe('text'); expect(input.dataset['storedMasked']).toBe('true'); await aiSettingsRenderer.toggleKeyVisibility('gpt'); - expect(settingsService.getSecureKey).toHaveBeenCalledWith('cloud'); + expect(settingsService.getSecureKey).toHaveBeenCalledWith('cloud_api_key'); expect(input.value).toBe('stored-secret'); expect(input.dataset['storedMasked']).toBe('true'); expect(input.dataset['storedRevealed']).toBe('true'); @@ -284,7 +420,7 @@ describe('AISettingsRenderer', () => { ).toBe(true); expect( document.getElementById('gpt-thinking-section')?.classList.contains('is-hidden'), - ).toBe(true); + ).toBe(false); expect(document.getElementById('gpt-model-stats')?.textContent).toContain( 'Stats unavailable', ); @@ -299,6 +435,7 @@ describe('AISettingsRenderer', () => { id: 'gpt', name: 'GPT', apiProviderData: { models }, + providerPolicy: openRouterPolicy, } as never); await aiSettingsRenderer.toggleKeyVisibility('gpt'); @@ -316,6 +453,7 @@ describe('AISettingsRenderer', () => { id: 'gpt', name: 'GPT', apiProviderData: { models }, + providerPolicy: openRouterPolicy, } as never); const input = document.getElementById('gpt-api-key-input') as HTMLInputElement; @@ -331,7 +469,7 @@ describe('AISettingsRenderer', () => { await aiSettingsRenderer.checkKey('gpt'); expect(button.classList.contains('success')).toBe(true); expect(showToast).toHaveBeenCalledWith('ui.settings.key_valid:Key is valid', 'success'); - expect(settingsService.saveSecureKey).toHaveBeenCalledWith('cloud', 'valid-key'); + expect(settingsService.saveSecureKey).toHaveBeenCalledWith('cloud_api_key', 'valid-key'); expect(input.value).toBe('•••••••••'); expect(input.dataset['storedMasked']).toBe('true'); @@ -344,7 +482,7 @@ describe('AISettingsRenderer', () => { input.dispatchEvent(new Event('input', { bubbles: true })); await Promise.resolve(); await Promise.resolve(); - expect(settingsService.removeSecureKey).toHaveBeenCalledWith('cloud'); + expect(settingsService.removeSecureKey).toHaveBeenCalledWith('cloud_api_key'); expect(showToast).toHaveBeenCalledWith( 'ui.settings.key_removed:API key removed', 'success', @@ -385,6 +523,7 @@ describe('AISettingsRenderer', () => { id: 'gpt', name: 'GPT', apiProviderData: { models }, + providerPolicy: openRouterPolicy, } as never); const input = document.getElementById('gpt-api-key-input') as HTMLInputElement; @@ -413,12 +552,13 @@ describe('AISettingsRenderer', () => { id: CUSTOM_TEXT_PROVIDER_ID, name: 'Custom', apiProviderData: { models }, + providerPolicy: customTextPolicy, } as never); expect(container.querySelector(`#${CUSTOM_TEXT_PROVIDER_ID}-model-stats`)).toBeNull(); }); - it('shows the thinking section on first render for custom text providers', async () => { + it('does not show OpenRouter-only thinking or internet controls for custom text providers', async () => { const container = document.getElementById('root') as HTMLElement; customModelsState = [ { @@ -434,13 +574,11 @@ describe('AISettingsRenderer', () => { id: CUSTOM_TEXT_PROVIDER_ID, name: 'Custom', apiProviderData: { models: [] }, + providerPolicy: customTextPolicy, } as never); - expect( - container - .querySelector(`#${CUSTOM_TEXT_PROVIDER_ID}-thinking-section`) - ?.classList.contains('is-hidden'), - ).toBe(false); + expect(container.querySelector(`#${CUSTOM_TEXT_PROVIDER_ID}-thinking-section`)).toBeNull(); + expect(container.querySelector(`#${CUSTOM_TEXT_PROVIDER_ID}-internet-section`)).toBeNull(); }); it('adds a custom model from the composer card and derives the title from model id', async () => { @@ -450,6 +588,7 @@ describe('AISettingsRenderer', () => { id: CUSTOM_TEXT_PROVIDER_ID, name: 'Custom', apiProviderData: { models: [] }, + providerPolicy: customTextPolicy, } as never); const input = container.querySelector( @@ -487,6 +626,7 @@ describe('AISettingsRenderer', () => { id: CUSTOM_TEXT_PROVIDER_ID, name: 'Custom', apiProviderData: { models: [] }, + providerPolicy: customTextPolicy, } as never); const removeButton = container.querySelector( diff --git a/src/features/ai/ui/AISettingsRenderer.ts b/src/features/ai/ui/AISettingsRenderer.ts index 676f0455..dfe4e74c 100644 --- a/src/features/ai/ui/AISettingsRenderer.ts +++ b/src/features/ai/ui/AISettingsRenderer.ts @@ -8,14 +8,10 @@ import type { ThinkingLevel } from '@/shared/services/state/UiStateStore'; import type { I18nUI } from '@/infrastructure/i18n/I18nUI'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { IAIModelData } from '../types/aiTypes'; +import type { CatalogProviderPolicy } from '@/shared/types/bindings'; import { BaseComponent } from '@/shared/ui/BaseComponent'; import { type TauriProvider } from '@/infrastructure/tauri/TauriProvider'; -import { CUSTOM_TEXT_PROVIDER_ID, isCustomProviderId } from '@/shared/utils/customProviderSupport'; -import { - getSharedCloudSecretService, - resolveProviderSecretService, - SHARED_CLOUD_KEY_PROVIDER_ID, -} from '@/shared/utils/providerSupport'; +import { isCustomProviderId } from '@/shared/utils/customProviderSupport'; import { bindAISettingsInteractions } from './AISettingsInteractionBinder'; import { AISettingsViewPolicy } from './AISettingsViewPolicy'; import { AISettingsKeyController } from './AISettingsKeyController'; @@ -154,6 +150,7 @@ class AISettingsRenderer extends BaseComponent { const appId = app.id; const models = await this._getProviderModels(app); + const providerPolicy = this._resolveProviderPolicy(app); const firstModel = models.length > 0 ? models[0] : undefined; const defaultModelId = firstModel ? firstModel.id : ''; @@ -171,13 +168,17 @@ class AISettingsRenderer extends BaseComponent { models, savedModel, apiBaseUrl: this._getApiBaseUrl(app), - showApiEndpointSelector: appId === CUSTOM_TEXT_PROVIDER_ID, - showModelStats: this._viewPolicy.shouldShowModelStats(appId), - showCustomModelComposer: isCustomProviderId(appId), + showApiEndpointSelector: providerPolicy.showApiEndpointSelector, + usesCustomProviderKey: providerPolicy.usesCustomProviderKey, + showModelStats: this._viewPolicy.shouldShowModelStats({ + ...app, + providerPolicy, + }), + showCustomModelComposer: providerPolicy.showCustomModelComposer, translate: t, viewPolicy: this._viewPolicy, - supportsInternetAccess: this._viewPolicy.supportsInternetAccess(appId, app.capability), - supportsThinking: this._viewPolicy.supportsThinking(appId, models), + supportsInternetAccess: providerPolicy.supportsInternetAccess, + supportsThinking: providerPolicy.supportsThinking, thinkingLevel: this._selectionController.getThinkingLevel(appId, this._aiSettings), internetAccessEnabled: this._selectionController.getInternetAccessEnabled( appId, @@ -220,15 +221,15 @@ class AISettingsRenderer extends BaseComponent { this._renderAbortController = new AbortController(); const renderSignal = this._renderAbortController.signal; - const keyProviderId = this._getKeyProviderId(appId); + const secretService = this._getSecretService(appId); const input = container.querySelector(`#${appId}-api-key-input`) as | HTMLInputElement | HTMLTextAreaElement | null; - if (input !== null) { - await this._keyController.hydrateStoredMask(input, keyProviderId); + if (input !== null && secretService !== null) { + await this._keyController.hydrateStoredMask(input, secretService); } bindAISettingsInteractions({ appId, @@ -245,7 +246,7 @@ class AISettingsRenderer extends BaseComponent { target.dataset['storedRevealed'] === 'true'; this._keyController.normalizeInput(event); if (hadStoredKey) { - void this._removeClearedStoredKey(target, keyProviderId, appId); + void this._removeClearedStoredKey(target, secretService, appId); } }, maybeClearStoredMask: (event) => { @@ -253,7 +254,10 @@ class AISettingsRenderer extends BaseComponent { }, openKeyProviderUrl: () => { if (this._tauri) { - void this._tauri.openUrl(this._getKeyProviderUrl(keyProviderId)); + const url = this._getKeyProviderUrl(appId); + if (url !== null) { + void this._tauri.openUrl(url); + } } }, toggleKeyVisibility: async () => this.toggleKeyVisibility(appId), @@ -285,7 +289,11 @@ class AISettingsRenderer extends BaseComponent { `#${appId}-api-key-input`, ); const btn = this._queryActiveElement(`#${appId}-key-toggle-btn`); - await this._keyController.toggleVisibility(input, btn, this._getKeyProviderId(appId)); + const secretService = this._getSecretService(appId); + if (secretService === null) { + return; + } + await this._keyController.toggleVisibility(input, btn, secretService); } /** @@ -299,20 +307,29 @@ class AISettingsRenderer extends BaseComponent { `#${appId}-api-key-input`, ); const btn = this._queryActiveElement(`#${appId}-key-check-btn`); + const secretService = this._getSecretService(appId); + if (secretService === null) { + return; + } await this._keyController.checkKey( input, btn, - this._getKeyProviderId(appId), + secretService, + this._getValidationProviderId(appId), this._getValidationBaseUrl(appId), ); } private async _removeClearedStoredKey( input: KeyInput, - keyProviderId: string, + secretService: string | null, appId: string, ): Promise { - const removed = await this._keyController.removeClearedStoredKey(input, keyProviderId); + if (secretService === null) { + return; + } + + const removed = await this._keyController.removeClearedStoredKey(input, secretService); if (removed && input.value.trim() === '') { this._resetKeyCheckButton(appId); } @@ -334,6 +351,7 @@ class AISettingsRenderer extends BaseComponent { i18nUI: this._i18nUI, contentRenderer: this._contentRenderer, viewPolicy: this._viewPolicy, + app: this._activeRenderTarget?.app ?? { id: appId }, }); } @@ -381,7 +399,7 @@ class AISettingsRenderer extends BaseComponent { .map((model) => ({ id: model.id, name: model.name.trim() !== '' ? model.name : model.id, - desc: translate('ui.settings.custom_model_desc', 'Manual OpenRouter model ID'), + desc: translate('ui.settings.custom_model_desc', ''), isCustom: true, })); @@ -523,7 +541,7 @@ class AISettingsRenderer extends BaseComponent { } private _getValidationBaseUrl(appId: string): string | undefined { - if (appId !== CUSTOM_TEXT_PROVIDER_ID) { + if (this._getActiveProviderPolicy(appId)?.showApiEndpointSelector !== true) { return undefined; } @@ -538,19 +556,57 @@ class AISettingsRenderer extends BaseComponent { await this.render(this._activeRenderTarget.container, this._activeRenderTarget.app); } - private _getKeyProviderId(appId: string): string { - const secretService = resolveProviderSecretService(appId); - return secretService !== null && secretService !== getSharedCloudSecretService() - ? appId - : SHARED_CLOUD_KEY_PROVIDER_ID; + private _getSecretService(appId: string): string | null { + return this._getActiveProviderPolicy(appId)?.secretService ?? null; } - private _getKeyProviderUrl(providerId: string): string { - return ( - { - [SHARED_CLOUD_KEY_PROVIDER_ID]: 'https://openrouter.ai/settings/keys', - }[providerId] ?? '#' - ); + private _getValidationProviderId(appId: string): string { + return this._getActiveProviderPolicy(appId)?.keyProviderId ?? appId; + } + + private _getKeyProviderUrl(appId: string): string | null { + const rawUrl = this._getActiveProviderPolicy(appId)?.keyProviderUrl; + if (typeof rawUrl !== 'string') { + return null; + } + + try { + const url = new URL(rawUrl.trim()); + return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : null; + } catch { + return null; + } + } + + private _getActiveProviderPolicy(appId: string): IApp['providerPolicy'] | null { + const app = this._activeRenderTarget?.app; + if (app?.id !== appId) { + return null; + } + + return this._resolveProviderPolicy(app); + } + + private _resolveProviderPolicy(app: IApp): CatalogProviderPolicy { + if (app.providerPolicy !== null && app.providerPolicy !== undefined) { + return app.providerPolicy; + } + + return { + isCloudProvider: false, + isCustomProvider: false, + isCleanApp: this._viewPolicy.isCleanApp(app.id), + secretService: null, + keyProviderId: null, + keyProviderUrl: null, + usesCustomProviderKey: false, + showApiEndpointSelector: false, + showCustomModelComposer: false, + showModelStats: true, + supportsInternetAccess: false, + supportsThinking: false, + imageOnly: app.capability === 'image', + }; } private _queryActiveElement(selector: string): T | null { diff --git a/src/features/ai/ui/AISettingsSelectionController.ts b/src/features/ai/ui/AISettingsSelectionController.ts index 4cab18c1..6befc0e1 100644 --- a/src/features/ai/ui/AISettingsSelectionController.ts +++ b/src/features/ai/ui/AISettingsSelectionController.ts @@ -1,6 +1,7 @@ import type { I18nUI } from '@/infrastructure/i18n/I18nUI'; import type { ThinkingLevel } from '@/shared/services/state/UiStateStore'; import type { AISettingsService } from '@/shared/services/ai/AISettingsService'; +import type { IApp } from '@/shared/types/coreTypes'; import type { IAIModelData } from '../types/aiTypes'; import { getModelDataFromModels } from '../utils/catalogHelpers'; import { renderModelStats } from './AISettingsMarkup'; @@ -16,6 +17,7 @@ type AISettingsSelectionRenderState = { }; type AISettingsSelectionSyncOptions = { + app: IApp; appId: string; modelKey: string; aiSettings: AISettingsService | null; @@ -79,7 +81,7 @@ export class AISettingsSelectionController { const modelData = this.getModelData(options.appId, options.modelKey); const hasReasoning = modelData?.capabilities?.reasoning === true || - options.viewPolicy.shouldForceThinkingVisibility(options.appId); + options.viewPolicy.shouldForceThinkingVisibility(options.app); const statsMarkup = this.renderModelStats( options.appId, options.modelKey, diff --git a/src/features/ai/ui/AISettingsViewPolicy.test.ts b/src/features/ai/ui/AISettingsViewPolicy.test.ts index 9e7bb5ad..09c4cf1b 100644 --- a/src/features/ai/ui/AISettingsViewPolicy.test.ts +++ b/src/features/ai/ui/AISettingsViewPolicy.test.ts @@ -9,26 +9,76 @@ describe('AISettingsViewPolicy', () => { const policy = new AISettingsViewPolicy(); it('should classify clean apps and feature support consistently', () => { - expect(policy.isCleanApp('axelate')).toBe(true); + const gptApp = { + id: 'gpt', + providerPolicy: { + isCloudProvider: true, + isCustomProvider: false, + isCleanApp: false, + secretService: 'cloud_api_key', + keyProviderId: 'cloud', + keyProviderUrl: 'https://openrouter.ai/settings/keys', + usesCustomProviderKey: false, + showApiEndpointSelector: false, + showCustomModelComposer: false, + showModelStats: true, + supportsInternetAccess: true, + supportsThinking: true, + imageOnly: false, + }, + }; + const customTextApp = { + id: CUSTOM_TEXT_PROVIDER_ID, + providerPolicy: { + ...gptApp.providerPolicy, + isCustomProvider: true, + secretService: 'custom_text_api_key', + keyProviderId: CUSTOM_TEXT_PROVIDER_ID, + keyProviderUrl: null, + usesCustomProviderKey: true, + showApiEndpointSelector: true, + showCustomModelComposer: true, + showModelStats: false, + supportsInternetAccess: false, + supportsThinking: false, + }, + }; + const imageApp = { + id: 'gemini-image', + capability: 'image' as const, + providerPolicy: { + ...gptApp.providerPolicy, + supportsInternetAccess: false, + supportsThinking: false, + imageOnly: true, + }, + }; + + expect(policy.isCleanApp('axelate')).toBe(false); + expect( + policy.isCleanApp({ + id: 'axelate', + providerPolicy: { ...gptApp.providerPolicy, isCleanApp: true }, + }), + ).toBe(true); expect(policy.isCleanApp('sample-integration')).toBe(false); expect(policy.isCleanApp('gpt')).toBe(false); - expect(policy.supportsInternetAccess('gpt', 'text')).toBe(true); - expect(policy.supportsInternetAccess('axelate')).toBe(false); - expect(policy.supportsInternetAccess('gemini-image', 'image')).toBe(false); - expect(policy.supportsInternetAccess('seedream-image', 'image')).toBe(false); - expect(policy.supportsInternetAccess(CUSTOM_TEXT_PROVIDER_ID, 'text')).toBe(true); + expect(policy.supportsInternetAccess(gptApp)).toBe(true); + expect(policy.supportsInternetAccess({ id: 'axelate' })).toBe(false); + expect(policy.supportsInternetAccess(imageApp)).toBe(false); + expect(policy.supportsInternetAccess(customTextApp)).toBe(false); + expect(policy.supportsThinking(gptApp)).toBe(true); + expect(policy.supportsThinking({ id: 'openrouter' })).toBe(false); + expect(policy.supportsThinking(customTextApp)).toBe(false); + expect(policy.isImageOnlyProvider(imageApp)).toBe(true); expect( - policy.supportsThinking('gpt', [ - { id: 'reasoner', capabilities: { reasoning: true } } as never, - ]), + policy.isImageOnlyProvider({ + id: CUSTOM_IMAGE_PROVIDER_ID, + capability: 'image', + }), ).toBe(true); - expect(policy.supportsThinking('openrouter', [])).toBe(false); - expect(policy.supportsThinking(CUSTOM_TEXT_PROVIDER_ID)).toBe(true); - expect(policy.isImageOnlyProvider('gemini-image', 'image')).toBe(true); - expect(policy.isImageOnlyProvider('seedream-image', 'image')).toBe(true); - expect(policy.isImageOnlyProvider(CUSTOM_IMAGE_PROVIDER_ID)).toBe(true); - expect(policy.shouldShowModelStats(CUSTOM_TEXT_PROVIDER_ID)).toBe(false); - expect(policy.shouldForceThinkingVisibility(CUSTOM_TEXT_PROVIDER_ID)).toBe(true); + expect(policy.shouldShowModelStats(customTextApp)).toBe(false); + expect(policy.shouldForceThinkingVisibility(customTextApp)).toBe(false); }); it('should format context windows compactly', () => { diff --git a/src/features/ai/ui/AISettingsViewPolicy.ts b/src/features/ai/ui/AISettingsViewPolicy.ts index 867be8d2..f067e1bb 100644 --- a/src/features/ai/ui/AISettingsViewPolicy.ts +++ b/src/features/ai/ui/AISettingsViewPolicy.ts @@ -1,38 +1,33 @@ -import { - CUSTOM_TEXT_PROVIDER_ID, - isCustomProviderId, - isCustomImageProviderId, -} from '@/shared/utils/customProviderSupport'; -import type { IAIModelData } from '../types/aiTypes'; +import type { IApp } from '@/shared/types/coreTypes'; export class AISettingsViewPolicy { - private static readonly _cleanAppIds = new Set(['axelate', 'axelate-platform']); + public isCleanApp(app: IApp | string): boolean { + const policy = typeof app === 'string' ? null : app.providerPolicy; + if (policy?.isCleanApp !== undefined) { + return policy.isCleanApp; + } - public isCleanApp(appId: string): boolean { - return AISettingsViewPolicy._cleanAppIds.has(appId); + return false; } - public supportsInternetAccess(appId: string, capability?: 'text' | 'image'): boolean { - return !this.isCleanApp(appId) && capability !== 'image' && !isCustomImageProviderId(appId); + public supportsInternetAccess(app: IApp): boolean { + return app.providerPolicy?.supportsInternetAccess ?? false; } - public supportsThinking(appId: string, models: readonly IAIModelData[] = []): boolean { - return ( - models.some((model) => model.capabilities?.reasoning === true) || - appId === CUSTOM_TEXT_PROVIDER_ID - ); + public supportsThinking(app: IApp): boolean { + return app.providerPolicy?.supportsThinking ?? false; } - public isImageOnlyProvider(appId: string, capability?: 'text' | 'image'): boolean { - return capability === 'image' || isCustomImageProviderId(appId); + public isImageOnlyProvider(app: IApp): boolean { + return app.providerPolicy?.imageOnly ?? app.capability === 'image'; } - public shouldShowModelStats(appId: string): boolean { - return !isCustomProviderId(appId); + public shouldShowModelStats(app: IApp): boolean { + return app.providerPolicy?.showModelStats ?? true; } - public shouldForceThinkingVisibility(appId: string): boolean { - return appId === CUSTOM_TEXT_PROVIDER_ID; + public shouldForceThinkingVisibility(app: IApp): boolean { + return app.providerPolicy?.supportsThinking === true && this.supportsThinking(app); } public formatCompactContext(contextWindow: number): string { diff --git a/src/features/console/services/ConsoleLogNormalizer.ts b/src/features/console/services/ConsoleLogNormalizer.ts index 85ad8443..3321df5b 100644 --- a/src/features/console/services/ConsoleLogNormalizer.ts +++ b/src/features/console/services/ConsoleLogNormalizer.ts @@ -12,16 +12,17 @@ export class ConsoleLogNormalizer { message: parsed.message, source, module_id: log.module_id ?? this._resolveModuleId(source, parsed.message), - display_time: parsed.time, - normalized_level: parsed.level, - scope: parsed.scope, - summary_message: summaryMessage, - source_label: this._formatSourceLabel(normalizedSource, source), + display_time: log.display_time ?? parsed.time, + normalized_level: log.normalized_level ?? parsed.level, + scope: log.scope ?? parsed.scope, + summary_message: log.summary_message ?? summaryMessage, + source_label: log.source_label ?? this._formatSourceLabel(normalizedSource, source), source_class: - source.startsWith('module:') === true ? 'src-MODULE' : `src-${normalizedSource}`, - page: this._extractPage(parsed.message), - action: this._extractAction(parsed.message), - expected: this._extractExpected(parsed.message), + log.source_class ?? + (source.startsWith('module:') === true ? 'src-MODULE' : `src-${normalizedSource}`), + page: log.page ?? this._extractPage(parsed.message), + action: log.action ?? this._extractAction(parsed.message), + expected: log.expected ?? this._extractExpected(parsed.message), }; } diff --git a/src/features/console/services/ConsoleLogService.test.ts b/src/features/console/services/ConsoleLogService.test.ts index 6ce0cf27..cec348af 100644 --- a/src/features/console/services/ConsoleLogService.test.ts +++ b/src/features/console/services/ConsoleLogService.test.ts @@ -15,10 +15,14 @@ describe('ConsoleLogService', () => { beforeEach(() => { vi.restoreAllMocks(); bridge = createMockBridge(); - service = new ConsoleLogService(bridge, { - warn: vi.fn(), - error: vi.fn(), - }); + service = new ConsoleLogService( + bridge, + { + warn: vi.fn(), + error: vi.fn(), + }, + (key) => key, + ); }); it('fetches only the requested console view in Tauri mode', async () => { @@ -132,11 +136,173 @@ describe('ConsoleLogService', () => { await expect(service.getAvailableViews()).resolves.toEqual([ { id: 'general', label: 'Platform' }, + { id: 'agent', label: 'ui.launcher.web.logs_agent' }, { id: 'module:custom-text', label: 'Custom' }, { id: 'module:axelate-telegram-parser', label: 'Parser' }, ]); }); + it('maps Agent Control audit entries into the agent console view', async () => { + setupTauri(bridge, true); + vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ + status: 'ok', + data: { + enabled: true, + apiBaseUrl: 'http://127.0.0.1:3000', + profiles: [], + approvals: [], + audit: [ + { + id: 'audit-2', + actorId: 'agent-1', + actorName: 'Trusted Local', + action: 'module.stop', + target: 'llamacpp', + result: 'denied', + createdAt: '2026-05-22T12:00:02Z', + }, + { + id: 'audit-1', + actorId: 'agent-1', + actorName: 'Trusted Local', + action: 'launcher.open-page', + target: 'settings', + result: 'success', + createdAt: '2026-05-22T12:00:01Z', + }, + ], + }, + }); + + const logs = await service.fetchLogs('agent'); + + expect(logs).toEqual([ + expect.objectContaining({ + source: 'agent-control', + source_label: 'Trusted Local', + normalized_level: 'INFO', + scope: 'launcher.open-page', + summary_message: 'settings -> success', + }), + expect.objectContaining({ + source_label: 'Trusted Local', + normalized_level: 'WARN', + scope: 'module.stop', + summary_message: 'llamacpp -> denied', + }), + ]); + expect(service.getLogsForView('agent').map((entry) => entry.summary_message)).toEqual([ + 'settings -> success', + 'llamacpp -> denied', + ]); + }); + + it('clears the agent console view locally without calling log file commands', async () => { + setupTauri(bridge, true); + vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ + status: 'ok', + data: { + enabled: true, + apiBaseUrl: 'http://127.0.0.1:3000', + profiles: [], + approvals: [], + audit: [ + { + id: 'audit-1', + actorId: 'agent-1', + actorName: 'Trusted Local', + action: 'launcher.open-page', + target: 'console', + result: 'success', + createdAt: '2026-05-22T12:00:01Z', + }, + ], + }, + }); + + await service.fetchLogs('agent'); + const cleared = await service.clearLogs('agent'); + + expect(cleared).toBe(true); + expect(bridge.invoke).not.toHaveBeenCalledWith('clear_console_logs', expect.anything()); + expect(service.getLogsForView('agent')).toEqual([]); + }); + + it('clears agent audit logs during clearAllLogs without clearing console log files for agent', async () => { + setupTauri(bridge, true); + vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ + status: 'ok', + data: { + enabled: true, + apiBaseUrl: 'http://127.0.0.1:3000', + profiles: [], + approvals: [], + audit: [ + { + id: 'audit-1', + actorId: 'agent-1', + actorName: 'Trusted Local', + action: 'launcher.open-page', + target: 'console', + result: 'success', + createdAt: '2026-05-22T12:00:01Z', + }, + ], + }, + }); + + await service.fetchLogs('agent'); + const cleared = await service.clearAllLogs(); + const afterClear = await service.fetchLogs('agent'); + + expect(cleared).toBe(true); + expect(bridge.invoke).toHaveBeenCalledWith('clear_logs'); + expect(bridge.invoke).not.toHaveBeenCalledWith('clear_console_logs', { + viewId: 'agent', + }); + expect(afterClear).toEqual([]); + expect(service.getLogsForView('agent')).toEqual([]); + }); + + it('keeps known runtime logs out of the general view after overview sync', async () => { + setupTauri(bridge, true); + vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ + status: 'ok', + data: { + views: [ + { id: 'general', label: 'Platform' }, + { id: 'engine:llamacpp', label: 'LLaMA.cpp' }, + { id: 'module:axelate-telegram-parser', label: 'Parser' }, + ], + status_items: [], + }, + }); + vi.mocked(bridge.invoke).mockResolvedValue([ + { timestamp: 10, source: 'frontend', level: 'INFO', message: 'platform' }, + { timestamp: 11, source: 'llamacpp', level: 'DEBUG', message: 'engine debug' }, + { + timestamp: 12, + source: 'module:axelate-telegram-parser', + level: 'INFO', + message: 'module info', + module_id: 'axelate-telegram-parser', + }, + ] satisfies ILogEntry[]); + + await service.getAvailableViews(); + const logs = await service.fetchLogs('general'); + + expect(logs).toEqual([ + expect.objectContaining({ + source: 'frontend', + message: 'platform', + }), + ]); + expect(service.getLogsForView('general').map((entry) => entry.message)).toEqual([ + 'platform', + ]); + }); + it('clears only the requested view', async () => { setupTauri(bridge, true); vi.mocked(bridge.invoke) diff --git a/src/features/console/services/ConsoleLogService.ts b/src/features/console/services/ConsoleLogService.ts index 69f72614..66541238 100644 --- a/src/features/console/services/ConsoleLogService.ts +++ b/src/features/console/services/ConsoleLogService.ts @@ -1,6 +1,7 @@ import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import { invokeSafe } from '@/shared/api/invoke'; import type { IBridge } from '@/shared/types/IBridge'; +import type { AgentAuditEntry, AgentControlState } from '@/shared/types/bindings'; import { ConsoleLogNormalizer } from './ConsoleLogNormalizer'; export interface ILogEntry { @@ -47,18 +48,23 @@ type ConsoleOverviewPayload = { }; type ConsoleLogServiceLogger = Pick; +type ConsoleLogTranslate = (key: string, fallback?: string) => string; export class ConsoleLogService { private static readonly _MAX_LOG_COUNT_PER_VIEW = 1200; + private static readonly _AGENT_VIEW_ID = 'agent'; private readonly _logsByView = new Map(); private readonly _lastTimestampByView = new Map(); private readonly _modulePathCache = new Map(); + private readonly _knownViewIds = new Set(['general', ConsoleLogService._AGENT_VIEW_ID]); private readonly _normalizer = new ConsoleLogNormalizer(); + private _agentAuditClearTimestamp = 0; constructor( private readonly bridge: IBridge, private readonly _tracer: ConsoleLogServiceLogger, + private readonly _translate: ConsoleLogTranslate, ) {} public init(): Promise { @@ -68,11 +74,20 @@ export class ConsoleLogService { public destroy(): void { this._logsByView.clear(); this._lastTimestampByView.clear(); + this._knownViewIds.clear(); + this._knownViewIds.add('general'); + this._knownViewIds.add(ConsoleLogService._AGENT_VIEW_ID); + this._agentAuditClearTimestamp = 0; } public async fetchLogs(viewId = 'general'): Promise { const normalizedViewId = this._canonicalViewId(viewId); - const since = this._lastTimestampByView.get(normalizedViewId) ?? 0; + const since = Math.max( + this._lastTimestampByView.get(normalizedViewId) ?? 0, + normalizedViewId === ConsoleLogService._AGENT_VIEW_ID + ? this._agentAuditClearTimestamp + : 0, + ); try { const logs = await this._fetchTauriLogs(normalizedViewId, since); @@ -87,6 +102,11 @@ export class ConsoleLogService { const normalizedViewId = this._canonicalViewId(viewId); try { + if (normalizedViewId === ConsoleLogService._AGENT_VIEW_ID) { + this._clearLocalAgentLogs(); + return true; + } + await this.bridge.invoke('clear_console_logs', { viewId: normalizedViewId }); this._logsByView.set(normalizedViewId, []); this._lastTimestampByView.set(normalizedViewId, 0); @@ -102,6 +122,7 @@ export class ConsoleLogService { await this.bridge.invoke('clear_logs'); this._logsByView.clear(); this._lastTimestampByView.clear(); + this._clearLocalAgentLogs(); return true; } catch (error) { this._tracer.error('[ConsoleLogService] Clear all logs failed:', error); @@ -125,7 +146,9 @@ export class ConsoleLogService { try { const result = await invokeSafe('get_console_overview'); if (result.status === 'ok') { - return this._normalizeViews(result.data.views); + const views = this._withAgentView(this._normalizeViews(result.data.views)); + this._rememberKnownViews(views); + return views; } } catch (error) { this._tracer.warn( @@ -133,7 +156,9 @@ export class ConsoleLogService { ); } - return [{ id: 'general', label: 'Platform' }]; + return this._withAgentView([ + { id: 'general', label: this._translate('ui.launcher.web.logs_general') }, + ]); } public async getStatusItems(): Promise { @@ -202,9 +227,14 @@ export class ConsoleLogService { return false; } + const normalizedViewId = this._canonicalViewId(viewId); + if (normalizedViewId === ConsoleLogService._AGENT_VIEW_ID) { + return false; + } + try { await this.bridge.invoke('open_console_log_target', { - viewId: this._canonicalViewId(viewId), + viewId: normalizedViewId, }); return true; } catch (error) { @@ -214,6 +244,10 @@ export class ConsoleLogService { } private async _fetchTauriLogs(viewId: string, since: number): Promise { + if (viewId === ConsoleLogService._AGENT_VIEW_ID) { + return await this._fetchAgentAuditLogs(since); + } + if (!this.bridge.isTauri()) { return await this.bridge.invoke('get_logs', { since }); } @@ -221,12 +255,63 @@ export class ConsoleLogService { return await this.bridge.invoke('get_console_logs', { viewId, since }); } + private async _fetchAgentAuditLogs(since: number): Promise { + const result = await invokeSafe('get_agent_control_state'); + if (result.status !== 'ok') { + throw new Error(result.error.message); + } + + return result.data.audit + .map((entry) => this._mapAgentAuditEntry(entry)) + .filter((entry) => entry.timestamp > since) + .sort((left, right) => left.timestamp - right.timestamp); + } + + private _mapAgentAuditEntry(entry: AgentAuditEntry): ILogEntry { + const timestamp = this._agentAuditTimestamp(entry.createdAt); + const action = entry.action.trim(); + const target = entry.target.trim(); + const result = entry.result.trim(); + const level = this._agentAuditLevel(result); + const actorName = entry.actorName.trim() || this._translate('ui.launcher.web.logs_agent'); + const targetText = + target === '' ? this._translate('ui.debug.logs_agent_target_launcher') : target; + const resultText = + result === '' ? this._translate('ui.debug.logs_agent_result_recorded') : result; + + return { + timestamp, + source: 'agent-control', + level, + message: `target=${targetText} result=${resultText}`, + module_id: null, + display_time: this._formatAgentAuditTime(timestamp), + normalized_level: level, + scope: action === '' ? null : action, + summary_message: `${targetText} -> ${resultText}`, + source_label: actorName, + source_class: 'src-AGENT', + action: action === '' ? null : action, + }; + } + private _appendLogs(viewId: string, newLogs: ILogEntry[]): ILogEntry[] { if (!Array.isArray(newLogs) || newLogs.length === 0) { return []; } - const normalizedLogs = newLogs.map((entry) => this._normalizer.normalize(entry)); + const normalizedLogs = this._filterLogsForView( + viewId, + newLogs.map((entry) => this._normalizer.normalize(entry)), + ); + if (normalizedLogs.length === 0) { + this._lastTimestampByView.set( + viewId, + newLogs.at(-1)?.timestamp ?? this._lastTimestampByView.get(viewId) ?? 0, + ); + return []; + } + const previousLogs = this._logsByView.get(viewId) ?? []; const existingKeys = new Set(previousLogs.map((entry) => this._dedupeKey(entry))); const appendedLogs = normalizedLogs.filter((entry) => { @@ -269,6 +354,57 @@ export class ConsoleLogService { return [...byId.values()]; } + private _withAgentView(views: readonly IConsoleLogView[]): IConsoleLogView[] { + if (views.some((view) => view.id === ConsoleLogService._AGENT_VIEW_ID)) { + return [...views]; + } + + const agentView = { + id: ConsoleLogService._AGENT_VIEW_ID, + label: this._translate('ui.launcher.web.logs_agent'), + }; + const generalIndex = views.findIndex((view) => view.id === 'general'); + if (generalIndex < 0) { + return [agentView, ...views]; + } + + return [...views.slice(0, generalIndex + 1), agentView, ...views.slice(generalIndex + 1)]; + } + + private _rememberKnownViews(views: readonly IConsoleLogView[]): void { + this._knownViewIds.clear(); + this._knownViewIds.add('general'); + this._knownViewIds.add(ConsoleLogService._AGENT_VIEW_ID); + views.forEach((view) => { + const id = this._canonicalViewId(view.id); + if (id !== '') { + this._knownViewIds.add(id); + } + }); + } + + private _filterLogsForView(viewId: string, logs: ILogEntry[]): ILogEntry[] { + if (viewId !== 'general') { + return logs; + } + + return logs.filter((entry) => !this._belongsToKnownRuntimeView(entry)); + } + + private _belongsToKnownRuntimeView(entry: ILogEntry): boolean { + const moduleId = entry.module_id?.trim(); + if (moduleId !== undefined && moduleId !== '') { + return this._knownViewIds.has(`module:${moduleId}`); + } + + if (entry.source.startsWith('module:')) { + return this._knownViewIds.has(`module:${entry.source.slice('module:'.length)}`); + } + + const engineId = this._canonicalEngineId(entry.source); + return engineId !== '' && this._knownViewIds.has(`engine:${engineId}`); + } + private _canonicalViewId(viewId: string): string { const trimmed = viewId.trim(); const engineView = trimmed.match(/^engine:(.+)$/); @@ -307,4 +443,55 @@ export class ConsoleLogService { return 'failed'; } } + + private _clearLocalAgentLogs(): void { + const cached = this._logsByView.get(ConsoleLogService._AGENT_VIEW_ID) ?? []; + const latestCachedTimestamp = cached.reduce( + (latest, entry) => Math.max(latest, entry.timestamp), + 0, + ); + const cursor = Math.max( + latestCachedTimestamp, + this._lastTimestampByView.get(ConsoleLogService._AGENT_VIEW_ID) ?? 0, + Date.now() / 1000, + ); + this._agentAuditClearTimestamp = cursor; + this._logsByView.set(ConsoleLogService._AGENT_VIEW_ID, []); + this._lastTimestampByView.set(ConsoleLogService._AGENT_VIEW_ID, cursor); + } + + private _agentAuditTimestamp(value: string): number { + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) { + return 0; + } + return parsed / 1000; + } + + private _formatAgentAuditTime(timestamp: number): string | null { + if (!Number.isFinite(timestamp) || timestamp <= 0) { + return null; + } + + return new Date(timestamp * 1000).toLocaleTimeString(); + } + + private _agentAuditLevel(result: string): string { + const normalized = result.trim().toLowerCase(); + if ( + normalized.includes('failed') || + normalized.includes('error') || + normalized.includes('rejected') + ) { + return 'ERROR'; + } + if ( + normalized.includes('denied') || + normalized.includes('pending') || + normalized.includes('revoked') + ) { + return 'WARN'; + } + return 'INFO'; + } } diff --git a/src/features/console/ui/ConsoleFilterControlHelper.ts b/src/features/console/ui/ConsoleFilterControlHelper.ts index 9f8cb1c3..96b5cdf4 100644 --- a/src/features/console/ui/ConsoleFilterControlHelper.ts +++ b/src/features/console/ui/ConsoleFilterControlHelper.ts @@ -7,6 +7,7 @@ type ConsoleFilterControlHelperDeps = { onCopyLogs: () => void; onOpenLogsFolder: () => void; onFiltersChanged: () => void; + translate: (key: string, fallback: string) => string; }; export class ConsoleFilterControlHelper { @@ -72,6 +73,7 @@ export class ConsoleFilterControlHelper { controls.addEventListener('click', handleClick); controls.addEventListener('contextmenu', handleContextMenu); + this._resetClearConfirmation(); this.syncButtons(); this._deps.registerCleanup(() => { controls.removeEventListener('click', handleClick); @@ -157,8 +159,11 @@ export class ConsoleFilterControlHelper { delete button.dataset['confirmingAll']; button.dataset['confirming'] = 'true'; button.classList.add('confirming'); - button.setAttribute('aria-label', 'Confirm clear console logs'); - button.title = 'Click again to clear logs'; + button.setAttribute( + 'aria-label', + this._t('ui.debug.logs_clear_confirm', 'Confirm clear console logs'), + ); + button.title = this._t('ui.debug.logs_clear_confirm_title', 'Click again to clear logs'); this._clearConfirmationTimeout = setTimeout(() => { this._resetClearConfirmation(); }, 2200); @@ -174,8 +179,14 @@ export class ConsoleFilterControlHelper { this._resetClearConfirmation(); button.dataset['confirmingAll'] = 'true'; button.classList.add('confirming'); - button.setAttribute('aria-label', 'Confirm clear all console logs'); - button.title = 'Right-click again to clear all logs'; + button.setAttribute( + 'aria-label', + this._t('ui.debug.logs_clear_all_confirm', 'Confirm clear all console logs'), + ); + button.title = this._t( + 'ui.debug.logs_clear_all_confirm_title', + 'Right-click again to clear all logs', + ); this._clearConfirmationTimeout = setTimeout(() => { this._resetClearConfirmation(); }, 2200); @@ -195,7 +206,11 @@ export class ConsoleFilterControlHelper { delete button.dataset['confirming']; delete button.dataset['confirmingAll']; button.classList.remove('confirming'); - button.setAttribute('aria-label', 'Clear Console'); - button.title = 'Clear Console'; + button.setAttribute('aria-label', this._t('ui.debug.logs_clear', 'Clear Console')); + button.title = this._t('ui.debug.logs_clear', 'Clear Console'); + } + + private _t(key: string, fallback: string): string { + return this._deps.translate(key, fallback); } } diff --git a/src/features/console/ui/ConsoleLogPresentationHelper.ts b/src/features/console/ui/ConsoleLogPresentationHelper.ts index b2e28cfd..3086d9f2 100644 --- a/src/features/console/ui/ConsoleLogPresentationHelper.ts +++ b/src/features/console/ui/ConsoleLogPresentationHelper.ts @@ -192,8 +192,8 @@ export class ConsoleLogPresentationHelper { const activeViewId = this._deps.getActiveViewId(); const fromView = activeViewId.startsWith('module:') ? activeViewId.slice('module:'.length) - : activeViewId !== 'general' - ? activeViewId + : activeViewId.startsWith('engine:') + ? activeViewId.slice('engine:'.length) : null; if (fromView !== null) { return fromView; diff --git a/src/features/console/ui/ConsoleUI.test.ts b/src/features/console/ui/ConsoleUI.test.ts index fc6a140c..e07b784f 100644 --- a/src/features/console/ui/ConsoleUI.test.ts +++ b/src/features/console/ui/ConsoleUI.test.ts @@ -495,6 +495,49 @@ describe('ConsoleUI lifecycle', () => { ); }); + it('should render the Agent console tab as a separate audit view', async () => { + const service = createServiceMock({ + getLogsForView: vi.fn((view: string) => + normalizeLogs( + view === 'agent' + ? [ + { + level: 'INFO', + message: 'settings -> success', + source: 'agent-control', + source_label: 'Trusted Local', + source_class: 'src-AGENT', + scope: 'launcher.open-page', + timestamp: 1, + }, + ] + : [], + ), + ), + getAvailableViews: vi.fn().mockResolvedValue([ + { id: 'general', label: 'General' }, + { id: 'agent', label: 'Agent' }, + ]), + fetchLogs: vi.fn().mockResolvedValue([]), + openLogsFolder: vi.fn().mockResolvedValue(false), + }); + + ui = new ConsoleUI(service, createDeps()); + ui.init(); + await flushPromises(); + + const agentTab = document.querySelector('[data-view="agent"]') as HTMLElement; + agentTab.click(); + + const openFolderButton = document.getElementById( + 'open-logs-folder-btn', + ) as HTMLButtonElement; + expect(agentTab.textContent).toContain('ui.launcher.web.logs_agent'); + expect(document.getElementById('logs-agent')?.hidden).toBe(false); + expect(document.getElementById('logs-agent')?.textContent).toContain('settings -> success'); + expect(openFolderButton.disabled).toBe(true); + }); + it('should expose tab scroll controls when log tabs overflow', async () => { const toolbar = document.querySelector('.console-toolbar-left') as HTMLElement; Object.defineProperty(toolbar, 'clientWidth', { configurable: true, value: 160 }); @@ -706,6 +749,49 @@ describe('ConsoleUI lifecycle', () => { expect(document.getElementById('logs-general')?.textContent).toContain('Page settings'); }); + it('should render an empty console when the selected level has no matching logs', async () => { + const service = createServiceMock({ + getLogsForView: vi.fn().mockReturnValue( + normalizeLogs([ + { + level: 'INFO', + message: '[NavigationService] Navigating to: settings', + source: 'frontend', + timestamp: 1, + }, + { + level: 'DEBUG', + message: '[NavigationUI] Page modules', + source: 'frontend', + timestamp: 2, + }, + ]), + ), + }); + + ui = new ConsoleUI(service, createDeps()); + ui.init(); + await ( + ui as unknown as { + _refreshLogsOnConsoleOpen: () => Promise; + } + )._refreshLogsOnConsoleOpen(); + await flushPromises(); + + const errorButton = document.querySelector( + '.console-filter-chip[data-level="ERROR"]', + ) as HTMLButtonElement; + errorButton.click(); + + const pane = document.getElementById('logs-general') as HTMLElement; + const emptyState = pane.querySelector('#console-filter-empty-state'); + expect(emptyState).not.toBeNull(); + expect(emptyState?.textContent).toContain('ui.debug.logs_filter_empty'); + expect(pane.querySelector('.log-entry-card')).toBeNull(); + expect(pane.textContent).not.toContain('Page settings'); + expect(pane.textContent).not.toContain('Page modules'); + }); + it('should allow multi-select level filters with ctrl or shift click', async () => { const service = createServiceMock({ getLogsForView: vi.fn().mockReturnValue( @@ -776,19 +862,20 @@ describe('ConsoleUI lifecycle', () => { it('should replace stale rendered rows with the empty state when filters match nothing', () => { const pane = document.createElement('div'); const staleRow = document.createElement('div'); + const emptyStateText = 'filtered empty state'; staleRow.textContent = 'stale debug row'; pane.append(staleRow); const helper = new ConsoleLogRenderHelper({ emptyStateId: 'console-filter-empty-state', - getEmptyStateText: () => 'No logs match selected levels', + getEmptyStateText: () => emptyStateText, getNormalizedLevel: () => 'INFO', matchesNormalizedLevel: () => false, }); helper.applyFiltersToPane(pane, []); - expect(pane.textContent).toBe('No logs match selected levels'); + expect(pane.textContent).toBe(emptyStateText); expect(pane.querySelector('.log-entry-card')).toBeNull(); }); diff --git a/src/features/console/ui/ConsoleUI.ts b/src/features/console/ui/ConsoleUI.ts index 69ebccbc..d37d421d 100644 --- a/src/features/console/ui/ConsoleUI.ts +++ b/src/features/console/ui/ConsoleUI.ts @@ -1,4 +1,4 @@ -import type { ConsoleLogService } from '../services/ConsoleLogService'; +import type { ConsoleLogService, IConsoleLogView } from '../services/ConsoleLogService'; import type { EventBus } from '@/shared/services/EventBus'; import { ConsoleClipboardHelper } from './ConsoleClipboardHelper'; import { ConsoleFilterControlHelper } from './ConsoleFilterControlHelper'; @@ -108,6 +108,7 @@ export class ConsoleUI { onFiltersChanged: () => { this._applyFiltersToActivePane(); }, + translate: (key, fallback) => this._translate(key, fallback), }); this._presentationHelper = new ConsoleLogPresentationHelper({ getActiveViewId: () => this._viewState.activeViewId, @@ -117,8 +118,11 @@ export class ConsoleUI { emptyStateId: this._emptyStateId, getEmptyStateText: () => this._viewState.activeLevels.size === ConsoleUI._FILTER_LEVELS.length - ? 'No logs yet' - : 'No logs match selected levels', + ? this._translate('ui.debug.logs_none', 'No logs yet') + : this._translate( + 'ui.debug.logs_filter_empty', + 'No logs match selected levels', + ), getNormalizedLevel: (log) => this._presentationHelper.getNormalizedLevel(log), matchesNormalizedLevel: (level) => this._matchesNormalizedLevel(level), }); @@ -288,6 +292,7 @@ export class ConsoleUI { private setLogView(view: string, btn: HTMLElement): void { this._viewState.activeViewId = view; this._activateTab('.console-tab', '.logs-pane', `logs-${view}`, btn); + this._syncViewActionState(); this.renderLogs(true); const requestedView = view; void this.service @@ -334,9 +339,12 @@ export class ConsoleUI { public async openLogsFolder(): Promise { const opened = await this.service.openLogsFolder(this._viewState.activeViewId); if (!opened) { + const isAgentView = this._viewState.activeViewId === 'agent'; this._showToast( - this._translate('ui.debug.logs_open_folder_failed', 'Failed to open logs folder'), - 'error', + isAgentView + ? this._translate('ui.debug.logs_folder_unavailable', '') + : this._translate('ui.debug.logs_open_folder_failed', ''), + isAgentView ? 'info' : 'error', 1800, ); } @@ -394,12 +402,15 @@ export class ConsoleUI { return false; } - const views = await this.service.getAvailableViews(); + const views = (await this.service.getAvailableViews()).map((view) => + this._localizeView(view), + ); this._viewState.ensureKnownActiveView(new Set(views.map((view) => view.id))); if (!this._viewHelper.shouldRebuildViews(toolbar, views)) { this._syncActivePane(`logs-${this._viewState.activeViewId}`); this._syncTabScrollControls?.(); + this._syncViewActionState(); return false; } @@ -417,6 +428,7 @@ export class ConsoleUI { this._activePane = null; this._syncActivePane(`logs-${this._viewState.activeViewId}`); this._syncTabScrollControls?.(); + this._syncViewActionState(); return true; } @@ -466,6 +478,7 @@ export class ConsoleUI { button?.classList.add('active'); this._activeTabButton = button ?? null; this._syncActivePane(paneId); + this._syncViewActionState(); } private _translate(key: string, fallback: string): string { @@ -516,4 +529,38 @@ export class ConsoleUI { this._activePane = null; } + + private _localizeView(view: IConsoleLogView): IConsoleLogView { + if (view.id === 'general') { + return { + ...view, + label: this._translate('ui.launcher.web.logs_general', view.label), + }; + } + + if (view.id === 'agent') { + return { + ...view, + label: this._translate('ui.launcher.web.logs_agent', view.label), + }; + } + + return view; + } + + private _syncViewActionState(): void { + const openFolderButton = document.getElementById('open-logs-folder-btn'); + if (!(openFolderButton instanceof HTMLButtonElement)) { + return; + } + + const isAgentView = this._viewState.activeViewId === 'agent'; + openFolderButton.disabled = isAgentView; + openFolderButton.classList.toggle('is-disabled', isAgentView); + const title = isAgentView + ? this._translate('ui.debug.logs_folder_agent_disabled', '') + : this._translate('ui.debug.logs_open_folder', ''); + openFolderButton.title = title; + openFolderButton.setAttribute('aria-label', title); + } } diff --git a/src/features/console/ui/ConsoleViewHelper.ts b/src/features/console/ui/ConsoleViewHelper.ts index 16c62e8e..884fe2ab 100644 --- a/src/features/console/ui/ConsoleViewHelper.ts +++ b/src/features/console/ui/ConsoleViewHelper.ts @@ -28,8 +28,12 @@ export class ConsoleViewHelper { public createViewButton(view: IConsoleLogView, activeViewId: string): HTMLButtonElement { const button = document.createElement('button'); button.className = 'console-tab'; + button.type = 'button'; button.dataset['view'] = view.id; button.textContent = view.label; + if (view.id === 'agent') { + button.classList.add('console-tab--agent'); + } if (view.id === activeViewId) { button.classList.add('active'); } @@ -40,6 +44,9 @@ export class ConsoleViewHelper { const pane = document.createElement('div'); pane.id = `logs-${view.id}`; pane.className = 'logs-pane'; + if (view.id === 'agent') { + pane.classList.add('logs-pane--agent'); + } if (view.id === activeViewId) { pane.classList.add('active'); } diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts index f07557ca..27cd9862 100644 --- a/src/features/settings/index.ts +++ b/src/features/settings/index.ts @@ -7,3 +7,4 @@ export { SettingsService } from './services/SettingsService'; export { SettingsUI } from './ui/SettingsUI'; export { ModuleSettingsUI } from './ui/ModuleSettingsUI'; export { GeneralSettingsRenderer } from './ui/GeneralSettingsRenderer'; +export { AgentControlSettingsRenderer } from './ui/AgentControlSettingsRenderer'; diff --git a/src/features/settings/services/SettingsService.test.ts b/src/features/settings/services/SettingsService.test.ts index da95cb65..62d76046 100644 --- a/src/features/settings/services/SettingsService.test.ts +++ b/src/features/settings/services/SettingsService.test.ts @@ -8,6 +8,13 @@ const mocks = vi.hoisted(() => ({ invokeSafe: vi.fn(), commands: { controlModule: vi.fn(), + getAgentControlState: vi.fn(), + setAgentControlEnabled: vi.fn(), + createAgentProfile: vi.fn(), + rotateAgentProfile: vi.fn(), + copyAgentProfileToken: vi.fn(), + deleteAgentProfile: vi.fn(), + decideAgentApproval: vi.fn(), }, })); @@ -22,6 +29,13 @@ vi.mock('@/shared/types/bindings', async (importOriginal) => { commands: { ...actual.commands, controlModule: mocks.commands.controlModule, + getAgentControlState: mocks.commands.getAgentControlState, + setAgentControlEnabled: mocks.commands.setAgentControlEnabled, + createAgentProfile: mocks.commands.createAgentProfile, + rotateAgentProfile: mocks.commands.rotateAgentProfile, + copyAgentProfileToken: mocks.commands.copyAgentProfileToken, + deleteAgentProfile: mocks.commands.deleteAgentProfile, + decideAgentApproval: mocks.commands.decideAgentApproval, }, }; }); @@ -56,6 +70,18 @@ describe('SettingsService', () => { }), ); mocks.invokeSafe.mockImplementation((promise: Promise) => promise); + mocks.commands.getAgentControlState.mockReturnValue( + Promise.resolve({ + status: 'ok', + data: { + enabled: false, + apiBaseUrl: 'http://127.0.0.1:17878', + profiles: [], + audit: [], + approvals: [], + }, + }), + ); }); describe('loadSettings', () => { @@ -168,6 +194,108 @@ describe('SettingsService', () => { }); }); + describe('Agent Control', () => { + it('should load redacted agent control state', async () => { + const result = await service.getAgentControlState(); + + expect(result.apiBaseUrl).toBe('http://127.0.0.1:17878'); + expect(mocks.commands.getAgentControlState).toHaveBeenCalled(); + }); + + it('should create trusted local profiles through generated commands', async () => { + mocks.commands.createAgentProfile.mockReturnValueOnce( + Promise.resolve({ + status: 'ok', + data: { + profile: { + id: 'agent-1', + name: 'Trusted Local', + scopes: ['observe', 'operate'], + tokenPrefix: 'axl_agent_123', + createdAt: '2026-05-22T00:00:00Z', + lastSeenAt: null, + revoked: false, + }, + }, + }), + ); + + const result = await service.createAgentProfile('Trusted Local', [ + 'observe', + 'operate', + ]); + + expect(result.profile.tokenPrefix).toBe('axl_agent_123'); + expect(mocks.commands.createAgentProfile).toHaveBeenCalledWith('Trusted Local', [ + 'observe', + 'operate', + ]); + }); + + it('should rotate, delete, toggle, and decide approvals via backend-owned state', async () => { + const stateResponse = Promise.resolve({ + status: 'ok', + data: { + enabled: true, + apiBaseUrl: 'http://127.0.0.1:17878', + profiles: [], + audit: [], + approvals: [], + }, + }); + mocks.commands.setAgentControlEnabled.mockReturnValueOnce(stateResponse); + mocks.commands.deleteAgentProfile.mockReturnValueOnce(stateResponse); + mocks.commands.decideAgentApproval.mockReturnValueOnce(stateResponse); + mocks.commands.rotateAgentProfile.mockReturnValueOnce( + Promise.resolve({ + status: 'ok', + data: { + profile: { + id: 'agent-1', + name: 'Trusted Local', + scopes: ['observe'], + tokenPrefix: 'axl_agent_456', + createdAt: '2026-05-22T00:00:00Z', + lastSeenAt: null, + revoked: false, + }, + }, + }), + ); + mocks.commands.copyAgentProfileToken.mockReturnValueOnce( + Promise.resolve({ + status: 'ok', + data: null, + }), + ); + + await service.setAgentControlEnabled(true); + await service.rotateAgentProfile('agent-1'); + await service.copyAgentProfileToken('agent-1'); + await service.deleteAgentProfile('agent-1'); + await service.decideAgentApproval('approval-1', false); + + expect(mocks.commands.setAgentControlEnabled).toHaveBeenCalledWith(true); + expect(mocks.commands.rotateAgentProfile).toHaveBeenCalledWith('agent-1'); + expect(mocks.commands.copyAgentProfileToken).toHaveBeenCalledWith('agent-1'); + expect(mocks.commands.deleteAgentProfile).toHaveBeenCalledWith('agent-1'); + expect(mocks.commands.decideAgentApproval).toHaveBeenCalledWith('approval-1', false); + }); + + it('should preserve AppError messages from generated command failures', async () => { + mocks.commands.copyAgentProfileToken.mockReturnValueOnce( + Promise.resolve({ + status: 'error', + error: { Validation: 'token unavailable' }, + }), + ); + + await expect(service.copyAgentProfileToken('agent-1')).rejects.toThrow( + 'token unavailable', + ); + }); + }); + describe('loadGpuInfo', () => { it('should return GPU info from backend', async () => { const gpuInfo = { @@ -226,30 +354,23 @@ describe('SettingsService', () => { }); describe('saveSecureKey', () => { - it('should store cloud provider keys in the shared OpenRouter slot', async () => { - await service.saveSecureKey('gemini', 'my-api-key'); + it('should store keys in the backend-provided secure service slot', async () => { + await service.saveSecureKey('cloud_api_key', 'my-api-key'); expect(tauri.invoke).toHaveBeenCalledWith('save_secure_key', { service: 'cloud_api_key', key: 'my-api-key', }); }); - it('should reject unknown provider secure key storage', async () => { - await expect(service.saveSecureKey('unknown-provider', 'my-api-key')).rejects.toThrow( - 'Provider does not support frontend-managed secrets', - ); - expect(tauri.invoke).not.toHaveBeenCalledWith('save_secure_key', expect.anything()); - }); - it('should handle error gracefully', async () => { (tauri.invoke as ReturnType).mockRejectedValue(new Error('fail')); - await expect(service.saveSecureKey('gemini', 'k')).rejects.toThrow('fail'); + await expect(service.saveSecureKey('cloud_api_key', 'k')).rejects.toThrow('fail'); }); }); describe('removeSecureKey', () => { it('should remove secure key through tauri provider helper', async () => { - await service.removeSecureKey('gemini'); + await service.removeSecureKey('cloud_api_key'); expect(tauri.removeSecureKey).toHaveBeenCalledWith('cloud_api_key'); expect(tauri.invoke).not.toHaveBeenCalledWith('remove_secure_key', expect.anything()); @@ -258,7 +379,7 @@ describe('SettingsService', () => { it('should fall back to invoke when helper is unavailable', async () => { delete (tauri as unknown as { removeSecureKey?: unknown }).removeSecureKey; - await service.removeSecureKey('gemini'); + await service.removeSecureKey('cloud_api_key'); expect(tauri.invoke).toHaveBeenCalledWith('remove_secure_key', { service: 'cloud_api_key', @@ -270,7 +391,7 @@ describe('SettingsService', () => { new Error('fail'), ); - await expect(service.removeSecureKey('gemini')).rejects.toThrow('fail'); + await expect(service.removeSecureKey('cloud_api_key')).rejects.toThrow('fail'); }); }); @@ -314,7 +435,7 @@ describe('SettingsService', () => { it('should return true when a stored key exists', async () => { (tauri.invoke as ReturnType).mockResolvedValue(true); - const result = await service.hasSecureKey('gemini'); + const result = await service.hasSecureKey('cloud_api_key'); expect(result).toBe(true); expect(tauri.invoke).toHaveBeenCalledWith('has_secure_key', { @@ -325,7 +446,7 @@ describe('SettingsService', () => { it('should return false on error', async () => { (tauri.invoke as ReturnType).mockRejectedValue(new Error('fail')); - const result = await service.hasSecureKey('gemini'); + const result = await service.hasSecureKey('cloud_api_key'); expect(result).toBe(false); }); @@ -336,7 +457,7 @@ describe('SettingsService', () => { const meta = { exists: true, length: 24 }; (tauri.getSecureKeyMeta as ReturnType).mockResolvedValue(meta); - const result = await service.getSecureKeyMeta('gemini'); + const result = await service.getSecureKeyMeta('cloud_api_key'); expect(result).toEqual(meta); expect(tauri.getSecureKeyMeta).toHaveBeenCalledWith('cloud_api_key'); @@ -347,7 +468,7 @@ describe('SettingsService', () => { new Error('fail'), ); - const result = await service.getSecureKeyMeta('gemini'); + const result = await service.getSecureKeyMeta('cloud_api_key'); expect(result).toEqual({ exists: false, length: 0 }); }); @@ -357,7 +478,7 @@ describe('SettingsService', () => { it('should return the decrypted key from backend', async () => { (tauri.getSecureKey as ReturnType).mockResolvedValue('secret'); - const result = await service.getSecureKey('gemini'); + const result = await service.getSecureKey('cloud_api_key'); expect(result).toBe('secret'); expect(tauri.getSecureKey).toHaveBeenCalledWith('cloud_api_key'); @@ -366,7 +487,7 @@ describe('SettingsService', () => { it('should return null on error', async () => { (tauri.getSecureKey as ReturnType).mockRejectedValue(new Error('fail')); - const result = await service.getSecureKey('gemini'); + const result = await service.getSecureKey('cloud_api_key'); expect(result).toBeNull(); }); diff --git a/src/features/settings/services/SettingsService.ts b/src/features/settings/services/SettingsService.ts index 2f92703f..77abc858 100644 --- a/src/features/settings/services/SettingsService.ts +++ b/src/features/settings/services/SettingsService.ts @@ -2,10 +2,15 @@ import type { SecureKeyMeta, TauriProvider } from '@/infrastructure/tauri/TauriP import type { IApp } from '@/shared/types/coreTypes'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; -import type { AppSettings, GpuInfo } from '@/shared/types/bindings'; +import type { + AgentControlState, + AgentProfileTokenResponse, + AgentScope, + AppSettings, + GpuInfo, +} from '@/shared/types/bindings'; import { commands } from '@/shared/types/bindings'; import { invokeSafe } from '@/shared/api/invoke'; -import { resolveProviderSecretService } from '@/shared/utils/providerSupport'; export type ISettings = AppSettings; export type SettingsValue = string | number | boolean; type SettingsLogger = Pick; @@ -19,6 +24,59 @@ export interface ICustomModel { base_model_id?: string; } +function appErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + if (typeof error !== 'object' || error === null) { + return String(error); + } + + const record = error as Record; + if (typeof record['message'] === 'string') { + return record['message']; + } + + const details = record['details']; + if (typeof details === 'object' && details !== null) { + return appErrorMessage(details); + } + + for (const key of [ + 'Validation', + 'NotFound', + 'PermissionDenied', + 'FrontendSecretForbidden', + 'Io', + 'Serialization', + 'Config', + ]) { + const value = record[key]; + if (typeof value === 'string') { + return value; + } + } + + for (const key of ['External', 'Internal']) { + const value = record[key]; + if (typeof value === 'object' && value !== null) { + const message = (value as Record)['message']; + if (typeof message === 'string') { + return message; + } + } + } + + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + export class SettingsService { private settings: ISettings = {} as ISettings; private _gpuInfoPromise: Promise | null = null; @@ -99,6 +157,72 @@ export class SettingsService { } } + public async getAgentControlState(): Promise { + const result = await invokeSafe(commands.getAgentControlState()); + if (result.status === 'ok') { + return result.data; + } + this._tracer.error('[SettingsService] Failed to load Agent Control state:', result.error); + throw new Error(appErrorMessage(result.error)); + } + + public async setAgentControlEnabled(enabled: boolean): Promise { + const result = await invokeSafe(commands.setAgentControlEnabled(enabled)); + if (result.status === 'ok') { + return result.data; + } + this._tracer.error('[SettingsService] Failed to update Agent Control:', result.error); + throw new Error(appErrorMessage(result.error)); + } + + public async createAgentProfile( + name: string | null = null, + scopes: AgentScope[] | null = null, + ): Promise { + const result = await invokeSafe(commands.createAgentProfile(name, scopes)); + if (result.status === 'ok') { + return result.data; + } + this._tracer.error('[SettingsService] Failed to create Agent profile:', result.error); + throw new Error(appErrorMessage(result.error)); + } + + public async rotateAgentProfile(id: string): Promise { + const result = await invokeSafe(commands.rotateAgentProfile(id)); + if (result.status === 'ok') { + return result.data; + } + this._tracer.error('[SettingsService] Failed to rotate Agent profile:', result.error); + throw new Error(appErrorMessage(result.error)); + } + + public async copyAgentProfileToken(id: string): Promise { + const result = await invokeSafe(commands.copyAgentProfileToken(id)); + if (result.status === 'ok') { + return; + } + this._tracer.error('[SettingsService] Failed to copy Agent profile token:', result.error); + throw new Error(appErrorMessage(result.error)); + } + + public async deleteAgentProfile(id: string): Promise { + const result = await invokeSafe(commands.deleteAgentProfile(id)); + if (result.status === 'ok') { + return result.data; + } + this._tracer.error('[SettingsService] Failed to delete Agent profile:', result.error); + throw new Error(appErrorMessage(result.error)); + } + + public async decideAgentApproval(id: string, approved: boolean): Promise { + const result = await invokeSafe(commands.decideAgentApproval(id, approved)); + if (result.status === 'ok') { + return result.data; + } + this._tracer.error('[SettingsService] Failed to decide Agent approval:', result.error); + throw new Error(appErrorMessage(result.error)); + } + public async loadGpuInfo(): Promise { if (this._gpuInfoPromise !== null) { return await this._gpuInfoPromise; @@ -130,11 +254,10 @@ export class SettingsService { * Save API key securely using Tauri secure storage. * Fallback to localStorage is PROHIBITED for security reasons. */ - public async saveSecureKey(provider: string, key: string): Promise { - const storageKey = this._resolveSecureKeyService(provider); + public async saveSecureKey(secretService: string, key: string): Promise { try { await this._tauri.invoke('save_secure_key', { - service: storageKey, + service: secretService, key: key, }); } catch (e) { @@ -146,16 +269,15 @@ export class SettingsService { /** * Remove a securely stored API key. */ - public async removeSecureKey(provider: string): Promise { - const storageKey = this._resolveSecureKeyService(provider); + public async removeSecureKey(secretService: string): Promise { try { if (typeof this._tauri.removeSecureKey === 'function') { - await this._tauri.removeSecureKey(storageKey); + await this._tauri.removeSecureKey(secretService); return; } await this._tauri.invoke('remove_secure_key', { - service: storageKey, + service: secretService, }); } catch (e) { this._tracer.error('[SettingsService] Failed to remove secure key:', e); @@ -166,11 +288,10 @@ export class SettingsService { /** * Checks whether a secure API key exists without exposing the secret value. */ - public async hasSecureKey(provider: string): Promise { - const storageKey = this._resolveSecureKeyService(provider); + public async hasSecureKey(secretService: string): Promise { try { return await this._tauri.invoke('has_secure_key', { - service: storageKey, + service: secretService, }); } catch (e) { this._tracer.error('[SettingsService] Failed to check secure key presence:', e); @@ -181,10 +302,9 @@ export class SettingsService { /** * Returns non-sensitive metadata for a stored key. */ - public async getSecureKeyMeta(provider: string): Promise { - const storageKey = this._resolveSecureKeyService(provider); + public async getSecureKeyMeta(secretService: string): Promise { try { - return await this._tauri.getSecureKeyMeta(storageKey); + return await this._tauri.getSecureKeyMeta(secretService); } catch (e) { this._tracer.error('[SettingsService] Failed to get secure key metadata:', e); return { exists: false, length: 0 }; @@ -194,10 +314,9 @@ export class SettingsService { /** * Returns the decrypted secure key for explicit user reveal flows. */ - public async getSecureKey(provider: string): Promise { - const storageKey = this._resolveSecureKeyService(provider); + public async getSecureKey(secretService: string): Promise { try { - return await this._tauri.getSecureKey(storageKey); + return await this._tauri.getSecureKey(secretService); } catch (e) { this._tracer.error('[SettingsService] Failed to get secure key:', e); return null; @@ -282,13 +401,4 @@ export class SettingsService { throw e; } } - - private _resolveSecureKeyService(provider: string): string { - const service = resolveProviderSecretService(provider); - if (service === null) { - throw new Error(`Provider does not support frontend-managed secrets: ${provider}`); - } - - return service; - } } diff --git a/src/features/settings/ui/AgentControlSettingsRenderer.test.ts b/src/features/settings/ui/AgentControlSettingsRenderer.test.ts new file mode 100644 index 00000000..4cdeb5e0 --- /dev/null +++ b/src/features/settings/ui/AgentControlSettingsRenderer.test.ts @@ -0,0 +1,523 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AgentControlSettingsRenderer } from './AgentControlSettingsRenderer'; +import type { SettingsService } from '../services/SettingsService'; +import type { AgentControlState, AgentScope } from '@/shared/types/bindings'; +import type { LoggerService } from '@/infrastructure/logging/LoggerService'; +import type { IAppSettingsUIContext } from './SettingsContext'; + +function state(overrides: Partial = {}): AgentControlState { + return { + enabled: false, + apiBaseUrl: 'http://127.0.0.1:17878', + profiles: [], + audit: [], + approvals: [], + ...overrides, + }; +} + +function copyTokenButton(): HTMLButtonElement | undefined { + return Array.from(document.querySelectorAll('button')).find( + (button) => button.getAttribute('aria-label') === 'Copy token', + ); +} + +describe('AgentControlSettingsRenderer', () => { + let service: Pick< + SettingsService, + | 'getAgentControlState' + | 'setAgentControlEnabled' + | 'createAgentProfile' + | 'rotateAgentProfile' + | 'copyAgentProfileToken' + | 'deleteAgentProfile' + | 'decideAgentApproval' + >; + let context: IAppSettingsUIContext; + let translations: Record; + let currentState: AgentControlState; + + beforeEach(() => { + document.body.innerHTML = '
'; + vi.spyOn(globalThis, 'confirm').mockReturnValue(true); + currentState = state(); + translations = { + 'ui.launcher.settings.agent_control_create_profile': 'Selected Permissions', + 'ui.launcher.settings.agent_control_trusted_local': 'Selected Permissions', + 'ui.launcher.settings.agent_control_create_full_access': 'Create Full Access', + 'ui.launcher.settings.agent_control_full_access': 'Full Access', + 'ui.launcher.settings.agent_control_connection': 'Connection', + 'ui.launcher.settings.agent_control_base_url': 'Base URL', + 'ui.launcher.settings.agent_control_token_once': 'Token shown once', + 'ui.launcher.settings.agent_control_copy_token': 'Copy token', + 'ui.launcher.settings.agent_control_copied': 'Copied', + 'ui.launcher.settings.agent_control_profiles': 'Agents', + 'ui.launcher.settings.agent_control_no_profiles': 'No agents yet', + 'ui.launcher.settings.agent_control_approvals': 'Approvals', + 'ui.launcher.settings.agent_control_no_approvals': 'No pending approvals', + 'ui.launcher.settings.agent_control_deny': 'Deny', + 'ui.launcher.settings.agent_control_delete_profile': 'Delete', + 'ui.launcher.settings.agent_control_delete_profile_hint': 'Delete this token', + 'ui.launcher.settings.agent_control_confirm_action': 'Confirm', + 'ui.launcher.settings.agent_control_revoked': 'Revoked', + 'ui.launcher.settings.agent_control_active': 'Active', + 'ui.launcher.settings.agent_control_create_profile_hint': 'Create token', + 'ui.launcher.settings.agent_control_create_full_access_hint': 'Create full token', + 'ui.launcher.settings.agent_control_rotate': 'Generate new token', + 'ui.launcher.settings.agent_control_rotate_hint': 'Create a new token', + 'ui.launcher.settings.agent_control_profile_created': 'Profile created', + 'ui.launcher.settings.agent_control_token_rotated': 'Token created', + 'ui.launcher.settings.agent_control_never_seen': 'Never connected', + 'ui.launcher.settings.agent_control_permissions': 'Permissions', + 'ui.launcher.settings.agent_control_scope_observe': 'observe', + 'ui.launcher.settings.agent_control_scope_observe_hint': 'read state', + 'ui.launcher.settings.agent_control_scope_operate': 'operate', + 'ui.launcher.settings.agent_control_scope_operate_hint': 'operate modules', + 'ui.launcher.settings.agent_control_scope_configure': 'configure', + 'ui.launcher.settings.agent_control_scope_configure_hint': 'change settings', + 'ui.launcher.settings.agent_control_scope_draft-create': 'draft-create', + 'ui.launcher.settings.agent_control_scope_draft-create_hint': 'create drafts', + 'ui.launcher.settings.agent_control_scope_full-access': 'full access', + 'ui.launcher.settings.agent_control_scope_full-access_hint': 'all permissions', + }; + service = { + getAgentControlState: vi.fn().mockImplementation(() => Promise.resolve(currentState)), + setAgentControlEnabled: vi.fn().mockImplementation((enabled: boolean) => { + currentState = state({ ...currentState, enabled }); + return Promise.resolve(currentState); + }), + createAgentProfile: vi + .fn() + .mockImplementation( + (name: string | null = null, scopes: AgentScope[] | null = null) => { + const profile = { + id: 'agent-1', + name: name ?? 'Trusted Local', + scopes: + scopes ?? + ([ + 'observe', + 'operate', + 'configure', + 'draft-create', + ] as AgentScope[]), + tokenPrefix: 'axl_agent_abc', + createdAt: '2026-05-22T00:00:00Z', + lastSeenAt: null, + revoked: false, + }; + currentState = state({ + ...currentState, + profiles: [...currentState.profiles, profile], + }); + return Promise.resolve({ profile }); + }, + ), + rotateAgentProfile: vi.fn().mockImplementation((id: string) => { + const profile = currentState.profiles.find((entry) => entry.id === id); + if (profile === undefined) { + throw new Error('Missing profile'); + } + const rotated = { + ...profile, + tokenPrefix: 'axl_agent_new', + }; + currentState = state({ + ...currentState, + profiles: currentState.profiles.map((entry) => + entry.id === id ? rotated : entry, + ), + }); + return Promise.resolve({ profile: rotated }); + }), + copyAgentProfileToken: vi.fn().mockResolvedValue(undefined), + deleteAgentProfile: vi.fn().mockImplementation((id: string) => { + currentState = state({ + ...currentState, + profiles: currentState.profiles.filter((profile) => profile.id !== id), + }); + return Promise.resolve(currentState); + }), + decideAgentApproval: vi.fn().mockImplementation((id: string, approved: boolean) => { + currentState = state({ + ...currentState, + approvals: currentState.approvals.map((approval) => + approval.id === id + ? { + ...approval, + status: approved ? 'approved' : 'denied', + decidedAt: '2026-05-22T00:00:00Z', + } + : approval, + ), + }); + return Promise.resolve(currentState); + }), + }; + context = { + t: (key: string, fallback = '') => translations[key] ?? fallback, + showToast: vi.fn(), + toggleNavItem: vi.fn(), + toggleMonitorItem: vi.fn(), + i18nUI: { applyTranslations: vi.fn() }, + } as unknown as IAppSettingsUIContext; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('creates a Trusted Local profile and shows a copy token action for the new token', async () => { + const renderer = new AgentControlSettingsRenderer( + service as SettingsService, + { error: vi.fn(), warn: vi.fn() } as unknown as LoggerService, + ); + + renderer.init(context); + await vi.waitFor(() => { + expect(document.body.textContent).toContain('Selected Permissions'); + }); + + const createButton = Array.from( + document.querySelectorAll('button'), + ).find((button) => button.textContent === 'Selected Permissions'); + createButton?.click(); + + await vi.waitFor(() => { + expect(copyTokenButton()).not.toBeUndefined(); + }); + expect(service.copyAgentProfileToken).not.toHaveBeenCalled(); + expect(service.createAgentProfile).toHaveBeenCalledWith('Selected Permissions', [ + 'observe', + 'operate', + 'configure', + 'draft-create', + ]); + + const copyButton = copyTokenButton(); + expect(copyButton?.textContent).toBe('📋'); + copyButton?.click(); + + await vi.waitFor(() => { + expect(service.copyAgentProfileToken).toHaveBeenCalledWith('agent-1'); + }); + expect(document.body.textContent).not.toContain('axl_agent_abc_secret'); + expect(context.showToast).toHaveBeenCalledWith('Profile created', 'success'); + expect(context.showToast).toHaveBeenCalledWith('Copied', 'success'); + }); + + it('creates local access tokens with the selected permissions', async () => { + const renderer = new AgentControlSettingsRenderer( + service as SettingsService, + { error: vi.fn(), warn: vi.fn() } as unknown as LoggerService, + ); + + renderer.init(context); + await vi.waitFor(() => { + expect( + document.querySelector('.agent-control-field-label--icon')?.getAttribute('title'), + ).toBe('Permissions'); + }); + + const configureButton = Array.from( + document.querySelectorAll('.agent-control-scope-btn'), + ).find((button) => button.textContent === 'configure'); + configureButton?.click(); + + const createButton = Array.from( + document.querySelectorAll('button'), + ).find((button) => button.textContent === 'Selected Permissions'); + createButton?.click(); + + await vi.waitFor(() => { + expect(service.createAgentProfile).toHaveBeenCalledWith('Selected Permissions', [ + 'observe', + 'operate', + 'draft-create', + ]); + }); + }); + + it('renders pending approval requests and denies without mutating directly', async () => { + service.getAgentControlState = vi.fn().mockResolvedValue( + state({ + approvals: [ + { + id: 'approval-1', + agentId: 'agent-1', + agentName: 'Codex', + action: 'package.install', + target: 'demo', + diff: 'Install demo', + risk: 'dangerous', + status: 'pending', + createdAt: '2026-05-22T00:00:00Z', + decidedAt: null, + }, + ], + }), + ); + service.decideAgentApproval = vi.fn().mockResolvedValue(state()); + const renderer = new AgentControlSettingsRenderer( + service as SettingsService, + { error: vi.fn(), warn: vi.fn() } as unknown as LoggerService, + ); + + renderer.init(context); + await vi.waitFor(() => { + expect(document.body.textContent).toContain('package.install'); + }); + + const denyButton = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent === 'Deny', + ); + denyButton?.click(); + + await vi.waitFor(() => { + expect(service.decideAgentApproval).toHaveBeenCalledWith('approval-1', false); + }); + }); + + it('creates a manual Full Access profile only after confirmation', async () => { + service.createAgentProfile = vi.fn().mockImplementation(() => { + const profile = { + id: 'agent-full', + name: 'Full Access', + scopes: ['full-access'] as AgentScope[], + tokenPrefix: 'axl_agent_full', + createdAt: '2026-05-22T00:00:00Z', + lastSeenAt: null, + revoked: false, + }; + currentState = state({ + ...currentState, + profiles: [...currentState.profiles, profile], + }); + return Promise.resolve({ profile }); + }); + const renderer = new AgentControlSettingsRenderer( + service as SettingsService, + { error: vi.fn(), warn: vi.fn() } as unknown as LoggerService, + ); + + renderer.init(context); + await vi.waitFor(() => { + expect(document.body.textContent).toContain('Create Full Access'); + }); + + const fullAccessButton = Array.from( + document.querySelectorAll('button'), + ).find((button) => button.textContent === 'Create Full Access'); + fullAccessButton?.click(); + + expect(service.createAgentProfile).not.toHaveBeenCalled(); + expect(fullAccessButton?.textContent).toBe('Confirm'); + fullAccessButton?.click(); + + await vi.waitFor(() => { + expect(service.createAgentProfile).toHaveBeenCalledWith('Full Access', ['full-access']); + }); + expect(globalThis.confirm).not.toHaveBeenCalled(); + await vi.waitFor(() => { + expect(copyTokenButton()).not.toBeUndefined(); + }); + const copyButton = copyTokenButton(); + expect(copyButton?.textContent).toBe('📋'); + copyButton?.click(); + await vi.waitFor(() => { + expect(service.copyAgentProfileToken).toHaveBeenCalledWith('agent-full'); + }); + expect(document.body.textContent).not.toContain('axl_agent_full_secret'); + }); + + it('keeps audit entries out of the settings panel', async () => { + service.getAgentControlState = vi.fn().mockResolvedValue( + state({ + audit: [ + { + id: 'audit-1', + actorId: 'agent-1', + actorName: 'Trusted Local', + action: 'launcher.open-page', + target: 'console', + result: 'success', + createdAt: '2026-05-22T00:00:00Z', + }, + ], + }), + ); + const renderer = new AgentControlSettingsRenderer( + service as SettingsService, + { error: vi.fn(), warn: vi.fn() } as unknown as LoggerService, + ); + + renderer.init(context); + await vi.waitFor(() => { + expect(document.body.textContent).toContain('Connection'); + }); + + expect(document.body.textContent).not.toContain('Action log'); + expect(document.body.textContent).not.toContain('launcher.open-page'); + }); + + it('renders the Agent API base URL as an editable launcher-styled input', async () => { + const renderer = new AgentControlSettingsRenderer( + service as SettingsService, + { error: vi.fn(), warn: vi.fn() } as unknown as LoggerService, + ); + + renderer.init(context); + await vi.waitFor(() => { + expect(document.querySelector('.agent-control-url-input')).not.toBeNull(); + }); + + const input = document.querySelector('.agent-control-url-input'); + expect(input?.value).toBe('http://127.0.0.1:17878'); + expect(input?.readOnly).toBe(false); + + if (input === null) { + throw new Error('Expected Agent API base URL input'); + } + input.value = 'http://localhost:17878'; + input.dispatchEvent(new FocusEvent('blur')); + + expect(input.value).toBe('http://127.0.0.1:17878'); + }); + + it('keeps revoked profiles visible with only delete available', async () => { + service.getAgentControlState = vi.fn().mockResolvedValue( + state({ + profiles: [ + { + id: 'agent-1', + name: 'Trusted Local', + scopes: ['observe', 'operate'] as AgentScope[], + tokenPrefix: 'axl_agent_revoked', + createdAt: '2026-05-22T00:00:00Z', + lastSeenAt: null, + revoked: true, + }, + ], + }), + ); + const renderer = new AgentControlSettingsRenderer( + service as SettingsService, + { error: vi.fn(), warn: vi.fn() } as unknown as LoggerService, + ); + + renderer.init(context); + await vi.waitFor(() => { + expect(document.body.textContent).toContain('Revoked'); + }); + + const row = document.querySelector('.agent-control-row'); + if (row === null) { + throw new Error('Expected revoked profile row to render'); + } + expect(row.textContent).toContain('Delete'); + const buttons = Array.from(row.querySelectorAll('button')).map((button) => + button.textContent.trim(), + ); + expect(buttons).toEqual(['Delete']); + }); + + it('generates a replacement token and shows a copy token action', async () => { + service.getAgentControlState = vi.fn().mockResolvedValue( + state({ + profiles: [ + { + id: 'agent-1', + name: 'Trusted Local', + scopes: ['observe'] as AgentScope[], + tokenPrefix: 'axl_agent_replace', + createdAt: '2026-05-22T00:00:00Z', + lastSeenAt: null, + revoked: false, + }, + ], + }), + ); + service.rotateAgentProfile = vi.fn().mockImplementation((id: string) => + Promise.resolve({ + profile: { + id, + name: 'Trusted Local', + scopes: ['observe'] as AgentScope[], + tokenPrefix: 'axl_agent_new', + createdAt: '2026-05-22T00:00:00Z', + lastSeenAt: null, + revoked: false, + }, + }), + ); + const renderer = new AgentControlSettingsRenderer( + service as SettingsService, + { error: vi.fn(), warn: vi.fn() } as unknown as LoggerService, + ); + + renderer.init(context); + await vi.waitFor(() => { + expect(document.body.textContent).toContain('Generate new token'); + }); + + const rotateButton = Array.from( + document.querySelectorAll('button'), + ).find((button) => button.textContent === 'Generate new token'); + rotateButton?.click(); + + await vi.waitFor(() => { + expect(service.rotateAgentProfile).toHaveBeenCalledWith('agent-1'); + }); + await vi.waitFor(() => { + expect(copyTokenButton()).not.toBeUndefined(); + }); + const copyButton = copyTokenButton(); + expect(copyButton?.textContent).toBe('📋'); + copyButton?.click(); + await vi.waitFor(() => { + expect(service.copyAgentProfileToken).toHaveBeenCalledWith('agent-1'); + }); + expect(context.showToast).toHaveBeenCalledWith('Token created', 'success'); + }); + + it('deletes profiles after confirmation', async () => { + service.getAgentControlState = vi.fn().mockResolvedValue( + state({ + profiles: [ + { + id: 'agent-1', + name: 'Trusted Local', + scopes: ['observe'] as AgentScope[], + tokenPrefix: 'axl_agent_delete', + createdAt: '2026-05-22T00:00:00Z', + lastSeenAt: null, + revoked: false, + }, + ], + }), + ); + service.deleteAgentProfile = vi.fn().mockResolvedValue(state()); + const renderer = new AgentControlSettingsRenderer( + service as SettingsService, + { error: vi.fn(), warn: vi.fn() } as unknown as LoggerService, + ); + + renderer.init(context); + await vi.waitFor(() => { + expect(document.body.textContent).toContain('Delete'); + }); + + const deleteButton = Array.from( + document.querySelectorAll('button'), + ).find((button) => button.textContent === 'Delete'); + deleteButton?.click(); + + expect(service.deleteAgentProfile).not.toHaveBeenCalled(); + expect(deleteButton?.textContent).toBe('Confirm'); + deleteButton?.click(); + + await vi.waitFor(() => { + expect(service.deleteAgentProfile).toHaveBeenCalledWith('agent-1'); + }); + }); +}); diff --git a/src/features/settings/ui/AgentControlSettingsRenderer.ts b/src/features/settings/ui/AgentControlSettingsRenderer.ts new file mode 100644 index 00000000..b09465b8 --- /dev/null +++ b/src/features/settings/ui/AgentControlSettingsRenderer.ts @@ -0,0 +1,537 @@ +import type { + AgentApprovalRequest, + AgentControlState, + AgentProfile, + AgentScope, +} from '@/shared/types/bindings'; +import type { LoggerService } from '@/infrastructure/logging/LoggerService'; +import type { IAppSettingsUIContext } from './SettingsContext'; +import type { SettingsService } from '../services/SettingsService'; + +const TRUSTED_LOCAL_SCOPES: AgentScope[] = ['observe', 'operate', 'configure', 'draft-create']; +const FULL_ACCESS_SCOPES: AgentScope[] = ['full-access']; +const AGENT_CONTROL_DOCS_URL = + 'https://github.com/F0RLE/Axelate/blob/nightly/docs/localization/en/AGENT_CONTROL.md'; + +export class AgentControlSettingsRenderer { + private _context: IAppSettingsUIContext | null = null; + private _panel: HTMLElement | null = null; + private _state: AgentControlState | null = null; + private _pendingTokenProfileId: string | null = null; + private _confirmResetTimer: ReturnType | null = null; + private _isDestroyed = false; + private _isBusy = false; + private readonly _selectedLocalScopes = new Set(TRUSTED_LOCAL_SCOPES); + + public constructor( + private readonly _service: SettingsService, + private readonly _tracer: Pick, + ) {} + + public init(context: IAppSettingsUIContext): void { + if (this._isDestroyed) return; + this._context = context; + const panel = document.getElementById('agent-control-panel'); + if (!(panel instanceof HTMLElement)) { + this._tracer.warn('[AgentControlSettingsRenderer] #agent-control-panel not found'); + return; + } + this._panel = panel; + this._renderLoading(); + void this._refresh(); + } + + public refresh(): void { + if (this._isDestroyed || this._panel === null) { + return; + } + void this._refresh(); + } + + public destroy(): void { + this._isDestroyed = true; + this._context = null; + this._panel = null; + this._state = null; + this._pendingTokenProfileId = null; + this._clearPendingConfirmation(); + } + + private async _refresh(): Promise { + try { + this._state = await this._service.getAgentControlState(); + this._render(); + } catch (error) { + this._tracer.error('[AgentControlSettingsRenderer] Failed to refresh:', error); + this._renderError(); + } + } + + private _renderLoading(): void { + this._replacePanel(this._element('div', 'agent-control-empty', this._t('loading'))); + } + + private _renderError(): void { + this._replacePanel( + this._element( + 'div', + 'agent-control-empty agent-control-empty--error', + this._t('load_failed'), + ), + ); + } + + private _render(): void { + const state = this._state; + if (state === null) { + this._renderLoading(); + return; + } + + const root = this._element('div', 'agent-control'); + root.classList.toggle('is-enabled', state.enabled); + root.append( + this._renderHeader(state), + this._renderConnection(state), + this._renderProfiles(state.profiles), + this._renderApprovals(state.approvals), + ); + this._replacePanel(root); + } + + private _renderHeader(state: AgentControlState): HTMLElement { + const header = this._element('div', 'agent-control-header'); + const help = this._helpLink(); + const toggle = this._button( + state.enabled ? this._t('disable') : this._t('enable'), + `agent-control-btn agent-control-engine-btn ${state.enabled ? 'stop-btn' : 'active-module-btn'}`, + () => { + void this._run(async () => { + this._state = await this._service.setAgentControlEnabled(!state.enabled); + this._toast( + state.enabled ? this._t('disabled') : this._t('enabled'), + 'success', + ); + this._render(); + }); + }, + ); + header.append(help, toggle); + return header; + } + + private _renderConnection(state: AgentControlState): HTMLElement { + const create = this._button( + this._t('create_profile'), + 'agent-control-btn agent-control-btn--primary', + () => { + void this._run(async () => { + const scopes = Array.from(this._selectedLocalScopes); + const response = await this._service.createAgentProfile( + this._t('trusted_local'), + scopes.length > 0 ? scopes : TRUSTED_LOCAL_SCOPES, + ); + this._pendingTokenProfileId = response.profile.id; + this._state = await this._service.getAgentControlState(); + this._toast(this._t('profile_created'), 'success'); + this._render(); + }); + }, + ); + create.title = this._t('create_profile_hint'); + create.setAttribute('aria-label', this._t('create_profile_hint')); + const createFullAccess = this._button( + this._t('create_full_access'), + 'agent-control-btn agent-control-btn--danger', + (button) => { + if (!this._confirmDangerousButton(button)) { + return; + } + void this._run(async () => { + const response = await this._service.createAgentProfile( + this._t('full_access'), + FULL_ACCESS_SCOPES, + ); + this._pendingTokenProfileId = response.profile.id; + this._state = await this._service.getAgentControlState(); + this._toast(this._t('profile_created'), 'success'); + this._render(); + }); + }, + ); + createFullAccess.title = this._t('create_full_access_hint'); + createFullAccess.setAttribute('aria-label', this._t('create_full_access_hint')); + const section = this._section(this._t('connection'), [create, createFullAccess]); + section.append(this._renderScopePicker()); + const endpoint = this._element('div', 'agent-control-endpoint'); + const baseUrlInput = document.createElement('input'); + baseUrlInput.className = 'agent-control-url-input'; + baseUrlInput.type = 'text'; + baseUrlInput.inputMode = 'url'; + baseUrlInput.value = state.apiBaseUrl; + baseUrlInput.spellcheck = false; + baseUrlInput.autocomplete = 'off'; + baseUrlInput.addEventListener('blur', () => { + baseUrlInput.value = state.apiBaseUrl; + }); + baseUrlInput.addEventListener('keydown', (event) => { + if (event.key === 'Escape' || event.key === 'Enter') { + baseUrlInput.blur(); + } + }); + endpoint.append( + this._element('div', 'agent-control-field-label', this._t('base_url')), + baseUrlInput, + ); + section.append(endpoint); + + return section; + } + + private _renderScopePicker(): HTMLElement { + const wrap = this._element('div', 'agent-control-permissions'); + const label = this._element( + 'div', + 'agent-control-field-label agent-control-field-label--icon', + '⚙️', + ); + label.title = this._t('permissions'); + label.setAttribute('aria-label', this._t('permissions')); + wrap.append(label); + const list = this._element('div', 'agent-control-scope-picker'); + TRUSTED_LOCAL_SCOPES.forEach((scope) => { + const selected = this._selectedLocalScopes.has(scope); + const button = this._button( + this._scopeLabel(scope), + `agent-control-scope-btn ${selected ? 'is-selected' : ''}`, + () => { + if (this._selectedLocalScopes.has(scope)) { + this._selectedLocalScopes.delete(scope); + } else { + this._selectedLocalScopes.add(scope); + } + this._render(); + }, + ); + button.title = this._scopeHint(scope); + button.setAttribute('aria-pressed', String(selected)); + button.setAttribute('aria-label', this._scopeHint(scope)); + list.append(button); + }); + wrap.append(list); + return wrap; + } + + private _renderProfiles(profiles: AgentProfile[]): HTMLElement { + const section = this._section(this._t('profiles')); + if (profiles.length === 0) { + section.append(this._element('div', 'agent-control-empty', this._t('no_profiles'))); + return section; + } + + const list = this._element('div', 'agent-control-list'); + profiles.forEach((profile) => { + const row = this._element( + 'div', + `agent-control-row ${profile.revoked ? 'is-revoked' : 'is-active'}`, + ); + const main = this._element('div', 'agent-control-row-main'); + const titleLine = this._element('div', 'agent-control-title-line'); + titleLine.append( + this._element('div', 'agent-control-row-title', profile.name), + this._element( + 'span', + `agent-control-row-status ${profile.revoked ? 'is-revoked' : 'is-active'}`, + profile.revoked ? this._t('revoked') : this._t('active'), + ), + ); + main.append( + titleLine, + this._element( + 'div', + 'agent-control-row-meta', + `${profile.tokenPrefix} · ${this._lastSeen(profile.lastSeenAt)}`, + ), + this._renderScopes(profile.scopes), + ); + const actions = this._element('div', 'agent-control-row-actions'); + if (this._pendingTokenProfileId === profile.id && !profile.revoked) { + const copyToken = this._button( + '📋', + 'agent-control-btn agent-control-btn--icon', + () => { + void this._run(async () => { + await this._service.copyAgentProfileToken(profile.id); + if (this._pendingTokenProfileId === profile.id) { + this._pendingTokenProfileId = null; + } + this._toast(this._t('copied'), 'success'); + this._render(); + }); + }, + ); + copyToken.title = this._t('copy_token'); + copyToken.setAttribute('aria-label', this._t('copy_token')); + actions.append(copyToken); + } + if (!profile.revoked) { + const rotate = this._button(this._t('rotate'), 'agent-control-btn', () => { + void this._run(async () => { + const response = await this._service.rotateAgentProfile(profile.id); + this._pendingTokenProfileId = response.profile.id; + this._state = await this._service.getAgentControlState(); + this._toast(this._t('token_rotated'), 'success'); + this._render(); + }); + }); + rotate.title = this._t('rotate_hint'); + rotate.setAttribute('aria-label', this._t('rotate_hint')); + actions.append(rotate); + } + const remove = this._button( + this._t('delete_profile'), + 'agent-control-btn agent-control-btn--danger', + (button) => { + if (!this._confirmDangerousButton(button)) { + return; + } + void this._run(async () => { + if (this._pendingTokenProfileId === profile.id) { + this._pendingTokenProfileId = null; + } + this._state = await this._service.deleteAgentProfile(profile.id); + this._toast(this._t('profile_deleted'), 'success'); + this._render(); + }); + }, + ); + remove.title = this._t('delete_profile_hint'); + remove.setAttribute('aria-label', this._t('delete_profile_hint')); + actions.append(remove); + row.append(main, actions); + list.append(row); + }); + section.append(list); + return section; + } + + private _renderScopes(scopes: AgentScope[]): HTMLElement { + const wrap = this._element('div', 'agent-control-scopes'); + scopes.forEach((scope) => { + wrap.append(this._element('span', 'agent-control-scope', this._scopeLabel(scope))); + }); + return wrap; + } + + private _renderApprovals(approvals: AgentApprovalRequest[]): HTMLElement { + const section = this._section(this._t('approvals')); + const pending = approvals.filter((approval) => approval.status === 'pending'); + if (pending.length === 0) { + section.append(this._element('div', 'agent-control-empty', this._t('no_approvals'))); + return section; + } + + const list = this._element('div', 'agent-control-list'); + pending.forEach((approval) => { + const row = this._element('div', 'agent-control-row agent-control-row--approval'); + const main = this._element('div', 'agent-control-row-main'); + const titleLine = this._element('div', 'agent-control-title-line'); + titleLine.append( + this._element('div', 'agent-control-row-title', approval.action), + this._element('span', 'agent-control-risk', approval.risk), + ); + main.append( + titleLine, + this._element( + 'div', + 'agent-control-row-meta', + `${approval.agentName} · ${approval.target}`, + ), + this._element('div', 'agent-control-diff', approval.diff), + ); + const actions = this._element('div', 'agent-control-row-actions'); + actions.append( + this._button( + this._t('approve'), + 'agent-control-btn agent-control-btn--primary', + () => { + void this._decideApproval(approval.id, true); + }, + ), + this._button(this._t('deny'), 'agent-control-btn', () => { + void this._decideApproval(approval.id, false); + }), + ); + row.append(main, actions); + list.append(row); + }); + section.append(list); + return section; + } + + private async _decideApproval(id: string, approved: boolean): Promise { + await this._run(async () => { + this._state = await this._service.decideAgentApproval(id, approved); + this._toast( + approved ? this._t('approval_approved') : this._t('approval_denied'), + 'success', + ); + this._render(); + }); + } + + private async _run(action: () => Promise): Promise { + if (this._isBusy) return; + this._isBusy = true; + this._setButtonsDisabled(true); + try { + await action(); + } catch (error) { + this._tracer.error('[AgentControlSettingsRenderer] Action failed:', error); + this._toast(this._t('action_failed'), 'error'); + } finally { + this._isBusy = false; + this._setButtonsDisabled(false); + } + } + + private _section(title: string, actions: HTMLButtonElement[] = []): HTMLElement { + const section = this._element('section', 'agent-control-section'); + const header = this._element('div', 'agent-control-section-header'); + header.append(this._element('div', 'agent-control-section-title', title)); + if (actions.length > 0) { + const actionWrap = this._element('div', 'agent-control-actions'); + actionWrap.append(...actions); + header.append(actionWrap); + } + section.append(header); + return section; + } + + private _confirmDangerousButton(button: HTMLButtonElement): boolean { + if (button.dataset['confirming'] === 'true') { + this._clearPendingConfirmation(); + return true; + } + + this._clearPendingConfirmation(); + button.dataset['confirming'] = 'true'; + button.dataset['label'] = button.textContent; + button.classList.add('is-confirming'); + button.textContent = this._t('confirm_action'); + this._confirmResetTimer = globalThis.setTimeout(() => { + this._resetConfirmingButton(button); + this._confirmResetTimer = null; + }, 2400); + return false; + } + + private _clearPendingConfirmation(): void { + if (this._confirmResetTimer !== null) { + globalThis.clearTimeout(this._confirmResetTimer); + this._confirmResetTimer = null; + } + this._panel + ?.querySelectorAll('.agent-control-btn.is-confirming') + .forEach((button) => { + this._resetConfirmingButton(button); + }); + } + + private _resetConfirmingButton(button: HTMLButtonElement): void { + button.classList.remove('is-confirming'); + delete button.dataset['confirming']; + const label = button.dataset['label']; + if (label !== undefined) { + button.textContent = label; + delete button.dataset['label']; + } + } + + private _button( + label: string, + className: string, + onClick: (button: HTMLButtonElement) => void, + ): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.className = className; + button.textContent = label; + button.addEventListener('click', () => { + onClick(button); + }); + return button; + } + + private _helpLink(): HTMLAnchorElement { + const label = this._t('docs_help'); + const link = document.createElement('a'); + link.className = 'module-action-badge left integration-help-badge agent-control-help'; + link.href = AGENT_CONTROL_DOCS_URL; + link.target = '_blank'; + link.rel = 'noreferrer'; + link.title = label; + link.setAttribute('aria-label', label); + const icon = document.createElement('span'); + icon.className = 'badge-icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = '?'; + link.append(icon); + return link; + } + + private _element( + tag: 'div' | 'span' | 'section', + className: string, + text?: string, + ): HTMLElement { + const element = document.createElement(tag); + element.className = className; + if (text !== undefined) { + element.textContent = text; + } + return element; + } + + private _replacePanel(content: HTMLElement): void { + this._panel?.replaceChildren(content); + } + + private _setButtonsDisabled(disabled: boolean): void { + this._panel?.querySelectorAll('button').forEach((button) => { + button.disabled = disabled; + }); + } + + private _lastSeen(value: string | null): string { + if (value === null) { + return this._t('never_seen'); + } + return `${this._t('last_seen')} ${this._formatDate(value)}`; + } + + private _formatDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleString(); + } + + private _scopeLabel(scope: AgentScope): string { + return this._t(`scope_${scope}`); + } + + private _scopeHint(scope: AgentScope): string { + return this._t(`scope_${scope}_hint`); + } + + private _t(key: string): string { + const i18nKey = `ui.launcher.settings.agent_control_${key}`; + return this._context?.t(i18nKey, i18nKey) ?? i18nKey; + } + + private _toast(message: string, type: 'success' | 'error' | 'info'): void { + this._context?.showToast(message, type); + } +} diff --git a/src/features/settings/ui/SettingsPageUI.test.ts b/src/features/settings/ui/SettingsPageUI.test.ts index b60389aa..08f94dcd 100644 --- a/src/features/settings/ui/SettingsPageUI.test.ts +++ b/src/features/settings/ui/SettingsPageUI.test.ts @@ -2,6 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const initRenderer = vi.fn(); const destroyRenderer = vi.fn(); +const initAgentRenderer = vi.fn(); +const destroyAgentRenderer = vi.fn(); +const refreshAgentRenderer = vi.fn(); vi.mock('./GeneralSettingsRenderer', () => ({ GeneralSettingsRenderer: class { @@ -10,6 +13,14 @@ vi.mock('./GeneralSettingsRenderer', () => ({ }, })); +vi.mock('./AgentControlSettingsRenderer', () => ({ + AgentControlSettingsRenderer: class { + public init = initAgentRenderer; + public destroy = destroyAgentRenderer; + public refresh = refreshAgentRenderer; + }, +})); + import { SettingsUI } from './SettingsUI'; import type { SettingsService } from '../services/SettingsService'; import type { UISettingsService } from '@/shared/services/ui/UISettingsService'; @@ -27,6 +38,9 @@ describe('SettingsUI page lifecycle', () => { beforeEach(() => { initRenderer.mockReset(); destroyRenderer.mockReset(); + initAgentRenderer.mockReset(); + destroyAgentRenderer.mockReset(); + refreshAgentRenderer.mockReset(); document.body.innerHTML = ''; ( globalThis as unknown as { @@ -41,6 +55,7 @@ describe('SettingsUI page lifecycle', () => { settingsUI = null; document.body.innerHTML = ''; vi.clearAllMocks(); + vi.restoreAllMocks(); }); function createSettingsUI(): SettingsUI { @@ -50,11 +65,15 @@ describe('SettingsUI page lifecycle', () => { {} as AISettingsService, { t: (_key: string, defaultValue = '') => defaultValue } as unknown as I18nService, { applyTranslations: vi.fn() } as unknown as I18nUI, - {} as TauriProvider, + { + writeToClipboard: vi.fn().mockResolvedValue(undefined), + listen: vi.fn().mockResolvedValue(vi.fn()), + } as unknown as TauriProvider, {} as NavigationService, { tracer: { info: vi.fn(), + warn: vi.fn(), error: vi.fn(), } as unknown as LoggerService, showToast: (message: string, type?: 'success' | 'error' | 'warning' | 'info') => { @@ -78,6 +97,43 @@ describe('SettingsUI page lifecycle', () => { await ui.init(); expect(initRenderer).toHaveBeenCalledTimes(1); + expect(initAgentRenderer).toHaveBeenCalledTimes(1); + }); + + it('refreshes Agent Control when backend reports agent state changes', async () => { + let listener: () => void = () => { + throw new Error('Expected Agent Control listener to be registered'); + }; + const tauri = { + writeToClipboard: vi.fn().mockResolvedValue(undefined), + listen: vi.fn().mockImplementation((_event: string, callback: () => void) => { + listener = callback; + return Promise.resolve(vi.fn()); + }), + } as unknown as TauriProvider; + document.body.innerHTML = '
'; + settingsUI = new SettingsUI( + {} as SettingsService, + {} as UISettingsService, + {} as AISettingsService, + { t: (_key: string, defaultValue = '') => defaultValue } as unknown as I18nService, + { applyTranslations: vi.fn() } as unknown as I18nUI, + tauri, + {} as NavigationService, + { + tracer: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as LoggerService, + showToast: vi.fn(), + }, + ); + + await settingsUI.init(); + listener(); + + expect(refreshAgentRenderer).toHaveBeenCalledTimes(1); }); it('should wait for container insertion without polling loops', async () => { @@ -93,6 +149,7 @@ describe('SettingsUI page lifecycle', () => { await initPromise; expect(initRenderer).toHaveBeenCalledTimes(1); + expect(initAgentRenderer).toHaveBeenCalledTimes(1); }); it('should abort pending wait when destroyed before container appears', async () => { @@ -103,6 +160,43 @@ describe('SettingsUI page lifecycle', () => { await initPromise; expect(initRenderer).not.toHaveBeenCalled(); + expect(initAgentRenderer).not.toHaveBeenCalled(); expect(destroyRenderer).toHaveBeenCalledTimes(1); + expect(destroyAgentRenderer).toHaveBeenCalledTimes(1); + }); + + it('should keep jump control geometry in the already zoom-compensated page viewport', async () => { + document.documentElement.style.setProperty('--app-zoom', '1.25'); + document.body.innerHTML = ` +
+ +
+
+
+
+ `; + + const page = document.getElementById('page-settings') as HTMLElement; + Object.defineProperty(page, 'clientHeight', { + configurable: true, + value: 800, + }); + Object.defineProperty(page, 'scrollHeight', { + configurable: true, + value: 1600, + }); + Object.defineProperty(page, 'offsetTop', { + configurable: true, + value: 0, + }); + vi.spyOn(HTMLElement.prototype, 'getClientRects').mockReturnValue({ length: 1 } as never); + + const ui = createSettingsUI(); + + await ui.init(); + + expect( + (document.getElementById('settings-section-jump') as HTMLButtonElement).style.top, + ).toBe('752px'); }); }); diff --git a/src/features/settings/ui/SettingsUI.ts b/src/features/settings/ui/SettingsUI.ts index e5cea581..5662b0b6 100644 --- a/src/features/settings/ui/SettingsUI.ts +++ b/src/features/settings/ui/SettingsUI.ts @@ -4,6 +4,7 @@ */ import { GeneralSettingsRenderer } from './GeneralSettingsRenderer'; +import { AgentControlSettingsRenderer } from './AgentControlSettingsRenderer'; import type { IAppSettingsUIContext } from './SettingsContext'; import type { SettingsService } from '../services/SettingsService'; import type { UISettingsService } from '@/shared/services/ui/UISettingsService'; @@ -19,24 +20,36 @@ type SettingsUIDeps = { tracer: LoggerService; }; +const SECTION_SCROLL_DURATION_MS = 460; +const WHEEL_LOCK_RELEASE_DELAY_MS = 120; +const WHEEL_SECTION_DELTA_THRESHOLD = 8; + +function easeInOutCubic(progress: number): number { + return progress < 0.5 ? 4 * progress ** 3 : 1 - (-2 * progress + 2) ** 3 / 2; +} + export class SettingsUI { private readonly _generalRenderer: GeneralSettingsRenderer; + private readonly _agentControlRenderer: AgentControlSettingsRenderer; private _context!: IAppSettingsUIContext; private _isInitialized = false; private _isDestroyed = false; private _initAbortController: AbortController | null = null; + private _agentControlUnlisten: (() => void) | null = null; + private _sectionJumpUnlisten: (() => void) | null = null; public constructor( - _service: SettingsService, + service: SettingsService, uiSettings: UISettingsService, _aiSettings: AISettingsService, private readonly _i18n: I18nService, private readonly _i18nUI: I18nUI, - _tauri: TauriProvider, + private readonly _tauri: TauriProvider, _navigation: NavigationService, private readonly _deps: SettingsUIDeps, ) { this._generalRenderer = new GeneralSettingsRenderer(uiSettings, this._deps.tracer); + this._agentControlRenderer = new AgentControlSettingsRenderer(service, this._deps.tracer); } public async init(): Promise { @@ -77,6 +90,9 @@ export class SettingsUI { } this._generalRenderer.init(this._context); + this._agentControlRenderer.init(this._context); + this._setupSectionJump(); + await this._listenForAgentControlChanges(); } public close(): void { @@ -89,10 +105,183 @@ export class SettingsUI { this._isInitialized = false; this._initAbortController?.abort(); this._initAbortController = null; + this._agentControlUnlisten?.(); + this._agentControlUnlisten = null; + this._sectionJumpUnlisten?.(); + this._sectionJumpUnlisten = null; this._generalRenderer.destroy(); + this._agentControlRenderer.destroy(); this._deps.tracer.info('[SettingsUI] Destroyed.'); } + private _setupSectionJump(): void { + if (this._sectionJumpUnlisten !== null) { + return; + } + + const page = document.getElementById('page-settings'); + const button = document.getElementById('settings-section-jump'); + if (!(page instanceof HTMLElement) || !(button instanceof HTMLButtonElement)) { + return; + } + + const sections = Array.from(page.querySelectorAll('.settings-section')).filter( + (section) => section.offsetParent !== null || section.getClientRects().length > 0, + ); + if (sections.length < 2) { + button.hidden = true; + return; + } + + const label = this._i18n.t('ui.launcher.settings.section_jump'); + button.title = label; + button.setAttribute('aria-label', label); + + const sectionScrollTop = (section: HTMLElement | undefined) => + Math.min( + Math.max(0, page.scrollHeight - page.clientHeight), + Math.max(0, (section?.offsetTop ?? 0) - page.offsetTop), + ); + const getTopScroll = () => sectionScrollTop(sections[0]); + const getAgentScroll = () => sectionScrollTop(sections[1]); + const getIsAgentSection = () => page.scrollTop >= (getTopScroll() + getAgentScroll()) / 2; + const syncButtonPosition = () => { + button.style.top = `${page.scrollTop + page.clientHeight - 48}px`; + }; + + const update = () => { + const isAgentSection = getIsAgentSection(); + button.classList.toggle('is-up', isAgentSection); + syncButtonPosition(); + }; + + let animationFrame: number | null = null; + let unlockTimer: ReturnType | null = null; + let wheelLocked = false; + + const clearUnlockTimer = () => { + if (unlockTimer !== null) { + globalThis.clearTimeout(unlockTimer); + unlockTimer = null; + } + }; + + const releaseWheelLockSoon = () => { + clearUnlockTimer(); + unlockTimer = globalThis.setTimeout(() => { + wheelLocked = false; + unlockTimer = null; + }, WHEEL_LOCK_RELEASE_DELAY_MS); + }; + + const finishAnimation = (releaseLock = true) => { + if (animationFrame !== null) { + cancelAnimationFrame(animationFrame); + animationFrame = null; + } + page.classList.remove('is-section-scrolling'); + if (releaseLock) { + clearUnlockTimer(); + wheelLocked = false; + } + }; + + const scrollToSection = (toTop: boolean) => { + const target = toTop ? getTopScroll() : getAgentScroll(); + const start = page.scrollTop; + const distance = target - start; + finishAnimation(false); + clearUnlockTimer(); + + if (Math.abs(distance) < 1) { + page.scrollTop = target; + update(); + wheelLocked = false; + return; + } + + wheelLocked = true; + const reduceMotion = globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (reduceMotion === true) { + page.scrollTop = target; + update(); + releaseWheelLockSoon(); + return; + } + + const startedAt = performance.now(); + page.classList.add('is-section-scrolling'); + + const tick = (now: number) => { + const elapsed = now - startedAt; + const progress = Math.min(1, elapsed / SECTION_SCROLL_DURATION_MS); + page.scrollTop = start + distance * easeInOutCubic(progress); + update(); + + if (progress < 1) { + animationFrame = requestAnimationFrame(tick); + return; + } + + page.scrollTop = target; + update(); + finishAnimation(false); + releaseWheelLockSoon(); + }; + + animationFrame = requestAnimationFrame(tick); + }; + + const handleClick = () => { + scrollToSection(getIsAgentSection()); + }; + + const handleWheel = (event: WheelEvent) => { + if (wheelLocked) { + event.preventDefault(); + return; + } + if (Math.abs(event.deltaY) < WHEEL_SECTION_DELTA_THRESHOLD) { + return; + } + event.preventDefault(); + scrollToSection(event.deltaY < 0); + }; + + page.addEventListener('scroll', update, { passive: true }); + page.addEventListener('wheel', handleWheel, { passive: false }); + globalThis.addEventListener('resize', update); + button.addEventListener('click', handleClick); + update(); + + this._sectionJumpUnlisten = () => { + page.removeEventListener('scroll', update); + page.removeEventListener('wheel', handleWheel); + globalThis.removeEventListener('resize', update); + button.removeEventListener('click', handleClick); + finishAnimation(); + }; + } + + private async _listenForAgentControlChanges(): Promise { + if (this._agentControlUnlisten !== null) { + return; + } + try { + this._agentControlUnlisten = await this._tauri.listen( + 'agent-control:state-changed', + () => { + this._agentControlRenderer.refresh(); + }, + ); + } catch (error) { + this._deps.tracer.warn( + '[SettingsUI] Failed to listen for Agent Control updates:', + error, + ); + } + } + private async _waitForContainer( id: string, timeoutMs: number, diff --git a/src/package-lock.json b/src/package-lock.json index 46160472..9bd7185b 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -2092,9 +2092,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/public/templates/pages/console.html b/src/public/templates/pages/console.html index 543e4242..caaeb7eb 100644 --- a/src/public/templates/pages/console.html +++ b/src/public/templates/pages/console.html @@ -3,22 +3,22 @@
-
+ +
diff --git a/src/shared/services/CatalogLoadSnapshot.ts b/src/shared/services/CatalogLoadSnapshot.ts deleted file mode 100644 index 934d3335..00000000 --- a/src/shared/services/CatalogLoadSnapshot.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { AppConfig } from '@/shared/types/bindings'; -import type { IModule } from '@/shared/types/coreTypes'; - -export type EngineDefinition = { - id: string; - installed: boolean; - installed_compute_modes?: Array<'gpu' | 'cpu'>; -}; - -export type CatalogLoadSnapshot = { - config: AppConfig; - installedModules: IModule[]; - engineDefs: EngineDefinition[]; -}; diff --git a/src/shared/services/CatalogService.test.ts b/src/shared/services/CatalogService.test.ts index 7fac3c3f..ee80fff7 100644 --- a/src/shared/services/CatalogService.test.ts +++ b/src/shared/services/CatalogService.test.ts @@ -1,16 +1,53 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { CatalogService } from './CatalogService'; -import type { IModule } from '@/shared/types/coreTypes'; +import type { + CatalogAppItem, + CatalogProviderPolicy, + CatalogSnapshot, +} from '@/shared/types/bindings'; import { createCatalogHarness, - createMockAppConfig, + createMockCatalogSnapshot, setupBridgeMocks, type MockCatalogBridge, } from '@/test/helpers/catalogTestUtils'; +function catalogItem(overrides: Partial): CatalogAppItem { + return { + id: 'item', + nameKey: null, + descKey: null, + name: null, + desc: null, + icon: null, + preview: null, + category: 'services', + type: 'local', + capability: 'text', + installed: false, + installedComputeModes: [], + repoUrl: null, + expectedHash: null, + dlType: null, + comingSoon: false, + managedExternally: false, + version: '1.0.0', + configSchema: null, + settingsUi: null, + apiProviderData: null, + status: null, + ...overrides, + }; +} + describe('CatalogService', () => { let mockBridge: MockCatalogBridge; let service: CatalogService; + const expectedOpenAiPolicy: Partial = { + isCloudProvider: true, + secretService: 'cloud_api_key', + supportsInternetAccess: true, + }; beforeEach(() => { ({ mockBridge, service } = createCatalogHarness()); @@ -21,384 +58,283 @@ describe('CatalogService', () => { }); describe('Initialization', () => { - it('should correctly initialize catalog state on instantiation', () => { + it('initializes with empty catalog arrays', () => { const catalog = service.getCatalog(); - expect(Array.isArray(catalog.ai)).toBe(true); - expect(Array.isArray(catalog.services)).toBe(true); + + expect(catalog.ai).toEqual([]); + expect(catalog.services).toEqual([]); }); }); describe('loadCatalog', () => { - it('should load config and modules from bridge when in Tauri environment', async () => { - const mockConfig = createMockAppConfig({ - catalog: { ai: [{ id: 'test-ai', name: 'Test AI' }], services: [] }, + it('loads the backend-owned catalog snapshot through one command', async () => { + const snapshot = createMockCatalogSnapshot({ + ai: [ + catalogItem({ + id: 'openai', + name: 'OpenAI', + category: 'ai', + type: 'api', + installed: true, + apiProviderData: { + id: 'openai', + name: 'OpenAI', + type: 'openai-compatible', + models: [], + }, + providerPolicy: { + isCloudProvider: true, + isCustomProvider: false, + isCleanApp: false, + secretService: 'cloud_api_key', + keyProviderId: 'cloud', + keyProviderUrl: 'https://openrouter.ai/settings/keys', + usesCustomProviderKey: false, + showApiEndpointSelector: false, + showCustomModelComposer: false, + showModelStats: true, + supportsInternetAccess: true, + supportsThinking: false, + imageOnly: false, + }, + }), + ], + services: [ + catalogItem({ + id: 'sample-integration', + name: 'Sample Integration', + desc: 'Runs external workflows.', + icon: 'plug', + installed: true, + settingsUi: 'settings-ui/index.html', + status: 'stopped', + }), + ], + stars: ['openai'], }); - const mockModules: IModule[] = [ - { id: 'test-ai', configSchema: { setting: {} } } as unknown as IModule, - ]; - - setupBridgeMocks(mockBridge, mockConfig, mockModules); + setupBridgeMocks(mockBridge, snapshot); await service.loadCatalog(); - expect(mockBridge.invoke).toHaveBeenCalledWith('get_config'); - expect(mockBridge.invoke).toHaveBeenCalledWith('get_modules'); + expect(mockBridge.invoke).toHaveBeenCalledTimes(1); + expect(mockBridge.invoke).toHaveBeenCalledWith('get_catalog_snapshot'); const catalog = service.getCatalog(); - expect(catalog.ai.length).toBe(1); - expect(catalog.ai[0]?.id).toBe('test-ai'); - expect(catalog.ai[0]?.configSchema).toEqual({ setting: {} }); - expect(catalog.ai[0]?.type).toBe('api'); // is mapped to api if no type provided in AI + expect(catalog.stars).toEqual(['openai']); + expect(catalog.ai.at(0)).toMatchObject({ + id: 'openai', + type: 'api', + installed: true, + apiProviderData: { + id: 'openai', + name: 'OpenAI', + type: 'openai-compatible', + models: [], + }, + }); + expect(catalog.ai.at(0)?.providerPolicy).toMatchObject(expectedOpenAiPolicy); + expect(catalog.services.at(0)).toMatchObject({ + id: 'sample-integration', + category: 'services', + type: 'local', + installed: true, + settingsUi: 'settings-ui/index.html', + status: 'stopped', + }); }); - it('should preserve comingSoon placeholders as non-installed AI apps', async () => { - const mockConfig = createMockAppConfig({ - catalog: { + it('preserves backend decisions for coming soon and installed compute modes', async () => { + setupBridgeMocks( + mockBridge, + createMockCatalogSnapshot({ ai: [ - { + catalogItem({ id: 'future-image', name: 'Future Image', + category: 'ai', type: 'local', + capability: 'image', comingSoon: true, - }, + installed: false, + }), + catalogItem({ + id: 'llamacpp', + name: 'Llama.cpp', + category: 'ai', + type: 'local', + installed: true, + installedComputeModes: ['gpu', 'cpu', 'bad-mode'], + }), ], - services: [], - }, - }); - - setupBridgeMocks(mockBridge, mockConfig); - - await service.loadCatalog(); - - const app = service.getAppById('future-image'); - expect(app?.comingSoon).toBe(true); - expect(app?.installed).toBe(false); - expect(app?.type).toBe('local'); - }); - - it('should keep an explicitly empty catalog empty', async () => { - const invalidConfig = createMockAppConfig(); - - setupBridgeMocks(mockBridge, invalidConfig); + }), + ); await service.loadCatalog(); - const catalog = service.getCatalog(); - - expect(catalog.ai).toHaveLength(0); - expect(catalog.services).toHaveLength(0); - }); - - it('should inject apiProviderData for API modules', async () => { - const mockApiConfig = createMockAppConfig({ - catalog: { ai: [{ id: 'gpt-4', name: 'GPT 4', type: 'api' }], services: [] }, - apiProviders: [{ id: 'gpt-4', models: { default: 'gpt-4' } }], + expect(service.getAppById('future-image')).toMatchObject({ + comingSoon: true, + installed: false, + capability: 'image', }); - - setupBridgeMocks(mockBridge, mockApiConfig); - - await service.loadCatalog(); - - const app = service.getAppById('gpt-4'); - expect(app).toBeDefined(); - expect(app?.type).toBe('api'); - expect(app?.installed).toBe(true); - expect(app?.apiProviderData).toEqual({ id: 'gpt-4', models: { default: 'gpt-4' } }); + expect(service.getAppById('llamacpp')?.installedComputeModes).toEqual(['gpu', 'cpu']); }); - }); - describe('getAppById', () => { - it('should return undefined for unknown app id', () => { - expect(service.getAppById('non-existent')).toBeUndefined(); - }); - - it('should correctly retrieve an app by ID from loaded catalog', async () => { - const mockConfig = createMockAppConfig({ - catalog: { - ai: [{ id: 'ai-app', name: 'AI App' }], - services: [{ id: 'service-app', name: 'Service App' }], - }, - }); - - setupBridgeMocks(mockBridge, mockConfig); + it('passes backend-provided schema, preview, and localized keys through to the UI model', async () => { + setupBridgeMocks( + mockBridge, + createMockCatalogSnapshot({ + services: [ + catalogItem({ + id: 'worker', + nameKey: 'catalog.worker.name', + descKey: 'catalog.worker.desc', + preview: { + title: 'Worker', + description: 'Worker integration', + sticker: '*', + image: null, + }, + configSchema: { + timeout: { + fieldType: 'number', + label: 'Timeout', + default: 30, + required: false, + }, + }, + }), + ], + }), + ); await service.loadCatalog(); - expect(service.getAppById('ai-app')).toBeDefined(); - expect(service.getAppById('service-app')).toBeDefined(); - expect(service.getAppById('service-app')?.id).toBe('service-app'); - }); - }); - - describe('getCatalogCategory defaults', () => { - it('should return empty array for unknown category', () => { - // getCatalogCategory is now on GlobalBridge, not CatalogService - // Test service-level method instead - const catalog = service.getCatalog(); - expect(Array.isArray(catalog.ai)).toBe(true); - expect(Array.isArray(catalog.services)).toBe(true); - }); - - it('should return services array for services category', async () => { - const mockConfig = createMockAppConfig({ - catalog: { - ai: [], - services: [{ id: 'svc', name: 'Service' }], + expect(service.getAppById('worker')).toMatchObject({ + nameKey: 'catalog.worker.name', + descKey: 'catalog.worker.desc', + preview: { + title: 'Worker', + description: 'Worker integration', + sticker: '*', + }, + configSchema: { + timeout: { + fieldType: 'number', + label: 'Timeout', + default: 30, + required: false, + }, }, }); - - setupBridgeMocks(mockBridge, mockConfig); - - await service.loadCatalog(); - - const catalog = service.getCatalog(); - expect(catalog.services.length).toBe(1); - expect(catalog.services.at(0)?.id).toBe('svc'); }); - }); - describe('bridge failure handling', () => { - it('should load config through bridge even when isTauri=false', async () => { - const mockConfig = createMockAppConfig({ - catalog: { ai: [{ id: 'fetched-ai', name: 'Fetched AI' }], services: [] }, + it('reloads the snapshot when backend reports integration folder changes', async () => { + const firstSnapshot = createMockCatalogSnapshot({ + services: [catalogItem({ id: 'parser', name: 'Parser', installed: true })], }); + const secondSnapshot = createMockCatalogSnapshot({ services: [] }); + const listener = { integrationsChanged: null as null | (() => void) }; + let snapshotCalls = 0; - mockBridge.isTauri.mockReturnValue(false); - setupBridgeMocks(mockBridge, mockConfig); - - await service.loadCatalog(); - - const catalog = service.getCatalog(); - expect(catalog.ai.length).toBeGreaterThan(0); - expect(mockBridge.invoke).toHaveBeenCalledWith('get_config'); - }); - - it('should use an empty catalog when bridge returns null config', async () => { - mockBridge.isTauri.mockReturnValue(false); - setupBridgeMocks(mockBridge, null); + mockBridge.isTauri.mockReturnValue(true); + mockBridge.listen.mockImplementation((event: string, callback: () => void) => { + if (event === 'integrations_changed') { + listener.integrationsChanged = callback; + } + return Promise.resolve(() => {}); + }); + mockBridge.invoke.mockImplementation((cmd: string) => { + if (cmd !== 'get_catalog_snapshot') return Promise.resolve(undefined); + snapshotCalls += 1; + return Promise.resolve(snapshotCalls === 1 ? firstSnapshot : secondSnapshot); + }); await service.loadCatalog(); + await Promise.resolve(); - const catalog = service.getCatalog(); - expect(catalog.ai).toHaveLength(0); - expect(catalog.services).toHaveLength(0); - }); + expect(service.getAppById('parser')).toBeDefined(); - it('should use an empty catalog when bridge throws', async () => { - mockBridge.isTauri.mockReturnValue(false); - mockBridge.invoke.mockRejectedValue(new Error('Bridge error')); + if (listener.integrationsChanged === null) { + throw new Error('integrations_changed listener was not registered'); + } - await service.loadCatalog(); + listener.integrationsChanged(); + await new Promise((resolve) => globalThis.setTimeout(resolve, 0)); - const catalog = service.getCatalog(); - expect(catalog.ai).toHaveLength(0); - expect(catalog.services).toHaveLength(0); + expect(snapshotCalls).toBe(2); + expect(service.getAppById('parser')).toBeUndefined(); + expect(mockBridge.listen).toHaveBeenCalledTimes(1); }); - it('should use an empty catalog when bridge returns malformed catalog shape', async () => { - setupBridgeMocks( - mockBridge, - createMockAppConfig({ - catalog: { ai: null, services: undefined }, - apiProviders: null, - }), - ); + it('uses an empty catalog when the backend snapshot is unavailable', async () => { + setupBridgeMocks(mockBridge, null); await service.loadCatalog(); - const catalog = service.getCatalog(); - expect(catalog.ai).toHaveLength(0); - expect(catalog.services).toHaveLength(0); + expect(service.getCatalog().ai).toEqual([]); + expect(service.getCatalog().services).toEqual([]); expect(globalThis.dispatchEvent).toHaveBeenCalledWith( expect.objectContaining({ type: 'catalog-loaded' }), ); }); - }); - describe('_ensureValidConfig null config', () => { - it('should use an empty catalog when bridge invoke returns null', async () => { - setupBridgeMocks(mockBridge, null); + it('uses an empty catalog when the backend snapshot shape is malformed', async () => { + setupBridgeMocks(mockBridge, { + ai: null, + services: undefined, + stars: null, + } as unknown as CatalogSnapshot); await service.loadCatalog(); - const catalog = service.getCatalog(); - expect(catalog.ai).toHaveLength(0); - expect(catalog.services).toHaveLength(0); + expect(service.getCatalog().ai).toEqual([]); + expect(service.getCatalog().services).toEqual([]); }); - }); - // ---------------------------------------------------------- loadCatalog inner error (line 94) - describe('loadCatalog inner error handling', () => { - it('should catch errors in inner processing (e.g. stars access fail)', async () => { - // Provide a valid config but with a stars getter that throws inside the try block - const badConfig = createMockAppConfig({ - catalog: { - ai: [{ id: 'ok', name: 'OK' }], - services: [], - get stars() { - throw new Error('Stars access fail'); - }, + it('does not throw when snapshot processing fails', async () => { + const snapshot = createMockCatalogSnapshot(); + Object.defineProperty(snapshot, 'stars', { + get() { + throw new Error('Stars access fail'); }, }); - setupBridgeMocks(mockBridge, badConfig); + setupBridgeMocks(mockBridge, snapshot); - // The inner try-catch at line 56-95 catches the error await expect(service.loadCatalog()).resolves.not.toThrow(); }); }); - describe('catalog hydration', () => { - it('should mark api-type apps as installed=true', async () => { - const config = createMockAppConfig({ - catalog: { - ai: [ - { id: 'api-mod', name: 'API Module', type: 'api' }, - { id: 'local-mod', name: 'Local Module', type: 'local' }, - ], - services: [], - }, - apiProviders: [{ id: 'api-mod', models: { default: 'model-1' } }], - }); - - setupBridgeMocks(mockBridge, config); - - await service.loadCatalog(); - - const apiApp = service.getAppById('api-mod'); - const localApp = service.getAppById('local-mod'); - - expect(apiApp?.installed).toBe(true); - expect(localApp?.installed).not.toBe(true); - }); - - it('should mark non-engine local modules as installed when present in installed modules', async () => { - const config = createMockAppConfig({ - catalog: { - ai: [], - services: [{ id: 'local-mod', name: 'Local Module', type: 'local' }], - }, - }); - - setupBridgeMocks(mockBridge, config, [ - { id: 'local-mod', configSchema: { setting: {} } } as unknown as IModule, - ]); - - await service.loadCatalog(); - - const localApp = service.getAppById('local-mod'); - expect(localApp?.installed).toBe(true); - expect(localApp?.configSchema).toEqual({ setting: {} }); + describe('getAppById', () => { + it('returns undefined for unknown app id', () => { + expect(service.getAppById('non-existent')).toBeUndefined(); }); - it('should add discovered integration folders with manifest metadata to services', async () => { - const config = createMockAppConfig({ - catalog: { - ai: [{ id: 'gpt', name: 'GPT', type: 'api' }], - services: [], - }, - apiProviders: [{ id: 'gpt', models: { default: 'gpt-5' } }], - }); - - setupBridgeMocks(mockBridge, config, [ - { - id: 'sample-integration', - name: 'Sample Integration', - description: 'Sample integration workflow module for Axelate.', - version: '0.3.0', - icon: 'plug', - preview: { - title: 'Sample Integration', - description: - 'Runs an external workflow and processes discovered information through Axelate AI.', - sticker: '🤖', - }, - settingsUi: 'settings-ui/index.html', - status: 'stopped', - configSchema: undefined, - } as unknown as IModule, - ]); - - await service.loadCatalog(); - - const integration = service.getAppById('sample-integration'); - expect(integration).toBeDefined(); - expect(integration?.category).toBe('services'); - expect(integration?.type).toBe('local'); - expect(integration?.installed).toBe(true); - expect(integration?.name).toBe('Sample Integration'); - expect(integration?.desc).toContain('Runs an external workflow'); - expect(integration?.icon).toBe('🤖'); - expect(integration?.settingsUi).toBe('settings-ui/index.html'); - expect(service.getCatalog().services.some((app) => app.id === integration?.id)).toBe( - true, + it('retrieves apps by id after loading the snapshot', async () => { + setupBridgeMocks( + mockBridge, + createMockCatalogSnapshot({ + ai: [catalogItem({ id: 'ai-app', category: 'ai', type: 'api' })], + services: [catalogItem({ id: 'service-app' })], + }), ); - }); - - it('should reload catalog when backend reports integration folder changes', async () => { - const config = createMockAppConfig({ - catalog: { - ai: [], - services: [{ id: 'catalog-anchor', name: 'Catalog Anchor', type: 'local' }], - stars: [], - }, - }); - const firstModules = [ - { - id: 'parser', - name: 'Parser', - description: 'Parser integration', - version: '1.0.0', - icon: '🤖', - } as unknown as IModule, - ]; - const secondModules: IModule[] = []; - const listener = { integrationsChanged: null as null | (() => void) }; - let moduleListCalls = 0; - - mockBridge.isTauri.mockReturnValue(true); - mockBridge.listen.mockImplementation((event: string, callback: () => void) => { - if (event === 'integrations_changed') { - listener.integrationsChanged = callback; - } - return Promise.resolve(() => {}); - }); - mockBridge.invoke.mockImplementation((cmd: string) => { - if (cmd === 'get_config') return Promise.resolve(config); - if (cmd === 'get_engine_definitions') return Promise.resolve([]); - if (cmd === 'get_modules') { - moduleListCalls += 1; - return Promise.resolve(moduleListCalls === 1 ? firstModules : secondModules); - } - return Promise.resolve(undefined); - }); await service.loadCatalog(); - await Promise.resolve(); - expect(service.getAppById('parser')).toBeDefined(); - if (listener.integrationsChanged === null) { - throw new Error('integrations_changed listener was not registered'); - } - listener.integrationsChanged(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - await new Promise((resolve) => globalThis.setTimeout(resolve, 0)); - - expect(moduleListCalls).toBe(2); - expect(service.getAppById('parser')).toBeUndefined(); - expect(mockBridge.listen).toHaveBeenCalledTimes(1); + expect(service.getAppById('ai-app')).toBeDefined(); + expect(service.getAppById('service-app')?.id).toBe('service-app'); }); + }); - it('should unsubscribe the integration watcher if destroy runs while binding is pending', async () => { + describe('watcher cleanup', () => { + it('unsubscribes the integration watcher if destroy runs while binding is pending', async () => { let resolveListen: (unlisten: () => void) => void = () => { throw new Error('listen promise was not started'); }; const unlisten = vi.fn(); - setupBridgeMocks(mockBridge, createMockAppConfig()); + setupBridgeMocks(mockBridge, createMockCatalogSnapshot()); mockBridge.isTauri.mockReturnValue(true); mockBridge.listen.mockReturnValue( new Promise((resolve) => { @@ -415,47 +351,4 @@ describe('CatalogService', () => { expect(unlisten).toHaveBeenCalledTimes(1); }); }); - - describe('_initGlobalExposures DEV branch (L29)', () => { - it('should skip __DEV_CATALOG when DEV is false', () => { - const origDev = import.meta.env['DEV']; - (import.meta.env as Record)['DEV'] = false; - - const { service: s } = createCatalogHarness(); - expect(s).toBeDefined(); - - (import.meta.env as Record)['DEV'] = origDev; - }); - }); - - describe('_loadModuleList bridge branches (L126)', () => { - const webConfig = createMockAppConfig({ - catalog: { ai: [{ id: 'ai1', name: 'AI' }], services: [] }, - }); - - it('should return modules when bridge response is ok (L126 true branch)', async () => { - mockBridge.isTauri.mockReturnValue(false); - setupBridgeMocks(mockBridge, webConfig, [ - { id: 'mod1', name: 'Module 1' }, - ] as IModule[]); - - await service.loadCatalog(); - - expect(mockBridge.invoke).toHaveBeenCalledWith('get_modules'); - }); - - it('should return empty array when bridge response fails (L126 false branch)', async () => { - mockBridge.isTauri.mockReturnValue(false); - mockBridge.invoke.mockImplementation((cmd: string) => { - if (cmd === 'get_config') return Promise.resolve(webConfig); - if (cmd === 'get_modules') return Promise.reject(new Error('modules failed')); - if (cmd === 'get_engine_definitions') return Promise.resolve([]); - return Promise.resolve(undefined); - }); - - await service.loadCatalog(); - - expect(service.getCatalog().ai.length).toBeGreaterThan(0); - }); - }); }); diff --git a/src/shared/services/CatalogService.ts b/src/shared/services/CatalogService.ts index 54251f4a..daeae0cd 100644 --- a/src/shared/services/CatalogService.ts +++ b/src/shared/services/CatalogService.ts @@ -4,20 +4,15 @@ */ import type { IBridge } from '@/shared/types/IBridge'; -import type { IApp, IModule, IConfigField, ICatalogData } from '@/shared/types/coreTypes'; -import type { AppConfig, ModuleItem, ApiProvider } from '@/shared/types/bindings'; +import type { IApp, IConfigField, ICatalogData } from '@/shared/types/coreTypes'; +import type { CatalogAppItem, CatalogSnapshot } from '@/shared/types/bindings'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; -import type { CatalogLoadSnapshot, EngineDefinition } from './CatalogLoadSnapshot'; type CatalogLogger = Pick; -const EMPTY_CONFIG: AppConfig = { - version: '1.0.0', - apiProviders: [], - catalog: { - ai: [], - services: [], - stars: [], - }, +const EMPTY_SNAPSHOT: CatalogSnapshot = { + ai: [], + services: [], + stars: [], }; export class CatalogService { @@ -40,16 +35,9 @@ export class CatalogService { const snapshot = await this._loadSnapshot(); try { - this._appData.stars = snapshot.config.catalog.stars; - - const ai = snapshot.config.catalog.ai; - const services = snapshot.config.catalog.services; - - this._appData.ai = this._mapModuleItems(ai, 'ai'); - this._appData.services = this._mapModuleItems(services, 'services'); - - // Hydrate with schemas, providers & engine install status - this._hydrateApps(snapshot.config, snapshot.installedModules, snapshot.engineDefs); + this._appData.stars = snapshot.stars; + this._appData.ai = snapshot.ai.map((item) => this._mapSnapshotItem(item)); + this._appData.services = snapshot.services.map((item) => this._mapSnapshotItem(item)); const event = new CustomEvent('catalog-loaded'); globalThis.dispatchEvent(event); @@ -95,198 +83,55 @@ export class CatalogService { }); } - private async _loadSnapshot(): Promise { - const [config, installedModules, engineDefs] = await Promise.all([ - this._loadConfig(), - this._loadInstalledModules(), - this._loadEngineDefs(), - ]); - - return { - config: this._ensureValidConfig(config), - installedModules, - engineDefs, - }; - } - - private async _loadConfig(): Promise { - try { - return await this._bridge.invoke('get_config'); - } catch (e) { - this._tracer.warn(`[CatalogService] Backend config failed: ${String(e)}`); - return null; - } - } - - /** - * Fetches engine definitions (with real-time `installed` status) from backend. - */ - private async _loadEngineDefs(): Promise { + private async _loadSnapshot(): Promise { try { - if (this._bridge.isTauri()) { - return await this._bridge.invoke('get_engine_definitions'); - } + const snapshot = await this._bridge.invoke('get_catalog_snapshot'); + return this._ensureValidSnapshot(snapshot); } catch (e) { - this._tracer.warn(`[CatalogService] Engine definitions unavailable: ${String(e)}`); + this._tracer.warn(`[CatalogService] Backend catalog snapshot failed: ${String(e)}`); + return EMPTY_SNAPSHOT; } - return []; } - /** - * Loads the list of installed modules. - */ - private async _loadInstalledModules(): Promise { - try { - const modules = await this._bridge.invoke('get_modules'); - return modules; - } catch (e) { - this._tracer.warn(`[CatalogService] Module list failed: ${String(e)}`); - return []; - } - } - - /** - * Maps raw module items to IApp format. - */ - private _mapModuleItems(items: ModuleItem[], category: 'ai' | 'services'): IApp[] { - return items.map((item) => { - // Resolve capability from capabilities array (first match wins) - const caps = (item as ModuleItem & { capabilities?: string[] }).capabilities ?? []; - let capability: 'text' | 'image' = 'text'; - if (caps.includes('image')) capability = 'image'; - - return { - id: item.id, - nameKey: item.nameKey, - descKey: item.descKey, - name: item.name, - desc: item.desc, - icon: item.icon, - preview: item.preview ?? null, - category: category, - type: category === 'ai' && item.type !== 'local' ? 'api' : 'local', - capability, - repoUrl: item.repoUrl ?? '', - expectedHash: item.expectedHash ?? '', - dlType: item.dlType ?? undefined, - comingSoon: (item as ModuleItem & { comingSoon?: boolean }).comingSoon === true, - managedExternally: - (item as ModuleItem & { managedExternally?: boolean }).managedExternally === - true, - version: item.version ?? '1.0.0', - installed: (item as ModuleItem & { installed?: boolean }).installed ?? false, - } as IApp; - }); - } - - /** - * Hydrates apps with schemas, providers, and model data. - */ - private _hydrateApps( - config: AppConfig, - installedModules: IModule[], - engineDefs: EngineDefinition[] = [], - ): void { - const installedMap = new Map(installedModules.map((m) => [m.id.toLowerCase(), m])); - // Build a fast lookup for engine installation status - const engineInstallMap = new Map(engineDefs.map((e) => [e.id.toLowerCase(), e.installed])); - const engineComputeModesMap = new Map( - engineDefs.map((engine) => [ - engine.id.toLowerCase(), - (engine.installed_compute_modes ?? []) as Array<'gpu' | 'cpu'>, - ]), - ); - - const mergeAppSchema = (app: IApp) => { - const isApi = - app.type === 'api' || config.apiProviders.some((p: ApiProvider) => p.id === app.id); - const installedModule = installedMap.get(app.id.toLowerCase()); - - if (app.comingSoon === true) { - app.installed = false; - return; - } - - if (app.managedExternally === true) { - app.installed = true; - } - - if (isApi) { - app.installed = true; - } else if (app.type === 'local' && engineInstallMap.has(app.id.toLowerCase())) { - // Use real-time detection from is_engine_installed() - app.installed = engineInstallMap.get(app.id.toLowerCase()) ?? false; - app.installedComputeModes = engineComputeModesMap.get(app.id.toLowerCase()) ?? []; - } else if (app.type === 'local' && installedModule) { - // Non-engine local modules should render as installed immediately. - // Otherwise the modal first paints the "download" style and only then - // flips after a late async install check. - app.installed = true; - } - - const provider = config.apiProviders.find((p: ApiProvider) => p.id === app.id); - if (provider) { - app.apiProviderData = provider as unknown as Record; - } - - if (installedModule?.configSchema) { - app.configSchema = installedModule.configSchema as unknown as Record< - string, - IConfigField - >; - } - - if (installedModule?.settingsUi !== undefined) { - app.settingsUi = installedModule.settingsUi; - } - - if (installedModule?.preview !== undefined) { - app.preview = installedModule.preview; - } + private _mapSnapshotItem(item: CatalogAppItem): IApp { + const app: IApp = { + id: item.id, + preview: item.preview ?? null, + category: item.category, + type: item.type === 'api' ? 'api' : 'local', + capability: item.capability === 'image' ? 'image' : 'text', + installed: item.installed, + installedComputeModes: this._mapComputeModes(item.installedComputeModes ?? []), + repoUrl: item.repoUrl ?? '', + expectedHash: item.expectedHash ?? '', + comingSoon: item.comingSoon, + managedExternally: item.managedExternally, + version: item.version, }; - this._appData.ai.forEach(mergeAppSchema); - this._appData.services.forEach(mergeAppSchema); - this._appendDiscoveredIntegrations(installedModules); - } - - private _appendDiscoveredIntegrations(installedModules: IModule[]): void { - const knownIds = new Set( - [...this._appData.ai, ...this._appData.services].map((app) => app.id.toLowerCase()), - ); - - const discovered = installedModules - .filter((module) => !knownIds.has(module.id.toLowerCase())) - .map((module) => this._mapInstalledIntegration(module)); - - if (discovered.length === 0) return; - - this._appData.services.push(...discovered); - this._tracer.debug( - `[CatalogService] Added ${String(discovered.length)} discovered integration(s).`, - ); - } + if (item.nameKey !== null) app.nameKey = item.nameKey; + if (item.descKey !== null) app.descKey = item.descKey; + if (item.name !== null) app.name = item.name; + if (item.desc !== null) app.desc = item.desc; + if (item.icon !== null) app.icon = item.icon; + if (item.dlType !== null) app.dlType = item.dlType; + if (item.configSchema !== null && item.configSchema !== undefined) { + app.configSchema = item.configSchema as Record; + } + if (item.settingsUi !== undefined) { + app.settingsUi = item.settingsUi; + } + if (item.apiProviderData !== null && item.apiProviderData !== undefined) { + app.apiProviderData = item.apiProviderData as Record; + } + if (item.providerPolicy !== null && item.providerPolicy !== undefined) { + app.providerPolicy = item.providerPolicy; + } + if (item.status !== undefined) { + app.status = item.status; + } - private _mapInstalledIntegration(module: IModule): IApp { - return { - id: module.id, - name: module.preview?.title ?? module.name, - desc: module.preview?.description ?? module.description, - icon: module.preview?.sticker ?? module.icon, - preview: module.preview ?? null, - category: 'services', - type: 'local', - capability: 'text', - repoUrl: '', - expectedHash: '', - comingSoon: false, - managedExternally: false, - version: module.version, - installed: true, - configSchema: module.configSchema as unknown as Record, - settingsUi: module.settingsUi, - status: module.status, - }; + return app; } /** @@ -306,32 +151,38 @@ export class CatalogService { ); } - private _ensureValidConfig(config: AppConfig | null): AppConfig { - if (!config) { - this._tracer.warn('[CatalogService] Config is unavailable. Using empty catalog.'); - return EMPTY_CONFIG; + private _mapComputeModes(modes: string[]): Array<'gpu' | 'cpu'> { + return modes.filter((mode): mode is 'gpu' | 'cpu' => mode === 'gpu' || mode === 'cpu'); + } + + private _ensureValidSnapshot(snapshot: CatalogSnapshot | null): CatalogSnapshot { + if (!snapshot) { + this._tracer.warn( + '[CatalogService] Catalog snapshot is unavailable. Using empty catalog.', + ); + return EMPTY_SNAPSHOT; } - if (!this._hasCatalogArrays(config)) { - this._tracer.warn('[CatalogService] Config shape is invalid. Using empty catalog.'); - return EMPTY_CONFIG; + if (!this._hasSnapshotArrays(snapshot)) { + this._tracer.warn( + '[CatalogService] Catalog snapshot shape is invalid. Using empty catalog.', + ); + return EMPTY_SNAPSHOT; } - return config; + return snapshot; } - private _hasCatalogArrays(config: AppConfig): boolean { - const candidate = config as unknown as { - catalog?: { - ai?: unknown; - services?: unknown; - }; - apiProviders?: unknown; + private _hasSnapshotArrays(snapshot: CatalogSnapshot): boolean { + const candidate = snapshot as unknown as { + ai?: unknown; + services?: unknown; + stars?: unknown; }; return ( - Array.isArray(candidate.catalog?.ai) && - Array.isArray(candidate.catalog.services) && - Array.isArray(candidate.apiProviders) + Array.isArray(candidate.ai) && + Array.isArray(candidate.services) && + Array.isArray(candidate.stars) ); } } diff --git a/src/shared/shell/AppUI.test.ts b/src/shared/shell/AppUI.test.ts index ec584e8e..da8bd517 100644 --- a/src/shared/shell/AppUI.test.ts +++ b/src/shared/shell/AppUI.test.ts @@ -326,7 +326,7 @@ describe('AppUI lifecycle', () => { expect(document.getElementById('action-feedback')?.classList.contains('error')).toBe(true); }); - it('injects dedicated custom providers into ai modal selections', () => { + it('uses backend-provided custom providers in ai modal selections', () => { getCatalogCategoryMock.mockReturnValue([ { id: 'gpt', @@ -335,6 +335,20 @@ describe('AppUI lifecycle', () => { capability: 'text', installed: true, }, + { + id: CUSTOM_TEXT_PROVIDER_ID, + name: 'Custom Text', + type: 'api', + capability: 'text', + installed: true, + }, + { + id: CUSTOM_IMAGE_PROVIDER_ID, + name: 'Custom Image', + type: 'api', + capability: 'image', + installed: true, + }, ]); appUI = createAppUI(); const modalManager = ( @@ -729,6 +743,60 @@ describe('AppUI lifecycle', () => { expect(card.dataset['runtimeStatus']).toBe('running'); }); + it('should refresh selected dashboard card runtime status even without a catalog status', async () => { + appUI = createAppUI(); + document.body.innerHTML = ` +
+
+
+
+
+ `; + + const app = { + id: 'gemini', + name: 'Gemini', + type: 'api', + installed: true, + } as IApp; + + platformServiceMock.getStatus.mockResolvedValueOnce('running'); + appUI.updateModuleCard('ai_text', app); + await Promise.resolve(); + + const card = document.getElementById('ai-module-card') as HTMLElement; + expect(platformServiceMock.getStatus).toHaveBeenCalledWith(app); + expect(card.dataset['runtimeStatus']).toBe('running'); + expect(card.classList.contains('module-running')).toBe(true); + }); + + it('should show selected dashboard card errors with the red marker class', async () => { + appUI = createAppUI(); + document.body.innerHTML = ` +
+
+
+
+
+ `; + + const app = { + id: 'gemini', + name: 'Gemini', + type: 'api', + installed: true, + } as IApp; + + platformServiceMock.getStatus.mockResolvedValueOnce('failed'); + appUI.updateModuleCard('ai_text', app); + await Promise.resolve(); + + const card = document.getElementById('ai-module-card') as HTMLElement; + expect(card.dataset['runtimeStatus']).toBe('error'); + expect(card.classList.contains('engine-error')).toBe(true); + expect(card.classList.contains('module-running')).toBe(false); + }); + it('should show a placeholder toast instead of selecting or downloading coming-soon modules', async () => { appUI = createAppUI(); const toastSpy = vi.spyOn(appUI, 'showToast'); diff --git a/src/shared/shell/AppUI.ts b/src/shared/shell/AppUI.ts index 605dd055..b02f137e 100644 --- a/src/shared/shell/AppUI.ts +++ b/src/shared/shell/AppUI.ts @@ -1,6 +1,5 @@ import type { IApp } from '../types/coreTypes'; import { CategoryKey } from '../types/categoryKeys'; -import { appendCustomProviderApps } from '../utils/customProviderSupport'; import { isAiCategory } from '../utils/moduleCategoryPolicy'; import type { EventBus } from '../services/EventBus'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; @@ -360,6 +359,28 @@ export class AppUI { this._dashboardSupport.applySelectedCardState(card, app, category); this._selectionState.set(category, app); this._updateMultiSlotBadge(); + void this._refreshSelectedCardRuntimeStatus(category, app); + } + + private async _refreshSelectedCardRuntimeStatus(category: string, app: IApp): Promise { + if (app.installed === false) { + return; + } + + try { + const status = await this._platformService.getStatus(app); + if (this._selectionState.get(category)?.id !== app.id) { + return; + } + this._dashboardSupport.updateRuntimeStatus(category, app, status); + } catch (error) { + this._deps.tracer.warn( + `[AppUI] Failed to refresh runtime status for ${app.id}: ${String(error)}`, + ); + if (this._selectionState.get(category)?.id === app.id) { + this._dashboardSupport.updateRuntimeStatus(category, app, 'error'); + } + } } /** @@ -540,8 +561,7 @@ export class AppUI { private _getCatalogApps(category: string): IApp[] { try { - const apps = this._catalogResolver(category); - return category === CategoryKey.AI ? appendCustomProviderApps(apps) : apps; + return this._catalogResolver(category); } catch (err: unknown) { this._deps.tracer.warn( `[AppUI] Failed to read catalog category ${category}: ${String(err)}`, diff --git a/src/shared/shell/ui/AppUiDashboardSupport.ts b/src/shared/shell/ui/AppUiDashboardSupport.ts index a51e787b..5226e199 100644 --- a/src/shared/shell/ui/AppUiDashboardSupport.ts +++ b/src/shared/shell/ui/AppUiDashboardSupport.ts @@ -122,6 +122,7 @@ export class AppUiDashboardSupport { 'has-launch', 'module-running', 'module-stopped', + 'engine-error', ); card.classList.add('empty'); delete card.dataset['currentModule']; diff --git a/src/shared/shell/ui/card/ModuleCardRenderer.test.ts b/src/shared/shell/ui/card/ModuleCardRenderer.test.ts index 5d981c2a..d2a0ac5d 100644 --- a/src/shared/shell/ui/card/ModuleCardRenderer.test.ts +++ b/src/shared/shell/ui/card/ModuleCardRenderer.test.ts @@ -340,4 +340,23 @@ describe('ModuleCardRenderer', () => { expect(card.querySelector('.app-card-overlay')).toBeNull(); expect(configureActionBtn).toHaveBeenCalled(); }); + + it('maps dashboard runtime status to selected card marker classes', () => { + const card = document.createElement('div'); + + renderer.updateSlotCardRuntimeStatus(card, 'running'); + expect(card.dataset['runtimeStatus']).toBe('running'); + expect(card.classList.contains('module-running')).toBe(true); + expect(card.classList.contains('engine-error')).toBe(false); + + renderer.updateSlotCardRuntimeStatus(card, 'failed'); + expect(card.dataset['runtimeStatus']).toBe('error'); + expect(card.classList.contains('module-running')).toBe(false); + expect(card.classList.contains('engine-error')).toBe(true); + + renderer.updateSlotCardRuntimeStatus(card, undefined); + expect(card.dataset['runtimeStatus']).toBe('stopped'); + expect(card.classList.contains('module-stopped')).toBe(true); + expect(card.classList.contains('engine-error')).toBe(false); + }); }); diff --git a/src/shared/shell/ui/card/ModuleCardRenderer.ts b/src/shared/shell/ui/card/ModuleCardRenderer.ts index abedffd9..2d366543 100644 --- a/src/shared/shell/ui/card/ModuleCardRenderer.ts +++ b/src/shared/shell/ui/card/ModuleCardRenderer.ts @@ -327,10 +327,24 @@ export class ModuleCardRenderer { } public updateSlotCardRuntimeStatus(card: HTMLElement, status: string | null | undefined): void { - const normalizedStatus = status === 'running' ? 'running' : 'stopped'; + const normalizedStatus = this._normalizeRuntimeStatus(status); card.dataset['runtimeStatus'] = normalizedStatus; card.classList.toggle('module-running', normalizedStatus === 'running'); - card.classList.toggle('module-stopped', normalizedStatus !== 'running'); + card.classList.toggle('engine-error', normalizedStatus === 'error'); + card.classList.toggle('module-stopped', normalizedStatus === 'stopped'); + } + + private _normalizeRuntimeStatus( + status: string | null | undefined, + ): 'running' | 'stopped' | 'error' { + const normalized = status?.trim().toLowerCase(); + if (normalized === 'running') { + return 'running'; + } + if (normalized === 'error' || normalized === 'failed') { + return 'error'; + } + return 'stopped'; } private _updateCardIcon(card: HTMLElement, app: IApp): void { diff --git a/src/shared/types/bindings.ts b/src/shared/types/bindings.ts index 7377a8b9..18cf82ec 100644 --- a/src/shared/types/bindings.ts +++ b/src/shared/types/bindings.ts @@ -11,8 +11,24 @@ import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core"; export const commands = { /** Checks backend health status */ getHealth: () => typedError(__TAURI_INVOKE("get_health")), + /** Returns redacted Agent Control state. */ + getAgentControlState: () => typedError(__TAURI_INVOKE("get_agent_control_state")), + /** Enables or disables trusted local Agent Control profiles. */ + setAgentControlEnabled: (enabled: boolean) => typedError(__TAURI_INVOKE("set_agent_control_enabled", { enabled })), + /** Creates a trusted local agent profile and returns its one-time token. */ + createAgentProfile: (name: string | null, scopes: AgentScope[] | null) => typedError(__TAURI_INVOKE("create_agent_profile", { name, scopes })), + /** Rotates a trusted local agent token and returns the replacement token once. */ + rotateAgentProfile: (id: string) => typedError(__TAURI_INVOKE("rotate_agent_profile", { id })), + /** Copies a one-time agent token to the OS clipboard without exposing it to the frontend. */ + copyAgentProfileToken: (id: string) => typedError(__TAURI_INVOKE("copy_agent_profile_token", { id })), + /** Deletes a trusted local agent profile. */ + deleteAgentProfile: (id: string) => typedError(__TAURI_INVOKE("delete_agent_profile", { id })), + /** Applies a user decision to a pending agent approval request. */ + decideAgentApproval: (id: string, approved: boolean) => typedError(__TAURI_INVOKE("decide_agent_approval", { id, approved })), /** Loads application configuration with module installation status */ getConfig: () => typedError(__TAURI_INVOKE("get_config")).then((v) => ((v.status === "ok" ? { ...v, data: ({...v.data,apiProviders:v.data.apiProviders.map(i=>({...i,models:i.models==null?i.models:i.models.map(i=>({...i,pricing:i.pricing==null?i.pricing:({...i.pricing,input:i.pricing.input==null?i.pricing.input:i.pricing.input,output:i.pricing.output==null?i.pricing.output:i.pricing.output})}))})),catalog:({...v.data.catalog,ai:v.data.catalog.ai.map(i=>({...i,configSchema:i.configSchema==null?i.configSchema:i.configSchema})),services:v.data.catalog.services.map(i=>({...i,configSchema:i.configSchema==null?i.configSchema:i.configSchema}))})}) } : v) as typeof v)), + /** Returns a frontend-ready catalog snapshot with backend-owned installation and provider metadata. */ + getCatalogSnapshot: () => typedError(__TAURI_INVOKE("get_catalog_snapshot")).then((v) => ((v.status === "ok" ? { ...v, data: ({...v.data,ai:v.data.ai.map(i=>({...i,configSchema:i.configSchema==null?i.configSchema:Object.fromEntries(Object.entries(i.configSchema).map(([k,v])=>[k,({...v,default:v.default==null?v.default:v.default,min:v.min==null?v.min:v.min,max:v.max==null?v.max:v.max,step:v.step==null?v.step:v.step})])),apiProviderData:i.apiProviderData==null?i.apiProviderData:({...i.apiProviderData,models:i.apiProviderData.models==null?i.apiProviderData.models:i.apiProviderData.models.map(i=>({...i,pricing:i.pricing==null?i.pricing:({...i.pricing,input:i.pricing.input==null?i.pricing.input:i.pricing.input,output:i.pricing.output==null?i.pricing.output:i.pricing.output})}))})})),services:v.data.services.map(i=>({...i,configSchema:i.configSchema==null?i.configSchema:Object.fromEntries(Object.entries(i.configSchema).map(([k,v])=>[k,({...v,default:v.default==null?v.default:v.default,min:v.min==null?v.min:v.min,max:v.max==null?v.max:v.max,step:v.step==null?v.step:v.step})])),apiProviderData:i.apiProviderData==null?i.apiProviderData:({...i.apiProviderData,models:i.apiProviderData.models==null?i.apiProviderData.models:i.apiProviderData.models.map(i=>({...i,pricing:i.pricing==null?i.pricing:({...i.pricing,input:i.pricing.input==null?i.pricing.input:i.pricing.input,output:i.pricing.output==null?i.pricing.output:i.pricing.output})}))})}))}) } : v) as typeof v)), /** Retrieves application settings (theme, language, GPU, debug) */ getSettings: () => typedError(__TAURI_INVOKE("get_settings")), /** Saves application settings */ @@ -88,7 +104,7 @@ export const commands = { setMonitoringPaused: (paused: boolean) => typedError(__TAURI_INVOKE("set_monitoring_paused", { paused })), /** Retrieves list of all available modules (AI and services) */ getModules: () => typedError(__TAURI_INVOKE("get_modules")).then((v) => ((v.status === "ok" ? { ...v, data: v.data.map(i=>({...i,config:Object.fromEntries(Object.entries(i.config).map(([k,v])=>[k,v])),configSchema:i.configSchema==null?i.configSchema:Object.fromEntries(Object.entries(i.configSchema).map(([k,v])=>[k,({...v,default:v.default==null?v.default:v.default,min:v.min==null?v.min:v.min,max:v.max==null?v.max:v.max,step:v.step==null?v.step:v.step})]))})) } : v) as typeof v)), - /** Controls a module (start, stop, restart) */ + /** Controls a module (start, stop, restart, repair) */ controlModule: (request: ControlRequest) => typedError(__TAURI_INVOKE("control_module", { request })), /** Retrieves runtime status of a specific module */ getModuleStatus: (moduleId: string) => typedError(__TAURI_INVOKE("get_module_status", { moduleId })), @@ -232,6 +248,108 @@ export const commands = { }; /* Types */ +/** Risky action request that must not mutate launcher state until approved. */ +export type AgentApprovalRequest = { + /** Stable approval request id. */ + id: string, + /** Agent profile id. */ + agentId: string, + /** Agent display name. */ + agentName: string, + /** Requested action name. */ + action: string, + /** Target resource id or description. */ + target: string, + /** Human-readable dry-run or diff summary. */ + diff: string, + /** Risk label such as high or dangerous. */ + risk: string, + /** Current decision state. */ + status: AgentApprovalStatus, + /** Creation timestamp in RFC3339 UTC. */ + createdAt: string, + /** Decision timestamp in RFC3339 UTC. */ + decidedAt: string | null, +}; + +/** Approval state for risky agent requests. */ +export type AgentApprovalStatus = +/** Waiting for a user decision. */ +"pending" | +/** User approved the request. */ +"approved" | +/** User denied the request. */ +"denied"; + +/** Agent action audit entry. */ +export type AgentAuditEntry = { + /** Stable audit entry id. */ + id: string, + /** Agent profile id or launcher-env for development tokens. */ + actorId: string, + /** Agent display name or development token label. */ + actorName: string, + /** Action name such as module.start. */ + action: string, + /** Target resource id. */ + target: string, + /** Result label such as success, denied, or pending-approval. */ + result: string, + /** Timestamp in RFC3339 UTC. */ + createdAt: string, +}; + +/** Full public Agent Control state for Settings UI. */ +export type AgentControlState = { + /** Whether trusted local agent profiles are accepted by the local API. */ + enabled: boolean, + /** Local API base URL. */ + apiBaseUrl: string, + /** Known agent profiles. */ + profiles: AgentProfile[], + /** Recent audit entries. */ + audit: AgentAuditEntry[], + /** Recent approval requests. */ + approvals: AgentApprovalRequest[], +}; + +/** Public trusted local agent profile metadata. */ +export type AgentProfile = { + /** Stable profile id. */ + id: string, + /** User-facing agent name. */ + name: string, + /** Granted capability scopes. */ + scopes: AgentScope[], + /** Non-secret token prefix for recognition in the UI. */ + tokenPrefix: string, + /** Creation timestamp in RFC3339 UTC. */ + createdAt: string, + /** Last successful API authentication timestamp in RFC3339 UTC. */ + lastSeenAt: string | null, + /** Whether the profile has been revoked. */ + revoked: boolean, +}; + +/** Agent profile creation/rotation response. Raw bearer tokens stay backend-owned. */ +export type AgentProfileTokenResponse = { + /** Public profile metadata. */ + profile: AgentProfile, +}; + +/** Agent capability scope. */ +export type AgentScope = +/** Read launcher state, statuses, inventories, and sanitized logs. */ +"observe" | +/** Start, stop, restart, select, and inspect operational runtime state. */ +"operate" | +/** Change non-secret launcher, module, model, and provider settings. */ +"configure" | +/** Create integration drafts without installing or running them silently. */ +"draft-create" | +/** User-granted full local launcher access. */ +"full-access"; + /** Complete AI model definition */ export type AiModel = { /** Model ID (moved from dict key) */ @@ -401,6 +519,96 @@ export type Capability = /** Image understanding (multimodal LLM) */ "vision"; +/** Frontend-ready catalog application item. */ +export type CatalogAppItem = { + /** Unique item identifier. */ + id: string, + /** Localization key for name. */ + nameKey: string | null, + /** Localization key for description. */ + descKey: string | null, + /** Display name. */ + name: string | null, + /** Description text. */ + desc: string | null, + /** Icon/emoji. */ + icon: string | null, + /** Optional module-owned card preview metadata. */ + preview?: ModulePreview | null, + /** Catalog category. */ + category: string, + /** Runtime type used by the launcher UI. */ + type: string, + /** Primary AI output capability. */ + capability: string | null, + /** Whether item files/runtime are currently present. */ + installed: boolean, + /** Installed compute modes for local engines. */ + installedComputeModes?: string[], + /** Download repository URL. */ + repoUrl: string | null, + /** Expected integrity hash. */ + expectedHash: string | null, + /** Download strategy. */ + dlType: string | null, + /** Placeholder marker. */ + comingSoon: boolean, + /** Whether runtime is managed outside Axelate. */ + managedExternally: boolean, + /** Semantic version. */ + version: string, + /** Configuration schema. */ + configSchema?: { [key in string]: ConfigField } | null, + /** Optional module-owned settings UI entry. */ + settingsUi?: string | null, + /** API provider metadata for provider cards. */ + apiProviderData?: ApiProvider | null, + /** Backend-owned UI/runtime policy for this catalog item. */ + providerPolicy?: CatalogProviderPolicy | null, + /** Current runtime status for integrations. */ + status?: string | null, +}; + +/** Frontend rendering/runtime policy derived from backend catalog/provider metadata. */ +export type CatalogProviderPolicy = { + /** Whether the card is a cloud/API provider. */ + isCloudProvider: boolean, + /** Whether the card is a user-defined OpenAI-compatible provider slot. */ + isCustomProvider: boolean, + /** Whether the card should render as a no-settings module. */ + isCleanApp: boolean, + /** Secure-storage service name used for this provider key. */ + secretService: string | null, + /** Logical key provider used by the settings UI. */ + keyProviderId: string | null, + /** URL opened when the user clicks the API key label. */ + keyProviderUrl: string | null, + /** Whether the key field uses a custom-provider label and storage slot. */ + usesCustomProviderKey: boolean, + /** Whether the API endpoint selector should be visible. */ + showApiEndpointSelector: boolean, + /** Whether custom manual model IDs can be managed in the UI. */ + showCustomModelComposer: boolean, + /** Whether model comparison stats should be shown. */ + showModelStats: boolean, + /** Whether the internet access toggle should be shown. */ + supportsInternetAccess: boolean, + /** Whether reasoning controls should be shown for built-in models. */ + supportsThinking: boolean, + /** Whether this provider/card is image-only. */ + imageOnly: boolean, +}; + +/** Frontend-ready catalog snapshot assembled by the backend. */ +export type CatalogSnapshot = { + /** AI provider and engine cards. */ + ai: CatalogAppItem[], + /** Service/integration cards. */ + services: CatalogAppItem[], + /** Starred/favorite item ids. */ + stars: string[], +}; + /** AI chat message with role and content */ export type ChatMessage = { /** Unique message identifier (UUID v4) */ @@ -561,7 +769,7 @@ export type ConsoleStatusItem = { export type ControlRequest = { /** Module identifier (optional for global actions) */ module_id: string | null, - /** Control action ("start", "stop", "restart") */ + /** Control action ("start", "stop", "restart", "repair") */ action: string, }; diff --git a/src/shared/types/coreTypes.ts b/src/shared/types/coreTypes.ts index 649bde04..98b4e12d 100644 --- a/src/shared/types/coreTypes.ts +++ b/src/shared/types/coreTypes.ts @@ -36,6 +36,7 @@ export interface IApp { configSchema?: Record; settingsUi?: string | null; apiProviderData?: Record; // Dynamic provider metadata for rich UI + providerPolicy?: Bindings.CatalogProviderPolicy | null; status?: string | null; } diff --git a/src/shared/utils/customProviderSupport.test.ts b/src/shared/utils/customProviderSupport.test.ts index c31db3b6..12abdc51 100644 --- a/src/shared/utils/customProviderSupport.test.ts +++ b/src/shared/utils/customProviderSupport.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'; import { CUSTOM_IMAGE_PROVIDER_ID, CUSTOM_TEXT_PROVIDER_ID, - appendCustomProviderApps, getCustomProviderDisplayName, isCustomImageProviderId, isCustomProviderId, @@ -11,27 +10,6 @@ import { } from './customProviderSupport'; describe('customProviderSupport', () => { - it('appends custom text and image providers once', () => { - const result = appendCustomProviderApps([ - { - id: 'gpt', - name: 'GPT', - type: 'api', - capability: 'text', - installed: true, - }, - ]); - - expect(result.map((app) => app.id)).toEqual([ - 'gpt', - CUSTOM_TEXT_PROVIDER_ID, - CUSTOM_IMAGE_PROVIDER_ID, - ]); - expect( - appendCustomProviderApps(result).filter((app) => app.id === CUSTOM_TEXT_PROVIDER_ID), - ).toHaveLength(1); - }); - it('resolves custom provider metadata and backend ids', () => { expect(isCustomProviderId(CUSTOM_TEXT_PROVIDER_ID)).toBe(true); expect(isCustomProviderId('gpt')).toBe(false); diff --git a/src/shared/utils/customProviderSupport.ts b/src/shared/utils/customProviderSupport.ts index 0f50a650..e57d7793 100644 --- a/src/shared/utils/customProviderSupport.ts +++ b/src/shared/utils/customProviderSupport.ts @@ -1,5 +1,3 @@ -import type { IApp } from '@/shared/types/coreTypes'; - export const CUSTOM_TEXT_PROVIDER_ID = 'custom-text'; export const CUSTOM_IMAGE_PROVIDER_ID = 'custom-image'; @@ -55,34 +53,3 @@ export function resolveCustomProviderBackendId(providerId: string): string { export function getCustomProviderDisplayName(providerId: string): string | null { return CUSTOM_PROVIDER_SPECS.find((provider) => provider.id === providerId)?.name ?? null; } - -export function appendCustomProviderApps(apps: IApp[]): IApp[] { - const byId = new Map(apps.map((app) => [app.id, app])); - - CUSTOM_PROVIDER_SPECS.forEach((provider) => { - if (byId.has(provider.id)) { - return; - } - - byId.set(provider.id, { - id: provider.id, - name: provider.name, - nameKey: provider.nameKey, - desc: provider.desc, - descKey: provider.descKey, - icon: provider.icon, - category: 'ai', - type: 'api', - capability: provider.capability, - installed: true, - apiProviderData: { - id: provider.id, - type: 'api', - baseUrl: 'https://openrouter.ai/api/v1', - models: [], - }, - }); - }); - - return Array.from(byId.values()); -} diff --git a/src/shared/utils/providerSupport.test.ts b/src/shared/utils/providerSupport.test.ts deleted file mode 100644 index 93ae5514..00000000 --- a/src/shared/utils/providerSupport.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - getSharedCloudSecretService, - isCloudProviderId, - resolveProviderSecretService, -} from './providerSupport'; -import { CUSTOM_IMAGE_PROVIDER_ID, CUSTOM_TEXT_PROVIDER_ID } from './customProviderSupport'; - -describe('providerSupport', () => { - it('maps built-in cloud providers to the shared OpenRouter secret', () => { - expect(getSharedCloudSecretService()).toBe('cloud_api_key'); - expect(resolveProviderSecretService('gpt')).toBe('cloud_api_key'); - expect(resolveProviderSecretService('gemini')).toBe('cloud_api_key'); - }); - - it('keeps custom text provider keys separate from the shared cloud secret', () => { - expect(resolveProviderSecretService(CUSTOM_TEXT_PROVIDER_ID)).toBe('custom_text_api_key'); - expect(resolveProviderSecretService(CUSTOM_IMAGE_PROVIDER_ID)).toBe('cloud_api_key'); - }); - - it('does not create frontend-managed secret slots for unknown providers', () => { - expect(isCloudProviderId('openai')).toBe(false); - expect(resolveProviderSecretService('openai')).toBeNull(); - expect(isCloudProviderId('groq')).toBe(false); - expect(resolveProviderSecretService('groq')).toBeNull(); - expect(isCloudProviderId('local-runtime')).toBe(false); - expect(resolveProviderSecretService('local-runtime')).toBeNull(); - expect(resolveProviderSecretService('unknown-provider')).toBeNull(); - }); -}); diff --git a/src/shared/utils/providerSupport.ts b/src/shared/utils/providerSupport.ts deleted file mode 100644 index 6ab1bccd..00000000 --- a/src/shared/utils/providerSupport.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { CUSTOM_IMAGE_PROVIDER_ID, CUSTOM_TEXT_PROVIDER_ID } from './customProviderSupport'; - -export const SHARED_CLOUD_KEY_PROVIDER_ID = 'cloud'; - -const CLOUD_PROVIDER_IDS = new Set([ - 'gpt', - 'gemini', - 'gemini-image', - 'gpt-image', - 'seedream-image', - 'cloud', - 'anthropic', - 'claude', - 'deepseek', - CUSTOM_TEXT_PROVIDER_ID, - CUSTOM_IMAGE_PROVIDER_ID, -]); - -export function isCloudProviderId(providerId: string): boolean { - return CLOUD_PROVIDER_IDS.has(providerId); -} - -export function getSharedCloudSecretService(): string { - return `${SHARED_CLOUD_KEY_PROVIDER_ID}_api_key`; -} - -function getCustomProviderSecretService(providerId: string): string { - return `${providerId.replaceAll('-', '_')}_api_key`; -} - -export function resolveProviderSecretService(providerId: string): string | null { - if (!isCloudProviderId(providerId)) { - return null; - } - - if (providerId === CUSTOM_TEXT_PROVIDER_ID) { - return getCustomProviderSecretService(providerId); - } - - return getSharedCloudSecretService(); -} diff --git a/src/styles/features/console-page.css b/src/styles/features/console-page.css index dd8bb498..49a90f44 100644 --- a/src/styles/features/console-page.css +++ b/src/styles/features/console-page.css @@ -225,6 +225,36 @@ color: var(--premium-purple-text); } +.console-tab--agent { + min-width: 74px; + padding: 0.45rem 0.68rem; + border-color: rgba(255, 255, 255, 0.075); + background: rgba(255, 255, 255, 0.028); + color: var(--text-secondary); +} + +.console-tab--agent::before { + content: ''; + width: 5px; + height: 5px; + border-radius: 999px; + background: rgba(77, 214, 143, 0.82); + box-shadow: 0 0 8px rgba(77, 214, 143, 0.2); +} + +.console-tab--agent:hover, +.console-tab--agent.active { + background: rgba(255, 255, 255, 0.055); + border-color: rgba(255, 255, 255, 0.1); + box-shadow: none; + color: var(--text-primary); +} + +.console-tab--agent.active::before { + background: rgba(108, 224, 160, 0.96); + box-shadow: 0 0 10px rgba(77, 214, 143, 0.28); +} + .console-btn { display: inline-flex; align-items: center; @@ -250,6 +280,24 @@ color: var(--premium-purple-text); } +.console-btn:disabled, +.console-btn.is-disabled { + cursor: default; + pointer-events: none; + opacity: 0.42; + background: rgba(255, 255, 255, 0.035); + border-color: rgba(255, 255, 255, 0.055); + box-shadow: none; + color: rgba(232, 227, 241, 0.58); +} + +.console-btn:disabled:hover, +.console-btn.is-disabled:hover { + background: rgba(255, 255, 255, 0.035); + border-color: rgba(255, 255, 255, 0.055); + color: rgba(232, 227, 241, 0.58); +} + .console-btn.confirming { background: rgba(220, 38, 38, 0.86); border-color: rgba(248, 113, 113, 0.76); @@ -486,6 +534,10 @@ color: var(--text-secondary); } +.src-AGENT { + color: rgba(171, 235, 202, 0.78); +} + .level-ERROR .log-msg { color: var(--text-primary); } diff --git a/src/styles/features/settings-page.css b/src/styles/features/settings-page.css index 5fc439a1..94f0ff5f 100644 --- a/src/styles/features/settings-page.css +++ b/src/styles/features/settings-page.css @@ -15,29 +15,121 @@ #page-settings { overflow-y: auto; overflow-x: hidden; - padding: var(--content-padding-top) 1.5rem 1.5rem 1.5rem; - align-items: center; + padding: 0 1.5rem; + align-items: stretch; position: relative; background: transparent; + scroll-snap-type: y mandatory; + scroll-padding-top: 0; + scroll-behavior: auto; + scrollbar-width: none; + -ms-overflow-style: none; +} + +#page-settings.is-section-scrolling { + scroll-snap-type: none; +} + +#page-settings::-webkit-scrollbar { + width: 0; + height: 0; } .settings-wrapper { width: 100%; max-width: 1240px; + height: 100%; min-height: 100%; margin: 0 auto; - padding: 1rem 0 1.5rem; + padding: 0; box-sizing: border-box; display: flex; + flex-direction: column; + gap: 0; +} + +.settings-section-jump { + position: absolute; + left: 50%; + top: calc(100% - 3rem); + z-index: 30; + width: 2rem; + height: 2rem; + margin: 0; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + box-shadow: none; + color: rgba(248, 246, 252, 0.72); + cursor: pointer; + display: flex; align-items: center; + justify-content: center; + opacity: 0.34; + transition: + opacity 0.16s ease, + background 0.16s ease, + border-color 0.16s ease, + transform 0.16s ease; + transform: translateX(-50%); +} + +.settings-section-jump::before { + content: ''; + width: 4px; + height: 4px; + background: transparent; + box-shadow: + -8px -4px 0 currentcolor, + 8px -4px 0 currentcolor, + -4px 0 0 currentcolor, + 4px 0 0 currentcolor, + 0 4px 0 currentcolor; + image-rendering: pixelated; + transform: translateY(1px); + transition: transform 0.18s ease; +} + +.settings-section-jump.is-up::before { + transform: translateY(-1px) rotate(180deg); +} + +.settings-section-jump:hover, +.settings-section-jump:focus-visible { + opacity: 0.72; + background: rgba(255, 255, 255, 0.035); + border-color: rgba(255, 255, 255, 0.035); +} + +.settings-section-jump:active { + transform: translateX(-50%) translateY(1px); } #page-settings.active .settings-wrapper { - animation: settingsContentRise 0.3s cubic-bezier(0.22, 1, 0.36, 1); + animation: settings-content-rise 0.3s cubic-bezier(0.22, 1, 0.36, 1); +} + +.settings-section { + flex: 0 0 100%; + height: 100%; + min-height: 100%; + scroll-snap-align: start; + scroll-snap-stop: always; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 1rem; + box-sizing: border-box; + padding: 1rem 0; +} + +.settings-section--agent { + justify-content: center; } .settings-grid { - width: 100%; + width: min(100%, 1120px); display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.5rem; @@ -45,7 +137,12 @@ animation: none; } -#settings-grid .module-card { +#page-settings.active .settings-section { + animation: settings-section-fade 0.38s cubic-bezier(0.22, 1, 0.36, 1); +} + +.settings-grid .module-card, +.module-card--agent-control { display: flex; flex-direction: column; align-items: stretch; @@ -59,23 +156,25 @@ 0 0 0 1px rgba(255, 255, 255, 0.03); } -#settings-grid .module-card:hover { +.settings-grid .module-card:hover, +.module-card--agent-control:hover { border-color: transparent; - background: rgba(var(--background-raw), 0.075); + background: rgba(var(--background-raw), 0.065); transform: none; box-shadow: - inset -1px 0 0 rgba(255, 255, 255, 0.022), - 0 0 0 1px rgba(255, 255, 255, 0.035); + inset -1px 0 0 rgba(255, 255, 255, 0.018), + 0 0 0 1px rgba(255, 255, 255, 0.03); cursor: default; } -#settings-grid .module-title { +.settings-grid .module-title, +.module-card--agent-control .module-title { align-self: center; margin-bottom: 0.5rem; font-size: 1.18rem; } -#settings-grid .module-desc { +.settings-grid .module-desc { align-self: center; margin-bottom: 1.35rem; min-height: 2.6em; @@ -87,6 +186,518 @@ line-height: 1.35; } +.module-card--agent-control { + display: grid; + position: relative; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 0.9rem 1rem; + width: min(100%, 1120px); + max-height: calc(100% - 2rem); + padding: 1.35rem 1.6rem 1.45rem; + overflow-y: auto; + overflow-x: hidden; + text-align: center; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.module-card--agent-control::-webkit-scrollbar { + width: 0; + height: 0; +} + +.module-card--agent-control .module-title { + grid-column: 1; + grid-row: 1; + justify-self: center; + align-self: center; + max-width: min(100%, 34rem); + margin-bottom: 0; + text-align: center; + overflow-wrap: anywhere; +} + +#agent-control-panel { + width: 100%; +} + +.module-card--agent-control #agent-control-panel, +.module-card--agent-control .agent-control { + display: contents; +} + +.module-card--agent-control #agent-control-panel > .agent-control-empty, +.module-card--agent-control .agent-control-section { + grid-column: 1 / -1; +} + +.module-card--agent-control .agent-control-section:first-of-type { + margin-top: 0.18rem; +} + +.agent-control { + display: grid; + gap: 0.82rem; + color: rgba(248, 246, 252, 0.92); +} + +.agent-control-header, +.agent-control-actions, +.agent-control-row, +.agent-control-row-actions, +.agent-control-scopes, +.agent-control-title-line, +.agent-control-section-header { + display: flex; + align-items: center; +} + +.agent-control-header { + grid-column: 2; + grid-row: 1; + justify-content: flex-end; + gap: 1rem; + min-height: 0; + padding-bottom: 0; + white-space: nowrap; +} + +.agent-control-section { + display: grid; + gap: 0.78rem; + padding: 0.86rem 0.92rem; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.036); + background: rgba(255, 255, 255, 0.016); + box-shadow: none; + overflow: hidden; + text-align: left; +} + +.agent-control-section-header { + justify-content: space-between; + gap: 0.75rem; +} + +.agent-control-section-title { + font-size: 0.82rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0; + color: var(--text-primary); +} + +.agent-control-actions { + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.42rem; +} + +.agent-control-label-row, +.agent-control-inline-actions { + display: flex; + align-items: center; +} + +.agent-control-label-row { + justify-content: space-between; + gap: 0.75rem; +} + +.agent-control-inline-actions { + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.36rem; +} + +.agent-control-btn { + min-height: 2.28rem; + padding: 0.52rem 0.82rem; + border-radius: var(--module-button-radius); + border: 1px solid var(--module-button-border-hover); + color: #ffffff; + background: var(--module-button-bg); + font-family: var(--app-font-family); + font-size: 0.82rem; + font-weight: 600; + line-height: 1; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: + background 0.16s ease, + border-color 0.16s ease, + color 0.16s ease; +} + +.agent-control-btn:hover { + background: var(--module-button-bg-hover); + border-color: var(--module-button-border-hover); + color: #ffffff; +} + +.agent-control-btn:disabled { + cursor: default; + opacity: 0.55; +} + +.module-card--agent-control .agent-control-help { + position: static; + flex: 0 0 auto; + opacity: 0.82; + text-decoration: none; +} + +.module-card--agent-control:hover .agent-control-help, +.module-card--agent-control .agent-control-help:focus-visible { + opacity: 1; +} + +.agent-control-btn--primary { + background: var(--module-button-bg); + border-color: var(--module-button-border-hover); + color: #ffffff; +} + +.agent-control-btn--icon { + width: 2.28rem; + min-width: 2.28rem; + padding: 0; + font-size: 1rem; +} + +.agent-control-btn--ghost { + min-height: 1.8rem; + padding: 0.36rem 0.58rem; + border-color: rgba(255, 255, 255, 0.055); + background: rgba(255, 255, 255, 0.035); + color: var(--text-secondary); + font-size: 0.72rem; +} + +.agent-control-btn--ghost:hover { + background: rgba(255, 255, 255, 0.07); + border-color: rgba(255, 255, 255, 0.09); + color: var(--text-primary); +} + +.agent-control-engine-btn { + min-width: 118px; + min-height: 2.5rem; + padding: 0.58rem 1.28rem; + border-radius: var(--module-button-radius); + background: var(--module-button-bg); + border: 1px solid var(--module-button-border-hover); + color: #ffffff; + font-size: 0.95rem; + font-weight: 600; + justify-content: center; +} + +.agent-control-engine-btn.active-module-btn:hover { + background: var(--module-button-bg-hover); + border-color: var(--module-button-border-hover); + color: #ffffff; +} + +.agent-control-engine-btn.stop-btn:hover { + background: #d32f2f; + border-color: #d32f2f; + color: #ffffff; +} + +.agent-control-btn--danger { + background: rgba(194, 33, 49, 0.16); + border-color: rgba(194, 33, 49, 0.34); + color: rgba(255, 226, 230, 0.96); +} + +.agent-control-btn--danger:hover { + background: rgba(194, 33, 49, 0.92); + border-color: rgba(194, 33, 49, 0.95); + color: #ffffff; +} + +.agent-control-btn.is-confirming { + background: rgba(194, 33, 49, 0.92); + border-color: rgba(194, 33, 49, 0.95); + color: #ffffff; +} + +.agent-control-endpoint { + display: grid; + gap: 0.38rem; + min-width: 0; +} + +.agent-control-permissions { + display: flex; + align-items: center; + gap: 0.48rem; + min-width: 0; +} + +.agent-control-scope-picker { + display: flex; + flex-wrap: wrap; + gap: 0.38rem; +} + +.agent-control-scope-btn { + min-height: 2rem; + padding: 0.34rem 0.62rem; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.055); + background: rgba(255, 255, 255, 0.028); + color: var(--text-secondary); + font-family: var(--app-font-family); + font-size: 0.74rem; + font-weight: 600; + line-height: 1; + cursor: pointer; + transition: + background 0.16s ease, + border-color 0.16s ease, + color 0.16s ease; +} + +.agent-control-scope-btn:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.075); + color: var(--text-primary); +} + +.agent-control-scope-btn.is-selected { + background: var(--module-button-bg); + border-color: var(--module-button-border-hover); + color: #ffffff; +} + +.agent-control-field-label { + color: var(--text-secondary); + font-size: 0.82rem; + font-weight: 600; + text-transform: none; + text-align: left; +} + +.agent-control-field-label--icon { + width: 2rem; + min-width: 2rem; + height: 2rem; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.028); + border: 1px solid rgba(255, 255, 255, 0.055); + font-size: 0.92rem; + line-height: 1; + cursor: help; + user-select: none; +} + +.agent-control-code, +.agent-control-diff { + width: 100%; + box-sizing: border-box; + overflow-wrap: anywhere; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.055); + background: rgba(10, 10, 14, 0.26); + color: var(--text-primary); + font-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', monospace; + font-size: 0.76rem; + line-height: 1.45; + padding: 0.62rem 0.72rem; + text-align: left; +} + +.agent-control-url-input { + appearance: none; + -webkit-appearance: none; + width: 100%; + min-height: 3rem; + box-sizing: border-box; + padding: 0.62rem 0.72rem; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.055); + outline: none; + background: rgba(10, 10, 14, 0.26); + color: var(--text-primary); + -webkit-text-fill-color: var(--text-primary); + caret-color: var(--text-primary); + font-family: var(--app-font-family); + font-size: 0.86rem; + line-height: 1.45; + text-align: left; + cursor: text; + user-select: text !important; + -webkit-user-select: text !important; + transition: + background 0.16s ease, + border-color 0.16s ease; +} + +.module-card--agent-control input.agent-control-url-input:hover, +.module-card--agent-control input.agent-control-url-input:focus, +.module-card--agent-control input.agent-control-url-input:active { + background: rgba(10, 10, 14, 0.32) !important; + border-color: rgba(255, 255, 255, 0.09) !important; + box-shadow: none !important; + outline: none !important; +} + +.module-card--agent-control input.agent-control-url-input:-webkit-autofill, +.module-card--agent-control input.agent-control-url-input:-webkit-autofill:hover, +.module-card--agent-control input.agent-control-url-input:-webkit-autofill:focus { + border-color: rgba(255, 255, 255, 0.09) !important; + box-shadow: 0 0 0 1000px rgba(10, 10, 14, 0.32) inset !important; + -webkit-text-fill-color: var(--text-primary) !important; + caret-color: var(--text-primary) !important; +} + +.agent-control-url-input::selection { + background: rgba(var(--primary-raw), 0.45); + color: #ffffff; +} + +.agent-control-token { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.52rem 0.75rem; + align-items: end; + padding: 0.72rem; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.034); + background: rgba(0, 0, 0, 0.08); +} + +.agent-control-token-label { + grid-column: 1; + grid-row: 1; +} + +.agent-control-token > .agent-control-code { + grid-column: 1; + grid-row: 2; +} + +.agent-control-token > .agent-control-actions { + grid-column: 2; + grid-row: 2; + flex-wrap: nowrap; +} + +.agent-control-token-label, +.agent-control-row-meta, +.agent-control-empty { + color: var(--text-secondary); + font-size: 0.82rem; + line-height: 1.4; + text-align: left; +} + +.agent-control-empty--error { + color: rgba(255, 146, 154, 0.9); +} + +.agent-control-list { + display: grid; + gap: 0.58rem; +} + +.agent-control-row { + justify-content: space-between; + gap: 1rem; + min-width: 0; + padding: 0.78rem 0.86rem; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.034); + background: rgba(255, 255, 255, 0.014); +} + +.agent-control-row:hover { + background: rgba(255, 255, 255, 0.028); + border-color: rgba(255, 255, 255, 0.06); +} + +.agent-control-row--approval { + align-items: flex-start; +} + +.agent-control-row-main { + display: grid; + gap: 0.38rem; + min-width: 0; +} + +.agent-control-title-line { + flex-wrap: wrap; + gap: 0.5rem; + min-width: 0; +} + +.agent-control-row-title { + font-size: 0.94rem; + font-weight: 800; + color: rgba(252, 249, 255, 0.94); + overflow-wrap: anywhere; +} + +.agent-control-row-actions, +.agent-control-scopes { + flex-wrap: wrap; + gap: 0.36rem; +} + +.agent-control-row-status, +.agent-control-risk { + display: inline-flex; + align-items: center; + width: fit-content; + min-height: 1.36rem; + padding: 0 0.5rem; + border-radius: 10px; + font-size: 0.68rem; + font-weight: 800; + line-height: 1; + text-transform: uppercase; +} + +.agent-control-row-status.is-active { + background: rgba(39, 150, 91, 0.18); + color: rgba(149, 239, 190, 0.9); + border: 1px solid rgba(87, 217, 148, 0.2); +} + +.agent-control-row-status.is-revoked { + background: rgba(177, 86, 71, 0.18); + color: rgba(255, 178, 162, 0.9); + border: 1px solid rgba(255, 144, 120, 0.2); +} + +.agent-control-risk { + background: rgba(187, 137, 47, 0.16); + color: rgba(255, 214, 143, 0.9); + border: 1px solid rgba(255, 199, 102, 0.18); +} + +.agent-control-scope { + display: inline-flex; + align-items: center; + min-height: 1.42rem; + padding: 0.1rem 0.52rem; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.045); + background: rgba(255, 255, 255, 0.045); + color: var(--text-secondary); + font-size: 0.72rem; + font-weight: 600; +} + #taskbar-toggles, #monitor-toggles { display: grid !important; @@ -126,7 +737,7 @@ -webkit-backdrop-filter: none; } -@keyframes settingsContentRise { +@keyframes settings-content-rise { from { opacity: 0.72; } @@ -136,6 +747,18 @@ } } +@keyframes settings-section-fade { + from { + opacity: 0.78; + transform: translateY(6px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + .monitor-toggle-btn::before { content: ''; position: absolute; @@ -271,6 +894,15 @@ .settings-grid { grid-template-columns: 1fr; } + + .settings-section { + min-height: auto; + justify-content: flex-start; + } + + .settings-section-jump { + display: none; + } } @media (max-width: 600px) { @@ -278,6 +910,92 @@ #monitor-toggles { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .agent-control-row { + align-items: stretch; + flex-direction: column; + } + + .module-card--agent-control { + grid-template-columns: 1fr; + } + + .module-card--agent-control .module-title { + grid-column: 1; + grid-row: 1; + } + + .agent-control-header, + .agent-control-section-header { + align-items: stretch; + flex-direction: column; + } + + .agent-control-header { + grid-column: 1; + grid-row: 2; + } + + .module-card--agent-control #agent-control-panel > .agent-control-empty, + .module-card--agent-control .agent-control-section { + grid-column: 1; + } + + .agent-control-actions { + justify-content: flex-start; + } + + .agent-control-token { + grid-template-columns: 1fr; + } + + .agent-control-token > .agent-control-code, + .agent-control-token > .agent-control-actions { + grid-column: 1; + grid-row: auto; + } + + .agent-control-token > .agent-control-actions { + flex-wrap: wrap; + } + + .agent-control-row-actions { + justify-content: flex-start; + } +} + +@media (max-width: 1400px), (max-height: 820px) { + .module-card--agent-control { + grid-template-columns: 1fr; + gap: 0.72rem; + padding: 1.15rem 1.25rem 1.25rem; + } + + .module-card--agent-control .module-title { + grid-column: 1; + grid-row: 1; + max-width: 100%; + padding: 0 4rem; + } + + .agent-control-header { + grid-column: 1; + grid-row: 2; + justify-content: center; + } + + .module-card--agent-control #agent-control-panel > .agent-control-empty, + .module-card--agent-control .agent-control-section { + grid-column: 1; + } + + .agent-control-section { + padding: 0.74rem 0.82rem; + } + + .agent-control-url-input { + min-height: 2.7rem; + } } @media (max-width: 900px) { diff --git a/src/test/helpers/catalogTestUtils.ts b/src/test/helpers/catalogTestUtils.ts index 07ea6690..2d5bfc93 100644 --- a/src/test/helpers/catalogTestUtils.ts +++ b/src/test/helpers/catalogTestUtils.ts @@ -1,31 +1,26 @@ import { vi } from 'vitest'; import { CatalogService } from '@/shared/services/CatalogService'; -import type { IModule } from '@/shared/types/coreTypes'; -import type { AppConfig } from '@/shared/types/bindings'; +import type { CatalogSnapshot } from '@/shared/types/bindings'; import type { IBridge } from '@/shared/types/IBridge'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import { createMockBridge } from '@/test/mocks/mockBridge'; -export function createMockAppConfig(overrides?: unknown): AppConfig { +export function createMockCatalogSnapshot(overrides?: Partial): CatalogSnapshot { return { - catalog: { ai: [], services: [] }, - apiProviders: [], - autoStartModules: [], - ...(overrides as Record), - } as unknown as AppConfig; + ai: [], + services: [], + stars: [], + ...overrides, + }; } export function setupBridgeMocks( bridge: { isTauri: ReturnType; invoke: ReturnType }, - config: AppConfig | null, - modules: IModule[] = [], - engineDefinitions: unknown[] = [], + snapshot: CatalogSnapshot | null, ): void { bridge.isTauri.mockReturnValue(true); bridge.invoke.mockImplementation((cmd: string) => { - if (cmd === 'get_config') return Promise.resolve(config); - if (cmd === 'get_modules') return Promise.resolve(modules); - if (cmd === 'get_engine_definitions') return Promise.resolve(engineDefinitions); + if (cmd === 'get_catalog_snapshot') return Promise.resolve(snapshot); return Promise.resolve(undefined); }); } diff --git a/src/test/integration/CatalogService.integration.test.ts b/src/test/integration/CatalogService.integration.test.ts index f85283d6..56f6cf5f 100644 --- a/src/test/integration/CatalogService.integration.test.ts +++ b/src/test/integration/CatalogService.integration.test.ts @@ -1,19 +1,46 @@ /** * @module test/integration/CatalogService.integration.test.ts - * @description Integration tests for CatalogService — verifies full lifecycle - * from backend calls through catalog hydration to update events. + * @description Integration tests for CatalogService snapshot loading lifecycle. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { CatalogService } from '@/shared/services/CatalogService'; -import type { IModule } from '@/shared/types/coreTypes'; +import type { CatalogAppItem } from '@/shared/types/bindings'; import { createCatalogHarness, - createMockAppConfig, + createMockCatalogSnapshot, setupBridgeMocks, type MockCatalogBridge, } from '@/test/helpers/catalogTestUtils'; +function item(overrides: Partial): CatalogAppItem { + return { + id: 'item', + nameKey: null, + descKey: null, + name: null, + desc: null, + icon: null, + preview: null, + category: 'services', + type: 'local', + capability: 'text', + installed: false, + installedComputeModes: [], + repoUrl: null, + expectedHash: null, + dlType: null, + comingSoon: false, + managedExternally: false, + version: '1.0.0', + configSchema: null, + settingsUi: null, + apiProviderData: null, + status: null, + ...overrides, + }; +} + describe('CatalogService Integration', () => { let mockBridge: MockCatalogBridge; let service: CatalogService; @@ -26,36 +53,54 @@ describe('CatalogService Integration', () => { vi.clearAllMocks(); }); - it('should load full catalog with apps, schemas, and installed status', async () => { - const mockConfig = createMockAppConfig({ - catalog: { - ai: [{ id: 'llamacpp', name: 'Llama.cpp', type: 'local' }], - services: [{ id: 'my-worker', name: 'My Worker' }], - }, - }); - - const mockModules: IModule[] = [ - { - id: 'llamacpp', - configSchema: { ctx_size: { type: 'number', default: 4096 } }, - } as unknown as IModule, - ]; - - setupBridgeMocks(mockBridge, mockConfig, mockModules); + it('loads full catalog data already assembled by the backend', async () => { + setupBridgeMocks( + mockBridge, + createMockCatalogSnapshot({ + ai: [ + item({ + id: 'llamacpp', + name: 'Llama.cpp', + category: 'ai', + installed: true, + installedComputeModes: ['gpu'], + configSchema: { + ctx_size: { + fieldType: 'number', + label: 'Context size', + default: 4096, + required: false, + }, + }, + }), + ], + services: [item({ id: 'my-worker', name: 'My Worker', installed: true })], + }), + ); await service.loadCatalog(); const catalog = service.getCatalog(); expect(catalog.ai).toHaveLength(1); - expect(catalog.ai.at(0)?.id).toBe('llamacpp'); - expect(catalog.ai.at(0)?.configSchema).toBeDefined(); - expect(catalog.services).toHaveLength(1); + expect(catalog.ai.at(0)).toMatchObject({ + id: 'llamacpp', + installed: true, + installedComputeModes: ['gpu'], + configSchema: { + ctx_size: { + fieldType: 'number', + label: 'Context size', + default: 4096, + required: false, + }, + }, + }); expect(catalog.services.at(0)?.id).toBe('my-worker'); }); - it('should handle Tauri backend failure with an empty catalog', async () => { + it('handles backend failure with an empty catalog', async () => { mockBridge.isTauri.mockReturnValue(true); - mockBridge.invoke.mockResolvedValue(null); + mockBridge.invoke.mockRejectedValue(new Error('Backend down')); await service.loadCatalog(); @@ -64,12 +109,13 @@ describe('CatalogService Integration', () => { expect(catalog.services).toHaveLength(0); }); - it('should dispatch catalog-loaded event after successful load', async () => { - const mockConfig = createMockAppConfig({ - catalog: { ai: [{ id: 'gpt', name: 'GPT' }], services: [] }, - }); - - setupBridgeMocks(mockBridge, mockConfig); + it('dispatches catalog-loaded after load completes', async () => { + setupBridgeMocks( + mockBridge, + createMockCatalogSnapshot({ + ai: [item({ id: 'openai', name: 'OpenAI', category: 'ai', type: 'api' })], + }), + ); await service.loadCatalog(); @@ -78,45 +124,8 @@ describe('CatalogService Integration', () => { ); }); - it('should handle empty config and keep empty catalog', async () => { - const mockConfig = createMockAppConfig({ - catalog: { ai: [], services: [] }, - }); - - setupBridgeMocks(mockBridge, mockConfig); - - await service.loadCatalog(); - - const catalog = service.getCatalog(); - expect(catalog.ai).toHaveLength(0); - expect(catalog.services).toHaveLength(0); - }); - - it('should handle partial data — config OK but modules fail', async () => { - const mockConfig = createMockAppConfig({ - catalog: { - ai: [{ id: 'gpt', name: 'GPT', type: 'api' }], - services: [{ id: 'worker', name: 'Worker' }], - }, - }); - - mockBridge.isTauri.mockReturnValue(true); - mockBridge.invoke.mockImplementation((cmd: string) => { - if (cmd === 'get_config') return Promise.resolve(mockConfig); - if (cmd === 'get_modules') return Promise.reject(new Error('Backend down')); - if (cmd === 'get_engine_definitions') return Promise.resolve([]); - return Promise.resolve(undefined); - }); - - await service.loadCatalog(); - - const catalog = service.getCatalog(); - expect(catalog.ai).toHaveLength(1); - expect(catalog.ai.at(0)?.installed).toBe(true); // API modules always installed - }); - - it('should use an empty catalog when Tauri bridge is unavailable', async () => { - mockBridge.isTauri.mockReturnValue(false); + it('keeps empty backend snapshots empty', async () => { + setupBridgeMocks(mockBridge, createMockCatalogSnapshot()); await service.loadCatalog();