From 4ec47a0ffc35dbc23c1b03afcc578049ee21b389 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 21 May 2026 19:43:50 +0300 Subject: [PATCH 01/54] chore(deps): patch brace-expansion audit finding --- src/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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": { From f72ac9e62951744d7d4782cfa0291c04238a9265 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 21 May 2026 19:44:59 +0300 Subject: [PATCH 02/54] test: lock provider routing and console filters --- .../AIBridgeMessageController.test.ts | 23 +++++++++++ src/features/ai/ui/AISettingsRenderer.test.ts | 34 +++++++++++++++ src/features/console/ui/ConsoleUI.test.ts | 41 +++++++++++++++++++ src/shared/utils/providerSupport.test.ts | 5 +++ 4 files changed, 103 insertions(+) diff --git a/src/features/ai/services/AIBridgeMessageController.test.ts b/src/features/ai/services/AIBridgeMessageController.test.ts index 0bc23a74..1607997c 100644 --- a/src/features/ai/services/AIBridgeMessageController.test.ts +++ b/src/features/ai/services/AIBridgeMessageController.test.ts @@ -161,6 +161,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(); diff --git a/src/features/ai/ui/AISettingsRenderer.test.ts b/src/features/ai/ui/AISettingsRenderer.test.ts index ffbc5a6a..34e54018 100644 --- a/src/features/ai/ui/AISettingsRenderer.test.ts +++ b/src/features/ai/ui/AISettingsRenderer.test.ts @@ -229,6 +229,40 @@ describe('AISettingsRenderer', () => { expect(settingsService.getSecureKeyMeta).toHaveBeenCalledWith(CUSTOM_TEXT_PROVIDER_ID); }); + 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 }, + } 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: [] }, + } 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('.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('validates custom provider keys against the selected API endpoint', async () => { vi.useFakeTimers(); const container = document.getElementById('root') as HTMLElement; diff --git a/src/features/console/ui/ConsoleUI.test.ts b/src/features/console/ui/ConsoleUI.test.ts index fc6a140c..a6d94635 100644 --- a/src/features/console/ui/ConsoleUI.test.ts +++ b/src/features/console/ui/ConsoleUI.test.ts @@ -706,6 +706,47 @@ 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; + expect(pane.textContent).toBe('No logs match selected levels'); + 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( diff --git a/src/shared/utils/providerSupport.test.ts b/src/shared/utils/providerSupport.test.ts index 93ae5514..5a31d220 100644 --- a/src/shared/utils/providerSupport.test.ts +++ b/src/shared/utils/providerSupport.test.ts @@ -12,6 +12,11 @@ describe('providerSupport', () => { expect(getSharedCloudSecretService()).toBe('cloud_api_key'); expect(resolveProviderSecretService('gpt')).toBe('cloud_api_key'); expect(resolveProviderSecretService('gemini')).toBe('cloud_api_key'); + expect(resolveProviderSecretService('claude')).toBe('cloud_api_key'); + expect(resolveProviderSecretService('deepseek')).toBe('cloud_api_key'); + expect(resolveProviderSecretService('gpt-image')).toBe('cloud_api_key'); + expect(resolveProviderSecretService('gemini-image')).toBe('cloud_api_key'); + expect(resolveProviderSecretService('seedream-image')).toBe('cloud_api_key'); }); it('keeps custom text provider keys separate from the shared cloud secret', () => { From e355a9a01ddff107cffa85e08cee4b5b35949b59 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 21 May 2026 19:45:50 +0300 Subject: [PATCH 03/54] refactor: split console log target helpers --- src-tauri/src/api/system/log_targets.rs | 102 +++++++++++++++++++++++ src-tauri/src/api/system/logs.rs | 104 ++---------------------- src-tauri/src/api/system/mod.rs | 1 + 3 files changed, 108 insertions(+), 99 deletions(-) create mode 100644 src-tauri/src/api/system/log_targets.rs 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..79e5b1d9 --- /dev/null +++ b/src-tauri/src/api/system/log_targets.rs @@ -0,0 +1,102 @@ +use crate::domain::engine::manager::canonical_engine_id; +use crate::errors::AppError; +use std::fs; +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)); + } + + 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(()) +} + +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 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(()) +} + +pub(super) 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(()) +} diff --git a/src-tauri/src/api/system/logs.rs b/src-tauri/src/api/system/logs.rs index 942844bb..f0acd5bc 100644 --- a/src-tauri/src/api/system/logs.rs +++ b/src-tauri/src/api/system/logs.rs @@ -6,11 +6,15 @@ use crate::infrastructure::logging::{self as logs, LogEntry}; use crate::models::{SelectedModule, UIState}; use std::collections::{BTreeMap, BTreeSet}; use std::fs; -use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Arc; use tauri::State; +use super::log_targets::{ + canonical_console_view_id, clear_all_console_log_files, clear_console_log_target, + resolve_console_log_target, +}; + struct ConsoleOverviewBuilder; struct ConsoleLabelFormatter; @@ -203,104 +207,6 @@ const fn describe_status(status: ConsoleRuntimeStatus) -> &'static str { } } -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, diff --git a/src-tauri/src/api/system/mod.rs b/src-tauri/src/api/system/mod.rs index 8f1a9f1c..36a2b348 100644 --- a/src-tauri/src/api/system/mod.rs +++ b/src-tauri/src/api/system/mod.rs @@ -4,6 +4,7 @@ pub mod bootstrap; pub mod config; /// Health check commands pub mod health; +mod log_targets; /// Logging commands pub mod logs; From 41c4a402e76e2008061af25171665ca138ebf57a Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 21 May 2026 19:46:50 +0300 Subject: [PATCH 04/54] ci: add informational cross-platform checks --- .github/workflows/ci.yml | 57 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 628d9a9f..9a628dc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,3 +136,60 @@ 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 + continue-on-error: true + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v6 + + - 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@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 From 808f1918d91f14f4fa791fa25b9ca95234323261 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 21 May 2026 20:13:28 +0300 Subject: [PATCH 05/54] fix: address review hardening feedback --- .github/workflows/ci.yml | 16 +++-- .github/workflows/codeql.yml | 4 +- .github/workflows/dependency-review.yml | 4 +- .github/workflows/release.yml | 5 +- .github/workflows/security-audit.yml | 10 ++- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 3 + src-tauri/resources/locales/en.json | 2 + src-tauri/resources/locales/ru.json | 2 + src-tauri/resources/locales/zh.json | 2 + src-tauri/src/api/system/log_targets.rs | 69 ++++++++++++++----- src-tauri/src/api/system/logs.rs | 40 +++++++++++ .../domain/modules/controller/lifecycle.rs | 2 + .../src/domain/modules/controller/process.rs | 4 ++ src/features/console/ui/ConsoleUI.test.ts | 9 ++- src/features/console/ui/ConsoleUI.ts | 7 +- 16 files changed, 147 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a628dc1..d4408cc0 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 @@ -147,7 +151,9 @@ jobs: matrix: os: [ubuntu-latest, macos-latest] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Install Linux system dependencies if: runner.os == 'Linux' @@ -161,7 +167,7 @@ jobs: libxdo-dev - 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/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/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..15525bd3 100644 --- a/src-tauri/resources/locales/en.json +++ b/src-tauri/resources/locales/en.json @@ -105,6 +105,8 @@ "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_none": "No logs yet", "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", diff --git a/src-tauri/resources/locales/ru.json b/src-tauri/resources/locales/ru.json index 67f230d1..5a013552 100644 --- a/src-tauri/resources/locales/ru.json +++ b/src-tauri/resources/locales/ru.json @@ -105,6 +105,8 @@ "ui.debug.logs_copied": "Логи скопированы", "ui.debug.logs_copy_failed": "Не удалось скопировать логи", "ui.debug.logs_empty": "Нет логов для копирования", + "ui.debug.logs_filter_empty": "Нет логов выбранных уровней", + "ui.debug.logs_none": "Логов пока нет", "ui.deepseek.model.v4_flash.desc": "Оптимизированная по эффективности Mixture-of-Experts модель для быстрого инференса, высокой пропускной способности, reasoning и кода", "ui.deepseek.model.v4_pro.desc": "Крупная Mixture-of-Experts модель для продвинутого reasoning, кода и долгих агентных рабочих процессов", "ui.downloads.no_active": "Нет активных загрузок", diff --git a/src-tauri/resources/locales/zh.json b/src-tauri/resources/locales/zh.json index 588347da..97970687 100644 --- a/src-tauri/resources/locales/zh.json +++ b/src-tauri/resources/locales/zh.json @@ -105,6 +105,8 @@ "ui.debug.logs_copied": "日志已复制", "ui.debug.logs_copy_failed": "复制日志失败", "ui.debug.logs_empty": "没有可复制的日志", + "ui.debug.logs_filter_empty": "没有匹配所选级别的日志", + "ui.debug.logs_none": "暂无日志", "ui.deepseek.model.v4_flash.desc": "面向快速推理、高吞吐、推理与编码的效率优化 Mixture-of-Experts 模型", "ui.deepseek.model.v4_pro.desc": "面向高级推理、编码和长周期智能体工作流的大规模 Mixture-of-Experts 模型", "ui.downloads.no_active": "无活动下载", diff --git a/src-tauri/src/api/system/log_targets.rs b/src-tauri/src/api/system/log_targets.rs index 79e5b1d9..98abe8a5 100644 --- a/src-tauri/src/api/system/log_targets.rs +++ b/src-tauri/src/api/system/log_targets.rs @@ -1,6 +1,7 @@ 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 { @@ -15,7 +16,11 @@ pub(super) fn resolve_console_log_target(view_id: &str) -> Result Result<(), AppError> { @@ -44,21 +49,23 @@ pub(super) fn canonical_console_view_id(view_id: &str) -> String { } pub(super) fn clear_console_log_target(view_id: &str, target: &Path) -> Result<(), AppError> { - if view_id == "general" { - clear_log_file(&target.join("axelate.log"))?; + if !valid_log_root(target)? { return Ok(()); } - if !target.exists() { + 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 path = entry?.path(); - if path - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("log")) - { + 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)?; } } @@ -66,6 +73,37 @@ pub(super) fn clear_console_log_target(view_id: &str, target: &Path) -> Result<( 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(()); @@ -79,21 +117,20 @@ fn clear_log_file(path: &Path) -> Result<(), AppError> { } pub(super) fn clear_all_console_log_files(root: &Path) -> Result<(), AppError> { - if !root.exists() { + if !valid_log_root(root)? { return Ok(()); } for entry in fs::read_dir(root)? { - let path = entry?.path(); - if path.is_dir() { + 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 path - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("log")) - { + if file_type.is_file() && has_log_extension(&path) { clear_log_file(&path)?; } } diff --git a/src-tauri/src/api/system/logs.rs b/src-tauri/src/api/system/logs.rs index f0acd5bc..507cb8e5 100644 --- a/src-tauri/src/api/system/logs.rs +++ b/src-tauri/src/api/system/logs.rs @@ -705,6 +705,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(); @@ -742,6 +749,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(); 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/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/features/console/ui/ConsoleUI.test.ts b/src/features/console/ui/ConsoleUI.test.ts index a6d94635..22ac11cc 100644 --- a/src/features/console/ui/ConsoleUI.test.ts +++ b/src/features/console/ui/ConsoleUI.test.ts @@ -741,7 +741,9 @@ describe('ConsoleUI lifecycle', () => { errorButton.click(); const pane = document.getElementById('logs-general') as HTMLElement; - expect(pane.textContent).toBe('No logs match selected levels'); + 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'); @@ -817,19 +819,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..0b7ab791 100644 --- a/src/features/console/ui/ConsoleUI.ts +++ b/src/features/console/ui/ConsoleUI.ts @@ -117,8 +117,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), }); From 9b0c17c43c00dcf4e4fbc100121ccab775ef5603 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 21 May 2026 20:20:41 +0300 Subject: [PATCH 06/54] refactor: split release target classification --- .../domain/modules/github_release_targets.rs | 152 +++++++++++++++++ .../src/domain/modules/github_releases.rs | 155 +----------------- src-tauri/src/domain/modules/mod.rs | 1 + 3 files changed, 159 insertions(+), 149 deletions(-) create mode 100644 src-tauri/src/domain/modules/github_release_targets.rs 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..21998597 --- /dev/null +++ b/src-tauri/src/domain/modules/github_release_targets.rs @@ -0,0 +1,152 @@ +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) + && !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, + 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..3a42ef12 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] 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. From 4c1a629b7b5175b7419180e56e32c7cebe98e2bf Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 21 May 2026 20:26:19 +0300 Subject: [PATCH 07/54] refactor: split local session context planning --- src-tauri/src/domain/ai/session.rs | 173 +------------------- src-tauri/src/domain/ai/session_context.rs | 179 ++++++++++++++++++++- 2 files changed, 181 insertions(+), 171 deletions(-) diff --git a/src-tauri/src/domain/ai/session.rs b/src-tauri/src/domain/ai/session.rs index 6a06e3a4..09e12130 100644 --- a/src-tauri/src/domain/ai/session.rs +++ b/src-tauri/src/domain/ai/session.rs @@ -12,30 +12,12 @@ use std::sync::{ 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, -} - /// Manages persistence and retrieval of chat sessions. /// /// Designed for DI via `app.manage(Arc::new(ChatSessionManager::new()))`. @@ -314,10 +296,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(); @@ -354,153 +334,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() diff --git a/src-tauri/src/domain/ai/session_context.rs b/src-tauri/src/domain/ai/session_context.rs index 25d7a51f..0b216e0d 100644 --- a/src-tauri/src/domain/ai/session_context.rs +++ b/src-tauri/src/domain/ai/session_context.rs @@ -1,6 +1,12 @@ 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 LEGACY_SUMMARY_PREFIXES: [&str; 5] = [ "Conversation recap from earlier turns:\n", @@ -10,6 +16,30 @@ const LEGACY_SUMMARY_PREFIXES: [&str; 5] = [ "Контекст:", ]; +struct LocalContextBudget { + available_tokens: usize, + summary_tokens: usize, +} + +struct LocalContextState { + turn_ranges: Vec<(usize, usize)>, + recent_start_index: 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_changed = state.refresh_summary(session, budget.summary_tokens, model); + let context = state.build_context(session, budget.available_tokens, 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()), @@ -177,6 +207,153 @@ pub(super) fn merge_summary( None } +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 + } +} + fn normalize_summary_lines(summary: &str) -> Vec { let stripped = LEGACY_SUMMARY_PREFIXES .iter() From b209c50916b4f5ba9b6bf79015da7f84ae27a40c Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 21 May 2026 20:28:48 +0300 Subject: [PATCH 08/54] fix: gate window transparency on macos --- src-tauri/src/app/window.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 Date: Thu, 21 May 2026 20:34:38 +0300 Subject: [PATCH 09/54] refactor: split streaming chunk parser --- src-tauri/src/domain/ai/mod.rs | 1 + src-tauri/src/domain/ai/streaming.rs | 367 +------------------ src-tauri/src/domain/ai/streaming_chunks.rs | 370 ++++++++++++++++++++ 3 files changed, 376 insertions(+), 362 deletions(-) create mode 100644 src-tauri/src/domain/ai/streaming_chunks.rs diff --git a/src-tauri/src/domain/ai/mod.rs b/src-tauri/src/domain/ai/mod.rs index 6e5b2a3c..35bbf2e2 100644 --- a/src-tauri/src/domain/ai/mod.rs +++ b/src-tauri/src/domain/ai/mod.rs @@ -23,6 +23,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/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..99385d85 --- /dev/null +++ b/src-tauri/src/domain/ai/streaming_chunks.rs @@ -0,0 +1,370 @@ +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("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)] + + 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" + )); + } +} From 00752845590d493a8d44837c31f9516e39f1b000 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 21 May 2026 20:40:30 +0300 Subject: [PATCH 10/54] fix: clean cross-platform warning paths --- src-tauri/src/api/ai/mod.rs | 2 ++ src-tauri/src/infrastructure/system/startup.rs | 10 ---------- 2 files changed, 2 insertions(+), 10 deletions(-) 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/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 } From cc8e3f7841de1022437a1fca6e0a748c6e2553e1 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 21 May 2026 20:53:49 +0300 Subject: [PATCH 11/54] fix: quiet cross-platform probe warnings --- src-tauri/src/domain/system/hardware_probe.rs | 23 ++++++------------- src-tauri/src/utils/memory.rs | 2 ++ 2 files changed, 9 insertions(+), 16 deletions(-) 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/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 } From 2055e335456766097a861d998862a11bf5be475d Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 21 May 2026 21:00:39 +0300 Subject: [PATCH 12/54] refactor: split console overview builder --- src-tauri/src/api/system/console_overview.rs | 416 ++++++++++++++++++ src-tauri/src/api/system/logs.rs | 422 +------------------ src-tauri/src/api/system/mod.rs | 1 + 3 files changed, 425 insertions(+), 414 deletions(-) create mode 100644 src-tauri/src/api/system/console_overview.rs 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..9059dff2 --- /dev/null +++ b/src-tauri/src/api/system/console_overview.rs @@ -0,0 +1,416 @@ +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(); + 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)) + } +} + +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/logs.rs b/src-tauri/src/api/system/logs.rs index 507cb8e5..4fe055e5 100644 --- a/src-tauri/src/api/system/logs.rs +++ b/src-tauri/src/api/system/logs.rs @@ -1,10 +1,7 @@ -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::process::Command; use std::sync::Arc; @@ -15,56 +12,10 @@ use super::log_targets::{ resolve_console_log_target, }; -struct ConsoleOverviewBuilder; - -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] @@ -198,332 +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", - } -} - -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")] { @@ -541,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; diff --git a/src-tauri/src/api/system/mod.rs b/src-tauri/src/api/system/mod.rs index 36a2b348..73321239 100644 --- a/src-tauri/src/api/system/mod.rs +++ b/src-tauri/src/api/system/mod.rs @@ -2,6 +2,7 @@ pub mod bootstrap; /// Configuration management commands pub mod config; +mod console_overview; /// Health check commands pub mod health; mod log_targets; From 616f0995e72cba86005e93e154bd98a717dd9384 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 21 May 2026 21:28:08 +0300 Subject: [PATCH 13/54] refactor: split engine id normalization --- src-tauri/src/domain/engine/engine_ids.rs | 32 +++++++++++++++++++++++ src-tauri/src/domain/engine/manager.rs | 30 ++------------------- src-tauri/src/domain/engine/mod.rs | 1 + 3 files changed, 35 insertions(+), 28 deletions(-) create mode 100644 src-tauri/src/domain/engine/engine_ids.rs 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 From 987f1eb241d621c96e1326f855e6cddc31275428 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 21 May 2026 21:44:40 +0300 Subject: [PATCH 14/54] refactor: split AI provider resolution --- src-tauri/src/domain/ai/ai_dispatch.rs | 244 +---------------- .../src/domain/ai/ai_provider_resolution.rs | 252 ++++++++++++++++++ src-tauri/src/domain/ai/mod.rs | 1 + 3 files changed, 256 insertions(+), 241 deletions(-) create mode 100644 src-tauri/src/domain/ai/ai_provider_resolution.rs 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/mod.rs b/src-tauri/src/domain/ai/mod.rs index 35bbf2e2..eef65375 100644 --- a/src-tauri/src/domain/ai/mod.rs +++ b/src-tauri/src/domain/ai/mod.rs @@ -1,4 +1,5 @@ mod ai_dispatch; +mod ai_provider_resolution; /// AI service implementation pub mod ai_service; /// Custom model management service From 2a47b44e4f506d022ab0a3190682adeaee6d2c9a Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 21 May 2026 21:51:01 +0300 Subject: [PATCH 15/54] refactor: split AI key validation --- src-tauri/src/domain/ai/ai_service.rs | 193 +--------------------- src-tauri/src/domain/ai/ai_validation.rs | 196 +++++++++++++++++++++++ src-tauri/src/domain/ai/mod.rs | 1 + 3 files changed, 198 insertions(+), 192 deletions(-) create mode 100644 src-tauri/src/domain/ai/ai_validation.rs diff --git a/src-tauri/src/domain/ai/ai_service.rs b/src-tauri/src/domain/ai/ai_service.rs index f791a519..6bb4a816 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,113 +371,6 @@ 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}")) - }; - - request - .build() - .map_err(|e| crate::errors::AppError::External { - request_id: None, - message: e.to_string(), - }) -} - -/// 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) -} - /// Counts tokens in text using tiktoken pub fn count_tokens(text: &str, model: Option<&str>) -> Result { use tiktoken_rs::{bpe_for_model, cl100k_base}; @@ -609,91 +503,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..00e362e8 --- /dev/null +++ b/src-tauri/src/domain/ai/ai_validation.rs @@ -0,0 +1,196 @@ +//! API key validation helpers for cloud AI providers. + +/// 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}")) + }; + + request + .build() + .map_err(|e| crate::errors::AppError::External { + request_id: None, + message: e.to_string(), + }) +} + +/// 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" + ); + } +} diff --git a/src-tauri/src/domain/ai/mod.rs b/src-tauri/src/domain/ai/mod.rs index eef65375..da29e5e1 100644 --- a/src-tauri/src/domain/ai/mod.rs +++ b/src-tauri/src/domain/ai/mod.rs @@ -2,6 +2,7 @@ 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; From 28be161ba190b4b9d1149523f952a2958ff00580 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Thu, 21 May 2026 22:00:32 +0300 Subject: [PATCH 16/54] fix: honor JS module package manager --- .../modules/controller/script_runtime.rs | 72 +++++++++++++++---- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/domain/modules/controller/script_runtime.rs b/src-tauri/src/domain/modules/controller/script_runtime.rs index 6d1ee93a..006f6a48 100644 --- a/src-tauri/src/domain/modules/controller/script_runtime.rs +++ b/src-tauri/src/domain/modules/controller/script_runtime.rs @@ -154,10 +154,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 +190,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 +575,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 +586,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 +629,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 +668,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 +875,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(_)) + )); + } } From bc13584ccce292ffd8aa83cc22548ce8d08d1cc3 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 07:56:52 +0300 Subject: [PATCH 17/54] docs: refresh project roadmap and integration state --- docs/localization/en/CURRENT_STATE.md | 32 ++++++- docs/localization/en/INTEGRATION_API.md | 45 +++++++++- docs/localization/en/README.md | 3 +- docs/localization/en/ROADMAP.md | 90 +++++++++++++++---- docs/localization/en/TRUST_MODEL.md | 38 ++++++++ docs/localization/en/VISION.md | 45 ++++++++-- .../ru/INTEGRATION_DEVELOPMENT.md | 19 ++++ docs/localization/ru/README.md | 11 ++- .../zh/INTEGRATION_DEVELOPMENT.md | 17 ++++ docs/localization/zh/README.md | 7 +- 10 files changed, 272 insertions(+), 35 deletions(-) diff --git a/docs/localization/en/CURRENT_STATE.md b/docs/localization/en/CURRENT_STATE.md index 94a82e95..86486ae7 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-21. > 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,8 @@ 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 Today the repository is not yet: @@ -24,6 +26,7 @@ Today the repository is not yet: - a full package distribution platform - a managed runtime platform - a mature MCP-first workstation +- a permissioned agent-control platform - a finished public product with stable distribution and operations ## Current Stack @@ -121,6 +124,23 @@ 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. 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 + +This is not yet a full agent control plane. It is the right base for one because +it already uses local bearer tokens, scoped module routes, backend-owned state, +and documented `/v1` endpoints. + ### Image Provider Path The repository contains API provider catalog data for image-capable providers. @@ -276,6 +296,10 @@ The repository still contains surfaces or ideas that are ahead of the stable pro What does not exist yet as a finished system: +- agent scopes and approval prompts +- sanitized log APIs meant for external agents +- integration draft generation through the launcher +- an Axelate MCP server backed by documented launcher capabilities - package signing service - verified package distribution - managed runtime orchestration @@ -337,9 +361,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/INTEGRATION_API.md b/docs/localization/en/INTEGRATION_API.md index b942edba..a9c9c181 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 future agent control. The current contract is +module-scoped and conservative. A separate Agent Control layer can add broader +observe, operate, configure, and draft-create scopes later, but it should reuse +the same local, authenticated, backend-owned design. For scaffolding, validation, and examples, start with [Integration Development](INTEGRATION_DEVELOPMENT.md). @@ -34,6 +39,10 @@ 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 follow the same rule for now. They should not scrape the +desktop UI or read Axelate data files directly. The supported path is a +launcher-issued token and documented `/v1` endpoints. + Script integrations declare their runtime in `axelate-module.toml`. ```toml @@ -67,6 +76,16 @@ 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. +Future agent tokens should not reuse module tokens. They need their own scopes: + +- `observe`: read health, status, module lists, and sanitized logs +- `operate`: start, stop, restart, and repair existing items +- `configure`: update settings after user approval where needed +- `draft-create`: create integration drafts without installing them silently + +Secrets should stay out of all agent responses unless a later explicit consent +flow says otherwise. + ## Client Rules - Treat `AXELATE_HTTP_API_BASE` and `AXELATE_HTTP_API_TOKEN` as runtime values. @@ -179,6 +198,24 @@ settings = requests.get( Does not require authentication. Returns whether the local API server is alive. +### Future Agent Control + +The current `/v1/modules` and `/v1/ai` endpoints are enough for launcher-managed +integrations. They are not yet a full agent control plane. + +The planned Agent Control layer should add: + +- launcher overview and health summary +- provider and model inventory +- download and runtime status +- sanitized log reads +- dry-run responses for install, delete, repair, and settings changes +- audit entries for agent actions +- integration draft creation from templates + +Mutating operations should stay behind explicit scopes and user approval where +the action can install code, delete data, expose logs, or change credentials. + ### Integrations `GET /v1/modules` diff --git a/docs/localization/en/README.md b/docs/localization/en/README.md index 72c5e72f..4797832b 100644 --- a/docs/localization/en/README.md +++ b/docs/localization/en/README.md @@ -15,7 +15,8 @@ English documentation is the canonical reference for Axelate. ## 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 and future + agent-control base - [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..8e4d9a21 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,6 +140,8 @@ 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 Exit criteria: @@ -202,7 +207,37 @@ 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. Add Agent Control API + +Difficulty: `5/10` to `7/10` + +Work: + +- read-only launcher state endpoints for agents: + - installed modules + - active providers and models + - runtime health + - download status + - recent sanitized logs +- controlled operation endpoints: + - start, stop, restart, and repair modules + - 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 action +- clear split between module-scoped tokens, launcher-wide tokens, and future + agent 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 +245,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 +256,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 +267,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 +314,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 +331,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 +417,15 @@ 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 sanitized backend-owned responses +- 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 +433,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 +475,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..2fec80dc 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 @@ -45,6 +46,8 @@ 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. @@ -88,6 +91,35 @@ 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. + +The first useful agent scope is read-only: + +- launcher health +- installed module list +- module status +- download status +- recent sanitized logs +- available providers and models + +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. + ## What Users Should Be Able To Trust Current repository direction already supports these expectations: @@ -114,6 +146,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 +159,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 +172,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..4cc8c739 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 API, но это не означает, что внешние агенты сейчас могут управлять +лаунчером без отдельного permissioned слоя. ## Структура интеграции @@ -88,6 +91,22 @@ entry = "src/main.py" Используй эти значения при старте процесса. Не хардкодь порт и пути. +## Agent Control + +Текущий Integration API остается модульным и ограниченным: интеграция получает +runtime token, работает со своими настройками, статусом, логами и AI-запросами. + +Будущий Agent Control слой должен быть отдельным: + +- `observe` для чтения статуса, здоровья, списка модулей и очищенных логов +- `operate` для start, stop, restart и repair существующих объектов +- `configure` для изменений настроек, где может понадобиться подтверждение +- `draft-create` для создания черновиков интеграций без тихой установки + +Агенты не должны читать внутренние файлы Axelate, скрейпить UI или получать +секреты провайдеров. Для опасных действий нужны scopes, audit log и подтверждение +пользователя. + ## Вызов AI Python: diff --git a/docs/localization/ru/README.md b/docs/localization/ru/README.md index f5e25974..d4cea9cb 100644 --- a/docs/localization/ru/README.md +++ b/docs/localization/ru/README.md @@ -10,14 +10,19 @@ ## Канонические английские документы - [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 интеграций +- [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` описывают планы, а не уже поставленные +возможности. Сейчас полноценной permissioned agent-control платформы нет: +текущий Integration API только закладывает для нее основу. diff --git a/docs/localization/zh/INTEGRATION_DEVELOPMENT.md b/docs/localization/zh/INTEGRATION_DEVELOPMENT.md index 2afa53d3..dd95ea9a 100644 --- a/docs/localization/zh/INTEGRATION_DEVELOPMENT.md +++ b/docs/localization/zh/INTEGRATION_DEVELOPMENT.md @@ -37,6 +37,8 @@ npm run integration:doctor -- ./my-integration 真正的运行时契约仍然是 [Integration API](../en/INTEGRATION_API.md) 中描述的本地 HTTP API。 +它已经适合 launcher-managed 集成使用,也会成为未来 Agent Control API 的基础。 +但这不表示外部 agent 现在可以绕过权限层直接控制启动器。 ## 集成结构 @@ -83,6 +85,21 @@ Axelate 启动 script-runtime 集成时会设置: 在进程启动时读取这些值。不要硬编码端口或数据路径。 +## Agent Control + +当前 Integration API 仍然是模块级、受限制的接口:集成拿到 runtime token 后, +只能使用自己的设置、状态、日志目录和 AI 请求能力。 + +未来的 Agent Control 层应该是单独的能力面: + +- `observe` 读取状态、健康信息、模块列表和清理后的日志 +- `operate` 对已有对象执行 start、stop、restart 和 repair +- `configure` 修改设置,必要时需要用户确认 +- `draft-create` 创建集成草稿,但不能静默安装 + +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..39d2a056 100644 --- a/docs/localization/zh/README.md +++ b/docs/localization/zh/README.md @@ -9,13 +9,18 @@ ## 英文规范文档 - [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 +- [Integration API](../en/INTEGRATION_API.md) - 集成本地 HTTP API,也是未来 + agent-control 层的基础 - [Custom Integrations](../en/CUSTOM_INTEGRATIONS.md) - 自定义集成格式 - [Releases](../en/RELEASES.md) - 发布流程 英文目录中的 `VISION.md` 和 `ROADMAP.md` 是规划文档,不代表当前已经发布的功能。 +目前还没有完整的 permissioned agent-control 平台;现有 Integration API 只是为它打基础。 From 6be14935e08032543140207170d09735274c942a Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 07:58:14 +0300 Subject: [PATCH 18/54] feat: expose backend catalog snapshot --- src-tauri/src/api/system/config.rs | 586 ++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 1 + src-tauri/src/models/config.rs | 104 +++++ src/shared/types/bindings.ts | 92 +++++ 4 files changed, 781 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/api/system/config.rs b/src-tauri/src/api/system/config.rs index 6807990c..ea96fa8d 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,572 @@ 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()), + 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/lib.rs b/src-tauri/src/lib.rs index f496dcfa..3baa1353 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -99,6 +99,7 @@ pub fn create_specta_builder() -> Builder { .commands(collect_commands![ health::get_health, config::get_config, + config::get_catalog_snapshot, settings::get_settings, settings::save_settings, settings::save_setting, 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/shared/types/bindings.ts b/src/shared/types/bindings.ts index 7377a8b9..519d3c74 100644 --- a/src/shared/types/bindings.ts +++ b/src/shared/types/bindings.ts @@ -13,6 +13,8 @@ export const commands = { getHealth: () => typedError(__TAURI_INVOKE("get_health")), /** 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 */ @@ -401,6 +403,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) */ From f8322f708d189ce4f099fd0bef327c62388a18e1 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 07:59:30 +0300 Subject: [PATCH 19/54] refactor: consume backend provider catalog in frontend --- src/app/CoreStateRestore.test.ts | 20 +- src/app/CoreStateRestore.ts | 13 +- src/features/ai/services/AIBridge.test.ts | 78 ++- src/features/ai/services/AIBridge.ts | 16 +- src/features/ai/services/AIBridgeConfig.ts | 1 + .../AIBridgeMessageController.test.ts | 72 ++- .../ai/services/AIBridgeMessageController.ts | 24 +- .../services/AIBridgeProviderPolicy.test.ts | 43 +- .../ai/services/AIBridgeProviderPolicy.ts | 25 +- .../ai/services/AIChatTransport.test.ts | 21 +- src/features/ai/services/AIChatTransport.ts | 10 +- .../ai/services/AIProviderManager.test.ts | 102 ++- src/features/ai/services/AIProviderManager.ts | 22 +- .../ai/services/EngineStatusService.test.ts | 8 +- .../ai/services/EngineStatusService.ts | 4 - src/features/ai/types/aiTypes.ts | 3 + .../ai/ui/AISettingsContentRenderer.ts | 22 +- .../ai/ui/AISettingsKeyController.test.ts | 33 +- src/features/ai/ui/AISettingsKeyController.ts | 23 +- src/features/ai/ui/AISettingsRenderer.test.ts | 110 +++- src/features/ai/ui/AISettingsRenderer.ts | 112 +++- .../ai/ui/AISettingsSelectionController.ts | 4 +- .../ai/ui/AISettingsViewPolicy.test.ts | 82 ++- src/features/ai/ui/AISettingsViewPolicy.ts | 39 +- .../settings/services/SettingsService.test.ts | 31 +- .../settings/services/SettingsService.ts | 37 +- src/shared/services/CatalogLoadSnapshot.ts | 14 - src/shared/services/CatalogService.test.ts | 585 +++++++----------- src/shared/services/CatalogService.ts | 297 +++------ src/shared/shell/AppUI.test.ts | 16 +- src/shared/shell/AppUI.ts | 4 +- src/shared/types/coreTypes.ts | 1 + .../utils/customProviderSupport.test.ts | 22 - src/shared/utils/customProviderSupport.ts | 33 - src/shared/utils/providerSupport.test.ts | 36 -- src/shared/utils/providerSupport.ts | 41 -- src/test/helpers/catalogTestUtils.ts | 23 +- .../CatalogService.integration.test.ts | 149 ++--- 38 files changed, 1058 insertions(+), 1118 deletions(-) create mode 100644 src/features/ai/services/AIBridgeConfig.ts delete mode 100644 src/shared/services/CatalogLoadSnapshot.ts delete mode 100644 src/shared/utils/providerSupport.test.ts delete mode 100644 src/shared/utils/providerSupport.ts diff --git a/src/app/CoreStateRestore.test.ts b/src/app/CoreStateRestore.test.ts index 48f289bc..4c816f63 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,14 @@ 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', - expect.objectContaining({ id: CUSTOM_TEXT_PROVIDER_ID }), - ); + expect(updateModuleCard).toHaveBeenCalledWith('ai_text', customTextApp); }); }); diff --git a/src/app/CoreStateRestore.ts b/src/app/CoreStateRestore.ts index 407803e6..5ad70d63 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; @@ -74,7 +73,7 @@ export function restoreSelectedModules(args: RestoreSelectedModulesArgs): Restor function resolveRestoredApp( catalog: CatalogService, - category: string, + _category: string, selectedModule: Partial, ): IApp | null { if (typeof selectedModule.id !== 'string' || selectedModule.id === '') { @@ -86,13 +85,5 @@ function resolveRestoredApp( return catalogApp; } - if (!category.startsWith('ai')) { - return null; - } - - return ( - appendCustomProviderApps(catalog.getCatalog().ai).find( - (app) => app.id === selectedModule.id, - ) ?? null - ); + return null; } 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 1607997c..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 }, ], })); } @@ -193,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 () => { @@ -452,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..b50e5279 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 }, ], })); @@ -38,8 +65,8 @@ describe('AIBridgeProviderPolicy', () => { 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..900a79cc 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,7 @@ export class AIBridgeProviderPolicy { public constructor(private readonly _getCatalog?: ProviderCatalogGetter) {} public isCloudProvider(providerId: string): boolean { - return isCloudProviderId(providerId); + return this._catalogProvider(providerId)?.providerPolicy?.isCloudProvider ?? false; } public isImageProvider(providerId: string): boolean { @@ -67,20 +66,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..4d87a40b 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(); @@ -353,9 +362,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..2cf70305 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,8 @@ 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() ?? ''; + return baseUrl.length > 0 ? CLOUD_CHAT_REQUEST_TIMEOUT_MS : LOCAL_CHAT_REQUEST_TIMEOUT_MS; } public destroy(): void { diff --git a/src/features/ai/services/AIProviderManager.test.ts b/src/features/ai/services/AIProviderManager.test.ts index 7b5b0d10..dbadc40c 100644 --- a/src/features/ai/services/AIProviderManager.test.ts +++ b/src/features/ai/services/AIProviderManager.test.ts @@ -8,6 +8,36 @@ 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, +}; + // Mock catalogHelpers used internally vi.mock('@/features/ai/utils/catalogHelpers', () => ({ getModelData: vi.fn(() => null), @@ -31,7 +61,17 @@ 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: CUSTOM_TEXT_PROVIDER_ID, + capability: 'text', + providerPolicy: customTextProviderPolicy, + }, + ], + }), }, aiSettings: { setSelectedAIModel: vi.fn(), @@ -66,7 +106,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 +119,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 +129,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 +142,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 +163,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 +183,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 +197,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 +211,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 +221,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 +231,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 +241,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 +249,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 +264,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 +288,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 +296,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 +314,7 @@ describe('AIProviderManager', () => { () => Promise.resolve('original-key'), () => Promise.resolve(hasKey), ); - manager.setCore(mockCore); + manager.setContext(mockCore); await manager.startProvider('gemini'); hasKey = false; @@ -294,7 +334,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 +370,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 +400,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 +414,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,7 +429,7 @@ describe('AIProviderManager', () => { vi.mocked(mockCore.aiSettings.getSelectedAIModel).mockReturnValue( null as unknown as string, ); - manager.setCore(mockCore); + manager.setContext(mockCore); await manager.startProvider('gemini'); @@ -397,7 +437,7 @@ describe('AIProviderManager', () => { }); it('should return null from _getPersistedModel when core is not set (L143 true branch)', async () => { - // No setCore() called — _core is null → _getPersistedModel returns null + // No setContext() called: _getPersistedModel returns null // startProvider('local') resolves with fallback model from _getDefaultModel // 'local' provider: _resolveApiKey returns '' (no core), isLocal=true → proceeds const result = await manager.startProvider('local'); @@ -409,7 +449,7 @@ describe('AIProviderManager', () => { 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 +457,20 @@ describe('AIProviderManager', () => { expect(manager.model).toBe('default'); }); - it('should not invent a cloud model when catalog and persisted settings are empty', async () => { + it('should treat providers without backend policy as local fallback', 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(manager.model).toBe(''); - expect(mockCore.aiSettings.setSelectedAIModel).toHaveBeenCalledWith('gemini', ''); + expect(manager.model).toBe('default'); + expect(mockCore.aiSettings.setSelectedAIModel).toHaveBeenCalledWith( + 'gemini', + 'default', + ); }); it('should reflect model changes from settings without restarting the provider', async () => { @@ -435,7 +479,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..a0108a8d 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); } @@ -198,11 +195,12 @@ export class AIProviderManager { * 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); + return true; } private _getPersistedModel(providerId: string): string | null { @@ -247,6 +245,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..480ffdf5 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,22 @@ 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 apiKeyTitle = context.usesCustomProviderKey + ? `${apiKeyLabel}` + : ` + + ${apiKeyLabel} + + `; return `
@@ -144,11 +152,7 @@ export class AISettingsContentRenderer {
-

🔑 - - ${apiKeyLabel} - -

+

🔑 ${apiKeyTitle}

@@ -184,7 +188,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 34e54018..75d16eda 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; @@ -210,6 +265,7 @@ describe('AISettingsRenderer', () => { id: CUSTOM_TEXT_PROVIDER_ID, name: 'Custom', apiProviderData: { models: [] }, + providerPolicy: customTextPolicy, } as never); const openAiEndpointCard = container.querySelector( @@ -226,7 +282,7 @@ 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 () => { @@ -236,6 +292,7 @@ describe('AISettingsRenderer', () => { id: 'gpt', name: 'OpenAI', apiProviderData: { models }, + providerPolicy: openRouterPolicy, } as never); expect(container.textContent).toContain('OpenRouter API key'); @@ -247,11 +304,13 @@ describe('AISettingsRenderer', () => { 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(); @@ -263,6 +322,25 @@ describe('AISettingsRenderer', () => { ).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 () => { vi.useFakeTimers(); const container = document.getElementById('root') as HTMLElement; @@ -272,6 +350,7 @@ describe('AISettingsRenderer', () => { id: CUSTOM_TEXT_PROVIDER_ID, name: 'Custom', apiProviderData: { models: [] }, + providerPolicy: customTextPolicy, } as never); const input = document.getElementById( @@ -288,7 +367,7 @@ describe('AISettingsRenderer', () => { 'https://api.groq.com/openai/v1', ); expect(settingsService.saveSecureKey).toHaveBeenCalledWith( - CUSTOM_TEXT_PROVIDER_ID, + 'custom_text_api_key', 'gsk-valid-key', ); }); @@ -299,13 +378,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'); @@ -318,7 +398,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', ); @@ -333,6 +413,7 @@ describe('AISettingsRenderer', () => { id: 'gpt', name: 'GPT', apiProviderData: { models }, + providerPolicy: openRouterPolicy, } as never); await aiSettingsRenderer.toggleKeyVisibility('gpt'); @@ -350,6 +431,7 @@ describe('AISettingsRenderer', () => { id: 'gpt', name: 'GPT', apiProviderData: { models }, + providerPolicy: openRouterPolicy, } as never); const input = document.getElementById('gpt-api-key-input') as HTMLInputElement; @@ -365,7 +447,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'); @@ -378,7 +460,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', @@ -419,6 +501,7 @@ describe('AISettingsRenderer', () => { id: 'gpt', name: 'GPT', apiProviderData: { models }, + providerPolicy: openRouterPolicy, } as never); const input = document.getElementById('gpt-api-key-input') as HTMLInputElement; @@ -447,12 +530,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 = [ { @@ -468,13 +552,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 () => { @@ -484,6 +566,7 @@ describe('AISettingsRenderer', () => { id: CUSTOM_TEXT_PROVIDER_ID, name: 'Custom', apiProviderData: { models: [] }, + providerPolicy: customTextPolicy, } as never); const input = container.querySelector( @@ -521,6 +604,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..4aa8f09d 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,7 @@ class AISettingsRenderer extends BaseComponent { }, openKeyProviderUrl: () => { if (this._tauri) { - void this._tauri.openUrl(this._getKeyProviderUrl(keyProviderId)); + void this._tauri.openUrl(this._getKeyProviderUrl(appId)); } }, toggleKeyVisibility: async () => this.toggleKeyVisibility(appId), @@ -285,7 +286,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 +304,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 +348,7 @@ class AISettingsRenderer extends BaseComponent { i18nUI: this._i18nUI, contentRenderer: this._contentRenderer, viewPolicy: this._viewPolicy, + app: this._activeRenderTarget?.app ?? { id: appId }, }); } @@ -381,7 +396,10 @@ 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', + 'Manual OpenAI-compatible model ID', + ), 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,47 @@ 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 { + return this._getActiveProviderPolicy(appId)?.keyProviderUrl ?? '#'; + } + + 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/settings/services/SettingsService.test.ts b/src/features/settings/services/SettingsService.test.ts index da95cb65..645d00ed 100644 --- a/src/features/settings/services/SettingsService.test.ts +++ b/src/features/settings/services/SettingsService.test.ts @@ -226,30 +226,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 +251,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 +263,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 +307,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 +318,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 +329,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 +340,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 +350,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 +359,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..354f643d 100644 --- a/src/features/settings/services/SettingsService.ts +++ b/src/features/settings/services/SettingsService.ts @@ -5,7 +5,6 @@ import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { 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; @@ -130,11 +129,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 +144,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 +163,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 +177,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 +189,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 +276,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/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..89ed2a3b 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 = ( diff --git a/src/shared/shell/AppUI.ts b/src/shared/shell/AppUI.ts index 605dd055..da318f86 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'; @@ -540,8 +539,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/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 5a31d220..00000000 --- a/src/shared/utils/providerSupport.test.ts +++ /dev/null @@ -1,36 +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'); - expect(resolveProviderSecretService('claude')).toBe('cloud_api_key'); - expect(resolveProviderSecretService('deepseek')).toBe('cloud_api_key'); - expect(resolveProviderSecretService('gpt-image')).toBe('cloud_api_key'); - expect(resolveProviderSecretService('gemini-image')).toBe('cloud_api_key'); - expect(resolveProviderSecretService('seedream-image')).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/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(); From 2bccb2bac48580ba2addbc21be448cc62f8328ae Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 08:00:41 +0300 Subject: [PATCH 20/54] fix: harden AI provider sessions and streaming --- src-tauri/src/domain/ai/ai_service.rs | 117 +++++++- src-tauri/src/domain/ai/ai_validation.rs | 79 ++++++ src-tauri/src/domain/ai/provider_payload.rs | 38 ++- src-tauri/src/domain/ai/session.rs | 42 ++- src-tauri/src/domain/ai/session_context.rs | 280 +++++++++++++++++--- src-tauri/src/domain/ai/streaming_chunks.rs | 16 +- 6 files changed, 516 insertions(+), 56 deletions(-) diff --git a/src-tauri/src/domain/ai/ai_service.rs b/src-tauri/src/domain/ai/ai_service.rs index 6bb4a816..0fa91c7b 100644 --- a/src-tauri/src/domain/ai/ai_service.rs +++ b/src-tauri/src/domain/ai/ai_service.rs @@ -371,14 +371,18 @@ fn timeout_error(request_id: String, timeout: std::time::Duration) -> crate::err } } -/// Counts tokens in text using tiktoken +/// 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::{bpe_for_model, cl100k_base}; + use tiktoken_rs::cl100k_base; - if let Some(model_name) = model - && let Ok(bpe) = bpe_for_model(model_name) - { - return Ok(bpe.encode_with_special_tokens(text).len()); + if let Some(model_name) = model { + if let Some(count) = count_with_known_tiktoken_model(text, model_name) { + return Ok(count); + } + + if should_use_portable_token_estimate(model_name) { + return Ok(estimate_portable_token_count(text)); + } } let bpe = cl100k_base().map_err(|e| format!("Failed to load cl100k_base tokenizer: {e}"))?; @@ -386,6 +390,74 @@ pub fn count_tokens(text: &str, model: Option<&str>) -> Result { Ok(bpe.encode_with_special_tokens(text).len()) } +fn count_with_known_tiktoken_model(text: &str, model_name: &str) -> Option { + use tiktoken_rs::bpe_for_model; + + if let Ok(bpe) = bpe_for_model(model_name) { + return Some(bpe.encode_with_special_tokens(text).len()); + } + + let (_, model_id) = model_name.rsplit_once('/')?; + if model_id == model_name { + return None; + } + + bpe_for_model(model_id) + .ok() + .map(|bpe| bpe.encode_with_special_tokens(text).len()) +} + +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)) +} + +fn estimate_portable_token_count(text: &str) -> usize { + let trimmed = text.trim(); + if trimmed.is_empty() { + return 0; + } + + 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) + }; + + word_estimate.max(char_estimate).max(1) +} + /// Dispatches an image generation request to the appropriate provider (local engine). pub async fn process_image_request( request: super::types::ImageGenerationRequest, @@ -456,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!( diff --git a/src-tauri/src/domain/ai/ai_validation.rs b/src-tauri/src/domain/ai/ai_validation.rs index 00e362e8..54804316 100644 --- a/src-tauri/src/domain/ai/ai_validation.rs +++ b/src-tauri/src/domain/ai/ai_validation.rs @@ -1,5 +1,7 @@ //! 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, @@ -17,6 +19,7 @@ fn build_validation_request( .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 @@ -34,6 +37,60 @@ fn build_validation_request( }) } +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(), + )); + } + + if let Ok(ip) = normalized_host.parse::() + && is_restricted_ip(ip) + { + return Err(crate::errors::AppError::Validation( + "Unsupported validation base URL".to_string(), + )); + } + + Ok(()) +} + +const 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() +} + +const fn is_restricted_ipv6(ip: Ipv6Addr) -> bool { + ip.is_loopback() || ip.is_unspecified() || ip.is_unique_local() || ip.is_unicast_link_local() +} + /// Validates an API key against OpenRouter (or generic OpenAI endpoint). pub async fn validate_api_key( provider: String, @@ -193,4 +250,26 @@ mod tests { "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://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/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 09e12130..fb4f6ef6 100644 --- a/src-tauri/src/domain/ai/session.rs +++ b/src-tauri/src/domain/ai/session.rs @@ -9,6 +9,7 @@ use std::sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, }; +use std::time::Duration; use tokio::sync::Notify; use super::session_context::{ @@ -18,6 +19,10 @@ use super::types::{ChatMessage, ChatReply, ChatSession}; use super::session_persistence::SessionPersistence; +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. /// /// Designed for DI via `app.manage(Arc::new(ChatSessionManager::new()))`. @@ -87,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) { @@ -94,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); @@ -105,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); } } } @@ -315,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<()>, @@ -364,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 0b216e0d..afcc5796 100644 --- a/src-tauri/src/domain/ai/session_context.rs +++ b/src-tauri/src/domain/ai/session_context.rs @@ -7,6 +7,10 @@ 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", @@ -19,6 +23,7 @@ const LEGACY_SUMMARY_PREFIXES: [&str; 5] = [ struct LocalContextBudget { available_tokens: usize, summary_tokens: usize, + recent_turns: usize, } struct LocalContextState { @@ -33,9 +38,9 @@ pub(super) fn build_local_context_messages( model: &str, ) -> (Vec, bool) { let budget = LocalContextBudget::new(context_size); - let state = LocalContextState::from_session(session); + let state = LocalContextState::from_session(session, budget.recent_turns); let summary_changed = state.refresh_summary(session, budget.summary_tokens, model); - let context = state.build_context(session, budget.available_tokens, model); + let context = state.build_context(session, &budget, model); (context, summary_changed) } @@ -44,22 +49,15 @@ pub(super) fn extract_message_text(content: &serde_json::Value) -> Option 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) } } @@ -210,25 +208,62 @@ pub(super) fn merge_summary( 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(LOCAL_CONTEXT_RESERVE_TOKENS) + .saturating_sub(reserve_tokens) .max(512); - let summary_tokens = available_tokens.saturating_mul(LOCAL_SUMMARY_BUDGET_NUMERATOR) + 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(LOCAL_MIN_SUMMARY_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 { + fn from_session(session: &ChatSession, recent_turns: usize) -> Self { let turn_ranges = group_turn_ranges(&session.history); let recent_start_index = turn_ranges .len() - .checked_sub(LOCAL_RECENT_TURNS) + .checked_sub(recent_turns) .and_then(|index| turn_ranges.get(index)) .map_or(0, |(start, _)| *start); let persisted_summary_count = @@ -269,12 +304,17 @@ impl LocalContextState { return false; } - session.summary = merge_summary( + 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(self.recent_start_index).unwrap_or(u32::MAX); true } @@ -282,12 +322,18 @@ impl LocalContextState { fn build_context( &self, session: &ChatSession, - available_budget: usize, + budget: &LocalContextBudget, 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)); + 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 } @@ -324,11 +370,12 @@ impl LocalContextState { available_budget: usize, initial_tokens: usize, model: &str, + recent_turns: usize, ) -> Vec { let recent_turn_ranges = self .turn_ranges .len() - .checked_sub(LOCAL_RECENT_TURNS) + .checked_sub(recent_turns) .and_then(|start| self.turn_ranges.get(start..)) .unwrap_or(&self.turn_ranges); @@ -341,7 +388,7 @@ impl LocalContextState { }; let turn_tokens = estimate_messages_tokens(turn, model); if used_tokens + turn_tokens > available_budget { - continue; + break; } let mut turn_messages = turn.to_vec(); @@ -385,18 +432,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; @@ -404,7 +482,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(' '); @@ -420,7 +497,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 { @@ -428,3 +505,140 @@ 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 small_state = + LocalContextState::from_session(&session, LocalContextBudget::new(4096).recent_turns); + let large_state = LocalContextState::from_session( + &session, + LocalContextBudget::new(131_072).recent_turns, + ); + + assert_eq!(small_state.recent_start_index, 14); + assert_eq!(large_state.recent_start_index, 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, LOCAL_RECENT_TURNS); + + let changed = state.refresh_summary(&mut session, 0, "local-model"); + + 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, LOCAL_RECENT_TURNS); + + 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 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_chunks.rs b/src-tauri/src/domain/ai/streaming_chunks.rs index 99385d85..84a24d58 100644 --- a/src-tauri/src/domain/ai/streaming_chunks.rs +++ b/src-tauri/src/domain/ai/streaming_chunks.rs @@ -213,8 +213,8 @@ fn handle_stream_json_line( .and_then(provider_response::extract_stream_text) }) .or_else(|| { - json.get("message") - .and_then(|message| message.get("content")) + json.get("delta") + .and_then(|delta| delta.get("content")) .and_then(provider_response::extract_stream_text) }) .or_else(|| { @@ -367,4 +367,16 @@ mod tests { 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"); + } } From a98169fe1801f2aa7eeda0f28a888ec76f642214 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 08:01:51 +0300 Subject: [PATCH 21/54] fix: stabilize console log filtering and cleanup --- src-tauri/src/api/system/console_overview.rs | 7 +-- src-tauri/src/api/system/logs.rs | 45 ++++++++++++++++ .../src/infrastructure/logging/logger.rs | 25 ++++----- src-tauri/src/utils/paths.rs | 44 +++++++++++++-- .../services/ConsoleLogService.test.ts | 39 ++++++++++++++ .../console/services/ConsoleLogService.ts | 53 ++++++++++++++++++- 6 files changed, 190 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/api/system/console_overview.rs b/src-tauri/src/api/system/console_overview.rs index 9059dff2..8c8bf95a 100644 --- a/src-tauri/src/api/system/console_overview.rs +++ b/src-tauri/src/api/system/console_overview.rs @@ -341,13 +341,8 @@ impl ConsoleOverviewBuilder { }], 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 id = canonical_engine_id(&slot.engine.id); let detail = ConsoleLabelFormatter::format_capability(slot.capability); items .entry(id.clone()) diff --git a/src-tauri/src/api/system/logs.rs b/src-tauri/src/api/system/logs.rs index 4fe055e5..98188974 100644 --- a/src-tauri/src/api/system/logs.rs +++ b/src-tauri/src/api/system/logs.rs @@ -434,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/infrastructure/logging/logger.rs b/src-tauri/src/infrastructure/logging/logger.rs index aa83989f..b68e3fcc 100644 --- a/src-tauri/src/infrastructure/logging/logger.rs +++ b/src-tauri/src/infrastructure/logging/logger.rs @@ -555,17 +555,9 @@ fn parse_log_timestamp(line: &str) -> Option { 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) + let timestamp = chrono::Utc.from_utc_datetime(×tamp); + let seconds = u32::try_from(timestamp.timestamp()).ok()?; + Some(f64::from(seconds) + f64::from(timestamp.timestamp_subsec_millis()) / 1000.0) }) } @@ -851,7 +843,7 @@ 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> { @@ -901,4 +893,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/utils/paths.rs b/src-tauri/src/utils/paths.rs index e3cdf401..e9b44b92 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"; @@ -245,11 +247,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 +269,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 +312,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/features/console/services/ConsoleLogService.test.ts b/src/features/console/services/ConsoleLogService.test.ts index 6ce0cf27..e1d48c7b 100644 --- a/src/features/console/services/ConsoleLogService.test.ts +++ b/src/features/console/services/ConsoleLogService.test.ts @@ -137,6 +137,45 @@ describe('ConsoleLogService', () => { ]); }); + 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..f4bad180 100644 --- a/src/features/console/services/ConsoleLogService.ts +++ b/src/features/console/services/ConsoleLogService.ts @@ -54,6 +54,7 @@ export class ConsoleLogService { private readonly _logsByView = new Map(); private readonly _lastTimestampByView = new Map(); private readonly _modulePathCache = new Map(); + private readonly _knownViewIds = new Set(['general']); private readonly _normalizer = new ConsoleLogNormalizer(); constructor( @@ -68,6 +69,8 @@ export class ConsoleLogService { public destroy(): void { this._logsByView.clear(); this._lastTimestampByView.clear(); + this._knownViewIds.clear(); + this._knownViewIds.add('general'); } public async fetchLogs(viewId = 'general'): Promise { @@ -125,7 +128,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._normalizeViews(result.data.views); + this._rememberKnownViews(views); + return views; } } catch (error) { this._tracer.warn( @@ -226,7 +231,18 @@ export class ConsoleLogService { 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 +285,39 @@ export class ConsoleLogService { return [...byId.values()]; } + private _rememberKnownViews(views: readonly IConsoleLogView[]): void { + this._knownViewIds.clear(); + this._knownViewIds.add('general'); + 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:(.+)$/); From ba9e985215d422db843478a5868b902023a9742d Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 08:14:15 +0300 Subject: [PATCH 22/54] feat: add read-only agent launcher state endpoint --- docs/localization/en/INTEGRATION_API.md | 16 +++- .../src/domain/integration_api/routing.rs | 79 +++++++++++++++++++ src-tauri/src/domain/integration_api/tests.rs | 49 +++++++++++- src-tauri/src/domain/integration_api/types.rs | 42 ++++++++++ 4 files changed, 182 insertions(+), 4 deletions(-) diff --git a/docs/localization/en/INTEGRATION_API.md b/docs/localization/en/INTEGRATION_API.md index a9c9c181..15573d73 100644 --- a/docs/localization/en/INTEGRATION_API.md +++ b/docs/localization/en/INTEGRATION_API.md @@ -201,7 +201,21 @@ Does not require authentication. Returns whether the local API server is alive. ### Future Agent Control The current `/v1/modules` and `/v1/ai` endpoints are enough for launcher-managed -integrations. They are not yet a full agent control plane. +integrations. The first launcher-wide agent endpoint is read-only and uses the +launcher token, not a module-scoped integration token. + +`GET /v1/agent/state` + +Returns a sanitized 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. + +This is still not a full agent control plane. The planned Agent Control layer should add: diff --git a/src-tauri/src/domain/integration_api/routing.rs b/src-tauri/src/domain/integration_api/routing.rs index 8a4ab97d..31a2b8f2 100644 --- a/src-tauri/src/domain/integration_api/routing.rs +++ b/src-tauri/src/domain/integration_api/routing.rs @@ -16,6 +16,7 @@ use tauri::Emitter; use super::auth::{authorize_request, is_loopback_peer}; use super::http::{json_error, json_response, parse_json_body, request_path, status_for_app_error}; use super::types::{ + AgentLauncherStateResponse, AgentModelSummary, AgentModuleSummary, AgentProviderSummary, AuthorizedClient, HttpRequest, HttpResponse, ImageApiResponse, IntegrationImageRequest, IntegrationModuleStageRequest, IntegrationTextRequest, ModuleContextApiResponse, ModuleStageChangedEvent, TextApiResponse, @@ -64,6 +65,10 @@ async fn route_authorized_request( .collect::>(); match (request.method.as_str(), segments.as_slice()) { + ("GET", ["v1", "agent", "state"]) => { + ensure_launcher_client(client)?; + handle_agent_state_request(&context).await + } ("GET", ["v1", "modules"]) => { let modules = modules_visible_to_client(module_controller::get_all_modules().await, client); @@ -117,6 +122,71 @@ async fn route_authorized_request( } } +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, @@ -261,6 +331,15 @@ pub(super) fn ensure_module_route_owner( } } +pub(super) fn ensure_launcher_client(client: &AuthorizedClient) -> Result<(), AppError> { + match client { + AuthorizedClient::Launcher => Ok(()), + AuthorizedClient::Module(_) => Err(AppError::PermissionDenied( + "Integration token cannot access launcher-wide agent state".to_string(), + )), + } +} + fn handle_module_stage_request( request: &HttpRequest, context: &LauncherHttpApiContext, diff --git a/src-tauri/src/domain/integration_api/tests.rs b/src-tauri/src/domain/integration_api/tests.rs index 5ffca848..216df4e4 100644 --- a/src-tauri/src/domain/integration_api/tests.rs +++ b/src-tauri/src/domain/integration_api/tests.rs @@ -6,9 +6,10 @@ use super::http::{ parse_json_body, read_http_request, status_for_app_error, status_text, }; 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, backend_provider_id, ensure_launcher_client, 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, }; use super::types::{AuthorizedClient, IntegrationTextRequest, ModuleContextApiResponse}; use crate::domain::modules::controller::ModuleAction; @@ -128,6 +129,15 @@ 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 issuing_new_module_token_invalidates_previous_token() { let old_token = auth::issue_module_api_token("rotating-module").expect("old token"); @@ -514,6 +524,39 @@ fn selected_module_from_api_provider_maps_provider_type() { assert_eq!(selected_local.type_, "local"); } +#[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..0f2c3fe3 100644 --- a/src-tauri/src/domain/integration_api/types.rs +++ b/src-tauri/src/domain/integration_api/types.rs @@ -3,6 +3,8 @@ use crate::domain::ai::types::{ ChatMessage, ChatResponse, ImageGenerationResponse, WebSearchOptions, }; +use crate::domain::engine::types::EngineState; +use crate::models::{ModelCapabilities, SelectedModule}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::net::{SocketAddr, TcpStream}; @@ -122,6 +124,46 @@ 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 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 { From 3e721a7bcad11c383e327a174164ae30892a3ac2 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 08:19:43 +0300 Subject: [PATCH 23/54] feat: expose read-only agent console logs --- docs/localization/en/INTEGRATION_API.md | 8 ++ .../src/domain/integration_api/routing.rs | 100 +++++++++++++++++- src-tauri/src/domain/integration_api/tests.rs | 27 ++++- src-tauri/src/domain/integration_api/types.rs | 12 +++ 4 files changed, 140 insertions(+), 7 deletions(-) diff --git a/docs/localization/en/INTEGRATION_API.md b/docs/localization/en/INTEGRATION_API.md index 15573d73..07b1453d 100644 --- a/docs/localization/en/INTEGRATION_API.md +++ b/docs/localization/en/INTEGRATION_API.md @@ -215,6 +215,14 @@ Returns a sanitized launcher snapshot: Module-scoped integration tokens cannot call this route. +`GET /v1/agent/logs?viewId=engine:llama-cpp&since=0&limit=200` + +Returns recent frontend-facing console logs from memory. `viewId` is optional; +omit it to read the combined console stream. `limit` defaults to 200 and is +capped at 1000. + +Module-scoped integration tokens cannot call this route. + This is still not a full agent control plane. The planned Agent Control layer should add: diff --git a/src-tauri/src/domain/integration_api/routing.rs b/src-tauri/src/domain/integration_api/routing.rs index 31a2b8f2..81889e77 100644 --- a/src-tauri/src/domain/integration_api/routing.rs +++ b/src-tauri/src/domain/integration_api/routing.rs @@ -16,18 +16,27 @@ use tauri::Emitter; use super::auth::{authorize_request, is_loopback_peer}; use super::http::{json_error, json_response, parse_json_body, request_path, status_for_app_error}; use super::types::{ - AgentLauncherStateResponse, AgentModelSummary, AgentModuleSummary, AgentProviderSummary, - AuthorizedClient, HttpRequest, HttpResponse, ImageApiResponse, IntegrationImageRequest, - IntegrationModuleStageRequest, IntegrationTextRequest, ModuleContextApiResponse, - ModuleStageChangedEvent, TextApiResponse, + AgentLauncherStateResponse, AgentLogsResponse, AgentModelSummary, AgentModuleSummary, + AgentProviderSummary, AuthorizedClient, HttpRequest, HttpResponse, ImageApiResponse, + IntegrationImageRequest, IntegrationModuleStageRequest, 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"; +#[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, context: LauncherHttpApiContext, @@ -69,6 +78,10 @@ async fn route_authorized_request( ensure_launcher_client(client)?; handle_agent_state_request(&context).await } + ("GET", ["v1", "agent", "logs"]) => { + ensure_launcher_client(client)?; + handle_agent_logs_request(request) + } ("GET", ["v1", "modules"]) => { let modules = modules_visible_to_client(module_controller::get_all_modules().await, client); @@ -122,6 +135,85 @@ async fn route_authorized_request( } } +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 => crate::api::system::logs::get_logs(query.since)?, + }; + let skip = logs.len().saturating_sub(query.limit); + let logs = logs.into_iter().skip(skip).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, + }), + )) +} + +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" => { + result.view_id = Some(value.trim().to_string()).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 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 { diff --git a/src-tauri/src/domain/integration_api/tests.rs b/src-tauri/src/domain/integration_api/tests.rs index 216df4e4..6e3cf1c3 100644 --- a/src-tauri/src/domain/integration_api/tests.rs +++ b/src-tauri/src/domain/integration_api/tests.rs @@ -7,9 +7,9 @@ use super::http::{ }; use super::routing::{ agent_provider_summary, backend_provider_id, ensure_launcher_client, 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, + merge_json_settings, model_api_id, modules_visible_to_client, parse_agent_logs_query, + parse_module_action, resolve_session_id, selected_module_from_api_provider, + selected_module_from_catalog_item, tier_rank, }; use super::types::{AuthorizedClient, IntegrationTextRequest, ModuleContextApiResponse}; use crate::domain::modules::controller::ModuleAction; @@ -138,6 +138,27 @@ fn launcher_wide_agent_state_requires_launcher_client() { )); } +#[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_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 issuing_new_module_token_invalidates_previous_token() { let old_token = auth::issue_module_api_token("rotating-module").expect("old token"); diff --git a/src-tauri/src/domain/integration_api/types.rs b/src-tauri/src/domain/integration_api/types.rs index 0f2c3fe3..cfc42a6c 100644 --- a/src-tauri/src/domain/integration_api/types.rs +++ b/src-tauri/src/domain/integration_api/types.rs @@ -4,6 +4,7 @@ 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; @@ -135,6 +136,17 @@ pub(super) struct AgentLauncherStateResponse { 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 { From 4940440dcd4e7bfb48df74c77e278a0775af1d9f Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 08:28:53 +0300 Subject: [PATCH 24/54] fix: address provider and validation review issues --- src-tauri/resources/locales/en.json | 1 + src-tauri/resources/locales/ru.json | 1 + src-tauri/resources/locales/zh.json | 1 + src-tauri/src/domain/ai/ai_validation.rs | 17 +++++-- .../src/infrastructure/logging/logger.rs | 7 +-- .../services/AIBridgeProviderPolicy.test.ts | 1 + .../ai/services/AIBridgeProviderPolicy.ts | 3 +- .../ai/services/AIChatTransport.test.ts | 35 +++++++++++++ src/features/ai/services/AIChatTransport.ts | 49 ++++++++++++++++++- .../ai/services/AIProviderManager.test.ts | 33 +++++++------ src/features/ai/services/AIProviderManager.ts | 6 ++- .../ai/ui/AISettingsContentRenderer.ts | 6 ++- src/features/ai/ui/AISettingsRenderer.test.ts | 3 ++ 13 files changed, 137 insertions(+), 26 deletions(-) diff --git a/src-tauri/resources/locales/en.json b/src-tauri/resources/locales/en.json index 15525bd3..728274a0 100644 --- a/src-tauri/resources/locales/en.json +++ b/src-tauri/resources/locales/en.json @@ -287,6 +287,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 5a013552..315ab3b2 100644 --- a/src-tauri/resources/locales/ru.json +++ b/src-tauri/resources/locales/ru.json @@ -288,6 +288,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 97970687..9042d18b 100644 --- a/src-tauri/resources/locales/zh.json +++ b/src-tauri/resources/locales/zh.json @@ -284,6 +284,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/domain/ai/ai_validation.rs b/src-tauri/src/domain/ai/ai_validation.rs index 54804316..8c0bd6a1 100644 --- a/src-tauri/src/domain/ai/ai_validation.rs +++ b/src-tauri/src/domain/ai/ai_validation.rs @@ -61,7 +61,10 @@ fn validate_openai_compatible_base_url(base_url: &str) -> Result<(), crate::erro )); } - if let Ok(ip) = normalized_host.parse::() + 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( @@ -72,7 +75,7 @@ fn validate_openai_compatible_base_url(base_url: &str) -> Result<(), crate::erro Ok(()) } -const fn is_restricted_ip(ip: IpAddr) -> bool { +fn is_restricted_ip(ip: IpAddr) -> bool { match ip { IpAddr::V4(ip) => is_restricted_ipv4(ip), IpAddr::V6(ip) => is_restricted_ipv6(ip), @@ -87,8 +90,12 @@ const fn is_restricted_ipv4(ip: Ipv4Addr) -> bool { || ip.is_unspecified() } -const fn is_restricted_ipv6(ip: Ipv6Addr) -> bool { - ip.is_loopback() || ip.is_unspecified() || ip.is_unique_local() || ip.is_unicast_link_local() +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). @@ -258,6 +265,8 @@ mod tests { "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", diff --git a/src-tauri/src/infrastructure/logging/logger.rs b/src-tauri/src/infrastructure/logging/logger.rs index b68e3fcc..6f08aca6 100644 --- a/src-tauri/src/infrastructure/logging/logger.rs +++ b/src-tauri/src/infrastructure/logging/logger.rs @@ -550,14 +550,15 @@ fn infer_runtime_log_source(namespace: RuntimeLogNamespace, runtime_id: &str) -> } } +#[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| { + .map(|timestamp| { let timestamp = chrono::Utc.from_utc_datetime(×tamp); - let seconds = u32::try_from(timestamp.timestamp()).ok()?; - Some(f64::from(seconds) + f64::from(timestamp.timestamp_subsec_millis()) / 1000.0) + let seconds = timestamp.timestamp(); + seconds as f64 + f64::from(timestamp.timestamp_subsec_millis()) / 1000.0 }) } diff --git a/src/features/ai/services/AIBridgeProviderPolicy.test.ts b/src/features/ai/services/AIBridgeProviderPolicy.test.ts index b50e5279..d23c50c3 100644 --- a/src/features/ai/services/AIBridgeProviderPolicy.test.ts +++ b/src/features/ai/services/AIBridgeProviderPolicy.test.ts @@ -50,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(true); expect(policy.isImageProvider('comfyui')).toBe(true); expect(policy.isImageProvider('seedream-image')).toBe(true); expect(policy.isImageProvider(CUSTOM_IMAGE_PROVIDER_ID)).toBe(true); diff --git a/src/features/ai/services/AIBridgeProviderPolicy.ts b/src/features/ai/services/AIBridgeProviderPolicy.ts index 900a79cc..cfab419b 100644 --- a/src/features/ai/services/AIBridgeProviderPolicy.ts +++ b/src/features/ai/services/AIBridgeProviderPolicy.ts @@ -22,7 +22,8 @@ export class AIBridgeProviderPolicy { public constructor(private readonly _getCatalog?: ProviderCatalogGetter) {} public isCloudProvider(providerId: string): boolean { - return this._catalogProvider(providerId)?.providerPolicy?.isCloudProvider ?? false; + const policy = this._catalogProvider(providerId)?.providerPolicy; + return policy?.isCloudProvider ?? true; } public isImageProvider(providerId: string): boolean { diff --git a/src/features/ai/services/AIChatTransport.test.ts b/src/features/ai/services/AIChatTransport.test.ts index 4d87a40b..7c234150 100644 --- a/src/features/ai/services/AIChatTransport.test.ts +++ b/src/features/ai/services/AIChatTransport.test.ts @@ -206,6 +206,41 @@ 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 extract message from plain error objects', async () => { mockCore.tauriProvider.invoke.mockRejectedValue({ message: 'ipc object failed' }); diff --git a/src/features/ai/services/AIChatTransport.ts b/src/features/ai/services/AIChatTransport.ts index 2cf70305..18dd9560 100644 --- a/src/features/ai/services/AIChatTransport.ts +++ b/src/features/ai/services/AIChatTransport.ts @@ -433,7 +433,54 @@ export class AIChatTransport implements IChatTransport { private _chatRequestTimeoutMs(request: IChatRequest): number { const baseUrl = request.cloud_api_base_url?.trim() ?? ''; - return baseUrl.length > 0 ? CLOUD_CHAT_REQUEST_TIMEOUT_MS : LOCAL_CHAT_REQUEST_TIMEOUT_MS; + 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 { + if ( + hostname === 'localhost' || + hostname === '::1' || + hostname === '0.0.0.0' || + hostname.endsWith('.local') || + hostname.startsWith('127.') + ) { + return true; + } + + if (hostname.startsWith('10.') || hostname.startsWith('192.168.')) { + return true; + } + + const match = /^172\.(\d+)\./u.exec(hostname); + 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 dbadc40c..553aff63 100644 --- a/src/features/ai/services/AIProviderManager.test.ts +++ b/src/features/ai/services/AIProviderManager.test.ts @@ -38,6 +38,16 @@ const customTextProviderPolicy = { 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), @@ -65,6 +75,8 @@ function createMockCore( 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', @@ -436,14 +448,10 @@ describe('AIProviderManager', () => { expect(manager.model).toBe('catalog-best-model'); }); - it('should return null from _getPersistedModel when core is not set (L143 true branch)', async () => { - // No setContext() called: _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 () => { @@ -457,7 +465,7 @@ describe('AIProviderManager', () => { expect(manager.model).toBe('default'); }); - it('should treat providers without backend policy as local fallback', 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(''); @@ -465,12 +473,9 @@ describe('AIProviderManager', () => { const result = await manager.startProvider('gemini'); - expect(result).toBe(true); - expect(manager.model).toBe('default'); - expect(mockCore.aiSettings.setSelectedAIModel).toHaveBeenCalledWith( - 'gemini', - 'default', - ); + expect(result).toBe(false); + expect(manager.model).toBe(''); + expect(mockCore.aiSettings.setSelectedAIModel).not.toHaveBeenCalled(); }); it('should reflect model changes from settings without restarting the provider', async () => { diff --git a/src/features/ai/services/AIProviderManager.ts b/src/features/ai/services/AIProviderManager.ts index a0108a8d..7d064dbd 100644 --- a/src/features/ai/services/AIProviderManager.ts +++ b/src/features/ai/services/AIProviderManager.ts @@ -192,7 +192,6 @@ 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 { const policy = this._getCatalogProvider(providerId)?.providerPolicy; @@ -200,7 +199,10 @@ export class AIProviderManager { return !policy.isCloudProvider; } - return true; + this._tracer.error( + `[AIProviderManager] Missing provider policy for "${providerId}", denying startup`, + ); + return false; } private _getPersistedModel(providerId: string): string | null { diff --git a/src/features/ai/ui/AISettingsContentRenderer.ts b/src/features/ai/ui/AISettingsContentRenderer.ts index 480ffdf5..b8cf5e1e 100644 --- a/src/features/ai/ui/AISettingsContentRenderer.ts +++ b/src/features/ai/ui/AISettingsContentRenderer.ts @@ -138,10 +138,14 @@ export class AISettingsContentRenderer { '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} `; diff --git a/src/features/ai/ui/AISettingsRenderer.test.ts b/src/features/ai/ui/AISettingsRenderer.test.ts index 75d16eda..8aa361b3 100644 --- a/src/features/ai/ui/AISettingsRenderer.test.ts +++ b/src/features/ai/ui/AISettingsRenderer.test.ts @@ -236,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'; From ee46faf2e1dfaa4dbf92725b5baf18538b8234e6 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 08:46:44 +0300 Subject: [PATCH 25/54] feat: allow explicit local agent API token --- docs/localization/en/INTEGRATION_API.md | 5 +++++ src-tauri/src/domain/integration_api/auth.rs | 18 +++++++++++++++++- src-tauri/src/domain/integration_api/tests.rs | 10 ++++++++++ src-tauri/src/infrastructure/logging/logger.rs | 18 +++++++++++------- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/docs/localization/en/INTEGRATION_API.md b/docs/localization/en/INTEGRATION_API.md index 07b1453d..98383b55 100644 --- a/docs/localization/en/INTEGRATION_API.md +++ b/docs/localization/en/INTEGRATION_API.md @@ -43,6 +43,11 @@ External agents should follow the same rule for now. They should not scrape the desktop UI or read Axelate data files directly. The supported path is a launcher-issued 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. + Script integrations declare their runtime in `axelate-module.toml`. ```toml diff --git a/src-tauri/src/domain/integration_api/auth.rs b/src-tauri/src/domain/integration_api/auth.rs index f0f71a5e..146aa2c7 100644 --- a/src-tauri/src/domain/integration_api/auth.rs +++ b/src-tauri/src/domain/integration_api/auth.rs @@ -70,7 +70,7 @@ fn authorized_bearer_client(value: &str) -> Option { } 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 +86,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/tests.rs b/src-tauri/src/domain/integration_api/tests.rs index 6e3cf1c3..8c6a38dd 100644 --- a/src-tauri/src/domain/integration_api/tests.rs +++ b/src-tauri/src/domain/integration_api/tests.rs @@ -92,6 +92,16 @@ fn authorization_accepts_bearer_token() { assert!(auth::is_authorized(&headers)); } +#[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] fn authorization_rejects_old_header_token() { let mut headers = HashMap::new(); diff --git a/src-tauri/src/infrastructure/logging/logger.rs b/src-tauri/src/infrastructure/logging/logger.rs index 6f08aca6..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,13 +538,16 @@ 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}"), + ), } } @@ -600,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}"); } @@ -850,7 +854,7 @@ mod tests { 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, From 1c29d2d919db41b149cdf4da021942ef4db48912 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 08:54:23 +0300 Subject: [PATCH 26/54] fix: refresh selected module runtime marker --- src/shared/shell/AppUI.test.ts | 54 +++++++++++++++++++ src/shared/shell/AppUI.ts | 22 ++++++++ src/shared/shell/ui/AppUiDashboardSupport.ts | 1 + .../shell/ui/card/ModuleCardRenderer.test.ts | 19 +++++++ .../shell/ui/card/ModuleCardRenderer.ts | 18 ++++++- 5 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/shared/shell/AppUI.test.ts b/src/shared/shell/AppUI.test.ts index 89ed2a3b..da8bd517 100644 --- a/src/shared/shell/AppUI.test.ts +++ b/src/shared/shell/AppUI.test.ts @@ -743,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 da318f86..b02f137e 100644 --- a/src/shared/shell/AppUI.ts +++ b/src/shared/shell/AppUI.ts @@ -359,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'); + } + } } /** 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 { From 3efbbf67fcf2b82e3f5ad7e8158a644d2dfce797 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 09:05:48 +0300 Subject: [PATCH 27/54] fix: sync agent-started module selection --- .../src/domain/integration_api/routing.rs | 70 ++++++++++++++++++- src-tauri/src/domain/integration_api/tests.rs | 59 +++++++++++++++- src/app/CoreLifecycleController.test.ts | 32 +++++++++ 3 files changed, 158 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/domain/integration_api/routing.rs b/src-tauri/src/domain/integration_api/routing.rs index 81889e77..86af6bbd 100644 --- a/src-tauri/src/domain/integration_api/routing.rs +++ b/src-tauri/src/domain/integration_api/routing.rs @@ -7,7 +7,9 @@ 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::models::{ + AiModel, ApiProvider, ModelTier, Module, ModuleItem, ProviderType, SelectedModule, +}; use serde_json::json; use std::collections::HashMap; use std::net::SocketAddr; @@ -123,7 +125,11 @@ async fn route_authorized_request( ensure_module_route_owner(client, module_id)?; 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?; + if response.success && matches!(action, ModuleAction::Start | ModuleAction::Restart) { + sync_launcher_selected_module(&context, client, module_id).await?; + } Ok(json_response( 200, json!({ "ok": response.success, "response": response }), @@ -677,6 +683,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", @@ -694,6 +712,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, @@ -702,6 +728,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::Launcher) { + 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, diff --git a/src-tauri/src/domain/integration_api/tests.rs b/src-tauri/src/domain/integration_api/tests.rs index 8c6a38dd..a94cbbe8 100644 --- a/src-tauri/src/domain/integration_api/tests.rs +++ b/src-tauri/src/domain/integration_api/tests.rs @@ -9,7 +9,8 @@ use super::routing::{ agent_provider_summary, backend_provider_id, ensure_launcher_client, ensure_module_route_owner, merge_json_settings, model_api_id, modules_visible_to_client, parse_agent_logs_query, parse_module_action, resolve_session_id, selected_module_from_api_provider, - selected_module_from_catalog_item, tier_rank, + selected_module_from_catalog_item, selected_module_from_runtime_module, + selection_category_for_runtime_module, tier_rank, }; use super::types::{AuthorizedClient, IntegrationTextRequest, ModuleContextApiResponse}; use crate::domain::modules::controller::ModuleAction; @@ -555,6 +556,62 @@ 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 agent_provider_summary_does_not_expose_secret_or_endpoint_fields() { let provider = crate::models::ApiProvider { diff --git a/src/app/CoreLifecycleController.test.ts b/src/app/CoreLifecycleController.test.ts index 5b0cd611..73e57de2 100644 --- a/src/app/CoreLifecycleController.test.ts +++ b/src/app/CoreLifecycleController.test.ts @@ -133,4 +133,36 @@ 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', + }); + }); }); From baf94b5d6b9925a62883d714c93fa52c5a5ed894 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 10:26:09 +0300 Subject: [PATCH 28/54] feat: add trusted local agent control api --- src-tauri/src/api/agent_control.rs | 94 +++ src-tauri/src/api/mod.rs | 2 + src-tauri/src/domain/agent_control.rs | 548 ++++++++++++++++++ src-tauri/src/domain/integration_api/auth.rs | 24 +- src-tauri/src/domain/integration_api/mod.rs | 5 + .../src/domain/integration_api/routing.rs | 359 +++++++++++- src-tauri/src/domain/integration_api/types.rs | 49 ++ src-tauri/src/domain/mod.rs | 2 + src-tauri/src/lib.rs | 14 +- src-tauri/src/utils/paths.rs | 4 + src/app/CoreLifecycleController.ts | 46 ++ src/shared/types/bindings.ts | 116 ++++ 12 files changed, 1248 insertions(+), 15 deletions(-) create mode 100644 src-tauri/src/api/agent_control.rs create mode 100644 src-tauri/src/domain/agent_control.rs diff --git a/src-tauri/src/api/agent_control.rs b/src-tauri/src/api/agent_control.rs new file mode 100644 index 00000000..1962911a --- /dev/null +++ b/src-tauri/src/api/agent_control.rs @@ -0,0 +1,94 @@ +//! Tauri commands for trusted local Agent Control. + +use crate::domain::agent_control::{ + AgentApprovalRequest, AgentControlService, AgentControlState, AgentProfileTokenResponse, + AgentScope, +}; +use crate::errors::AppError; + +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 +} + +/// Revokes a trusted local agent profile. +#[tauri::command] +#[specta::specta] +pub async fn revoke_agent_profile( + service: tauri::State<'_, AgentControlService>, + id: String, +) -> Result { + service.revoke_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 +} + +/// Creates a pending approval request from the UI for tests and manual flows. +#[tauri::command] +#[specta::specta] +pub async fn create_agent_approval_request( + service: tauri::State<'_, AgentControlService>, + agent_id: String, + agent_name: String, + action: String, + target: String, + diff: String, + risk: String, +) -> Result { + let agent = crate::domain::agent_control::AuthorizedAgent { + id: agent_id, + name: agent_name, + scopes: crate::domain::agent_control::trusted_local_scopes(), + }; + service + .create_approval_request(&agent, action, target, diff, risk) + .await +} 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/domain/agent_control.rs b/src-tauri/src/domain/agent_control.rs new file mode 100644 index 00000000..f9b86ad0 --- /dev/null +++ b/src-tauri/src/domain/agent_control.rs @@ -0,0 +1,548 @@ +//! Trusted local agent profiles, scopes, audit entries, and approval requests. + +use crate::errors::AppError; +use crate::infrastructure::persistence::json_store::JsonStore; +use crate::utils::paths::FILE_AGENT_CONTROL; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use specta::Type; +use std::sync::Arc; + +const TOKEN_PREFIX_LEN: usize = 18; +const MAX_AUDIT_ENTRIES: usize = 200; +const MAX_APPROVAL_REQUESTS: usize = 100; + +/// Agent capability scope. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Type)] +#[serde(rename_all = "kebab-case")] +pub enum 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. + DraftCreate, +} + +/// Public trusted local agent profile metadata. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentProfile { + /// Stable profile id. + pub id: String, + /// User-facing agent name. + pub name: String, + /// Granted capability scopes. + pub scopes: Vec, + /// 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 response. The token is shown only once. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentProfileTokenResponse { + /// Public profile metadata. + pub profile: AgentProfile, + /// One-time bearer token. Store it in the calling agent, not in frontend state. + pub token: String, +} + +/// 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>, +} + +impl AgentControlService { + /// Creates a new service. + pub fn new(json_store: JsonStore) -> Self { + Self { + json_store, + lock: Arc::new(tokio::sync::Mutex::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?; + + Ok(AgentProfileTokenResponse { + profile: public_profile, + token, + }) + } + + /// 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"))); + }; + profile.token_hash = hash_token(&token); + profile.token_prefix = token_prefix(&token); + profile.revoked = false; + profile.last_seen_at = None; + let public_profile = public_profile(profile); + self.save_store_locked(&store).await?; + + Ok(AgentProfileTokenResponse { + profile: public_profile, + token, + }) + } + + /// 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.save_store_locked(&store).await?; + Ok(public_state(store, api_base_url)) + } + + /// 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"))); + }; + 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 + } +} + +/// 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(); + } + scopes +} + +const fn scope_rank(scope: AgentScope) -> u8 { + match scope { + AgentScope::Observe => 0, + AgentScope::Operate => 1, + AgentScope::Configure => 2, + AgentScope::DraftCreate => 3, + } +} + +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::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!(response.token.starts_with("axl_agent_")); + assert_eq!(response.profile.name, "Codex"); + assert!(response.profile.scopes.contains(&AgentScope::Observe)); + + let authorized = service + .authorize_token(&response.token) + .await + .expect("authorized"); + assert_eq!(authorized.name, "Codex"); + } + + #[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.authorize_token(&response.token).await.is_none()); + } + + #[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 agent = service + .authorize_token(&response.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) + ); + } +} diff --git a/src-tauri/src/domain/integration_api/auth.rs b/src-tauri/src/domain/integration_api/auth.rs index 146aa2c7..ccd46947 100644 --- a/src-tauri/src/domain/integration_api/auth.rs +++ b/src-tauri/src/domain/integration_api/auth.rs @@ -58,7 +58,29 @@ pub(super) fn authorize_request(headers: &HashMap) -> Option, + 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,7 +88,7 @@ fn authorized_bearer_client(value: &str) -> Option { return None; } - authorized_token_client(token) + Some(token) } fn authorized_token_client(token: &str) -> Option { diff --git a/src-tauri/src/domain/integration_api/mod.rs b/src-tauri/src/domain/integration_api/mod.rs index cd1394a7..14c9b079 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, } } } diff --git a/src-tauri/src/domain/integration_api/routing.rs b/src-tauri/src/domain/integration_api/routing.rs index 86af6bbd..f5a16699 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, @@ -15,13 +16,14 @@ use std::collections::HashMap; use std::net::SocketAddr; 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::{ AgentLauncherStateResponse, AgentLogsResponse, AgentModelSummary, AgentModuleSummary, - AgentProviderSummary, AuthorizedClient, HttpRequest, HttpResponse, ImageApiResponse, - IntegrationImageRequest, IntegrationModuleStageRequest, IntegrationTextRequest, - ModuleContextApiResponse, ModuleStageChangedEvent, TextApiResponse, + AgentOpenPageEvent, AgentProviderSummary, AuthorizedClient, HttpRequest, HttpResponse, + ImageApiResponse, IntegrationAgentApprovalRequest, IntegrationImageRequest, + IntegrationModuleStageRequest, IntegrationOpenPageRequest, IntegrationSelectModuleRequest, + IntegrationTextRequest, ModuleContextApiResponse, ModuleStageChangedEvent, TextApiResponse, }; use super::{LauncherHttpApiContext, SDK_API_VERSION, api_base_url}; @@ -53,7 +55,10 @@ 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"); }; @@ -78,13 +83,57 @@ async fn route_authorized_request( 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", "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).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)?; + 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)?; + 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 }), + )) + } ("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( @@ -94,6 +143,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( @@ -103,30 +153,68 @@ 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).await?; + 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.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) { sync_launcher_selected_module(&context, client, module_id).await?; } @@ -135,8 +223,14 @@ async fn route_authorized_request( 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")), } } @@ -165,6 +259,142 @@ fn handle_agent_logs_request(request: &HttpRequest) -> Result Result { + let state = context + .agent_control_service + .state(api_base_url().to_string()) + .await?; + Ok(json_response( + 200, + json!({ "ok": true, "approvals": state.approvals }), + )) +} + +async fn handle_agent_approval_request( + request: &HttpRequest, + context: &LauncherHttpApiContext, + agent: &crate::domain::agent_control::AuthorizedAgent, +) -> Result { + let payload: IntegrationAgentApprovalRequest = parse_json_body(request)?; + if payload.action.trim().is_empty() + || payload.target.trim().is_empty() + || payload.diff.trim().is_empty() + { + return Ok(json_error( + 400, + "Agent approval requests require action, target, and diff", + )); + } + + let approval = context + .agent_control_service + .create_approval_request( + agent, + payload.action.trim().to_string(), + payload.target.trim().to_string(), + payload.diff.trim().to_string(), + payload.risk.trim().to_string(), + ) + .await?; + 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 }), + )) +} + +#[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?, + }; + 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, @@ -421,7 +651,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(), @@ -431,13 +661,36 @@ pub(super) fn ensure_module_route_owner( pub(super) fn ensure_launcher_client(client: &AuthorizedClient) -> Result<(), AppError> { match client { - AuthorizedClient::Launcher => Ok(()), + 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) => Ok(()), + AuthorizedClient::Agent(_) => Err(AppError::PermissionDenied(format!( + "Agent token is missing required scope: {scope:?}" + ))), + } +} + +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, @@ -489,6 +742,46 @@ pub(super) fn parse_module_action(action: &str) -> Result &'static str { + match action { + ModuleAction::Start => "start", + ModuleAction::Stop => "stop", + ModuleAction::Restart => "restart", + ModuleAction::Install => "install", + ModuleAction::Uninstall => "uninstall", + ModuleAction::Update => "update", + } +} + +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, @@ -671,6 +964,46 @@ 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}" + ))), + } +} + pub(super) fn selected_module_from_catalog_item(module: &ModuleItem) -> SelectedModule { SelectedModule { id: module.id.clone(), @@ -733,7 +1066,7 @@ async fn sync_launcher_selected_module( client: &AuthorizedClient, module_id: &str, ) -> Result<(), AppError> { - if !matches!(client, AuthorizedClient::Launcher) { + if matches!(client, AuthorizedClient::Module(_)) { return Ok(()); } @@ -797,7 +1130,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/types.rs b/src-tauri/src/domain/integration_api/types.rs index cfc42a6c..7e71cbd9 100644 --- a/src-tauri/src/domain/integration_api/types.rs +++ b/src-tauri/src/domain/integration_api/types.rs @@ -1,5 +1,6 @@ //! 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, }; @@ -43,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)] @@ -92,6 +112,28 @@ 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, +} + // ── Integration response DTOs ──────────────────────────────────────────────── #[derive(Debug, Serialize)] @@ -186,3 +228,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/lib.rs b/src-tauri/src/lib.rs index 3baa1353..83930c91 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,6 +98,13 @@ 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::revoke_agent_profile, + agent_control::decide_agent_approval, + agent_control::create_agent_approval_request, config::get_config, config::get_catalog_snapshot, settings::get_settings, @@ -250,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(), @@ -265,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()); @@ -312,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/utils/paths.rs b/src-tauri/src/utils/paths.rs index e9b44b92..2eadf167 100644 --- a/src-tauri/src/utils/paths.rs +++ b/src-tauri/src/utils/paths.rs @@ -184,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")); diff --git a/src/app/CoreLifecycleController.ts b/src/app/CoreLifecycleController.ts index f997aaf0..dc4224b3 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,31 @@ 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); + }, + ); + if (this._deps.state.isDestroyed()) { + unlisten(); + return; + } + this._agentOpenPageUnlisten = unlisten; + } + + private async _applyAgentOpenPageRequest(payload: AgentOpenPagePayload): Promise { + const pageId = payload.pageId.trim(); + if (pageId === '') { + return; + } + await this._deps.bootstrap.navigationUI.showPage(pageId, null, false, false); + } } diff --git a/src/shared/types/bindings.ts b/src/shared/types/bindings.ts index 519d3c74..a6a3ba13 100644 --- a/src/shared/types/bindings.ts +++ b/src/shared/types/bindings.ts @@ -11,6 +11,20 @@ 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 })), + /** Revokes a trusted local agent profile. */ + revokeAgentProfile: (id: string) => typedError(__TAURI_INVOKE("revoke_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 })), + /** Creates a pending approval request from the UI for tests and manual flows. */ + createAgentApprovalRequest: (agentId: string, agentName: string, action: string, target: string, diff: string, risk: string) => typedError(__TAURI_INVOKE("create_agent_approval_request", { agentId, agentName, action, target, diff, risk })), /** 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. */ @@ -234,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 response. The token is shown only once. */ +export type AgentProfileTokenResponse = { + /** Public profile metadata. */ + profile: AgentProfile, + /** One-time bearer token. Store it in the calling agent, not in frontend state. */ + token: string, +}; + +/** 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"; + /** Complete AI model definition */ export type AiModel = { /** Model ID (moved from dict key) */ From 8b462ac42a4fb5d298ddcc3df2740c6d641ee377 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 10:28:00 +0300 Subject: [PATCH 29/54] feat: add agent control settings panel --- src-tauri/resources/locales/en.json | 41 ++ src-tauri/resources/locales/ru.json | 41 ++ src-tauri/resources/locales/zh.json | 41 ++ src/features/settings/index.ts | 1 + .../settings/services/SettingsService.test.ts | 107 +++++ .../settings/services/SettingsService.ts | 65 ++- .../ui/AgentControlSettingsRenderer.test.ts | 134 ++++++ .../ui/AgentControlSettingsRenderer.ts | 441 ++++++++++++++++++ .../settings/ui/SettingsPageUI.test.ts | 16 + src/features/settings/ui/SettingsUI.ts | 11 +- src/public/templates/pages/settings.html | 10 + src/styles/features/settings-page.css | 205 ++++++++ 12 files changed, 1110 insertions(+), 3 deletions(-) create mode 100644 src/features/settings/ui/AgentControlSettingsRenderer.test.ts create mode 100644 src/features/settings/ui/AgentControlSettingsRenderer.ts diff --git a/src-tauri/resources/locales/en.json b/src-tauri/resources/locales/en.json index 728274a0..1790c193 100644 --- a/src-tauri/resources/locales/en.json +++ b/src-tauri/resources/locales/en.json @@ -224,6 +224,47 @@ "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.agent_control_title": "Agent Control", + "ui.launcher.settings.agent_control_desc": "Trusted local automation", + "ui.launcher.settings.agent_control_loading": "Loading...", + "ui.launcher.settings.agent_control_load_failed": "Failed to load Agent Control", + "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_connection": "Connection", + "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": "Create Trusted Local", + "ui.launcher.settings.agent_control_trusted_local": "Trusted Local", + "ui.launcher.settings.agent_control_profile_created": "Profile created", + "ui.launcher.settings.agent_control_token_once": "Token shown once", + "ui.launcher.settings.agent_control_copy_token": "Copy token", + "ui.launcher.settings.agent_control_profiles": "Agents", + "ui.launcher.settings.agent_control_no_profiles": "No agents yet", + "ui.launcher.settings.agent_control_revoked": "Revoked", + "ui.launcher.settings.agent_control_active": "Active", + "ui.launcher.settings.agent_control_rotate": "Rotate", + "ui.launcher.settings.agent_control_revoke": "Revoke", + "ui.launcher.settings.agent_control_token_rotated": "Token rotated", + "ui.launcher.settings.agent_control_profile_revoked": "Profile revoked", + "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_audit": "Action log", + "ui.launcher.settings.agent_control_no_audit": "No actions yet", + "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_scope_observe": "observe", + "ui.launcher.settings.agent_control_scope_operate": "operate", + "ui.launcher.settings.agent_control_scope_configure": "configure", + "ui.launcher.settings.agent_control_scope_draft-create": "draft-create", "ui.launcher.web.app_title": "Axelate", "ui.launcher.web.chat": "Chat", "ui.launcher.web.chat_clear": "Clear chat", diff --git a/src-tauri/resources/locales/ru.json b/src-tauri/resources/locales/ru.json index 315ab3b2..5707b528 100644 --- a/src-tauri/resources/locales/ru.json +++ b/src-tauri/resources/locales/ru.json @@ -225,6 +225,47 @@ "ui.launcher.settings.monitor_vram": "VRAM", "ui.launcher.settings.taskbar_desc": "Настройка видимости страниц в боковой панели", "ui.launcher.settings.taskbar_title": "Управление боковой панелью", + "ui.launcher.settings.agent_control_title": "Управление агентом", + "ui.launcher.settings.agent_control_desc": "Доверенная локальная автоматизация", + "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_connection": "Подключение", + "ui.launcher.settings.agent_control_copy_base": "Копировать URL", + "ui.launcher.settings.agent_control_copy_config": "Копировать конфиг", + "ui.launcher.settings.agent_control_create_profile": "Создать Trusted Local", + "ui.launcher.settings.agent_control_trusted_local": "Trusted Local", + "ui.launcher.settings.agent_control_profile_created": "Профиль создан", + "ui.launcher.settings.agent_control_token_once": "Токен показан один раз", + "ui.launcher.settings.agent_control_copy_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_revoke": "Отозвать", + "ui.launcher.settings.agent_control_token_rotated": "Токен обновлен", + "ui.launcher.settings.agent_control_profile_revoked": "Профиль отозван", + "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_audit": "Журнал действий", + "ui.launcher.settings.agent_control_no_audit": "Действий пока нет", + "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_scope_observe": "просмотр", + "ui.launcher.settings.agent_control_scope_operate": "управление", + "ui.launcher.settings.agent_control_scope_configure": "настройки", + "ui.launcher.settings.agent_control_scope_draft-create": "черновики", "ui.launcher.web.app_title": "Axelate", "ui.launcher.web.chat": "Чат", "ui.launcher.web.chat_clear": "Очистить чат", diff --git a/src-tauri/resources/locales/zh.json b/src-tauri/resources/locales/zh.json index 9042d18b..4e7d80ce 100644 --- a/src-tauri/resources/locales/zh.json +++ b/src-tauri/resources/locales/zh.json @@ -221,6 +221,47 @@ "ui.launcher.settings.monitor_vram": "显存", "ui.launcher.settings.taskbar_desc": "配置侧边栏页面可见性", "ui.launcher.settings.taskbar_title": "侧边栏管理", + "ui.launcher.settings.agent_control_title": "代理控制", + "ui.launcher.settings.agent_control_desc": "受信任的本地自动化", + "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_connection": "连接", + "ui.launcher.settings.agent_control_copy_base": "复制 URL", + "ui.launcher.settings.agent_control_copy_config": "复制配置", + "ui.launcher.settings.agent_control_create_profile": "创建 Trusted Local", + "ui.launcher.settings.agent_control_trusted_local": "Trusted Local", + "ui.launcher.settings.agent_control_profile_created": "配置已创建", + "ui.launcher.settings.agent_control_token_once": "令牌仅显示一次", + "ui.launcher.settings.agent_control_copy_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_revoke": "撤销", + "ui.launcher.settings.agent_control_token_rotated": "令牌已轮换", + "ui.launcher.settings.agent_control_profile_revoked": "配置已撤销", + "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_audit": "操作日志", + "ui.launcher.settings.agent_control_no_audit": "暂无操作", + "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_scope_observe": "观察", + "ui.launcher.settings.agent_control_scope_operate": "操作", + "ui.launcher.settings.agent_control_scope_configure": "配置", + "ui.launcher.settings.agent_control_scope_draft-create": "草稿", "ui.launcher.web.app_title": "Axelate", "ui.launcher.web.chat": "聊天", "ui.launcher.web.chat_clear": "清除聊天", 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 645d00ed..aafdb033 100644 --- a/src/features/settings/services/SettingsService.test.ts +++ b/src/features/settings/services/SettingsService.test.ts @@ -8,6 +8,12 @@ const mocks = vi.hoisted(() => ({ invokeSafe: vi.fn(), commands: { controlModule: vi.fn(), + getAgentControlState: vi.fn(), + setAgentControlEnabled: vi.fn(), + createAgentProfile: vi.fn(), + rotateAgentProfile: vi.fn(), + revokeAgentProfile: vi.fn(), + decideAgentApproval: vi.fn(), }, })); @@ -22,6 +28,12 @@ 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, + revokeAgentProfile: mocks.commands.revokeAgentProfile, + decideAgentApproval: mocks.commands.decideAgentApproval, }, }; }); @@ -56,6 +68,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 +192,89 @@ 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, + }, + token: 'axl_agent_123secret', + }, + }), + ); + + const result = await service.createAgentProfile('Trusted Local', [ + 'observe', + 'operate', + ]); + + expect(result.token).toBe('axl_agent_123secret'); + expect(mocks.commands.createAgentProfile).toHaveBeenCalledWith('Trusted Local', [ + 'observe', + 'operate', + ]); + }); + + it('should rotate, revoke, 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.revokeAgentProfile.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, + }, + token: 'axl_agent_456secret', + }, + }), + ); + + await service.setAgentControlEnabled(true); + await service.rotateAgentProfile('agent-1'); + await service.revokeAgentProfile('agent-1'); + await service.decideAgentApproval('approval-1', false); + + expect(mocks.commands.setAgentControlEnabled).toHaveBeenCalledWith(true); + expect(mocks.commands.rotateAgentProfile).toHaveBeenCalledWith('agent-1'); + expect(mocks.commands.revokeAgentProfile).toHaveBeenCalledWith('agent-1'); + expect(mocks.commands.decideAgentApproval).toHaveBeenCalledWith('approval-1', false); + }); + }); + describe('loadGpuInfo', () => { it('should return GPU info from backend', async () => { const gpuInfo = { diff --git a/src/features/settings/services/SettingsService.ts b/src/features/settings/services/SettingsService.ts index 354f643d..6897e84d 100644 --- a/src/features/settings/services/SettingsService.ts +++ b/src/features/settings/services/SettingsService.ts @@ -2,7 +2,13 @@ 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'; export type ISettings = AppSettings; @@ -98,6 +104,63 @@ 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(result.error.message); + } + + 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(result.error.message); + } + + 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(result.error.message); + } + + 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(result.error.message); + } + + public async revokeAgentProfile(id: string): Promise { + const result = await invokeSafe(commands.revokeAgentProfile(id)); + if (result.status === 'ok') { + return result.data; + } + this._tracer.error('[SettingsService] Failed to revoke Agent profile:', result.error); + throw new Error(result.error.message); + } + + 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(result.error.message); + } + public async loadGpuInfo(): Promise { if (this._gpuInfoPromise !== null) { return await this._gpuInfoPromise; diff --git a/src/features/settings/ui/AgentControlSettingsRenderer.test.ts b/src/features/settings/ui/AgentControlSettingsRenderer.test.ts new file mode 100644 index 00000000..f2603186 --- /dev/null +++ b/src/features/settings/ui/AgentControlSettingsRenderer.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AgentControlSettingsRenderer } from './AgentControlSettingsRenderer'; +import type { SettingsService } from '../services/SettingsService'; +import type { AgentControlState } 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, + }; +} + +describe('AgentControlSettingsRenderer', () => { + let service: Pick< + SettingsService, + | 'getAgentControlState' + | 'setAgentControlEnabled' + | 'createAgentProfile' + | 'rotateAgentProfile' + | 'revokeAgentProfile' + | 'decideAgentApproval' + >; + let copyText: (text: string) => Promise; + let context: IAppSettingsUIContext; + + beforeEach(() => { + document.body.innerHTML = '
'; + const copyTextMock = vi.fn().mockResolvedValue(undefined); + copyText = async (text: string) => { + await copyTextMock(text); + }; + service = { + getAgentControlState: vi.fn().mockResolvedValue(state()), + setAgentControlEnabled: vi.fn().mockResolvedValue(state({ enabled: true })), + createAgentProfile: vi.fn().mockResolvedValue({ + profile: { + id: 'agent-1', + name: 'Trusted Local', + scopes: ['observe', 'operate', 'configure', 'draft-create'], + tokenPrefix: 'axl_agent_abc', + createdAt: '2026-05-22T00:00:00Z', + lastSeenAt: null, + revoked: false, + }, + token: 'axl_agent_abc_secret', + }), + rotateAgentProfile: vi.fn(), + revokeAgentProfile: vi.fn(), + decideAgentApproval: vi.fn(), + }; + context = { + t: (_key: string, fallback = '') => fallback, + showToast: vi.fn(), + toggleNavItem: vi.fn(), + toggleMonitorItem: vi.fn(), + i18nUI: { applyTranslations: vi.fn() }, + } as unknown as IAppSettingsUIContext; + }); + + it('creates a Trusted Local profile and renders the one-time token', async () => { + const renderer = new AgentControlSettingsRenderer( + service as SettingsService, + { error: vi.fn(), warn: vi.fn() } as unknown as LoggerService, + { copyText }, + ); + + renderer.init(context); + await vi.waitFor(() => { + expect(document.body.textContent).toContain('Create Trusted Local'); + }); + + const createButton = Array.from( + document.querySelectorAll('button'), + ).find((button) => button.textContent === 'Create Trusted Local'); + createButton?.click(); + + await vi.waitFor(() => { + expect(document.body.textContent).toContain('axl_agent_abc_secret'); + }); + expect(service.createAgentProfile).toHaveBeenCalledWith('Trusted Local', [ + 'observe', + 'operate', + 'configure', + '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, + { copyText }, + ); + + 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); + }); + }); +}); diff --git a/src/features/settings/ui/AgentControlSettingsRenderer.ts b/src/features/settings/ui/AgentControlSettingsRenderer.ts new file mode 100644 index 00000000..0b2a1af1 --- /dev/null +++ b/src/features/settings/ui/AgentControlSettingsRenderer.ts @@ -0,0 +1,441 @@ +import type { + AgentApprovalRequest, + AgentAuditEntry, + 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'; + +type AgentControlRuntime = { + copyText: (text: string) => Promise; +}; + +type OneTimeToken = { + profileId: string; + token: string; +}; + +const TRUSTED_LOCAL_SCOPES: AgentScope[] = ['observe', 'operate', 'configure', 'draft-create']; + +export class AgentControlSettingsRenderer { + private _context: IAppSettingsUIContext | null = null; + private _panel: HTMLElement | null = null; + private _state: AgentControlState | null = null; + private _oneTimeToken: OneTimeToken | null = null; + private _isDestroyed = false; + private _isBusy = false; + + public constructor( + private readonly _service: SettingsService, + private readonly _tracer: Pick, + private readonly _runtime: AgentControlRuntime, + ) {} + + 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 destroy(): void { + this._isDestroyed = true; + this._context = null; + this._panel = null; + this._state = null; + this._oneTimeToken = null; + } + + 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', 'Loading...')), + ); + } + + private _renderError(): void { + this._replacePanel( + this._element( + 'div', + 'agent-control-empty agent-control-empty--error', + this._t('load_failed', 'Failed to load Agent Control'), + ), + ); + } + + private _render(): void { + const state = this._state; + if (state === null) { + this._renderLoading(); + return; + } + + const root = this._element('div', 'agent-control'); + root.append( + this._renderHeader(state), + this._renderConnection(state), + this._renderProfiles(state.profiles), + this._renderApprovals(state.approvals), + this._renderAudit(state.audit), + ); + this._replacePanel(root); + } + + private _renderHeader(state: AgentControlState): HTMLElement { + const header = this._element('div', 'agent-control-header'); + const status = this._element( + 'span', + `agent-control-status ${state.enabled ? 'is-on' : 'is-off'}`, + state.enabled ? this._t('enabled', 'Enabled') : this._t('disabled', 'Disabled'), + ); + const toggle = this._button( + state.enabled ? this._t('disable', 'Disable') : this._t('enable', 'Enable'), + 'agent-control-btn', + () => { + void this._run(async () => { + this._state = await this._service.setAgentControlEnabled(!state.enabled); + this._toast( + state.enabled + ? this._t('disabled', 'Disabled') + : this._t('enabled', 'Enabled'), + 'success', + ); + this._render(); + }); + }, + ); + header.append(status, toggle); + return header; + } + + private _renderConnection(state: AgentControlState): HTMLElement { + const section = this._section(this._t('connection', 'Connection')); + const base = this._element('div', 'agent-control-code', state.apiBaseUrl); + const copyBase = this._button(this._t('copy_base', 'Copy URL'), 'agent-control-btn', () => { + void this._copy(state.apiBaseUrl); + }); + const copyConfig = this._button( + this._t('copy_config', 'Copy config'), + 'agent-control-btn agent-control-btn--primary', + () => { + void this._copy(this._configText(state)); + }, + ); + const create = this._button( + this._t('create_profile', 'Create Trusted Local'), + 'agent-control-btn agent-control-btn--primary', + () => { + void this._run(async () => { + const response = await this._service.createAgentProfile( + this._t('trusted_local', 'Trusted Local'), + TRUSTED_LOCAL_SCOPES, + ); + this._oneTimeToken = { + profileId: response.profile.id, + token: response.token, + }; + this._state = await this._service.getAgentControlState(); + this._toast(this._t('profile_created', 'Profile created'), 'success'); + this._render(); + }); + }, + ); + const actions = this._element('div', 'agent-control-actions'); + actions.append(copyBase, copyConfig, create); + section.append(base, actions); + + if (this._oneTimeToken !== null) { + section.append(this._renderOneTimeToken()); + } + + return section; + } + + private _renderOneTimeToken(): HTMLElement { + const token = this._oneTimeToken; + const box = this._element('div', 'agent-control-token'); + const label = this._element( + 'div', + 'agent-control-token-label', + this._t('token_once', 'Token shown once'), + ); + const value = this._element('div', 'agent-control-code', token?.token ?? ''); + const copy = this._button(this._t('copy_token', 'Copy token'), 'agent-control-btn', () => { + if (token !== null) { + void this._copy(token.token); + } + }); + box.append(label, value, copy); + return box; + } + + private _renderProfiles(profiles: AgentProfile[]): HTMLElement { + const section = this._section(this._t('profiles', 'Agents')); + if (profiles.length === 0) { + section.append( + this._element( + 'div', + 'agent-control-empty', + this._t('no_profiles', 'No agents yet'), + ), + ); + return section; + } + + const list = this._element('div', 'agent-control-list'); + profiles.forEach((profile) => { + const row = this._element('div', 'agent-control-row'); + const main = this._element('div', 'agent-control-row-main'); + main.append( + this._element('div', 'agent-control-row-title', profile.name), + this._element( + 'div', + 'agent-control-row-meta', + `${profile.revoked ? this._t('revoked', 'Revoked') : this._t('active', 'Active')} · ${profile.tokenPrefix} · ${this._lastSeen(profile.lastSeenAt)}`, + ), + this._renderScopes(profile.scopes), + ); + const actions = this._element('div', 'agent-control-row-actions'); + actions.append( + this._button(this._t('rotate', 'Rotate'), 'agent-control-btn', () => { + void this._run(async () => { + const response = await this._service.rotateAgentProfile(profile.id); + this._oneTimeToken = { + profileId: response.profile.id, + token: response.token, + }; + this._state = await this._service.getAgentControlState(); + this._toast(this._t('token_rotated', 'Token rotated'), 'success'); + this._render(); + }); + }), + this._button(this._t('revoke', 'Revoke'), 'agent-control-btn', () => { + void this._run(async () => { + this._state = await this._service.revokeAgentProfile(profile.id); + if (this._oneTimeToken?.profileId === profile.id) { + this._oneTimeToken = null; + } + this._toast(this._t('profile_revoked', 'Profile revoked'), 'success'); + this._render(); + }); + }), + ); + 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', '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', 'No pending 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'); + main.append( + this._element('div', 'agent-control-row-title', approval.action), + this._element( + 'div', + 'agent-control-row-meta', + `${approval.agentName} · ${approval.target} · ${approval.risk}`, + ), + this._element('div', 'agent-control-diff', approval.diff), + ); + const actions = this._element('div', 'agent-control-row-actions'); + actions.append( + this._button( + this._t('approve', 'Approve'), + 'agent-control-btn agent-control-btn--primary', + () => { + void this._decideApproval(approval.id, true); + }, + ), + this._button(this._t('deny', 'Deny'), 'agent-control-btn', () => { + void this._decideApproval(approval.id, false); + }), + ); + row.append(main, actions); + list.append(row); + }); + section.append(list); + return section; + } + + private _renderAudit(audit: AgentAuditEntry[]): HTMLElement { + const section = this._section(this._t('audit', 'Action log')); + if (audit.length === 0) { + section.append( + this._element('div', 'agent-control-empty', this._t('no_audit', 'No actions yet')), + ); + return section; + } + + const list = this._element('div', 'agent-control-audit'); + audit.slice(0, 6).forEach((entry) => { + const item = this._element( + 'div', + 'agent-control-audit-item', + `${entry.actorName} · ${entry.action} · ${entry.target} · ${entry.result}`, + ); + list.append(item); + }); + 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', 'Approved') + : this._t('approval_denied', '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', 'Action failed'), 'error'); + } finally { + this._isBusy = false; + this._setButtonsDisabled(false); + } + } + + private async _copy(text: string): Promise { + try { + await this._runtime.copyText(text); + this._toast(this._t('copied', 'Copied'), 'success'); + } catch (error) { + this._tracer.error('[AgentControlSettingsRenderer] Copy failed:', error); + this._toast(this._t('copy_failed', 'Copy failed'), 'error'); + } + } + + private _configText(state: AgentControlState): string { + const token = this._oneTimeToken?.token ?? ''; + return JSON.stringify( + { + name: this._t('trusted_local', 'Trusted Local'), + baseUrl: state.apiBaseUrl, + authorization: `Bearer ${token}`, + scopes: TRUSTED_LOCAL_SCOPES, + }, + null, + 2, + ); + } + + private _section(title: string): HTMLElement { + const section = this._element('section', 'agent-control-section'); + section.append(this._element('div', 'agent-control-section-title', title)); + return section; + } + + private _button(label: string, className: string, onClick: () => void): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.className = className; + button.textContent = label; + button.addEventListener('click', onClick); + return button; + } + + 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', 'Never connected'); + } + return `${this._t('last_seen', '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}`, scope); + } + + private _t(key: string, fallback: string): string { + return this._context?.t(`ui.launcher.settings.agent_control_${key}`, fallback) ?? fallback; + } + + 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..3d51b3f4 100644 --- a/src/features/settings/ui/SettingsPageUI.test.ts +++ b/src/features/settings/ui/SettingsPageUI.test.ts @@ -2,6 +2,8 @@ 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(); vi.mock('./GeneralSettingsRenderer', () => ({ GeneralSettingsRenderer: class { @@ -10,6 +12,13 @@ vi.mock('./GeneralSettingsRenderer', () => ({ }, })); +vi.mock('./AgentControlSettingsRenderer', () => ({ + AgentControlSettingsRenderer: class { + public init = initAgentRenderer; + public destroy = destroyAgentRenderer; + }, +})); + import { SettingsUI } from './SettingsUI'; import type { SettingsService } from '../services/SettingsService'; import type { UISettingsService } from '@/shared/services/ui/UISettingsService'; @@ -27,6 +36,8 @@ describe('SettingsUI page lifecycle', () => { beforeEach(() => { initRenderer.mockReset(); destroyRenderer.mockReset(); + initAgentRenderer.mockReset(); + destroyAgentRenderer.mockReset(); document.body.innerHTML = ''; ( globalThis as unknown as { @@ -55,6 +66,7 @@ describe('SettingsUI page lifecycle', () => { { tracer: { info: vi.fn(), + warn: vi.fn(), error: vi.fn(), } as unknown as LoggerService, showToast: (message: string, type?: 'success' | 'error' | 'warning' | 'info') => { @@ -78,6 +90,7 @@ describe('SettingsUI page lifecycle', () => { await ui.init(); expect(initRenderer).toHaveBeenCalledTimes(1); + expect(initAgentRenderer).toHaveBeenCalledTimes(1); }); it('should wait for container insertion without polling loops', async () => { @@ -93,6 +106,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 +117,8 @@ describe('SettingsUI page lifecycle', () => { await initPromise; expect(initRenderer).not.toHaveBeenCalled(); + expect(initAgentRenderer).not.toHaveBeenCalled(); expect(destroyRenderer).toHaveBeenCalledTimes(1); + expect(destroyAgentRenderer).toHaveBeenCalledTimes(1); }); }); diff --git a/src/features/settings/ui/SettingsUI.ts b/src/features/settings/ui/SettingsUI.ts index e5cea581..ea9254d8 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'; @@ -21,22 +22,26 @@ type SettingsUIDeps = { 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; public constructor( - _service: SettingsService, + service: SettingsService, uiSettings: UISettingsService, _aiSettings: AISettingsService, private readonly _i18n: I18nService, private readonly _i18nUI: I18nUI, - _tauri: TauriProvider, + tauri: TauriProvider, _navigation: NavigationService, private readonly _deps: SettingsUIDeps, ) { this._generalRenderer = new GeneralSettingsRenderer(uiSettings, this._deps.tracer); + this._agentControlRenderer = new AgentControlSettingsRenderer(service, this._deps.tracer, { + copyText: (text) => tauri.writeToClipboard(text), + }); } public async init(): Promise { @@ -77,6 +82,7 @@ export class SettingsUI { } this._generalRenderer.init(this._context); + this._agentControlRenderer.init(this._context); } public close(): void { @@ -90,6 +96,7 @@ export class SettingsUI { this._initAbortController?.abort(); this._initAbortController = null; this._generalRenderer.destroy(); + this._agentControlRenderer.destroy(); this._deps.tracer.info('[SettingsUI] Destroyed.'); } diff --git a/src/public/templates/pages/settings.html b/src/public/templates/pages/settings.html index 753c57be..1824ee9e 100644 --- a/src/public/templates/pages/settings.html +++ b/src/public/templates/pages/settings.html @@ -19,5 +19,15 @@
+ +
+
+ Agent Control +
+
+ Trusted local automation +
+
+
diff --git a/src/styles/features/settings-page.css b/src/styles/features/settings-page.css index 5fc439a1..09bf0571 100644 --- a/src/styles/features/settings-page.css +++ b/src/styles/features/settings-page.css @@ -87,6 +87,202 @@ line-height: 1.35; } +#settings-grid .module-card--agent-control { + grid-column: 1 / -1; + text-align: left; +} + +#settings-grid .module-card--agent-control .module-title, +#settings-grid .module-card--agent-control .module-desc { + align-self: flex-start; + text-align: left; +} + +#agent-control-panel { + width: 100%; +} + +.agent-control { + display: grid; + gap: 1rem; + color: rgba(248, 246, 252, 0.92); +} + +.agent-control-header, +.agent-control-actions, +.agent-control-row, +.agent-control-row-actions, +.agent-control-scopes { + display: flex; + align-items: center; +} + +.agent-control-header { + justify-content: space-between; + gap: 1rem; +} + +.agent-control-status { + display: inline-flex; + align-items: center; + min-height: 2rem; + padding: 0 0.85rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; + color: rgba(245, 248, 246, 0.94); + background: rgba(114, 125, 142, 0.2); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.agent-control-status.is-on { + background: rgba(38, 133, 87, 0.24); + border-color: rgba(73, 214, 143, 0.28); +} + +.agent-control-status.is-off { + background: rgba(154, 64, 74, 0.22); + border-color: rgba(255, 122, 132, 0.24); +} + +.agent-control-section { + display: grid; + gap: 0.7rem; + padding-top: 0.9rem; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.agent-control-section-title { + font-size: 0.78rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgba(246, 241, 255, 0.62); +} + +.agent-control-actions { + flex-wrap: wrap; + gap: 0.55rem; +} + +.agent-control-btn { + min-height: 2.15rem; + padding: 0 0.78rem; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(250, 248, 255, 0.88); + background: rgba(255, 255, 255, 0.06); + font: inherit; + font-size: 0.8rem; + font-weight: 700; + cursor: pointer; + transition: + background 0.16s ease, + border-color 0.16s ease, + color 0.16s ease; +} + +.agent-control-btn:hover { + background: rgba(255, 255, 255, 0.09); + border-color: rgba(255, 255, 255, 0.13); +} + +.agent-control-btn:disabled { + cursor: default; + opacity: 0.55; +} + +.agent-control-btn--primary { + background: rgba(124, 94, 186, 0.32); + border-color: rgba(172, 145, 232, 0.28); +} + +.agent-control-code, +.agent-control-diff { + width: 100%; + box-sizing: border-box; + overflow-wrap: anywhere; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.07); + background: rgba(0, 0, 0, 0.18); + color: rgba(245, 244, 248, 0.86); + font-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', monospace; + font-size: 0.76rem; + line-height: 1.45; + padding: 0.72rem 0.78rem; +} + +.agent-control-token { + display: grid; + gap: 0.55rem; +} + +.agent-control-token-label, +.agent-control-row-meta, +.agent-control-audit-item, +.agent-control-empty { + color: rgba(246, 241, 255, 0.62); + font-size: 0.78rem; + line-height: 1.4; +} + +.agent-control-empty--error { + color: rgba(255, 146, 154, 0.9); +} + +.agent-control-list, +.agent-control-audit { + display: grid; + gap: 0.65rem; +} + +.agent-control-row { + justify-content: space-between; + gap: 1rem; + min-width: 0; + padding: 0.75rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.055); +} + +.agent-control-row:last-child { + border-bottom: 0; +} + +.agent-control-row--approval { + align-items: flex-start; +} + +.agent-control-row-main { + display: grid; + gap: 0.42rem; + min-width: 0; +} + +.agent-control-row-title { + font-size: 0.9rem; + 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.4rem; +} + +.agent-control-scope { + display: inline-flex; + align-items: center; + min-height: 1.45rem; + padding: 0 0.5rem; + border-radius: 999px; + background: rgba(73, 92, 118, 0.26); + color: rgba(230, 238, 250, 0.74); + font-size: 0.68rem; + font-weight: 700; +} + #taskbar-toggles, #monitor-toggles { display: grid !important; @@ -278,6 +474,15 @@ #monitor-toggles { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .agent-control-row { + align-items: stretch; + flex-direction: column; + } + + .agent-control-row-actions { + justify-content: flex-start; + } } @media (max-width: 900px) { From 26aa5870ba3d48a52a814097b0640722bb782c0e Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 10:56:42 +0300 Subject: [PATCH 30/54] fix: route agent profile tokens through api auth --- src-tauri/src/domain/integration_api/auth.rs | 4 --- src-tauri/src/domain/integration_api/mod.rs | 4 --- src-tauri/src/domain/integration_api/tests.rs | 28 +++++++++++++++---- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/domain/integration_api/auth.rs b/src-tauri/src/domain/integration_api/auth.rs index ccd46947..258bffd0 100644 --- a/src-tauri/src/domain/integration_api/auth.rs +++ b/src-tauri/src/domain/integration_api/auth.rs @@ -48,10 +48,6 @@ 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") diff --git a/src-tauri/src/domain/integration_api/mod.rs b/src-tauri/src/domain/integration_api/mod.rs index 14c9b079..925f450e 100644 --- a/src-tauri/src/domain/integration_api/mod.rs +++ b/src-tauri/src/domain/integration_api/mod.rs @@ -302,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/tests.rs b/src-tauri/src/domain/integration_api/tests.rs index a94cbbe8..37f22762 100644 --- a/src-tauri/src/domain/integration_api/tests.rs +++ b/src-tauri/src/domain/integration_api/tests.rs @@ -5,6 +5,7 @@ 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::{ agent_provider_summary, backend_provider_id, ensure_launcher_client, ensure_module_route_owner, merge_json_settings, model_api_id, modules_visible_to_client, parse_agent_logs_query, @@ -84,13 +85,13 @@ 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] @@ -111,7 +112,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] @@ -213,13 +214,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] From a809fe6a5d8c271d03fdaaab9dabdaa4b36d606f Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 10:58:12 +0300 Subject: [PATCH 31/54] fix: hide revoke action for revoked agents --- .../ui/AgentControlSettingsRenderer.test.ts | 38 +++++++++++++++++++ .../ui/AgentControlSettingsRenderer.ts | 24 +++++++----- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/features/settings/ui/AgentControlSettingsRenderer.test.ts b/src/features/settings/ui/AgentControlSettingsRenderer.test.ts index f2603186..f7a716bb 100644 --- a/src/features/settings/ui/AgentControlSettingsRenderer.test.ts +++ b/src/features/settings/ui/AgentControlSettingsRenderer.test.ts @@ -131,4 +131,42 @@ describe('AgentControlSettingsRenderer', () => { expect(service.decideAgentApproval).toHaveBeenCalledWith('approval-1', false); }); }); + + it('keeps revoked profiles visible without offering another revoke action', async () => { + service.getAgentControlState = vi.fn().mockResolvedValue( + state({ + profiles: [ + { + id: 'agent-1', + name: 'Trusted Local', + scopes: ['observe', 'operate'], + 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, + { copyText }, + ); + + 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('Rotate'); + const buttons = Array.from(row.querySelectorAll('button')).map((button) => + button.textContent.trim(), + ); + expect(buttons).toEqual(['Rotate']); + }); }); diff --git a/src/features/settings/ui/AgentControlSettingsRenderer.ts b/src/features/settings/ui/AgentControlSettingsRenderer.ts index 0b2a1af1..26852ba9 100644 --- a/src/features/settings/ui/AgentControlSettingsRenderer.ts +++ b/src/features/settings/ui/AgentControlSettingsRenderer.ts @@ -227,17 +227,21 @@ export class AgentControlSettingsRenderer { this._render(); }); }), - this._button(this._t('revoke', 'Revoke'), 'agent-control-btn', () => { - void this._run(async () => { - this._state = await this._service.revokeAgentProfile(profile.id); - if (this._oneTimeToken?.profileId === profile.id) { - this._oneTimeToken = null; - } - this._toast(this._t('profile_revoked', 'Profile revoked'), 'success'); - this._render(); - }); - }), ); + if (!profile.revoked) { + actions.append( + this._button(this._t('revoke', 'Revoke'), 'agent-control-btn', () => { + void this._run(async () => { + this._state = await this._service.revokeAgentProfile(profile.id); + if (this._oneTimeToken?.profileId === profile.id) { + this._oneTimeToken = null; + } + this._toast(this._t('profile_revoked', 'Profile revoked'), 'success'); + this._render(); + }); + }), + ); + } row.append(main, actions); list.append(row); }); From 0851b6c10902b38f74db24614cd8119e2b675a74 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 11:08:08 +0300 Subject: [PATCH 32/54] fix: keep settings content within scroll bounds --- src/styles/features/settings-page.css | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/styles/features/settings-page.css b/src/styles/features/settings-page.css index 09bf0571..d5a59200 100644 --- a/src/styles/features/settings-page.css +++ b/src/styles/features/settings-page.css @@ -16,7 +16,7 @@ overflow-y: auto; overflow-x: hidden; padding: var(--content-padding-top) 1.5rem 1.5rem 1.5rem; - align-items: center; + align-items: stretch; position: relative; background: transparent; } @@ -28,8 +28,7 @@ margin: 0 auto; padding: 1rem 0 1.5rem; box-sizing: border-box; - display: flex; - align-items: center; + display: block; } #page-settings.active .settings-wrapper { From 433c9584b1c9e9b7e82a0bc096903293ee643584 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 11:25:02 +0300 Subject: [PATCH 33/54] fix: hide and delete agent tokens --- src-tauri/resources/locales/en.json | 6 ++ src-tauri/resources/locales/ru.json | 6 ++ src-tauri/resources/locales/zh.json | 6 ++ src-tauri/src/api/agent_control.rs | 10 +++ src-tauri/src/domain/agent_control.rs | 34 +++++++++ src-tauri/src/lib.rs | 1 + .../settings/services/SettingsService.test.ts | 7 +- .../settings/services/SettingsService.ts | 9 +++ .../ui/AgentControlSettingsRenderer.test.ts | 76 +++++++++++++++++-- .../ui/AgentControlSettingsRenderer.ts | 48 +++++++++++- src/shared/types/bindings.ts | 2 + 11 files changed, 194 insertions(+), 11 deletions(-) diff --git a/src-tauri/resources/locales/en.json b/src-tauri/resources/locales/en.json index 1790c193..2045a0e5 100644 --- a/src-tauri/resources/locales/en.json +++ b/src-tauri/resources/locales/en.json @@ -239,15 +239,21 @@ "ui.launcher.settings.agent_control_trusted_local": "Trusted Local", "ui.launcher.settings.agent_control_profile_created": "Profile created", "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": "Agents", "ui.launcher.settings.agent_control_no_profiles": "No agents yet", "ui.launcher.settings.agent_control_revoked": "Revoked", "ui.launcher.settings.agent_control_active": "Active", "ui.launcher.settings.agent_control_rotate": "Rotate", "ui.launcher.settings.agent_control_revoke": "Revoke", + "ui.launcher.settings.agent_control_delete_profile": "Delete", + "ui.launcher.settings.agent_control_delete_profile_confirm": "Delete this agent profile?", "ui.launcher.settings.agent_control_token_rotated": "Token rotated", "ui.launcher.settings.agent_control_profile_revoked": "Profile revoked", + "ui.launcher.settings.agent_control_profile_deleted": "Profile 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", diff --git a/src-tauri/resources/locales/ru.json b/src-tauri/resources/locales/ru.json index 5707b528..5504cc39 100644 --- a/src-tauri/resources/locales/ru.json +++ b/src-tauri/resources/locales/ru.json @@ -240,15 +240,21 @@ "ui.launcher.settings.agent_control_trusted_local": "Trusted Local", "ui.launcher.settings.agent_control_profile_created": "Профиль создан", "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_revoke": "Отозвать", + "ui.launcher.settings.agent_control_delete_profile": "Удалить", + "ui.launcher.settings.agent_control_delete_profile_confirm": "Удалить этот профиль агента?", "ui.launcher.settings.agent_control_token_rotated": "Токен обновлен", "ui.launcher.settings.agent_control_profile_revoked": "Профиль отозван", + "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": "Разрешить", diff --git a/src-tauri/resources/locales/zh.json b/src-tauri/resources/locales/zh.json index 4e7d80ce..3719b4d0 100644 --- a/src-tauri/resources/locales/zh.json +++ b/src-tauri/resources/locales/zh.json @@ -236,15 +236,21 @@ "ui.launcher.settings.agent_control_trusted_local": "Trusted Local", "ui.launcher.settings.agent_control_profile_created": "配置已创建", "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_revoke": "撤销", + "ui.launcher.settings.agent_control_delete_profile": "删除", + "ui.launcher.settings.agent_control_delete_profile_confirm": "删除此代理配置?", "ui.launcher.settings.agent_control_token_rotated": "令牌已轮换", "ui.launcher.settings.agent_control_profile_revoked": "配置已撤销", + "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": "批准", diff --git a/src-tauri/src/api/agent_control.rs b/src-tauri/src/api/agent_control.rs index 1962911a..94defaef 100644 --- a/src-tauri/src/api/agent_control.rs +++ b/src-tauri/src/api/agent_control.rs @@ -60,6 +60,16 @@ pub async fn revoke_agent_profile( service.revoke_profile(&id, api_base_url()).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] diff --git a/src-tauri/src/domain/agent_control.rs b/src-tauri/src/domain/agent_control.rs index f9b86ad0..f27d0222 100644 --- a/src-tauri/src/domain/agent_control.rs +++ b/src-tauri/src/domain/agent_control.rs @@ -268,6 +268,24 @@ impl AgentControlService { 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); + self.save_store_locked(&store).await?; + Ok(public_state(store, api_base_url)) + } + /// Authenticates a bearer token against enabled, non-revoked profiles. pub async fn authorize_token(&self, token: &str) -> Option { let _guard = self.lock.lock().await; @@ -514,6 +532,22 @@ mod tests { assert!(service.authorize_token(&response.token).await.is_none()); } + #[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.authorize_token(&response.token).await.is_none()); + } + #[tokio::test] async fn approval_decision_updates_status() { let _guard = TEST_LOCK.lock().await; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 83930c91..cd890fdc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -103,6 +103,7 @@ pub fn create_specta_builder() -> Builder { agent_control::create_agent_profile, agent_control::rotate_agent_profile, agent_control::revoke_agent_profile, + agent_control::delete_agent_profile, agent_control::decide_agent_approval, agent_control::create_agent_approval_request, config::get_config, diff --git a/src/features/settings/services/SettingsService.test.ts b/src/features/settings/services/SettingsService.test.ts index aafdb033..cbc11c81 100644 --- a/src/features/settings/services/SettingsService.test.ts +++ b/src/features/settings/services/SettingsService.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ createAgentProfile: vi.fn(), rotateAgentProfile: vi.fn(), revokeAgentProfile: vi.fn(), + deleteAgentProfile: vi.fn(), decideAgentApproval: vi.fn(), }, })); @@ -33,6 +34,7 @@ vi.mock('@/shared/types/bindings', async (importOriginal) => { createAgentProfile: mocks.commands.createAgentProfile, rotateAgentProfile: mocks.commands.rotateAgentProfile, revokeAgentProfile: mocks.commands.revokeAgentProfile, + deleteAgentProfile: mocks.commands.deleteAgentProfile, decideAgentApproval: mocks.commands.decideAgentApproval, }, }; @@ -231,7 +233,7 @@ describe('SettingsService', () => { ]); }); - it('should rotate, revoke, toggle, and decide approvals via backend-owned state', async () => { + it('should rotate, revoke, delete, toggle, and decide approvals via backend-owned state', async () => { const stateResponse = Promise.resolve({ status: 'ok', data: { @@ -244,6 +246,7 @@ describe('SettingsService', () => { }); mocks.commands.setAgentControlEnabled.mockReturnValueOnce(stateResponse); mocks.commands.revokeAgentProfile.mockReturnValueOnce(stateResponse); + mocks.commands.deleteAgentProfile.mockReturnValueOnce(stateResponse); mocks.commands.decideAgentApproval.mockReturnValueOnce(stateResponse); mocks.commands.rotateAgentProfile.mockReturnValueOnce( Promise.resolve({ @@ -266,11 +269,13 @@ describe('SettingsService', () => { await service.setAgentControlEnabled(true); await service.rotateAgentProfile('agent-1'); await service.revokeAgentProfile('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.revokeAgentProfile).toHaveBeenCalledWith('agent-1'); + expect(mocks.commands.deleteAgentProfile).toHaveBeenCalledWith('agent-1'); expect(mocks.commands.decideAgentApproval).toHaveBeenCalledWith('approval-1', false); }); }); diff --git a/src/features/settings/services/SettingsService.ts b/src/features/settings/services/SettingsService.ts index 6897e84d..b4455d39 100644 --- a/src/features/settings/services/SettingsService.ts +++ b/src/features/settings/services/SettingsService.ts @@ -152,6 +152,15 @@ export class SettingsService { throw new Error(result.error.message); } + 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(result.error.message); + } + public async decideAgentApproval(id: string, approved: boolean): Promise { const result = await invokeSafe(commands.decideAgentApproval(id, approved)); if (result.status === 'ok') { diff --git a/src/features/settings/ui/AgentControlSettingsRenderer.test.ts b/src/features/settings/ui/AgentControlSettingsRenderer.test.ts index f7a716bb..7165deb9 100644 --- a/src/features/settings/ui/AgentControlSettingsRenderer.test.ts +++ b/src/features/settings/ui/AgentControlSettingsRenderer.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AgentControlSettingsRenderer } from './AgentControlSettingsRenderer'; import type { SettingsService } from '../services/SettingsService'; import type { AgentControlState } from '@/shared/types/bindings'; @@ -24,6 +24,7 @@ describe('AgentControlSettingsRenderer', () => { | 'createAgentProfile' | 'rotateAgentProfile' | 'revokeAgentProfile' + | 'deleteAgentProfile' | 'decideAgentApproval' >; let copyText: (text: string) => Promise; @@ -31,10 +32,8 @@ describe('AgentControlSettingsRenderer', () => { beforeEach(() => { document.body.innerHTML = '
'; - const copyTextMock = vi.fn().mockResolvedValue(undefined); - copyText = async (text: string) => { - await copyTextMock(text); - }; + copyText = vi.fn().mockResolvedValue(undefined); + vi.spyOn(globalThis, 'confirm').mockReturnValue(true); service = { getAgentControlState: vi.fn().mockResolvedValue(state()), setAgentControlEnabled: vi.fn().mockResolvedValue(state({ enabled: true })), @@ -52,6 +51,7 @@ describe('AgentControlSettingsRenderer', () => { }), rotateAgentProfile: vi.fn(), revokeAgentProfile: vi.fn(), + deleteAgentProfile: vi.fn().mockResolvedValue(state()), decideAgentApproval: vi.fn(), }; context = { @@ -63,7 +63,11 @@ describe('AgentControlSettingsRenderer', () => { } as unknown as IAppSettingsUIContext; }); - it('creates a Trusted Local profile and renders the one-time token', async () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('creates a Trusted Local profile without revealing the one-time token immediately', async () => { const renderer = new AgentControlSettingsRenderer( service as SettingsService, { error: vi.fn(), warn: vi.fn() } as unknown as LoggerService, @@ -81,14 +85,31 @@ describe('AgentControlSettingsRenderer', () => { createButton?.click(); await vi.waitFor(() => { - expect(document.body.textContent).toContain('axl_agent_abc_secret'); + expect(document.body.textContent).toContain('Hidden'); }); + expect(document.body.textContent).not.toContain('axl_agent_abc_secret'); expect(service.createAgentProfile).toHaveBeenCalledWith('Trusted Local', [ 'observe', 'operate', 'configure', 'draft-create', ]); + + const copyTokenButton = Array.from( + document.querySelectorAll('button'), + ).find((button) => button.textContent === 'Copy token'); + copyTokenButton?.click(); + await vi.waitFor(() => { + expect(copyText).toHaveBeenCalledWith('axl_agent_abc_secret'); + }); + + const showTokenButton = Array.from( + document.querySelectorAll('button'), + ).find((button) => button.textContent === 'Show token'); + showTokenButton?.click(); + await vi.waitFor(() => { + expect(document.body.textContent).toContain('axl_agent_abc_secret'); + }); }); it('renders pending approval requests and denies without mutating directly', async () => { @@ -164,9 +185,48 @@ describe('AgentControlSettingsRenderer', () => { throw new Error('Expected revoked profile row to render'); } expect(row.textContent).toContain('Rotate'); + expect(row.textContent).toContain('Delete'); const buttons = Array.from(row.querySelectorAll('button')).map((button) => button.textContent.trim(), ); - expect(buttons).toEqual(['Rotate']); + expect(buttons).toEqual(['Rotate', 'Delete']); + }); + + it('deletes profiles after confirmation', async () => { + service.getAgentControlState = vi.fn().mockResolvedValue( + state({ + profiles: [ + { + id: 'agent-1', + name: 'Trusted Local', + scopes: ['observe'], + 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, + { copyText }, + ); + + 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(); + + 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 index 26852ba9..3dd4c181 100644 --- a/src/features/settings/ui/AgentControlSettingsRenderer.ts +++ b/src/features/settings/ui/AgentControlSettingsRenderer.ts @@ -16,6 +16,7 @@ type AgentControlRuntime = { type OneTimeToken = { profileId: string; token: string; + revealed: boolean; }; const TRUSTED_LOCAL_SCOPES: AgentScope[] = ['observe', 'operate', 'configure', 'draft-create']; @@ -151,6 +152,7 @@ export class AgentControlSettingsRenderer { this._oneTimeToken = { profileId: response.profile.id, token: response.token, + revealed: false, }; this._state = await this._service.getAgentControlState(); this._toast(this._t('profile_created', 'Profile created'), 'success'); @@ -177,13 +179,33 @@ export class AgentControlSettingsRenderer { 'agent-control-token-label', this._t('token_once', 'Token shown once'), ); - const value = this._element('div', 'agent-control-code', token?.token ?? ''); + const value = this._element( + 'div', + 'agent-control-code', + token?.revealed === true + ? token.token + : this._t('token_hidden', 'Hidden. Copy it now or reveal it explicitly.'), + ); const copy = this._button(this._t('copy_token', 'Copy token'), 'agent-control-btn', () => { if (token !== null) { void this._copy(token.token); } }); - box.append(label, value, copy); + const reveal = this._button( + token?.revealed === true + ? this._t('hide_token', 'Hide token') + : this._t('show_token', 'Show token'), + 'agent-control-btn', + () => { + if (token !== null) { + token.revealed = !token.revealed; + this._render(); + } + }, + ); + const actions = this._element('div', 'agent-control-actions'); + actions.append(copy, reveal); + box.append(label, value, actions); return box; } @@ -221,6 +243,7 @@ export class AgentControlSettingsRenderer { this._oneTimeToken = { profileId: response.profile.id, token: response.token, + revealed: false, }; this._state = await this._service.getAgentControlState(); this._toast(this._t('token_rotated', 'Token rotated'), 'success'); @@ -242,6 +265,21 @@ export class AgentControlSettingsRenderer { }), ); } + actions.append( + this._button(this._t('delete_profile', 'Delete'), 'agent-control-btn', () => { + if (!this._confirmDeleteProfile(profile.name)) { + return; + } + void this._run(async () => { + this._state = await this._service.deleteAgentProfile(profile.id); + if (this._oneTimeToken?.profileId === profile.id) { + this._oneTimeToken = null; + } + this._toast(this._t('profile_deleted', 'Profile deleted'), 'success'); + this._render(); + }); + }), + ); row.append(main, actions); list.append(row); }); @@ -384,6 +422,12 @@ export class AgentControlSettingsRenderer { return section; } + private _confirmDeleteProfile(name: string): boolean { + return globalThis.confirm( + this._t('delete_profile_confirm', `Delete agent profile "${name}"?`), + ); + } + private _button(label: string, className: string, onClick: () => void): HTMLButtonElement { const button = document.createElement('button'); button.type = 'button'; diff --git a/src/shared/types/bindings.ts b/src/shared/types/bindings.ts index a6a3ba13..f15bb8fd 100644 --- a/src/shared/types/bindings.ts +++ b/src/shared/types/bindings.ts @@ -21,6 +21,8 @@ export const commands = { rotateAgentProfile: (id: string) => typedError(__TAURI_INVOKE("rotate_agent_profile", { id })), /** Revokes a trusted local agent profile. */ revokeAgentProfile: (id: string) => typedError(__TAURI_INVOKE("revoke_agent_profile", { 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 })), /** Creates a pending approval request from the UI for tests and manual flows. */ From 352d6d8fb3c5ebb3358f90a7949041e35444ac90 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 11:39:56 +0300 Subject: [PATCH 34/54] fix: refresh agent control on api approvals --- .../src/domain/integration_api/routing.rs | 9 ++++ .../ui/AgentControlSettingsRenderer.ts | 7 +++ .../settings/ui/SettingsPageUI.test.ts | 44 ++++++++++++++++++- src/features/settings/ui/SettingsUI.ts | 27 +++++++++++- 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/domain/integration_api/routing.rs b/src-tauri/src/domain/integration_api/routing.rs index f5a16699..df35e8ff 100644 --- a/src-tauri/src/domain/integration_api/routing.rs +++ b/src-tauri/src/domain/integration_api/routing.rs @@ -298,6 +298,15 @@ async fn handle_agent_approval_request( payload.risk.trim().to_string(), ) .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()), diff --git a/src/features/settings/ui/AgentControlSettingsRenderer.ts b/src/features/settings/ui/AgentControlSettingsRenderer.ts index 3dd4c181..d6bd1cdd 100644 --- a/src/features/settings/ui/AgentControlSettingsRenderer.ts +++ b/src/features/settings/ui/AgentControlSettingsRenderer.ts @@ -48,6 +48,13 @@ export class AgentControlSettingsRenderer { 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; diff --git a/src/features/settings/ui/SettingsPageUI.test.ts b/src/features/settings/ui/SettingsPageUI.test.ts index 3d51b3f4..fd9ebf68 100644 --- a/src/features/settings/ui/SettingsPageUI.test.ts +++ b/src/features/settings/ui/SettingsPageUI.test.ts @@ -4,6 +4,7 @@ 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 { @@ -16,6 +17,7 @@ vi.mock('./AgentControlSettingsRenderer', () => ({ AgentControlSettingsRenderer: class { public init = initAgentRenderer; public destroy = destroyAgentRenderer; + public refresh = refreshAgentRenderer; }, })); @@ -38,6 +40,7 @@ describe('SettingsUI page lifecycle', () => { destroyRenderer.mockReset(); initAgentRenderer.mockReset(); destroyAgentRenderer.mockReset(); + refreshAgentRenderer.mockReset(); document.body.innerHTML = ''; ( globalThis as unknown as { @@ -61,7 +64,10 @@ 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: { @@ -93,6 +99,42 @@ describe('SettingsUI page lifecycle', () => { 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 () => { const ui = createSettingsUI(); const initPromise = ui.init(); diff --git a/src/features/settings/ui/SettingsUI.ts b/src/features/settings/ui/SettingsUI.ts index ea9254d8..c6978849 100644 --- a/src/features/settings/ui/SettingsUI.ts +++ b/src/features/settings/ui/SettingsUI.ts @@ -27,6 +27,7 @@ export class SettingsUI { private _isInitialized = false; private _isDestroyed = false; private _initAbortController: AbortController | null = null; + private _agentControlUnlisten: (() => void) | null = null; public constructor( service: SettingsService, @@ -34,13 +35,13 @@ export class SettingsUI { _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, { - copyText: (text) => tauri.writeToClipboard(text), + copyText: (text) => this._tauri.writeToClipboard(text), }); } @@ -83,6 +84,7 @@ export class SettingsUI { this._generalRenderer.init(this._context); this._agentControlRenderer.init(this._context); + await this._listenForAgentControlChanges(); } public close(): void { @@ -95,11 +97,32 @@ export class SettingsUI { this._isInitialized = false; this._initAbortController?.abort(); this._initAbortController = null; + this._agentControlUnlisten?.(); + this._agentControlUnlisten = null; this._generalRenderer.destroy(); this._agentControlRenderer.destroy(); this._deps.tracer.info('[SettingsUI] Destroyed.'); } + 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, From 6f18a6e9654c0cfbacd5fe439bce3d38c82c2b02 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 15:49:21 +0300 Subject: [PATCH 35/54] fix(ai): handle provider policy edge cases --- .../services/AIBridgeProviderPolicy.test.ts | 3 +- .../ai/services/AIBridgeProviderPolicy.ts | 14 ++++++-- .../ai/services/AIChatTransport.test.ts | 35 +++++++++++++++++++ src/features/ai/services/AIChatTransport.ts | 15 ++++---- 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/features/ai/services/AIBridgeProviderPolicy.test.ts b/src/features/ai/services/AIBridgeProviderPolicy.test.ts index d23c50c3..22662625 100644 --- a/src/features/ai/services/AIBridgeProviderPolicy.test.ts +++ b/src/features/ai/services/AIBridgeProviderPolicy.test.ts @@ -50,7 +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(true); + 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); @@ -61,6 +61,7 @@ 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', () => { diff --git a/src/features/ai/services/AIBridgeProviderPolicy.ts b/src/features/ai/services/AIBridgeProviderPolicy.ts index cfab419b..769c4e1a 100644 --- a/src/features/ai/services/AIBridgeProviderPolicy.ts +++ b/src/features/ai/services/AIBridgeProviderPolicy.ts @@ -23,7 +23,7 @@ export class AIBridgeProviderPolicy { public isCloudProvider(providerId: string): boolean { const policy = this._catalogProvider(providerId)?.providerPolicy; - return policy?.isCloudProvider ?? true; + 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 { diff --git a/src/features/ai/services/AIChatTransport.test.ts b/src/features/ai/services/AIChatTransport.test.ts index 7c234150..aa215205 100644 --- a/src/features/ai/services/AIChatTransport.test.ts +++ b/src/features/ai/services/AIChatTransport.test.ts @@ -241,6 +241,41 @@ describe('AIChatTransport', () => { }); }); + 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' }); diff --git a/src/features/ai/services/AIChatTransport.ts b/src/features/ai/services/AIChatTransport.ts index 18dd9560..e7888efb 100644 --- a/src/features/ai/services/AIChatTransport.ts +++ b/src/features/ai/services/AIChatTransport.ts @@ -460,21 +460,22 @@ export class AIChatTransport implements IChatTransport { } private _isLocalHostname(hostname: string): boolean { + const normalizedHostname = hostname.replace(/^\[(.*)\]$/u, '$1'); if ( - hostname === 'localhost' || - hostname === '::1' || - hostname === '0.0.0.0' || - hostname.endsWith('.local') || - hostname.startsWith('127.') + normalizedHostname === 'localhost' || + normalizedHostname === '::1' || + normalizedHostname === '0.0.0.0' || + normalizedHostname.endsWith('.local') || + normalizedHostname.startsWith('127.') ) { return true; } - if (hostname.startsWith('10.') || hostname.startsWith('192.168.')) { + if (normalizedHostname.startsWith('10.') || normalizedHostname.startsWith('192.168.')) { return true; } - const match = /^172\.(\d+)\./u.exec(hostname); + const match = /^172\.(\d+)\./u.exec(normalizedHostname); if (match?.[1] === undefined) { return false; } From 3f6136ab3be034185c119fb0c899fbccf427e2d5 Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 15:50:43 +0300 Subject: [PATCH 36/54] feat(agent): harden local control api --- src-tauri/src/api/agent_control.rs | 44 +- src-tauri/src/api/modules/mod.rs | 2 +- src-tauri/src/domain/agent_control.rs | 216 ++++++- .../src/domain/integration_api/routing.rs | 572 +++++++++++++++++- src-tauri/src/domain/integration_api/tests.rs | 117 +++- src-tauri/src/domain/integration_api/types.rs | 20 + .../src/domain/modules/controller/mod.rs | 61 +- .../modules/controller/script_runtime.rs | 36 ++ src-tauri/src/lib.rs | 2 +- src-tauri/src/models/modules.rs | 2 +- src/shared/types/bindings.ts | 16 +- 11 files changed, 1007 insertions(+), 81 deletions(-) diff --git a/src-tauri/src/api/agent_control.rs b/src-tauri/src/api/agent_control.rs index 94defaef..b886db8d 100644 --- a/src-tauri/src/api/agent_control.rs +++ b/src-tauri/src/api/agent_control.rs @@ -1,10 +1,11 @@ //! Tauri commands for trusted local Agent Control. use crate::domain::agent_control::{ - AgentApprovalRequest, AgentControlService, AgentControlState, AgentProfileTokenResponse, - AgentScope, + 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() @@ -50,6 +51,23 @@ pub async fn rotate_agent_profile( 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> { + let token = service.take_pending_token(&id).await?; + app.clipboard() + .write_text(token) + .map_err(|error| AppError::External { + message: format!("Failed to copy Agent Control token: {error}"), + request_id: None, + }) +} + /// Revokes a trusted local agent profile. #[tauri::command] #[specta::specta] @@ -80,25 +98,3 @@ pub async fn decide_agent_approval( ) -> Result { service.decide_approval(&id, approved, api_base_url()).await } - -/// Creates a pending approval request from the UI for tests and manual flows. -#[tauri::command] -#[specta::specta] -pub async fn create_agent_approval_request( - service: tauri::State<'_, AgentControlService>, - agent_id: String, - agent_name: String, - action: String, - target: String, - diff: String, - risk: String, -) -> Result { - let agent = crate::domain::agent_control::AuthorizedAgent { - id: agent_id, - name: agent_name, - scopes: crate::domain::agent_control::trusted_local_scopes(), - }; - service - .create_approval_request(&agent, action, target, diff, risk) - .await -} 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/domain/agent_control.rs b/src-tauri/src/domain/agent_control.rs index f27d0222..1eadd3fa 100644 --- a/src-tauri/src/domain/agent_control.rs +++ b/src-tauri/src/domain/agent_control.rs @@ -7,6 +7,7 @@ use chrono::Utc; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use specta::Type; +use std::collections::HashMap; use std::sync::Arc; const TOKEN_PREFIX_LEN: usize = 18; @@ -25,6 +26,8 @@ pub enum AgentScope { Configure, /// Create integration drafts without installing or running them silently. DraftCreate, + /// User-granted full local launcher access. + FullAccess, } /// Public trusted local agent profile metadata. @@ -47,14 +50,12 @@ pub struct AgentProfile { pub revoked: bool, } -/// Agent profile creation response. The token is shown only once. +/// 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, - /// One-time bearer token. Store it in the calling agent, not in frontend state. - pub token: String, } /// Agent action audit entry. @@ -169,6 +170,7 @@ struct AgentControlStore { pub struct AgentControlService { json_store: JsonStore, lock: Arc>, + pending_tokens: Arc>>, } impl AgentControlService { @@ -177,6 +179,7 @@ impl AgentControlService { Self { json_store, lock: Arc::new(tokio::sync::Mutex::new(())), + pending_tokens: Arc::new(tokio::sync::Mutex::new(HashMap::new())), } } @@ -224,10 +227,11 @@ impl AgentControlService { 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, - token, }) } @@ -239,16 +243,21 @@ impl AgentControlService { 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.revoked = false; 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, - token, }) } @@ -264,6 +273,7 @@ impl AgentControlService { 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)) } @@ -281,11 +291,25 @@ impl AgentControlService { if store.profiles.len() == before { return Err(AppError::NotFound(format!("Agent profile {id} not found"))); } - store.approvals.retain(|approval| approval.agent_id != id); + 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) + } + /// Authenticates a bearer token against enabled, non-revoked profiles. pub async fn authorize_token(&self, token: &str) -> Option { let _guard = self.lock.lock().await; @@ -377,6 +401,11 @@ impl AgentControlService { 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 { @@ -394,6 +423,15 @@ impl AgentControlService { 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. @@ -413,6 +451,9 @@ fn normalize_scopes(scopes: Option>) -> Vec { if scopes.is_empty() { return trusted_local_scopes(); } + if scopes.contains(&AgentScope::FullAccess) { + return vec![AgentScope::FullAccess]; + } scopes } @@ -422,6 +463,7 @@ const fn scope_rank(scope: AgentScope) -> u8 { AgentScope::Operate => 1, AgentScope::Configure => 2, AgentScope::DraftCreate => 3, + AgentScope::FullAccess => 4, } } @@ -506,14 +548,15 @@ mod tests { .await .expect("profile"); - assert!(response.token.starts_with("axl_agent_")); assert_eq!(response.profile.name, "Codex"); assert!(response.profile.scopes.contains(&AgentScope::Observe)); - - let authorized = service - .authorize_token(&response.token) + let token = service + .claim_pending_token_for_test(&response.profile.id) .await - .expect("authorized"); + .expect("pending token"); + assert!(token.starts_with("axl_agent_")); + + let authorized = service.authorize_token(&token).await.expect("authorized"); assert_eq!(authorized.name, "Codex"); } @@ -529,7 +572,27 @@ mod tests { .await .expect("revoke"); - assert!(service.authorize_token(&response.token).await.is_none()); + 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] @@ -545,7 +608,12 @@ mod tests { .expect("delete"); assert!(state.profiles.is_empty()); - assert!(service.authorize_token(&response.token).await.is_none()); + assert!( + service + .take_pending_token(&response.profile.id) + .await + .is_err() + ); } #[tokio::test] @@ -554,10 +622,11 @@ mod tests { reset_store().await; let service = service(); let response = service.create_profile(None, None).await.expect("profile"); - let agent = service - .authorize_token(&response.token) + let token = service + .claim_pending_token_for_test(&response.profile.id) .await - .expect("agent"); + .expect("pending token"); + let agent = service.authorize_token(&token).await.expect("agent"); let approval = service .create_approval_request( &agent, @@ -579,4 +648,117 @@ mod tests { 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/integration_api/routing.rs b/src-tauri/src/domain/integration_api/routing.rs index df35e8ff..a3ae5c0f 100644 --- a/src-tauri/src/domain/integration_api/routing.rs +++ b/src-tauri/src/domain/integration_api/routing.rs @@ -8,12 +8,15 @@ 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::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_with_agent_profiles, is_loopback_peer}; @@ -21,9 +24,10 @@ use super::http::{json_error, json_response, parse_json_body, request_path, stat use super::types::{ AgentLauncherStateResponse, AgentLogsResponse, AgentModelSummary, AgentModuleSummary, AgentOpenPageEvent, AgentProviderSummary, AuthorizedClient, HttpRequest, HttpResponse, - ImageApiResponse, IntegrationAgentApprovalRequest, IntegrationImageRequest, - IntegrationModuleStageRequest, IntegrationOpenPageRequest, IntegrationSelectModuleRequest, - IntegrationTextRequest, ModuleContextApiResponse, ModuleStageChangedEvent, TextApiResponse, + ImageApiResponse, IntegrationAgentApprovalRequest, IntegrationDraftCreateRequest, + IntegrationDraftCreateResponse, IntegrationImageRequest, IntegrationModuleStageRequest, + IntegrationOpenPageRequest, IntegrationSelectModuleRequest, IntegrationTextRequest, + ModuleContextApiResponse, ModuleStageChangedEvent, TextApiResponse, }; use super::{LauncherHttpApiContext, SDK_API_VERSION, api_base_url}; @@ -33,6 +37,7 @@ 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 { @@ -132,6 +137,19 @@ async fn route_authorized_request( json!({ "ok": true, "category": response.category, "module": response.module }), )) } + ("POST", ["v1", "integration-drafts"]) => { + ensure_agent_scope(client, AgentScope::DraftCreate)?; + 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 = @@ -215,7 +233,12 @@ async fn route_authorized_request( .to_string(), ) .await; - if response.success && matches!(action, ModuleAction::Start | ModuleAction::Restart) { + if response.success + && matches!( + action, + ModuleAction::Start | ModuleAction::Restart | ModuleAction::Repair + ) + { sync_launcher_selected_module(&context, client, module_id).await?; } Ok(json_response( @@ -235,6 +258,95 @@ async fn route_authorized_request( } } +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(), + }) +} + fn handle_agent_logs_request(request: &HttpRequest) -> Result { let query = parse_agent_logs_query(&request.path)?; let logs = match query.view_id.as_deref() { @@ -244,7 +356,11 @@ fn handle_agent_logs_request(request: &HttpRequest) -> Result crate::api::system::logs::get_logs(query.since)?, }; let skip = logs.len().saturating_sub(query.limit); - let logs = logs.into_iter().skip(skip).collect::>(); + let logs = logs + .into_iter() + .skip(skip) + .map(sanitize_agent_log_entry) + .collect::>(); Ok(json_response( 200, @@ -259,6 +375,311 @@ fn handle_agent_logs_request(request: &HttpRequest) -> Result 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)) + { + 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("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, ) -> Result { @@ -381,6 +802,8 @@ async fn handle_select_module_request( 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 @@ -416,7 +839,8 @@ pub(super) fn parse_agent_logs_query(path: &str) -> Result { - result.view_id = Some(value.trim().to_string()).filter(|value| !value.is_empty()); + 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)?; @@ -431,6 +855,49 @@ pub(super) fn parse_agent_logs_query(path: &str) -> Result 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() @@ -680,7 +1147,11 @@ pub(super) fn ensure_launcher_client(client: &AuthorizedClient) -> Result<(), Ap 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) => 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:?}" ))), @@ -745,6 +1216,7 @@ 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}" ))), @@ -756,6 +1228,7 @@ const fn module_action_name(action: ModuleAction) -> &'static str { ModuleAction::Start => "start", ModuleAction::Stop => "stop", ModuleAction::Restart => "restart", + ModuleAction::Repair => "repair", ModuleAction::Install => "install", ModuleAction::Uninstall => "uninstall", ModuleAction::Update => "update", @@ -1013,6 +1486,91 @@ fn validate_agent_selection_category(category: &str) -> Result<(), AppError> { } } +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(), diff --git a/src-tauri/src/domain/integration_api/tests.rs b/src-tauri/src/domain/integration_api/tests.rs index 37f22762..a5d4cf38 100644 --- a/src-tauri/src/domain/integration_api/tests.rs +++ b/src-tauri/src/domain/integration_api/tests.rs @@ -7,15 +7,19 @@ use super::http::{ }; use super::preflight_http_request; use super::routing::{ - agent_provider_summary, backend_provider_id, ensure_launcher_client, ensure_module_route_owner, + agent_provider_summary, backend_provider_id, default_draft_entry, draft_id_from_name, + draft_manifest_text, ensure_launcher_client, ensure_module_route_owner, escape_toml_string, merge_json_settings, model_api_id, modules_visible_to_client, parse_agent_logs_query, - parse_module_action, resolve_session_id, selected_module_from_api_provider, - selected_module_from_catalog_item, selected_module_from_runtime_module, - selection_category_for_runtime_module, tier_rank, + parse_module_action, redact_sensitive_log_text, resolve_session_id, sanitize_agent_log_entry, + 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::modules::controller::ModuleAction; use crate::errors::AppError; +use crate::infrastructure::logging::LogEntry; use crate::models::{ AiModel, ApiModelConfig, ModelStats, ModelTier, Module, ModuleItem, ProviderType, SelectedModule, @@ -164,6 +168,15 @@ fn agent_logs_query_defaults_and_clamps_limit() { 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()); @@ -171,6 +184,52 @@ fn agent_logs_query_rejects_invalid_since_and_limit() { assert!(parse_agent_logs_query("/v1/agent/logs?limit=0").is_err()); } +#[test] +fn agent_logs_redact_sensitive_values() { + let redacted = redact_sensitive_log_text( + "Authorization: Bearer axl_agent_secret token=abc api_key=\"sk-test\" 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("api_key=query")); + assert!(redacted.contains("Authorization: [REDACTED]")); + assert!(redacted.contains("token=[REDACTED]")); + assert!(redacted.contains("api_key=\"[REDACTED]\"")); + 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"); @@ -438,12 +497,50 @@ 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("C:/outside.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( @@ -630,6 +727,18 @@ fn runtime_module_selection_maps_ai_modules_to_text_slot() { 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 { diff --git a/src-tauri/src/domain/integration_api/types.rs b/src-tauri/src/domain/integration_api/types.rs index 7e71cbd9..08525b5d 100644 --- a/src-tauri/src/domain/integration_api/types.rs +++ b/src-tauri/src/domain/integration_api/types.rs @@ -134,6 +134,16 @@ pub(super) struct IntegrationSelectModuleRequest { 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)] @@ -154,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 { diff --git a/src-tauri/src/domain/modules/controller/mod.rs b/src-tauri/src/domain/modules/controller/mod.rs index e259b8ca..f0dc7890 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,28 @@ 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; + } + + 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 +476,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/script_runtime.rs b/src-tauri/src/domain/modules/controller/script_runtime.rs index 006f6a48..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, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cd890fdc..9b306ee9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -102,10 +102,10 @@ pub fn create_specta_builder() -> Builder { agent_control::set_agent_control_enabled, agent_control::create_agent_profile, agent_control::rotate_agent_profile, + agent_control::copy_agent_profile_token, agent_control::revoke_agent_profile, agent_control::delete_agent_profile, agent_control::decide_agent_approval, - agent_control::create_agent_approval_request, config::get_config, config::get_catalog_snapshot, settings::get_settings, 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/shared/types/bindings.ts b/src/shared/types/bindings.ts index f15bb8fd..6a8ec86d 100644 --- a/src/shared/types/bindings.ts +++ b/src/shared/types/bindings.ts @@ -19,14 +19,14 @@ export const commands = { 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 })), /** Revokes a trusted local agent profile. */ revokeAgentProfile: (id: string) => typedError(__TAURI_INVOKE("revoke_agent_profile", { 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 })), - /** Creates a pending approval request from the UI for tests and manual flows. */ - createAgentApprovalRequest: (agentId: string, agentName: string, action: string, target: string, diff: string, risk: string) => typedError(__TAURI_INVOKE("create_agent_approval_request", { agentId, agentName, action, target, diff, risk })), /** 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. */ @@ -106,7 +106,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 })), @@ -333,12 +333,10 @@ export type AgentProfile = { revoked: boolean, }; -/** Agent profile creation response. The token is shown only once. */ +/** Agent profile creation/rotation response. Raw bearer tokens stay backend-owned. */ export type AgentProfileTokenResponse = { /** Public profile metadata. */ profile: AgentProfile, - /** One-time bearer token. Store it in the calling agent, not in frontend state. */ - token: string, }; /** Agent capability scope. */ @@ -350,7 +348,9 @@ export type AgentScope = /** Change non-secret launcher, module, model, and provider settings. */ "configure" | /** Create integration drafts without installing or running them silently. */ -"draft-create"; +"draft-create" | +/** User-granted full local launcher access. */ +"full-access"; /** Complete AI model definition */ export type AiModel = { @@ -771,7 +771,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, }; From bdd41aa2000ffb7d6d7c0edff3301d5d3a2f07aa Mon Sep 17 00:00:00 2001 From: F0RLE Date: Fri, 22 May 2026 15:51:56 +0300 Subject: [PATCH 37/54] feat(agent): add launcher control ui --- src-tauri/resources/locales/en.json | 29 +- src-tauri/resources/locales/ru.json | 33 +- src-tauri/resources/locales/zh.json | 33 +- src/app/CoreLifecycleController.test.ts | 29 +- src/app/CoreLifecycleController.ts | 6 +- .../console/services/ConsoleLogNormalizer.ts | 19 +- .../services/ConsoleLogService.test.ts | 87 ++++ .../console/services/ConsoleLogService.ts | 139 +++++- .../console/ui/ConsoleFilterControlHelper.ts | 27 +- .../ui/ConsoleLogPresentationHelper.ts | 2 +- src/features/console/ui/ConsoleUI.test.ts | 43 ++ src/features/console/ui/ConsoleUI.ts | 58 ++- src/features/console/ui/ConsoleViewHelper.ts | 7 + .../settings/services/SettingsService.test.ts | 27 +- .../settings/services/SettingsService.ts | 76 ++- .../ui/AgentControlSettingsRenderer.test.ts | 130 +++++- .../ui/AgentControlSettingsRenderer.ts | 343 +++++++------- src/features/settings/ui/SettingsUI.ts | 4 +- src/public/templates/pages/console.html | 69 ++- src/public/templates/pages/settings.html | 45 +- src/styles/features/console-page.css | 34 ++ src/styles/features/settings-page.css | 435 ++++++++++++++---- 22 files changed, 1316 insertions(+), 359 deletions(-) diff --git a/src-tauri/resources/locales/en.json b/src-tauri/resources/locales/en.json index 2045a0e5..5c22f71a 100644 --- a/src-tauri/resources/locales/en.json +++ b/src-tauri/resources/locales/en.json @@ -106,7 +106,26 @@ "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_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", @@ -225,7 +244,6 @@ "ui.launcher.settings.taskbar_desc": "Configure sidebar page visibility", "ui.launcher.settings.taskbar_title": "Sidebar Management", "ui.launcher.settings.agent_control_title": "Agent Control", - "ui.launcher.settings.agent_control_desc": "Trusted local automation", "ui.launcher.settings.agent_control_loading": "Loading...", "ui.launcher.settings.agent_control_load_failed": "Failed to load Agent Control", "ui.launcher.settings.agent_control_enabled": "Enabled", @@ -233,10 +251,15 @@ "ui.launcher.settings.agent_control_enable": "Enable", "ui.launcher.settings.agent_control_disable": "Disable", "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": "Create Trusted Local", + "ui.launcher.settings.agent_control_create_full_access": "Create Full Access", "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 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_token_once": "Token shown once", "ui.launcher.settings.agent_control_token_hidden": "Hidden. Copy it now or reveal it explicitly.", @@ -260,8 +283,6 @@ "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_audit": "Action log", - "ui.launcher.settings.agent_control_no_audit": "No actions yet", "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", @@ -271,6 +292,7 @@ "ui.launcher.settings.agent_control_scope_operate": "operate", "ui.launcher.settings.agent_control_scope_configure": "configure", "ui.launcher.settings.agent_control_scope_draft-create": "draft-create", + "ui.launcher.settings.agent_control_scope_full-access": "full access", "ui.launcher.web.app_title": "Axelate", "ui.launcher.web.chat": "Chat", "ui.launcher.web.chat_clear": "Clear chat", @@ -300,6 +322,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", diff --git a/src-tauri/resources/locales/ru.json b/src-tauri/resources/locales/ru.json index 5504cc39..2ac8192f 100644 --- a/src-tauri/resources/locales/ru.json +++ b/src-tauri/resources/locales/ru.json @@ -106,7 +106,26 @@ "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_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": "Нет активных загрузок", @@ -226,7 +245,6 @@ "ui.launcher.settings.taskbar_desc": "Настройка видимости страниц в боковой панели", "ui.launcher.settings.taskbar_title": "Управление боковой панелью", "ui.launcher.settings.agent_control_title": "Управление агентом", - "ui.launcher.settings.agent_control_desc": "Доверенная локальная автоматизация", "ui.launcher.settings.agent_control_loading": "Загрузка...", "ui.launcher.settings.agent_control_load_failed": "Не удалось загрузить управление агентом", "ui.launcher.settings.agent_control_enabled": "Включено", @@ -234,10 +252,15 @@ "ui.launcher.settings.agent_control_enable": "Включить", "ui.launcher.settings.agent_control_disable": "Выключить", "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": "Создать Trusted Local", - "ui.launcher.settings.agent_control_trusted_local": "Trusted Local", + "ui.launcher.settings.agent_control_create_profile": "Создать доверенного локального", + "ui.launcher.settings.agent_control_create_full_access": "Создать полный доступ", + "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_token_once": "Токен показан один раз", "ui.launcher.settings.agent_control_token_hidden": "Скрыт. Скопируй сейчас или открой вручную.", @@ -261,8 +284,6 @@ "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_audit": "Журнал действий", - "ui.launcher.settings.agent_control_no_audit": "Действий пока нет", "ui.launcher.settings.agent_control_action_failed": "Действие не выполнено", "ui.launcher.settings.agent_control_copied": "Скопировано", "ui.launcher.settings.agent_control_copy_failed": "Не удалось скопировать", @@ -272,6 +293,7 @@ "ui.launcher.settings.agent_control_scope_operate": "управление", "ui.launcher.settings.agent_control_scope_configure": "настройки", "ui.launcher.settings.agent_control_scope_draft-create": "черновики", + "ui.launcher.settings.agent_control_scope_full-access": "полный доступ", "ui.launcher.web.app_title": "Axelate", "ui.launcher.web.chat": "Чат", "ui.launcher.web.chat_clear": "Очистить чат", @@ -301,6 +323,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": "Нет подключения к интернету", diff --git a/src-tauri/resources/locales/zh.json b/src-tauri/resources/locales/zh.json index 3719b4d0..bc2938b5 100644 --- a/src-tauri/resources/locales/zh.json +++ b/src-tauri/resources/locales/zh.json @@ -106,7 +106,26 @@ "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_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": "无活动下载", @@ -222,7 +241,6 @@ "ui.launcher.settings.taskbar_desc": "配置侧边栏页面可见性", "ui.launcher.settings.taskbar_title": "侧边栏管理", "ui.launcher.settings.agent_control_title": "代理控制", - "ui.launcher.settings.agent_control_desc": "受信任的本地自动化", "ui.launcher.settings.agent_control_loading": "正在加载...", "ui.launcher.settings.agent_control_load_failed": "无法加载代理控制", "ui.launcher.settings.agent_control_enabled": "已启用", @@ -230,10 +248,15 @@ "ui.launcher.settings.agent_control_enable": "启用", "ui.launcher.settings.agent_control_disable": "停用", "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": "创建 Trusted Local", - "ui.launcher.settings.agent_control_trusted_local": "Trusted Local", + "ui.launcher.settings.agent_control_create_profile": "创建受信任本地", + "ui.launcher.settings.agent_control_create_full_access": "创建完全访问", + "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_token_once": "令牌仅显示一次", "ui.launcher.settings.agent_control_token_hidden": "已隐藏。请立即复制,或手动显示。", @@ -257,8 +280,6 @@ "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_audit": "操作日志", - "ui.launcher.settings.agent_control_no_audit": "暂无操作", "ui.launcher.settings.agent_control_action_failed": "操作失败", "ui.launcher.settings.agent_control_copied": "已复制", "ui.launcher.settings.agent_control_copy_failed": "复制失败", @@ -268,6 +289,7 @@ "ui.launcher.settings.agent_control_scope_operate": "操作", "ui.launcher.settings.agent_control_scope_configure": "配置", "ui.launcher.settings.agent_control_scope_draft-create": "草稿", + "ui.launcher.settings.agent_control_scope_full-access": "完全访问", "ui.launcher.web.app_title": "Axelate", "ui.launcher.web.chat": "聊天", "ui.launcher.web.chat_clear": "清除聊天", @@ -297,6 +319,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": "网络连接不可用", diff --git a/src/app/CoreLifecycleController.test.ts b/src/app/CoreLifecycleController.test.ts index 73e57de2..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: {}, @@ -165,4 +165,31 @@ describe('CoreLifecycleController', () => { 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 dc4224b3..7f9fc760 100644 --- a/src/app/CoreLifecycleController.ts +++ b/src/app/CoreLifecycleController.ts @@ -308,7 +308,11 @@ export class CoreLifecycleController { } private async _applyAgentOpenPageRequest(payload: AgentOpenPagePayload): Promise { - const pageId = payload.pageId.trim(); + const rawPageId = (payload as { pageId?: unknown }).pageId; + if (typeof rawPageId !== 'string') { + return; + } + const pageId = rawPageId.trim(); if (pageId === '') { return; } 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 e1d48c7b..2ad80e5a 100644 --- a/src/features/console/services/ConsoleLogService.test.ts +++ b/src/features/console/services/ConsoleLogService.test.ts @@ -132,11 +132,98 @@ describe('ConsoleLogService', () => { await expect(service.getAvailableViews()).resolves.toEqual([ { id: 'general', label: 'Platform' }, + { id: 'agent', label: '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('keeps known runtime logs out of the general view after overview sync', async () => { setupTauri(bridge, true); vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ diff --git a/src/features/console/services/ConsoleLogService.ts b/src/features/console/services/ConsoleLogService.ts index f4bad180..6b2e51ac 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 { @@ -50,12 +51,14 @@ type ConsoleLogServiceLogger = Pick; 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']); + private readonly _knownViewIds = new Set(['general', ConsoleLogService._AGENT_VIEW_ID]); private readonly _normalizer = new ConsoleLogNormalizer(); + private _agentAuditClearTimestamp = 0; constructor( private readonly bridge: IBridge, @@ -71,11 +74,18 @@ export class ConsoleLogService { 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); @@ -90,6 +100,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); @@ -105,6 +120,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); @@ -128,7 +144,7 @@ export class ConsoleLogService { try { const result = await invokeSafe('get_console_overview'); if (result.status === 'ok') { - const views = this._normalizeViews(result.data.views); + const views = this._withAgentView(this._normalizeViews(result.data.views)); this._rememberKnownViews(views); return views; } @@ -138,7 +154,7 @@ export class ConsoleLogService { ); } - return [{ id: 'general', label: 'Platform' }]; + return this._withAgentView([{ id: 'general', label: 'Platform' }]); } public async getStatusItems(): Promise { @@ -207,9 +223,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) { @@ -219,6 +240,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 }); } @@ -226,6 +251,44 @@ 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() || 'Agent'; + const targetText = target === '' ? 'launcher' : target; + const resultText = 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 []; @@ -285,9 +348,24 @@ 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: '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 !== '') { @@ -356,4 +434,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..a2f99978 100644 --- a/src/features/console/ui/ConsoleLogPresentationHelper.ts +++ b/src/features/console/ui/ConsoleLogPresentationHelper.ts @@ -192,7 +192,7 @@ export class ConsoleLogPresentationHelper { const activeViewId = this._deps.getActiveViewId(); const fromView = activeViewId.startsWith('module:') ? activeViewId.slice('module:'.length) - : activeViewId !== 'general' + : activeViewId.startsWith('engine:') ? activeViewId : null; if (fromView !== null) { diff --git a/src/features/console/ui/ConsoleUI.test.ts b/src/features/console/ui/ConsoleUI.test.ts index 22ac11cc..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 }); diff --git a/src/features/console/ui/ConsoleUI.ts b/src/features/console/ui/ConsoleUI.ts index 0b7ab791..f85f6bfb 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, @@ -291,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 @@ -337,9 +339,18 @@ 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', + 'Agent actions are stored in the launcher audit log', + ) + : this._translate( + 'ui.debug.logs_open_folder_failed', + 'Failed to open logs folder', + ), + isAgentView ? 'info' : 'error', 1800, ); } @@ -397,12 +408,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; } @@ -420,6 +434,7 @@ export class ConsoleUI { this._activePane = null; this._syncActivePane(`logs-${this._viewState.activeViewId}`); this._syncTabScrollControls?.(); + this._syncViewActionState(); return true; } @@ -469,6 +484,7 @@ export class ConsoleUI { button?.classList.add('active'); this._activeTabButton = button ?? null; this._syncActivePane(paneId); + this._syncViewActionState(); } private _translate(key: string, fallback: string): string { @@ -519,4 +535,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', 'No folder for agent log') + : this._translate('ui.debug.logs_open_folder', 'Open Logs 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/services/SettingsService.test.ts b/src/features/settings/services/SettingsService.test.ts index cbc11c81..f6ade621 100644 --- a/src/features/settings/services/SettingsService.test.ts +++ b/src/features/settings/services/SettingsService.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ setAgentControlEnabled: vi.fn(), createAgentProfile: vi.fn(), rotateAgentProfile: vi.fn(), + copyAgentProfileToken: vi.fn(), revokeAgentProfile: vi.fn(), deleteAgentProfile: vi.fn(), decideAgentApproval: vi.fn(), @@ -33,6 +34,7 @@ vi.mock('@/shared/types/bindings', async (importOriginal) => { setAgentControlEnabled: mocks.commands.setAgentControlEnabled, createAgentProfile: mocks.commands.createAgentProfile, rotateAgentProfile: mocks.commands.rotateAgentProfile, + copyAgentProfileToken: mocks.commands.copyAgentProfileToken, revokeAgentProfile: mocks.commands.revokeAgentProfile, deleteAgentProfile: mocks.commands.deleteAgentProfile, decideAgentApproval: mocks.commands.decideAgentApproval, @@ -216,7 +218,6 @@ describe('SettingsService', () => { lastSeenAt: null, revoked: false, }, - token: 'axl_agent_123secret', }, }), ); @@ -226,7 +227,7 @@ describe('SettingsService', () => { 'operate', ]); - expect(result.token).toBe('axl_agent_123secret'); + expect(result.profile.tokenPrefix).toBe('axl_agent_123'); expect(mocks.commands.createAgentProfile).toHaveBeenCalledWith('Trusted Local', [ 'observe', 'operate', @@ -261,23 +262,43 @@ describe('SettingsService', () => { lastSeenAt: null, revoked: false, }, - token: 'axl_agent_456secret', }, }), ); + 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.revokeAgentProfile('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.revokeAgentProfile).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', () => { diff --git a/src/features/settings/services/SettingsService.ts b/src/features/settings/services/SettingsService.ts index b4455d39..782e98f0 100644 --- a/src/features/settings/services/SettingsService.ts +++ b/src/features/settings/services/SettingsService.ts @@ -24,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; @@ -110,7 +163,7 @@ export class SettingsService { return result.data; } this._tracer.error('[SettingsService] Failed to load Agent Control state:', result.error); - throw new Error(result.error.message); + throw new Error(appErrorMessage(result.error)); } public async setAgentControlEnabled(enabled: boolean): Promise { @@ -119,7 +172,7 @@ export class SettingsService { return result.data; } this._tracer.error('[SettingsService] Failed to update Agent Control:', result.error); - throw new Error(result.error.message); + throw new Error(appErrorMessage(result.error)); } public async createAgentProfile( @@ -131,7 +184,7 @@ export class SettingsService { return result.data; } this._tracer.error('[SettingsService] Failed to create Agent profile:', result.error); - throw new Error(result.error.message); + throw new Error(appErrorMessage(result.error)); } public async rotateAgentProfile(id: string): Promise { @@ -140,7 +193,16 @@ export class SettingsService { return result.data; } this._tracer.error('[SettingsService] Failed to rotate Agent profile:', result.error); - throw new Error(result.error.message); + 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 revokeAgentProfile(id: string): Promise { @@ -149,7 +211,7 @@ export class SettingsService { return result.data; } this._tracer.error('[SettingsService] Failed to revoke Agent profile:', result.error); - throw new Error(result.error.message); + throw new Error(appErrorMessage(result.error)); } public async deleteAgentProfile(id: string): Promise { @@ -158,7 +220,7 @@ export class SettingsService { return result.data; } this._tracer.error('[SettingsService] Failed to delete Agent profile:', result.error); - throw new Error(result.error.message); + throw new Error(appErrorMessage(result.error)); } public async decideAgentApproval(id: string, approved: boolean): Promise { @@ -167,7 +229,7 @@ export class SettingsService { return result.data; } this._tracer.error('[SettingsService] Failed to decide Agent approval:', result.error); - throw new Error(result.error.message); + throw new Error(appErrorMessage(result.error)); } public async loadGpuInfo(): Promise { diff --git a/src/features/settings/ui/AgentControlSettingsRenderer.test.ts b/src/features/settings/ui/AgentControlSettingsRenderer.test.ts index 7165deb9..1480c923 100644 --- a/src/features/settings/ui/AgentControlSettingsRenderer.test.ts +++ b/src/features/settings/ui/AgentControlSettingsRenderer.test.ts @@ -23,17 +23,45 @@ describe('AgentControlSettingsRenderer', () => { | 'setAgentControlEnabled' | 'createAgentProfile' | 'rotateAgentProfile' + | 'copyAgentProfileToken' | 'revokeAgentProfile' | 'deleteAgentProfile' | 'decideAgentApproval' >; - let copyText: (text: string) => Promise; let context: IAppSettingsUIContext; + let translations: Record; beforeEach(() => { document.body.innerHTML = '
'; - copyText = vi.fn().mockResolvedValue(undefined); vi.spyOn(globalThis, 'confirm').mockReturnValue(true); + translations = { + 'ui.launcher.settings.agent_control_create_profile': 'Create Trusted Local', + 'ui.launcher.settings.agent_control_trusted_local': 'Trusted Local', + '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_show_token': 'Show token', + '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_confirm_action': 'Confirm', + 'ui.launcher.settings.agent_control_revoked': 'Revoked', + 'ui.launcher.settings.agent_control_active': 'Active', + 'ui.launcher.settings.agent_control_rotate': 'Rotate', + 'ui.launcher.settings.agent_control_revoke': 'Revoke', + 'ui.launcher.settings.agent_control_never_seen': 'Never connected', + 'ui.launcher.settings.agent_control_scope_observe': 'observe', + 'ui.launcher.settings.agent_control_scope_operate': 'operate', + 'ui.launcher.settings.agent_control_scope_configure': 'configure', + 'ui.launcher.settings.agent_control_scope_draft-create': 'draft-create', + 'ui.launcher.settings.agent_control_scope_full-access': 'full access', + }; service = { getAgentControlState: vi.fn().mockResolvedValue(state()), setAgentControlEnabled: vi.fn().mockResolvedValue(state({ enabled: true })), @@ -47,15 +75,15 @@ describe('AgentControlSettingsRenderer', () => { lastSeenAt: null, revoked: false, }, - token: 'axl_agent_abc_secret', }), rotateAgentProfile: vi.fn(), + copyAgentProfileToken: vi.fn().mockResolvedValue(undefined), revokeAgentProfile: vi.fn(), deleteAgentProfile: vi.fn().mockResolvedValue(state()), decideAgentApproval: vi.fn(), }; context = { - t: (_key: string, fallback = '') => fallback, + t: (key: string, fallback = '') => translations[key] ?? fallback, showToast: vi.fn(), toggleNavItem: vi.fn(), toggleMonitorItem: vi.fn(), @@ -71,7 +99,6 @@ describe('AgentControlSettingsRenderer', () => { const renderer = new AgentControlSettingsRenderer( service as SettingsService, { error: vi.fn(), warn: vi.fn() } as unknown as LoggerService, - { copyText }, ); renderer.init(context); @@ -85,7 +112,7 @@ describe('AgentControlSettingsRenderer', () => { createButton?.click(); await vi.waitFor(() => { - expect(document.body.textContent).toContain('Hidden'); + expect(document.body.textContent).toContain('🔒'); }); expect(document.body.textContent).not.toContain('axl_agent_abc_secret'); expect(service.createAgentProfile).toHaveBeenCalledWith('Trusted Local', [ @@ -97,19 +124,12 @@ describe('AgentControlSettingsRenderer', () => { const copyTokenButton = Array.from( document.querySelectorAll('button'), - ).find((button) => button.textContent === 'Copy token'); + ).find((button) => button.getAttribute('aria-label') === 'Copy token'); copyTokenButton?.click(); await vi.waitFor(() => { - expect(copyText).toHaveBeenCalledWith('axl_agent_abc_secret'); - }); - - const showTokenButton = Array.from( - document.querySelectorAll('button'), - ).find((button) => button.textContent === 'Show token'); - showTokenButton?.click(); - await vi.waitFor(() => { - expect(document.body.textContent).toContain('axl_agent_abc_secret'); + expect(service.copyAgentProfileToken).toHaveBeenCalledWith('agent-1'); }); + expect(document.body.textContent).not.toContain('axl_agent_abc_secret'); }); it('renders pending approval requests and denies without mutating directly', async () => { @@ -135,7 +155,6 @@ describe('AgentControlSettingsRenderer', () => { const renderer = new AgentControlSettingsRenderer( service as SettingsService, { error: vi.fn(), warn: vi.fn() } as unknown as LoggerService, - { copyText }, ); renderer.init(context); @@ -153,6 +172,77 @@ describe('AgentControlSettingsRenderer', () => { }); }); + it('creates a manual Full Access profile only after confirmation', async () => { + service.createAgentProfile = vi.fn().mockResolvedValue({ + profile: { + id: 'agent-full', + name: 'Full Access', + scopes: ['full-access'], + tokenPrefix: 'axl_agent_full', + 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('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(document.body.textContent).toContain('🔒'); + }); + 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('keeps revoked profiles visible without offering another revoke action', async () => { service.getAgentControlState = vi.fn().mockResolvedValue( state({ @@ -172,7 +262,6 @@ describe('AgentControlSettingsRenderer', () => { const renderer = new AgentControlSettingsRenderer( service as SettingsService, { error: vi.fn(), warn: vi.fn() } as unknown as LoggerService, - { copyText }, ); renderer.init(context); @@ -212,7 +301,6 @@ describe('AgentControlSettingsRenderer', () => { const renderer = new AgentControlSettingsRenderer( service as SettingsService, { error: vi.fn(), warn: vi.fn() } as unknown as LoggerService, - { copyText }, ); renderer.init(context); @@ -225,6 +313,10 @@ describe('AgentControlSettingsRenderer', () => { ).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 index d6bd1cdd..d7d9d9b7 100644 --- a/src/features/settings/ui/AgentControlSettingsRenderer.ts +++ b/src/features/settings/ui/AgentControlSettingsRenderer.ts @@ -1,6 +1,5 @@ import type { AgentApprovalRequest, - AgentAuditEntry, AgentControlState, AgentProfile, AgentScope, @@ -9,30 +8,27 @@ import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { IAppSettingsUIContext } from './SettingsContext'; import type { SettingsService } from '../services/SettingsService'; -type AgentControlRuntime = { - copyText: (text: string) => Promise; -}; - type OneTimeToken = { profileId: string; - token: string; - revealed: boolean; + profileName: string; + scopes: AgentScope[]; }; const TRUSTED_LOCAL_SCOPES: AgentScope[] = ['observe', 'operate', 'configure', 'draft-create']; +const FULL_ACCESS_SCOPES: AgentScope[] = ['full-access']; export class AgentControlSettingsRenderer { private _context: IAppSettingsUIContext | null = null; private _panel: HTMLElement | null = null; private _state: AgentControlState | null = null; private _oneTimeToken: OneTimeToken | null = null; + private _confirmResetTimer: ReturnType | null = null; private _isDestroyed = false; private _isBusy = false; public constructor( private readonly _service: SettingsService, private readonly _tracer: Pick, - private readonly _runtime: AgentControlRuntime, ) {} public init(context: IAppSettingsUIContext): void { @@ -61,6 +57,7 @@ export class AgentControlSettingsRenderer { this._panel = null; this._state = null; this._oneTimeToken = null; + this._clearPendingConfirmation(); } private async _refresh(): Promise { @@ -74,9 +71,7 @@ export class AgentControlSettingsRenderer { } private _renderLoading(): void { - this._replacePanel( - this._element('div', 'agent-control-empty', this._t('loading', 'Loading...')), - ); + this._replacePanel(this._element('div', 'agent-control-empty', this._t('loading'))); } private _renderError(): void { @@ -84,7 +79,7 @@ export class AgentControlSettingsRenderer { this._element( 'div', 'agent-control-empty agent-control-empty--error', - this._t('load_failed', 'Failed to load Agent Control'), + this._t('load_failed'), ), ); } @@ -97,79 +92,87 @@ export class AgentControlSettingsRenderer { } 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._renderAudit(state.audit), ); this._replacePanel(root); } private _renderHeader(state: AgentControlState): HTMLElement { const header = this._element('div', 'agent-control-header'); - const status = this._element( - 'span', - `agent-control-status ${state.enabled ? 'is-on' : 'is-off'}`, - state.enabled ? this._t('enabled', 'Enabled') : this._t('disabled', 'Disabled'), - ); const toggle = this._button( - state.enabled ? this._t('disable', 'Disable') : this._t('enable', 'Enable'), - 'agent-control-btn', + 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', 'Disabled') - : this._t('enabled', 'Enabled'), + state.enabled ? this._t('disabled') : this._t('enabled'), 'success', ); this._render(); }); }, ); - header.append(status, toggle); + header.append(toggle); return header; } private _renderConnection(state: AgentControlState): HTMLElement { - const section = this._section(this._t('connection', 'Connection')); - const base = this._element('div', 'agent-control-code', state.apiBaseUrl); - const copyBase = this._button(this._t('copy_base', 'Copy URL'), 'agent-control-btn', () => { - void this._copy(state.apiBaseUrl); - }); - const copyConfig = this._button( - this._t('copy_config', 'Copy config'), - 'agent-control-btn agent-control-btn--primary', - () => { - void this._copy(this._configText(state)); - }, - ); const create = this._button( - this._t('create_profile', 'Create Trusted Local'), + this._t('create_profile'), 'agent-control-btn agent-control-btn--primary', () => { void this._run(async () => { const response = await this._service.createAgentProfile( - this._t('trusted_local', 'Trusted Local'), + this._t('trusted_local'), TRUSTED_LOCAL_SCOPES, ); this._oneTimeToken = { profileId: response.profile.id, - token: response.token, - revealed: false, + profileName: response.profile.name, + scopes: response.profile.scopes, }; this._state = await this._service.getAgentControlState(); - this._toast(this._t('profile_created', 'Profile created'), 'success'); + this._toast(this._t('profile_created'), 'success'); this._render(); }); }, ); - const actions = this._element('div', 'agent-control-actions'); - actions.append(copyBase, copyConfig, create); - section.append(base, actions); + 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._oneTimeToken = { + profileId: response.profile.id, + profileName: response.profile.name, + scopes: response.profile.scopes, + }; + this._state = await this._service.getAgentControlState(); + this._toast(this._t('profile_created'), 'success'); + this._render(); + }); + }, + ); + const section = this._section(this._t('connection'), [create, createFullAccess]); + const endpoint = this._element('div', 'agent-control-endpoint'); + endpoint.append( + this._element('div', 'agent-control-field-label', this._t('base_url')), + this._element('div', 'agent-control-code', state.apiBaseUrl), + ); + section.append(endpoint); if (this._oneTimeToken !== null) { section.append(this._renderOneTimeToken()); @@ -181,111 +184,102 @@ export class AgentControlSettingsRenderer { private _renderOneTimeToken(): HTMLElement { const token = this._oneTimeToken; const box = this._element('div', 'agent-control-token'); - const label = this._element( - 'div', - 'agent-control-token-label', - this._t('token_once', 'Token shown once'), - ); - const value = this._element( - 'div', - 'agent-control-code', - token?.revealed === true - ? token.token - : this._t('token_hidden', 'Hidden. Copy it now or reveal it explicitly.'), - ); - const copy = this._button(this._t('copy_token', 'Copy token'), 'agent-control-btn', () => { + const label = this._element('div', 'agent-control-token-label', this._t('token_once')); + const value = this._element('div', 'agent-control-code', '🔒 ••••••••••••••••'); + const copyTokenLabel = this._t('copy_token'); + const copy = this._button('📋', 'agent-control-btn agent-control-btn--icon', () => { if (token !== null) { - void this._copy(token.token); + void this._copyOneTimeToken(token.profileId); } }); - const reveal = this._button( - token?.revealed === true - ? this._t('hide_token', 'Hide token') - : this._t('show_token', 'Show token'), - 'agent-control-btn', - () => { - if (token !== null) { - token.revealed = !token.revealed; - this._render(); - } - }, - ); + copy.title = copyTokenLabel; + copy.setAttribute('aria-label', copyTokenLabel); const actions = this._element('div', 'agent-control-actions'); - actions.append(copy, reveal); + actions.append(copy); box.append(label, value, actions); return box; } private _renderProfiles(profiles: AgentProfile[]): HTMLElement { - const section = this._section(this._t('profiles', 'Agents')); + const section = this._section(this._t('profiles')); if (profiles.length === 0) { - section.append( - this._element( - 'div', - 'agent-control-empty', - this._t('no_profiles', 'No agents yet'), - ), - ); + 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'); + const row = this._element( + 'div', + `agent-control-row ${profile.revoked ? 'is-revoked' : 'is-active'}`, + ); const main = this._element('div', 'agent-control-row-main'); - main.append( + 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.revoked ? this._t('revoked', 'Revoked') : this._t('active', 'Active')} · ${profile.tokenPrefix} · ${this._lastSeen(profile.lastSeenAt)}`, + `${profile.tokenPrefix} · ${this._lastSeen(profile.lastSeenAt)}`, ), this._renderScopes(profile.scopes), ); const actions = this._element('div', 'agent-control-row-actions'); actions.append( - this._button(this._t('rotate', 'Rotate'), 'agent-control-btn', () => { + this._button(this._t('rotate'), 'agent-control-btn', () => { void this._run(async () => { const response = await this._service.rotateAgentProfile(profile.id); this._oneTimeToken = { profileId: response.profile.id, - token: response.token, - revealed: false, + profileName: response.profile.name, + scopes: response.profile.scopes, }; this._state = await this._service.getAgentControlState(); - this._toast(this._t('token_rotated', 'Token rotated'), 'success'); + this._toast(this._t('token_rotated'), 'success'); this._render(); }); }), ); if (!profile.revoked) { actions.append( - this._button(this._t('revoke', 'Revoke'), 'agent-control-btn', () => { + this._button(this._t('revoke'), 'agent-control-btn', () => { void this._run(async () => { this._state = await this._service.revokeAgentProfile(profile.id); if (this._oneTimeToken?.profileId === profile.id) { this._oneTimeToken = null; } - this._toast(this._t('profile_revoked', 'Profile revoked'), 'success'); + this._toast(this._t('profile_revoked'), 'success'); this._render(); }); }), ); } actions.append( - this._button(this._t('delete_profile', 'Delete'), 'agent-control-btn', () => { - if (!this._confirmDeleteProfile(profile.name)) { - return; - } - void this._run(async () => { - this._state = await this._service.deleteAgentProfile(profile.id); - if (this._oneTimeToken?.profileId === profile.id) { - this._oneTimeToken = null; + this._button( + this._t('delete_profile'), + 'agent-control-btn agent-control-btn--danger', + (button) => { + if (!this._confirmDangerousButton(button)) { + return; } - this._toast(this._t('profile_deleted', 'Profile deleted'), 'success'); - this._render(); - }); - }), + void this._run(async () => { + this._state = await this._service.deleteAgentProfile(profile.id); + if (this._oneTimeToken?.profileId === profile.id) { + this._oneTimeToken = null; + } + this._toast(this._t('profile_deleted'), 'success'); + this._render(); + }); + }, + ), ); row.append(main, actions); list.append(row); @@ -303,16 +297,10 @@ export class AgentControlSettingsRenderer { } private _renderApprovals(approvals: AgentApprovalRequest[]): HTMLElement { - const section = this._section(this._t('approvals', 'Approvals')); + 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', 'No pending approvals'), - ), - ); + section.append(this._element('div', 'agent-control-empty', this._t('no_approvals'))); return section; } @@ -320,25 +308,30 @@ export class AgentControlSettingsRenderer { pending.forEach((approval) => { const row = this._element('div', 'agent-control-row agent-control-row--approval'); const main = this._element('div', 'agent-control-row-main'); - main.append( + 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} · ${approval.risk}`, + `${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', 'Approve'), + this._t('approve'), 'agent-control-btn agent-control-btn--primary', () => { void this._decideApproval(approval.id, true); }, ), - this._button(this._t('deny', 'Deny'), 'agent-control-btn', () => { + this._button(this._t('deny'), 'agent-control-btn', () => { void this._decideApproval(approval.id, false); }), ); @@ -349,35 +342,11 @@ export class AgentControlSettingsRenderer { return section; } - private _renderAudit(audit: AgentAuditEntry[]): HTMLElement { - const section = this._section(this._t('audit', 'Action log')); - if (audit.length === 0) { - section.append( - this._element('div', 'agent-control-empty', this._t('no_audit', 'No actions yet')), - ); - return section; - } - - const list = this._element('div', 'agent-control-audit'); - audit.slice(0, 6).forEach((entry) => { - const item = this._element( - 'div', - 'agent-control-audit-item', - `${entry.actorName} · ${entry.action} · ${entry.target} · ${entry.result}`, - ); - list.append(item); - }); - 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', 'Approved') - : this._t('approval_denied', 'Denied'), + approved ? this._t('approval_approved') : this._t('approval_denied'), 'success', ); this._render(); @@ -392,55 +361,90 @@ export class AgentControlSettingsRenderer { await action(); } catch (error) { this._tracer.error('[AgentControlSettingsRenderer] Action failed:', error); - this._toast(this._t('action_failed', 'Action failed'), 'error'); + this._toast(this._t('action_failed'), 'error'); } finally { this._isBusy = false; this._setButtonsDisabled(false); } } - private async _copy(text: string): Promise { + private async _copyOneTimeToken(profileId: string): Promise { try { - await this._runtime.copyText(text); - this._toast(this._t('copied', 'Copied'), 'success'); + await this._service.copyAgentProfileToken(profileId); + this._oneTimeToken = null; + this._toast(this._t('copied'), 'success'); + this._render(); } catch (error) { this._tracer.error('[AgentControlSettingsRenderer] Copy failed:', error); - this._toast(this._t('copy_failed', 'Copy failed'), 'error'); + this._toast(this._t('copy_failed'), 'error'); } } - private _configText(state: AgentControlState): string { - const token = this._oneTimeToken?.token ?? ''; - return JSON.stringify( - { - name: this._t('trusted_local', 'Trusted Local'), - baseUrl: state.apiBaseUrl, - authorization: `Bearer ${token}`, - scopes: TRUSTED_LOCAL_SCOPES, - }, - null, - 2, - ); - } - - private _section(title: string): HTMLElement { + private _section(title: string, actions: HTMLButtonElement[] = []): HTMLElement { const section = this._element('section', 'agent-control-section'); - section.append(this._element('div', 'agent-control-section-title', title)); + 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 _confirmDeleteProfile(name: string): boolean { - return globalThis.confirm( - this._t('delete_profile_confirm', `Delete agent profile "${name}"?`), - ); + 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 _button(label: string, className: string, onClick: () => void): HTMLButtonElement { + 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.addEventListener('click', () => { + onClick(button); + }); return button; } @@ -469,9 +473,9 @@ export class AgentControlSettingsRenderer { private _lastSeen(value: string | null): string { if (value === null) { - return this._t('never_seen', 'Never connected'); + return this._t('never_seen'); } - return `${this._t('last_seen', 'Last seen')} ${this._formatDate(value)}`; + return `${this._t('last_seen')} ${this._formatDate(value)}`; } private _formatDate(value: string): string { @@ -483,11 +487,12 @@ export class AgentControlSettingsRenderer { } private _scopeLabel(scope: AgentScope): string { - return this._t(`scope_${scope}`, scope); + return this._t(`scope_${scope}`); } - private _t(key: string, fallback: string): string { - return this._context?.t(`ui.launcher.settings.agent_control_${key}`, fallback) ?? fallback; + 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 { diff --git a/src/features/settings/ui/SettingsUI.ts b/src/features/settings/ui/SettingsUI.ts index c6978849..a2a13cbd 100644 --- a/src/features/settings/ui/SettingsUI.ts +++ b/src/features/settings/ui/SettingsUI.ts @@ -40,9 +40,7 @@ export class SettingsUI { private readonly _deps: SettingsUIDeps, ) { this._generalRenderer = new GeneralSettingsRenderer(uiSettings, this._deps.tracer); - this._agentControlRenderer = new AgentControlSettingsRenderer(service, this._deps.tracer, { - copyText: (text) => this._tauri.writeToClipboard(text), - }); + this._agentControlRenderer = new AgentControlSettingsRenderer(service, this._deps.tracer); } public async init(): Promise { diff --git a/src/public/templates/pages/console.html b/src/public/templates/pages/console.html index 543e4242..ce0aa4eb 100644 --- a/src/public/templates/pages/console.html +++ b/src/public/templates/pages/console.html @@ -4,7 +4,9 @@ class="console-tab-scroll console-tab-scroll-left" type="button" aria-label="Previous log tabs" + data-i18n-aria-label="ui.debug.logs_tabs_previous" title="Previous" + data-i18n-title="ui.debug.logs_tabs_previous" hidden > ‹ @@ -18,7 +20,9 @@ class="console-tab-scroll console-tab-scroll-right" type="button" aria-label="Next log tabs" + data-i18n-aria-label="ui.debug.logs_tabs_next" title="Next" + data-i18n-title="ui.debug.logs_tabs_next" hidden > › @@ -32,39 +36,84 @@ -