diff --git a/.coderabbit.yaml b/.coderabbit.yaml index bf139edf..ee412d8e 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,18 +1,79 @@ # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "en-US" +tone_instructions: "Be direct and concise. Prioritize correctness, security, data loss, broken user flows, and missing tests over style-only comments." + reviews: - review_status: false - path_filters: - - "!src/dist/**" - - "!src/coverage/**" - - "!src/node_modules/**" - - "!src-tauri/target/**" - - "!src-tauri/gen/**" - - "!src/shared/types/bindings.ts" - - "!docs/localization/**" - - "!**/*.lock" - - "!**/package-lock.json" - auto_review: - enabled: true - drafts: true - base_branches: - - "nightly" + profile: "chill" + request_changes_workflow: false + high_level_summary: true + review_status: false + collapse_walkthrough: true + poem: false + suggested_labels: true + auto_apply_labels: false + labeling_instructions: + - label: "frontend" + instructions: "Use for changes under src app, features, shared UI, services, styles, tests, or frontend tooling." + - label: "backend" + instructions: "Use for Rust, Tauri commands, src-tauri resources, engine runtime, module lifecycle, secure storage, logging, or IPC bindings." + - label: "security" + instructions: "Use when changes affect secrets, authentication, filesystem access, command execution, downloads, archives, update/release trust, CSP, or IPC permissions." + - label: "ci" + instructions: "Use for GitHub Actions, Dependabot, repository automation, hooks, scripts, or workflow configuration." + - label: "dependencies" + instructions: "Use for dependency, lockfile, toolchain, npm, cargo, or GitHub Actions version changes." + - label: "release" + instructions: "Use for versioning, tagging, packaging, checksums, installers, release notes, or release workflow changes." + path_filters: + - "!src/dist/**" + - "!src/node_modules/**" + - "!src-tauri/target/**" + - "!src-tauri/gen/**" + - "!.cache/**" + - "!.github/ISSUE_TEMPLATE/**" + - "!**/.DS_Store" + path_instructions: + - path: "src-tauri/src/**/*.rs" + instructions: "Review as a Windows-first Tauri backend. Prioritize IPC boundary validation, path traversal, archive extraction safety, process spawning, cancellation, secure storage, logging, and error mapping. Check that frontend-callable commands never expose raw secrets and keep user data scoped to app directories." + - path: "src/**/*.ts" + instructions: "Review as vanilla TypeScript frontend code. Prioritize state consistency, async cancellation, event listener cleanup, DOM injection risks, user-visible regressions, and test coverage for changed flows. Avoid style-only comments unless they hide a real defect." + - path: ".github/workflows/*.yml" + instructions: "Review GitHub Actions for least-privilege permissions, pinned or versioned actions, correct branch/tag triggers, cache safety, and whether failures are intentional or accidentally continue-on-error." + - path: "src-tauri/resources/**/*.json" + instructions: "Review resource changes for schema consistency, localization key parity, default settings safety, and compatibility with existing config readers." + auto_review: + enabled: true + drafts: true + auto_incremental_review: true + auto_pause_after_reviewed_commits: 0 + base_branches: + - "nightly" + - "main" + tools: + eslint: + enabled: true + clippy: + enabled: true + actionlint: + enabled: true + gitleaks: + enabled: true + trufflehog: + enabled: true + semgrep: + enabled: true + +chat: + auto_reply: true + +knowledge_base: + code_guidelines: + enabled: true + filePatterns: + - "README.md" + - "CONTRIBUTING.md" + - "SECURITY.md" + - "AGENTS.md" + - "docs/localization/en/DEVELOPMENT_WORKFLOW.md" + - "docs/localization/en/TRUST_MODEL.md" + - "docs/localization/en/ARCHITECTURE.md" diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 00000000..5ac14a0f --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,31 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "Axelate" + +[setup] +script = "npm run setup" + +[[actions]] +name = "Development" +icon = "run" +command = "npm run dev" + +[[actions]] +name = "Development (release-like)" +icon = "run" +command = "npm run dev:release-like" + +[[actions]] +name = "Check project" +icon = "check" +command = "npm run verify" + +[[actions]] +name = "Doctor" +icon = "terminal" +command = "npm run doctor" + +[[actions]] +name = "Release" +icon = "package" +command = "npm run release" diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..c2d80092 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +* @F0RLE + diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..3e3e0118 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,46 @@ +name: Bug Report +description: Report a reproducible problem in Axelate. +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Use this for reproducible problems in the current application. + - type: textarea + id: description + attributes: + label: What happened? + description: Describe the problem and what you expected instead. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Reproduction steps + description: List the smallest set of steps that reproduces the issue. + placeholder: | + 1. Open ... + 2. Click ... + 3. See ... + validations: + required: true + - type: input + id: version + attributes: + label: Axelate version or commit + placeholder: v0.1.5 or commit SHA + validations: + required: true + - type: input + id: windows + attributes: + label: Windows version + placeholder: Windows 11 24H2 + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant logs or screenshots + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..a7a0ede2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Development setup + url: https://github.com/F0RLE/Axelate/blob/nightly/docs/en/GETTING_STARTED.md + about: Start here for local development prerequisites and setup. + - name: Release process + url: https://github.com/F0RLE/Axelate/blob/nightly/docs/en/RELEASES.md + about: Read this before tagging or preparing a release. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..19d2c073 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,24 @@ +name: Feature Request +description: Propose a product or developer workflow improvement. +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What user or developer problem should this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + description: Describe the smallest useful version of the change. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Note any simpler options or tradeoffs. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..78d5e5af --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ +## Summary + +- + +## Verification + +- [ ] `npm run verify` + +## Checklist + +- [ ] The PR targets `nightly`, unless this is a release PR to `main`. +- [ ] User-facing behavior is documented in `README.md` or `docs/localization/en` when needed. +- [ ] Rust-exported frontend bindings were regenerated with `npm run bindings:sync` when Rust types changed. +- [ ] No generated build output, cache files, local runtime data, or secrets are committed. diff --git a/.github/scripts/integration/doctor.mjs b/.github/scripts/integration/doctor.mjs new file mode 100644 index 00000000..e4b50213 --- /dev/null +++ b/.github/scripts/integration/doctor.mjs @@ -0,0 +1,228 @@ +#!/usr/bin/env node + +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const requireFromFrontend = createRequire(path.join(repoRoot, 'src', 'package.json')); +const { parse: parseToml } = requireFromFrontend('smol-toml'); + +const FORBIDDEN_ENTRIES = new Set([ + '.axelate', + '.git', + '.venv', + '__pycache__', + 'build', + 'dist', + 'node_modules', + 'target', +]); + +const VALID_RUNTIME_KINDS = new Set(['python', 'node', 'bun', 'binary']); +const REQUIRED_TOP_LEVEL_FIELDS = ['api_version', 'id', 'name', 'version', 'type']; + +const targetArg = process.argv.slice(2).find((arg) => !arg.startsWith('--')) ?? '.'; +const root = path.resolve(targetArg); +const manifestPath = path.join(root, 'axelate-module.toml'); +const findings = []; + +function add(level, message) { + findings.push({ level, message }); +} + +function readManifest() { + if (!existsSync(root)) { + add('error', `Integration folder does not exist: ${root}`); + return null; + } + + if (!statSync(root).isDirectory()) { + add('error', `Integration path must be a directory: ${root}`); + return null; + } + + if (!existsSync(manifestPath)) { + add('error', 'Missing axelate-module.toml'); + return null; + } + + return readFileSync(manifestPath, 'utf8'); +} + +function parseManifest(source) { + const parsed = parseToml(source); + const values = new Map(); + flattenToml(parsed, '', values); + return values; +} + +function flattenToml(value, prefix, values) { + if ( + value === null || + typeof value !== 'object' || + value instanceof Date || + Array.isArray(value) + ) { + values.set(prefix, value); + return; + } + + Object.entries(value).forEach(([key, child]) => { + const childKey = prefix.length === 0 ? key : `${prefix}.${key}`; + flattenToml(child, childKey, values); + }); +} + +function isSafeRelativePath(value) { + if (typeof value !== 'string' || value.trim().length === 0) { + return false; + } + + const normalized = value.replaceAll('\\', '/'); + if (path.isAbsolute(normalized)) { + return false; + } + + return !normalized.split('/').some((part) => part === '..' || part.length === 0); +} + +function checkExistingFile(values, key) { + const value = values.get(key); + if (value === undefined) { + return; + } + + if (!isSafeRelativePath(value)) { + add('error', `${key} must be a safe relative path`); + return; + } + + const resolved = path.resolve(root, value); + if (!resolved.startsWith(`${root}${path.sep}`) && resolved !== root) { + add('error', `${key} resolves outside the integration folder`); + return; + } + + if (!existsSync(resolved) || !statSync(resolved).isFile()) { + add('error', `${key} points to a missing file: ${value}`); + } +} + +function checkSettingsUi(values) { + const value = values.get('settings_ui'); + if (value === undefined) { + add('warn', 'No settings_ui configured; users will not get a custom settings panel.'); + return; + } + + if (!isSafeRelativePath(value)) { + add('error', 'settings_ui must be a safe relative path'); + return; + } + + const resolved = path.resolve(root, value); + if (!resolved.startsWith(`${root}${path.sep}`) && resolved !== root) { + add('error', 'settings_ui resolves outside the integration folder'); + return; + } + + if (!existsSync(resolved)) { + add('error', `settings_ui path does not exist: ${value}`); + return; + } + + const stats = statSync(resolved); + if (stats.isDirectory()) { + const indexPath = path.join(resolved, 'index.html'); + if (!existsSync(indexPath) || !statSync(indexPath).isFile()) { + add('error', `settings_ui directory must contain index.html: ${value}`); + } + return; + } + + if (!stats.isFile() || path.basename(resolved).toLowerCase() !== 'index.html') { + add('warn', 'settings_ui should usually point to an index.html file or directory.'); + } +} + +function checkFilesystemTree(directory = root) { + readdirSync(directory, { withFileTypes: true }).forEach((entry) => { + const entryPath = path.join(directory, entry.name); + const relativePath = path.relative(root, entryPath); + if (FORBIDDEN_ENTRIES.has(entry.name)) { + add('error', `Do not ship generated/runtime directory: ${relativePath}`); + return; + } + + if (entry.isSymbolicLink()) { + add('error', `Symlinks are not supported in integration imports: ${relativePath}`); + return; + } + + if (entry.isDirectory()) { + checkFilesystemTree(entryPath); + } + }); +} + +function checkManifest(values) { + REQUIRED_TOP_LEVEL_FIELDS.forEach((field) => { + if (!values.has(field)) { + add('error', `Missing required manifest field: ${field}`); + } + }); + + const id = values.get('id'); + if (typeof id === 'string' && !/^[A-Za-z0-9_-]+$/u.test(id)) { + add('error', 'id may contain only letters, numbers, "-" and "_"'); + } + + const runtimeKind = values.get('runtime.kind'); + if (!runtimeKind) { + add('error', 'Missing [runtime].kind'); + } else if (!VALID_RUNTIME_KINDS.has(runtimeKind)) { + add('error', `runtime.kind must be one of: ${Array.from(VALID_RUNTIME_KINDS).join(', ')}`); + } + + if (!values.has('runtime.entry')) { + add('error', 'Missing [runtime].entry'); + } else { + checkExistingFile(values, 'runtime.entry'); + } + + checkExistingFile(values, 'runtime.dependencies'); + checkSettingsUi(values); + + if (runtimeKind === 'binary' && !values.has('lifecycle.start.program')) { + add('error', 'binary integrations must define [lifecycle.start].program'); + } +} + +const source = readManifest(); +if (source !== null) { + try { + const values = parseManifest(source); + checkManifest(values); + checkFilesystemTree(); + } catch (error) { + add('error', `Failed to parse axelate-module.toml: ${String(error)}`); + } +} + +const errors = findings.filter((finding) => finding.level === 'error'); +const warnings = findings.filter((finding) => finding.level === 'warn'); + +if (findings.length === 0) { + console.log(`[integration:doctor] ok ${root}`); +} else { + findings.forEach((finding) => { + console.log(`[integration:doctor] ${finding.level}: ${finding.message}`); + }); +} + +console.log(`[integration:doctor] ${errors.length} error(s), ${warnings.length} warning(s)`); + +process.exit(errors.length > 0 ? 1 : 0); diff --git a/.github/scripts/integration/scaffold.mjs b/.github/scripts/integration/scaffold.mjs new file mode 100644 index 00000000..6a7a84e1 --- /dev/null +++ b/.github/scripts/integration/scaffold.mjs @@ -0,0 +1,482 @@ +#!/usr/bin/env node + +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const args = process.argv.slice(2); +const targetArg = args.find((arg) => !arg.startsWith('--')); + +if (!targetArg) { + console.error( + 'Usage: npm run integration:new -- [--id my-id] [--name "My Integration"] [--runtime python|node|bun]', + ); + process.exit(1); +} + +function optionValue(name, fallback) { + const index = args.indexOf(name); + if (index === -1 || index + 1 >= args.length) { + return fallback; + } + + return args[index + 1]; +} + +function slugFromName(value) { + return ( + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/gu, '-') + .replace(/^-+|-+$/gu, '') || 'my-integration' + ); +} + +function fail(message) { + console.error(`[integration:new] ${message}`); + process.exit(1); +} + +function validateId(value) { + const trimmed = String(value).trim(); + if (!/^[A-Za-z0-9_-]+$/u.test(trimmed)) { + fail('Integration id may contain only letters, numbers, "-" and "_".'); + } + + return trimmed; +} + +function validateName(value) { + const trimmed = String(value).trim().replace(/\s+/gu, ' '); + if (!/^[\p{L}\p{N} _-]+$/u.test(trimmed)) { + fail('Integration name may contain only letters, numbers, spaces, "-" and "_".'); + } + + return trimmed; +} + +function validateRuntime(value) { + const runtime = String(value).trim().toLowerCase(); + if (!['python', 'node', 'bun'].includes(runtime)) { + fail('Integration runtime must be one of: python, node, bun.'); + } + + return runtime; +} + +const target = path.resolve(targetArg); +const defaultId = slugFromName(path.basename(target)); +const id = validateId(optionValue('--id', defaultId)); +const name = validateName(optionValue('--name', id.replace(/[-_]+/gu, ' '))); +const runtime = validateRuntime(optionValue('--runtime', 'python')); + +if (existsSync(target)) { + console.error(`Target already exists: ${target}`); + process.exit(1); +} + +mkdirSync(path.join(target, 'src'), { recursive: true }); +mkdirSync(path.join(target, 'settings-ui'), { recursive: true }); + +const runtimeManifest = buildRuntimeManifest(runtime); +writeFileSync( + path.join(target, 'axelate-module.toml'), + `api_version = "1" +id = "${id}" +name = "${name}" +version = "0.1.0" +description = "Connects ${name} to Axelate AI." +author = "Your Name" +type = "service" +icon = "⚙" +readme = "README.md" +settings_ui = "settings-ui/index.html" + +[runtime] +${runtimeManifest} +`, +); + +writeFileSync( + path.join(target, 'README.md'), + `# ${name} + +Axelate integration scaffold. + +Runtime: ${runtime} + +## Run + +1. Import this folder in Axelate. +2. Open the integration settings and save a prompt. +3. Launch the integration card. + +Use \`npm run integration:doctor -- ${target}\` from the Axelate repository to validate the package. +`, +); + +if (runtime === 'python') { + writeFileSync( + path.join(target, 'src', 'main.py'), + buildPythonMain(), + ); +} else { + writeFileSync(path.join(target, 'package.json'), buildPackageJson(id, name, runtime)); + writeFileSync(path.join(target, 'src', 'axelate-client.mjs'), buildJavaScriptClient()); + writeFileSync(path.join(target, 'src', 'main.mjs'), buildJavaScriptMain()); +} + +function buildRuntimeManifest(selectedRuntime) { + if (selectedRuntime === 'python') { + return `kind = "python" +version = "3.11" +entry = "src/main.py"`; + } + + return `kind = "${selectedRuntime}" +version = "system" +entry = "src/main.mjs" +dependencies = "package.json" +package_manager = "${selectedRuntime === 'bun' ? 'bun' : 'npm'}"`; +} + +function buildPackageJson(packageId, packageName, selectedRuntime) { + return `${JSON.stringify( + { + name: packageId, + version: '0.1.0', + private: true, + description: `Connects ${packageName} to Axelate AI.`, + type: 'module', + scripts: { + start: `${selectedRuntime === 'bun' ? 'bun' : 'node'} src/main.mjs`, + }, + dependencies: {}, + }, + null, + 2, + )} +`; +} + +function buildPythonMain() { + return `from __future__ import annotations + +import json +import os +import urllib.parse +import urllib.error +import urllib.request + + +def required_env(name: str) -> str: + value = os.environ.get(name) + if value is None or value.strip() == "": + raise RuntimeError(f"Missing required environment variable: {name}") + return value + + +def validate_base_url(value: str) -> str: + parsed = urllib.parse.urlparse(value) + if parsed.scheme not in {"http", "https"} or parsed.hostname not in { + "localhost", + "127.0.0.1", + "::1", + }: + raise ValueError("AXELATE_HTTP_API_BASE must be an http(s) loopback URL.") + return value.rstrip("/") + + +BASE_URL = validate_base_url(required_env("AXELATE_HTTP_API_BASE")) +TOKEN = required_env("AXELATE_HTTP_API_TOKEN") +MODULE_ID = required_env("AXELATE_MODULE_ID") +MODULE_PATH_ID = urllib.parse.quote(MODULE_ID, safe="") + + +def request(method: str, path: str, payload: dict | None = None) -> dict: + data = None if payload is None else json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + f"{BASE_URL}{path}", + data=data, + method=method, + headers={ + "Authorization": f"Bearer {TOKEN}", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=120) as response: + text = response.read().decode("utf-8") + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", errors="replace") + raise RuntimeError(f"{method} {path} failed with HTTP {error.code}: {body}") from error + except urllib.error.URLError as error: + raise RuntimeError(f"{method} {path} failed: {error.reason}") from error + + return json.loads(text) if text.strip() else {} + + +def main() -> None: + settings = request("GET", f"/v1/modules/{MODULE_PATH_ID}/settings").get("settings", {}) + prompt = settings.get("prompt") or "Write a short status update." + request( + "POST", + f"/v1/modules/{MODULE_PATH_ID}/stage", + {"stage": "ai.request", "label": "Calling Axelate AI", "progress": 0.5}, + ) + result = request("POST", "/v1/ai/text", {"prompt": prompt, "sessionId": MODULE_ID}) + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() +`; +} + +function buildJavaScriptClient() { + return `export class AxelateClient { + constructor(env = globalThis.process?.env ?? {}) { + this.baseUrl = validateBaseUrl(requiredEnv(env, "AXELATE_HTTP_API_BASE")).replace(/\\/$/u, ""); + this.token = requiredEnv(env, "AXELATE_HTTP_API_TOKEN"); + this.moduleId = requiredEnv(env, "AXELATE_MODULE_ID"); + this.modulePathId = encodeURIComponent(this.moduleId); + } + + async request(method, path, payload) { + const response = await fetch(\`\${this.baseUrl}\${path}\`, { + method, + headers: { + Authorization: \`Bearer \${this.token}\`, + "Content-Type": "application/json", + }, + body: payload === undefined ? undefined : JSON.stringify(payload), + }); + + const body = await readResponseBody(response); + if (!response.ok) { + const message = + body && typeof body === "object" && "error" in body + ? body.error + : \`Axelate request failed: \${response.status}\`; + throw new Error(String(message)); + } + + return body; + } + + settings() { + return this.request("GET", \`/v1/modules/\${this.modulePathId}/settings\`).then( + (body) => body.settings ?? {}, + ); + } + + stage(stage, label, progress) { + const payload = { stage, label }; + if (progress !== undefined) { + payload.progress = progress; + } + return this.request("POST", \`/v1/modules/\${this.modulePathId}/stage\`, payload); + } + + aiText(prompt, options = {}) { + return this.request("POST", "/v1/ai/text", { + prompt, + sessionId: this.moduleId, + ...options, + }); + } +} + +function requiredEnv(env, name) { + const value = String(env[name] ?? ""); + if (value.trim().length === 0) { + throw new Error(\`Missing required Axelate integration env var: \${name}\`); + } + + return value; +} + +function validateBaseUrl(value) { + const url = new URL(value); + const allowedHosts = new Set(["localhost", "127.0.0.1", "[::1]"]); + if (!["http:", "https:"].includes(url.protocol) || !allowedHosts.has(url.hostname)) { + throw new Error("AXELATE_HTTP_API_BASE must be an http(s) loopback URL."); + } + + return value; +} + +async function readResponseBody(response) { + if (response.status === 204) { + return {}; + } + + const contentType = response.headers.get("content-type") ?? ""; + const text = await response.text(); + if (text.length === 0) { + return {}; + } + + if (contentType.includes("application/json")) { + return JSON.parse(text); + } + + return { text }; +} +`; +} + +function buildJavaScriptMain() { + return `import { AxelateClient } from "./axelate-client.mjs"; + +const client = new AxelateClient(); +const settings = await client.settings(); +const prompt = settings.prompt ?? "Write a short status update."; + +await client.stage("ai.request", "Calling Axelate AI", 0.5); +const result = await client.aiText(prompt); + +console.log(JSON.stringify(result, null, 2)); +`; +} + +writeFileSync( + path.join(target, 'settings-ui', 'axelate-settings-bridge.js'), + `const CHANNEL = "axelate:module-settings"; + +export class AxelateSettingsBridge { + constructor({ target = window.parent, allowedOrigin = window.location.origin } = {}) { + this.target = target; + this.allowedOrigin = allowedOrigin; + this.pending = new Map(); + this.context = null; + this.settings = {}; + window.addEventListener("message", (event) => this.handleMessage(event)); + } + + ready() { + this.target.postMessage({ channel: CHANNEL, type: "module-ready" }, this.allowedOrigin); + } + + rendered() { + this.target.postMessage({ channel: CHANNEL, type: "module-rendered" }, this.allowedOrigin); + } + + waitForHost() { + return new Promise((resolve) => { + if (this.context !== null) { + resolve({ context: this.context, settings: this.settings }); + return; + } + + this.pending.set("host-ready", { resolve }); + }); + } + + saveSettings(settings) { + return this.request("saveSettings", settings).then((savedSettings) => { + this.settings = savedSettings; + return savedSettings; + }); + } + + request(method, payload) { + const requestId = crypto.randomUUID(); + this.target.postMessage({ channel: CHANNEL, requestId, method, payload }, this.allowedOrigin); + + return new Promise((resolve, reject) => { + this.pending.set(requestId, { resolve, reject }); + }); + } + + handleMessage(event) { + if (event.origin !== this.allowedOrigin || event.source !== this.target) { + return; + } + + const payload = event.data; + if (payload?.channel !== CHANNEL) { + return; + } + + if (payload.type === "host-ready") { + this.context = payload.context; + this.settings = payload.settings ?? {}; + const waiter = this.pending.get("host-ready"); + if (waiter) { + this.pending.delete("host-ready"); + waiter.resolve({ context: this.context, settings: this.settings }); + } + return; + } + + if (typeof payload.requestId !== "string") { + return; + } + + const pending = this.pending.get(payload.requestId); + if (!pending) { + return; + } + + this.pending.delete(payload.requestId); + if (payload.ok) { + pending.resolve(payload.result); + } else { + pending.reject(new Error(payload.error ?? "Settings bridge request failed.")); + } + } +} +`, +); + +writeFileSync( + path.join(target, 'settings-ui', 'index.html'), + ` + + + + + ${name} Settings + + + + + + + + +`, +); + +console.log(`[integration:new] created ${target}`); +console.log(`[integration:new] validate with: npm run integration:doctor -- ${target}`); diff --git a/.github/scripts/lib/tooling-paths.mjs b/.github/scripts/lib/tooling-paths.mjs index 90b29e59..70a1a143 100644 --- a/.github/scripts/lib/tooling-paths.mjs +++ b/.github/scripts/lib/tooling-paths.mjs @@ -26,9 +26,7 @@ export function prependPathEntries(env, entries) { const existing = String(env[pathKey] ?? '') .split(path.delimiter) .filter(Boolean); - const normalized = new Set( - existing.map((entry) => (isWindows ? entry.toLowerCase() : entry)), - ); + const normalized = new Set(existing.map((entry) => (isWindows ? entry.toLowerCase() : entry))); for (const entry of entries) { if (!entry || !existsSync(entry)) { @@ -178,12 +176,7 @@ function findVsDevCmd() { const vswhere = programFilesX86 === undefined ? null - : path.join( - programFilesX86, - 'Microsoft Visual Studio', - 'Installer', - 'vswhere.exe', - ); + : path.join(programFilesX86, 'Microsoft Visual Studio', 'Installer', 'vswhere.exe'); if (vswhere && existsSync(vswhere)) { const result = spawnSync( diff --git a/.github/scripts/run-hook.mjs b/.github/scripts/run-hook.mjs index f80400ab..218ebb3e 100644 --- a/.github/scripts/run-hook.mjs +++ b/.github/scripts/run-hook.mjs @@ -63,15 +63,19 @@ function runCommitMsg() { fail('commit-msg hook requires a path to the commit message file'); } - run('npm', [ - 'exec', - '--', - 'commitlint', - '--config', - path.join(repoRoot, '.github', 'commitlint.config.js'), - '--edit', - commitMessageFile, - ], srcDir); + run( + 'npm', + [ + 'exec', + '--', + 'commitlint', + '--config', + path.join(repoRoot, '.github', 'commitlint.config.js'), + '--edit', + commitMessageFile, + ], + srcDir, + ); } const hooks = { diff --git a/.github/scripts/workflow.mjs b/.github/scripts/workflow.mjs index 5e6af4ba..dfb7a6b3 100644 --- a/.github/scripts/workflow.mjs +++ b/.github/scripts/workflow.mjs @@ -32,6 +32,7 @@ const passthroughArgs = rawArgs.filter((arg, index) => { let cachedEnv; const webView2RuntimeClientId = '{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}'; +const cargoLlvmCovVersion = '0.8.5'; const sleepSignal = new Int32Array(new SharedArrayBuffer(4)); function cleanupTargets() { @@ -105,7 +106,9 @@ function removePath(targetPath) { } const delayMs = attempt * 250; - log(`retry remove ${path.relative(repoRoot, targetPath) || '.'} in ${String(delayMs)}ms`); + log( + `retry remove ${path.relative(repoRoot, targetPath) || '.'} in ${String(delayMs)}ms`, + ); sleep(delayMs); } } @@ -168,6 +171,14 @@ function withPassthroughArgs(baseArgs) { return [...baseArgs, '--', ...passthroughArgs]; } +function withDirectPassthroughArgs(baseArgs) { + if (passthroughArgs.length === 0) { + return baseArgs; + } + + return [...baseArgs, ...passthroughArgs]; +} + function withEnvOverrides(overrides = {}) { return { ...toolEnv(), @@ -175,6 +186,53 @@ function withEnvOverrides(overrides = {}) { }; } +function ensureCargoLlvmCov() { + if (commandExists('cargo-llvm-cov', toolEnv())) { + const invocation = buildCommandInvocation('cargo', ['llvm-cov', '--version'], toolEnv()); + const result = spawnSync(invocation.command, invocation.args, { + cwd: tauriDir, + env: toolEnv(), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + shell: false, + }); + const versionOutput = result.stdout?.trim() || result.stderr?.trim() || ''; + const installedVersion = versionOutput.match(/\d+\.\d+\.\d+/u)?.[0]; + if (!result.error && result.status === 0 && installedVersion === cargoLlvmCovVersion) { + ensureLlvmTools(); + return; + } + + log( + `cargo-llvm-cov version mismatch (installed: ${installedVersion ?? 'unknown'}, expected: ${cargoLlvmCovVersion}); reinstalling`, + ); + } + + log(`installing cargo-llvm-cov version ${cargoLlvmCovVersion} with cargo install --locked`); + run('cargo', [ + 'install', + 'cargo-llvm-cov', + '--locked', + '--version', + cargoLlvmCovVersion, + '--force', + ]); + ensureLlvmTools(); +} + +function ensureLlvmTools() { + if (!commandExists('rustup', toolEnv())) { + return; + } + + run('rustup', ['component', 'add', 'llvm-tools']); +} + +function runRustCoverage(args = []) { + ensureCargoLlvmCov(); + run('cargo', ['llvm-cov', ...args], { cwd: tauriDir }); +} + function checkCommand(label, command, args = ['--version'], options = {}) { const cwd = options.cwd ?? repoRoot; const env = options.env ?? toolEnv(); @@ -411,13 +469,7 @@ function runDoctor() { if (isWindows) { results.push(checkWindowsWebView2Runtime(env)); - results.push( - checkAvailableCommand( - 'MSVC compiler', - 'cl', - env, - ), - ); + results.push(checkAvailableCommand('MSVC compiler', 'cl', env)); results.push(checkAvailableCommand('Windows SDK rc.exe', 'rc', env)); } @@ -544,7 +596,10 @@ function verifyReleaseHardening() { const targets = Array.isArray(tauriConfig.bundle?.targets) ? tauriConfig.bundle.targets : []; assertCondition(releaseProfile.get('lto') === 'true', 'Cargo release profile enables LTO'); - assertCondition(releaseProfile.get('panic') === '"abort"', 'Cargo release profile aborts on panic'); + assertCondition( + releaseProfile.get('panic') === '"abort"', + 'Cargo release profile aborts on panic', + ); assertCondition(releaseProfile.get('strip') === 'true', 'Cargo release profile strips symbols'); assertCondition( releaseProfile.get('overflow-checks') === 'true', @@ -632,13 +687,15 @@ function stopRunningApp() { const workspaceExecutables = new Set( workspaceAxelateExecutablePaths().map((candidate) => normalizeWindowsPath(candidate)), ); - const runningProcesses = getRunningWindowsProcesses('Axelate.exe', env).filter((processInfo) => { - if (!processInfo?.ExecutablePath) { - return false; - } + const runningProcesses = getRunningWindowsProcesses('Axelate.exe', env).filter( + (processInfo) => { + if (!processInfo?.ExecutablePath) { + return false; + } - return workspaceExecutables.has(normalizeWindowsPath(processInfo.ExecutablePath)); - }); + return workspaceExecutables.has(normalizeWindowsPath(processInfo.ExecutablePath)); + }, + ); for (const processInfo of runningProcesses) { stopWindowsProcessTree(processInfo, 'Axelate.exe'); @@ -717,10 +774,25 @@ function verifyProject() { ensureFrontendDependencies(); run('npm', ['run', 'format:check'], { cwd: srcDir }); run('npm', ['run', 'typecheck'], { cwd: srcDir }); - run('npm', ['run', 'lint'], { cwd: srcDir }); + lintProject(); run('npm', ['run', 'test'], { cwd: srcDir }); run('npm', ['run', 'build:bundle'], { cwd: srcDir }); - run('npm', ['run', 'check-size'], { cwd: srcDir }); +} + +function lintProject() { + run('npm', ['--prefix', 'src', 'run', 'lint']); + run('npm', [ + '--prefix', + 'src', + 'exec', + '--', + 'eslint', + '--config', + 'src/eslint.config.js', + '.github/scripts', + '.github/commitlint.config.js', + '--no-ignore', + ]); } function setupProject() { @@ -749,20 +821,26 @@ Tasks: release:checksums Generate SHA256 checksums for release bundles release:verify-hardening Validate release hardening settings run Launch the built app artifact - lint Run frontend lint checks + lint Run frontend and repository tooling lint checks format Format frontend files format:check Check frontend formatting test Run frontend tests test:coverage Run frontend tests with coverage + test:coverage:all Run frontend and Rust coverage + rust:test:coverage Run Rust tests with coverage summary + rust:test:coverage:lcov Generate Rust LCOV report at src-tauri/lcov.info + Note: Rust coverage passthrough args go directly to cargo-llvm-cov; bare "--" is stripped, so cargo test filters cannot be forwarded here. test:watch Run frontend tests in watch mode typecheck Run frontend type checks verify Run the full local verification pipeline doctor Check local development prerequisites setup Validate prerequisites, install frontend deps, and configure hooks install-deps Install frontend dependencies + integration:doctor Validate an Axelate integration folder + integration:new Scaffold a minimal Python, Node, or Bun integration folder update Update npm and cargo dependencies, then verify prepare Configure Git hooks - check-size Validate built frontend size + check-size Print a frontend bundle size report `); }, dev() { @@ -835,7 +913,7 @@ Tasks: runReleaseBinary(); }, lint() { - run('npm', ['--prefix', 'src', 'run', 'lint']); + lintProject(); }, format() { run('npm', ['--prefix', 'src', 'run', 'format']); @@ -849,6 +927,24 @@ Tasks: 'test:coverage'() { run('npm', ['--prefix', 'src', 'run', 'test:coverage']); }, + 'test:coverage:all'() { + tasks['test:coverage'](); + tasks['rust:test:coverage'](); + }, + 'rust:test:coverage'() { + runRustCoverage(withDirectPassthroughArgs(['--workspace', '--all-features'])); + }, + 'rust:test:coverage:lcov'() { + runRustCoverage( + withDirectPassthroughArgs([ + '--workspace', + '--all-features', + '--lcov', + '--output-path', + 'lcov.info', + ]), + ); + }, 'test:watch'() { run('npm', ['--prefix', 'src', 'run', 'test:watch']); }, @@ -870,6 +966,24 @@ Tasks: 'install-deps'() { run('npm', ['ci'], { cwd: srcDir }); }, + 'integration:doctor'() { + ensureFrontendDependencies(); + run( + 'node', + withPassthroughArgs([ + path.join(repoRoot, '.github', 'scripts', 'integration', 'doctor.mjs'), + ]), + ); + }, + 'integration:new'() { + ensureFrontendDependencies(); + run( + 'node', + withPassthroughArgs([ + path.join(repoRoot, '.github', 'scripts', 'integration', 'scaffold.mjs'), + ]), + ); + }, update() { run('npm', ['update'], { cwd: srcDir }); run('cargo', ['update'], { cwd: tauriDir }); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b5dd629..a6ced52e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ permissions: env: CARGO_TERM_COLOR: always + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: check-frontend: @@ -28,19 +29,21 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: "22" + 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.94.1 + toolchain: 1.95.0 - name: Rust Cache - uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2 + uses: Swatinem/rust-cache@919333daf4640a6fca9dd8b87468dc776e46b44b # node24 + continue-on-error: true with: workspaces: "src-tauri -> target" + cache-targets: false - name: Install Dependencies run: | @@ -63,11 +66,6 @@ jobs: npm run build # 'npm run build' runs bindings sync + typecheck + vite build - - name: Check Bundle Size - run: | - cd src - npm run check-size - - name: Run Frontend Tests run: | cd src @@ -89,13 +87,15 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable with: - toolchain: 1.94.1 + toolchain: 1.95.0 components: clippy, llvm-tools-preview - name: Rust Cache - uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2 + uses: Swatinem/rust-cache@919333daf4640a6fca9dd8b87468dc776e46b44b # node24 + continue-on-error: true with: workspaces: "src-tauri -> target" + cache-targets: false - name: Clippy (Strict Linting) run: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..ed6bc8ab --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,54 @@ +name: CodeQL + +on: + workflow_dispatch: + push: + branches: ["main", "nightly"] + schedule: + - cron: "27 3 * * 1" + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: CodeQL (${{ matrix.language }}) + runs-on: windows-latest + timeout-minutes: 35 + + strategy: + fail-fast: false + matrix: + include: + - language: javascript-typescript + build-mode: none + - language: rust + build-mode: none + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Rust + if: matrix.language == 'rust' + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 + with: + toolchain: 1.95.0 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: security-extended,security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 00000000..518d5e54 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,33 @@ +name: Dependency Review + +on: + pull_request: + branches: ["main", "nightly"] + paths: + - "src/package.json" + - "src/package-lock.json" + - "src-tauri/Cargo.toml" + - "src-tauri/Cargo.lock" + +concurrency: + group: dependency-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + dependency-review: + name: Dependency Review + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Review dependency changes + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + comment-summary-in-pr: always diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae6af6e7..ad0f8749 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,9 @@ concurrency: group: release-${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || inputs.tag }} cancel-in-progress: false +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build-and-release: runs-on: windows-latest @@ -58,19 +61,21 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: "22" + 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.94.1 + toolchain: 1.95.0 - name: Rust Cache - uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2 + uses: Swatinem/rust-cache@919333daf4640a6fca9dd8b87468dc776e46b44b # node24 + continue-on-error: true with: workspaces: "src-tauri -> target" + cache-targets: false - name: Install frontend dependencies run: | diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 00000000..c285e414 --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -0,0 +1,60 @@ +name: Security Audit + +on: + workflow_dispatch: + schedule: + - cron: "41 4 * * 1" + +concurrency: + group: security-audit-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + npm-audit: + name: npm Audit + runs-on: windows-latest + timeout-minutes: 20 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - 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: Install frontend dependencies + run: | + cd src + npm ci + + - name: Audit frontend dependencies + run: | + cd src + npm audit --audit-level=high + + cargo-audit: + name: Cargo Audit + runs-on: windows-latest + timeout-minutes: 25 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 + with: + toolchain: 1.95.0 + + - name: Install cargo-audit + run: cargo install cargo-audit --locked --quiet + + - name: Audit Rust dependencies + run: | + cd src-tauri + cargo audit diff --git a/.gitignore b/.gitignore index c1501db3..20c69365 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ /.history/ /.playwright-cli/ /build/ +/.nvmrc +__pycache__/ +*.py[cod] # Local app/runtime data /AxelateData/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..80c0b19d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "tabWidth": 4, + "printWidth": 100, + "endOfLine": "lf" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..a0cc6c73 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# Axelate Agent Notes + +## Project Shape +- Axelate is a desktop AI launcher/workstation built with Tauri 2. +- Frontend lives in `src/`: TypeScript, Vite, Vitest, ESLint, Prettier, plain DOM/CSS UI modules, Tauri JS API, Specta-generated backend bindings. +- Backend lives in `src-tauri/`: Rust 2024, Tauri commands, Tokio async runtime, Reqwest networking, Specta/Tauri Specta bindings, JSON/TOML persistence, tracing logs. +- Runtime modules and launcher assets live under `src-tauri/resources/`; generated frontend output lives under `src/dist/`. +- Root `package.json` is a workflow proxy. Most frontend commands run through `npm --prefix src ...`; Rust commands use `src-tauri/Cargo.toml`. + +## Verification +- Prefer targeted checks while iterating: + - Frontend tests: `npm --prefix src run test -- --run ` + - Frontend typecheck: `npm --prefix src run typecheck` + - Backend tests: `cargo test --manifest-path src-tauri/Cargo.toml` + - Backend targeted tests: `cargo test --manifest-path src-tauri/Cargo.toml --lib` +- Before larger commits, run the smallest meaningful frontend and backend checks for the touched surface. +- `check-size` is informational for this desktop app; do not treat bundle size as a primary design constraint unless CI or release policy explicitly requires it. + +## Frontend Notes +- Reuse existing feature/controller/service boundaries instead of adding global shortcuts or one-off DOM patches. +- Keep UI text in I18n resources under `src-tauri/resources/locales/`; do not hardcode Russian or English user-facing strings in TS/HTML. +- Errors from providers, modules, downloads, and engines should surface as notifications/status UI, not as assistant chat messages unless they are actual model responses. +- Shared Tauri IPC access should go through existing provider/service wrappers and generated bindings where available. +- Do not rely on browser preview behavior as product behavior; the shipped runtime is the Tauri webview. + +## Backend Notes +- Keep API command modules in `src-tauri/src/api/`, domain logic in `src-tauri/src/domain/`, and filesystem/config/crypto/system adapters in `src-tauri/src/infrastructure/`. +- Preserve strict Rust lint policy: avoid `unwrap`, `expect`, `panic`, `todo`, and unchecked indexing in production code. +- Prefer typed errors/results over stringly-typed failures. Frontend-facing errors should remain stable enough for UI handling and tests. +- Keep Specta bindings synchronized when command request/response types change: `npm --prefix src run bindings:sync`. + +## Cross-Platform Direction +- The app is currently Windows-first, but new work should keep Windows, Linux, and macOS viable unless a feature is explicitly Windows-only. +- Put OS-specific code behind Rust `cfg(...)` gates or small platform adapters. Avoid scattering Windows assumptions through domain logic. +- Avoid hardcoded path separators, drive-letter assumptions, shell-specific commands, and `.exe`-only binary names outside platform-specific code. +- External engine/module release parsing must account for OS and architecture explicitly: Windows/Linux/macOS, x64/arm64/x86, archive formats, checksums, and GitHub release URL variants. +- Current Windows-specific areas include bundling (`msi`/`nsis`, WebView2), WMI/windows APIs, Windows speech recognition, process/window integration, and some engine binary expectations. Treat these as adaptation points when adding other platforms. +- When adding a feature that cannot work cross-platform yet, expose it as a capability check and degrade cleanly in UI rather than failing late. + +## Git Hygiene +- Do not commit generated dependency folders such as `src/node_modules/`. +- Do not edit `src/dist/` unless the task is specifically about generated build output. +- Keep commits scoped to one behavior area where possible: frontend UI, backend core/API, release parsing, settings persistence, etc. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c831b2d..8522e54d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,10 +2,10 @@ Use these documents first: -- [Getting Started](docs/en/GETTING_STARTED.md) -- [Development Workflow](docs/en/DEVELOPMENT_WORKFLOW.md) -- [Releases](docs/en/RELEASES.md) -- [Current State](docs/en/CURRENT_STATE.md) +- [Getting Started](docs/localization/en/GETTING_STARTED.md) +- [Development Workflow](docs/localization/en/DEVELOPMENT_WORKFLOW.md) +- [Releases](docs/localization/en/RELEASES.md) +- [Current State](docs/localization/en/CURRENT_STATE.md) ## Working Rules @@ -22,20 +22,32 @@ Use these documents first: - Keep `main` release-ready. - Send dependency update work to `nightly`. - Create release tags only from commits that are ready to ship. +- Use squash merge for pull requests. Merge commits and rebase merges are disabled in the repository settings. ## Before Opening A PR - Run `npm run verify`. - If you changed Rust types exported to the frontend, run `npm run bindings:sync`. - Keep commit messages in Conventional Commits format. `npm run setup` installs Git hooks that enforce this. -- Expect GitHub `Strict CI` on pull requests targeting `main` or `nightly`. +- Expect GitHub `Strict CI` and CodeRabbit on pull requests targeting `main` or `nightly`. +- Expect `Dependency Review` only when npm or Cargo dependency files change. +- The protected branches do not require a second human approval right now because the project is maintained by a solo owner. + +## Repository Automation + +- `Strict CI` is the required merge gate for protected branches. +- `CodeQL`, `Dependency Review`, and scheduled `Security Audit` workflows provide additional security coverage without blocking every normal PR. +- CodeRabbit reviews pull requests against `nightly` and `main`; its feedback is advisory unless a concrete bug or risk is confirmed. +- Dependabot security and dependency update pull requests target `nightly`. +- Secret scanning and push protection are enabled in GitHub repository settings. ## Releases -- Read [Releases](docs/en/RELEASES.md) before tagging. +- Read [Releases](docs/localization/en/RELEASES.md) before tagging. - Tags must start with `v`. - Tag versions must match `package.json`, `src/package.json`, and `src-tauri/Cargo.toml`. - Release tags must point to a commit that is already reachable from `main`. +- Release tags matching `v*` are protected against deletion and non-fast-forward updates. - Pushing a matching `v*` tag triggers the GitHub release workflow. ## Docs Policy @@ -43,15 +55,15 @@ Use these documents first: These files should describe the repository as it works today: - `README.md` -- `docs/en/GETTING_STARTED.md` -- `docs/en/DEVELOPMENT_WORKFLOW.md` -- `docs/en/RELEASES.md` -- `docs/en/CURRENT_STATE.md` -- `docs/en/TRUST_MODEL.md` +- `docs/localization/en/GETTING_STARTED.md` +- `docs/localization/en/DEVELOPMENT_WORKFLOW.md` +- `docs/localization/en/RELEASES.md` +- `docs/localization/en/CURRENT_STATE.md` +- `docs/localization/en/TRUST_MODEL.md` These files are planning documents and should not be used as current feature inventory: -- `docs/en/VISION.md` -- `docs/en/ROADMAP.md` +- `docs/localization/en/VISION.md` +- `docs/localization/en/ROADMAP.md` Move future ideas into the planning documents instead of mixing them into current onboarding docs. diff --git a/README.md b/README.md index 87154d03..bbdb9d60 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,8 @@ It is not yet a finished marketplace, managed platform, or polished MCP-first op Install on Windows first: -- Node.js 20+ -- npm 10+ +- Node.js 26.1.0+ +- npm 11+ - Rust via `rustup` (`rust-toolchain.toml` pins the tested version) - WebView2 Runtime - Windows SDK @@ -81,6 +81,8 @@ npm run verify - `nightly` is the active development branch. - `main` is the release-ready branch. - Strict CI runs on pushes and pull requests targeting `main` or `nightly`. +- CodeQL, dependency review for dependency-file changes, scheduled security audits, Dependabot, and CodeRabbit are configured for repository review and security coverage. +- Protected branches require the strict frontend and backend CI checks, but not a second human approval; this matches the current solo-maintainer workflow. - Dependabot targets `nightly`. - GitHub releases are created by pushing a version tag that starts with `v`. - Release tags must point to a commit that is already reachable from `main`. @@ -92,26 +94,30 @@ git tag v0.1.5 git push origin v0.1.5 ``` -For the full release checklist, see [Releases](docs/en/RELEASES.md). +For the full release checklist, see [Releases](docs/localization/en/RELEASES.md). ## Docs Start here: -- [Getting Started](docs/en/GETTING_STARTED.md) -- [Development Workflow](docs/en/DEVELOPMENT_WORKFLOW.md) -- [Releases](docs/en/RELEASES.md) -- [Current State](docs/en/CURRENT_STATE.md) +- [User Guide](docs/localization/en/USER_GUIDE.md) +- [Getting Started](docs/localization/en/GETTING_STARTED.md) +- [Development Workflow](docs/localization/en/DEVELOPMENT_WORKFLOW.md) +- [Architecture](docs/localization/en/ARCHITECTURE.md) +- [Integration Development](docs/localization/en/INTEGRATION_DEVELOPMENT.md) +- [Releases](docs/localization/en/RELEASES.md) +- [Current State](docs/localization/en/CURRENT_STATE.md) - [Contributing](CONTRIBUTING.md) Current reference: -- [Trust Model](docs/en/TRUST_MODEL.md) +- [Trust Model](docs/localization/en/TRUST_MODEL.md) +- Integration Development: [RU](docs/localization/ru/INTEGRATION_DEVELOPMENT.md) · [ZH](docs/localization/zh/INTEGRATION_DEVELOPMENT.md) Planning only: -- [Vision](docs/en/VISION.md) -- [Roadmap](docs/en/ROADMAP.md) +- [Vision](docs/localization/en/VISION.md) +- [Roadmap](docs/localization/en/ROADMAP.md) `Vision` and `Roadmap` are planning documents. They are not setup guides and should not be read as a promise that those features already ship today. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..6c065e29 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,32 @@ +# Security Policy + +## Supported Versions + +Axelate is in active early development. Security fixes are made on `nightly` first and released through `main` when a tagged release is prepared. + +## Reporting A Vulnerability + +Do not open a public issue for a suspected vulnerability. + +Use GitHub private vulnerability reporting when available, or contact the repository owner through GitHub with enough detail to reproduce and assess the issue. + +Include: + +- affected version or commit +- operating system version +- reproduction steps +- expected impact +- relevant logs, screenshots, or proof-of-concept details + +## Security Defaults + +The repository uses GitHub secret scanning, push protection, Dependabot alerts, and Dependabot security updates. + +Additional repository security automation: + +- CodeQL scans TypeScript/JavaScript and Rust on protected branch pushes, weekly schedule, and manual dispatch. +- Dependency Review runs on pull requests targeting `main` and `nightly` when npm or Cargo dependency files change. +- Scheduled Security Audit runs `npm audit --audit-level=high` and `cargo audit`. +- CodeRabbit is configured to review security-sensitive Rust/Tauri, TypeScript, workflow, and resource changes. + +Release tags must match project versions and point to commits reachable from `main`. Tags matching `v*` are protected against deletion and non-fast-forward updates. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 00000000..a2e7f0de --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,12 @@ +# Support + +Axelate is currently an active development project. + +Use GitHub Issues for reproducible bugs and scoped feature requests. For setup and workflow questions, start with: + +- `README.md` +- `docs/localization/en/GETTING_STARTED.md` +- `docs/localization/en/DEVELOPMENT_WORKFLOW.md` +- `docs/localization/en/CURRENT_STATE.md` + +For security issues, follow `SECURITY.md` instead of opening a public issue. diff --git a/docs/en/LAUNCHER_SDK.md b/docs/en/LAUNCHER_SDK.md deleted file mode 100644 index f02bcde1..00000000 --- a/docs/en/LAUNCHER_SDK.md +++ /dev/null @@ -1,231 +0,0 @@ -# Launcher SDK - -This guide describes the stable contract external integrations use to control -Axelate. The contract is language-neutral: every integration talks to the -launcher through a local HTTP API. Language SDKs can wrap this contract later, -but the HTTP API is the source of truth. - -## Runtime Contract - -Axelate starts a local API server on `127.0.0.1` when the launcher starts. The -server is available only on the local machine and requires a per-process token. - -Launcher-managed integration processes receive these environment variables: - -- `AXELATE_HTTP_API_BASE`: local base URL, for example `http://127.0.0.1:3000` -- `AXELATE_HTTP_API_TOKEN`: bearer token for the current launcher process -- `AXELATE_RUNTIME_DIR`: shared launcher runtime directory -- `AXELATE_MODULE_RUNTIME_DIR`: writable runtime directory reserved for the integration -- `AXELATE_MODULE_ID`: current integration id - -External tools that are not launched by Axelate need the same two values from -the user or from their own launcher integration flow. - -Script integrations declare their runtime in `axelate-module.toml`. Legacy top-level -`entry` and `dependencies` fields are not supported. - -```toml -[runtime] -kind = "python" # python | node | bun | binary -version = "3.14" -entry = "src/main.py" -dependencies = "requirements.txt" -``` - -The launcher installs dependencies into its managed runtime under -`AxelateData/System/Runtime//envs//`. -Python uses `uv` and `requirements.txt`. Node and Bun use the declared package -manager and a package manifest outside the integration directory. Integrations must not -ship or write `.venv`, `node_modules`, caches, logs, or downloaded runtime -dependencies inside the integration directory. - -## Authentication - -Every endpoint except `GET /v1/health` requires one of these headers: - -```http -Authorization: Bearer -X-Axelate-Token: -``` - -Prefer `Authorization: Bearer ...` for new clients. - -## Client Rules - -- Treat `AXELATE_HTTP_API_BASE` and `AXELATE_HTTP_API_TOKEN` as runtime values. -- Do not hardcode the port. The launcher can choose any free port in its local - range. -- Do not store the token permanently. It changes between launcher processes. -- Send and receive JSON. -- Use `/v1` endpoints only; unversioned endpoints are not public API. -- If a request specifies an AI `provider`, the launcher updates the matching UI - card selection before running the request. - -## Quick Start - -### curl - -```bash -curl -X POST "$AXELATE_HTTP_API_BASE/v1/ai/text" \ - -H "Authorization: Bearer $AXELATE_HTTP_API_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"provider":"llamacpp","prompt":"Write a short status update"}' -``` - -### PowerShell - -```powershell -$headers = @{ - Authorization = "Bearer $env:AXELATE_HTTP_API_TOKEN" -} - -Invoke-RestMethod ` - -Method Post ` - -Uri "$env:AXELATE_HTTP_API_BASE/v1/ai/text" ` - -Headers $headers ` - -ContentType "application/json" ` - -Body (@{ - provider = "llamacpp" - prompt = "Write a short status update" - } | ConvertTo-Json) -``` - -### JavaScript - -```js -const baseUrl = process.env.AXELATE_HTTP_API_BASE; -const token = process.env.AXELATE_HTTP_API_TOKEN; - -const response = await fetch(`${baseUrl}/v1/ai/text`, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - provider: "llamacpp", - prompt: "Write a short status update", - }), -}); - -const result = await response.json(); -``` - -### Python - -```python -import os -import requests - -base_url = os.environ["AXELATE_HTTP_API_BASE"] -token = os.environ["AXELATE_HTTP_API_TOKEN"] - -response = requests.post( - f"{base_url}/v1/ai/text", - headers={"Authorization": f"Bearer {token}"}, - json={ - "provider": "llamacpp", - "prompt": "Write a short status update", - }, - timeout=120, -) -result = response.json() -``` - -## Endpoints - -### Health - -`GET /v1/health` - -Does not require authentication. Returns whether the local API server is alive. - -### Integrations - -`GET /v1/modules` - -Returns known integrations with launcher status, selected state, category, install -state, and metadata. - -`GET /v1/modules/{moduleId}/status` - -Returns one integration status. - -`POST /v1/modules/{moduleId}/stage` - -Reports the current user-visible stage of a running integration. The launcher -emits `module-stage-changed` for UI surfaces and writes the stage to logs. - -```json -{ - "stage": "parser.fetch", - "label": "Fetching external data", - "details": { "topics": 3 }, - "progress": 0.35 -} -``` - -`stage` is a stable machine-readable stage id. `label` is the human-readable -current action. `details` and `progress` are optional. - -`POST /v1/modules/{moduleId}/start` - -Starts an integration or long-running module script through the launcher module -controller. - -`POST /v1/modules/{moduleId}/stop` - -Stops the running module script. - -`POST /v1/modules/{moduleId}/restart` - -Restarts the module script. - -### AI Text - -`POST /v1/ai/text` - -Runs the selected or requested text AI provider through the same backend path as -launcher chat. - -```json -{ - "prompt": "Summarize this message", - "sessionId": "sample-integration", - "provider": "openai", - "model": "gpt-5.5", - "messages": [{ "role": "user", "content": "Optional chat history" }], - "thinkingLevel": "medium", - "maxTokens": 1024, - "webSearch": { "enabled": false } -} -``` - -`provider` and `model` are optional. When omitted, the launcher uses the active -`ai_text` module selection and its selected model. - -When `provider` is provided, the launcher also updates the visual `ai_text` -selection card so the UI matches the integration request. - -### AI Image - -`POST /v1/ai/image` - -Runs image generation through the selected or requested image AI provider. - -```json -{ - "prompt": "Pixel art launcher icon", - "provider": "openai", - "model": "image-model-id", - "width": 1024, - "height": 1024, - "steps": 30 -} -``` - -`provider` and `model` are optional. When omitted, the launcher uses the active -`ai_image` module selection and its selected model. - -When `provider` is provided, the launcher also updates the visual `ai_image` -selection card so the UI matches the integration request. diff --git a/docs/en/ROADMAP.md b/docs/en/ROADMAP.md deleted file mode 100644 index db558730..00000000 --- a/docs/en/ROADMAP.md +++ /dev/null @@ -1,333 +0,0 @@ -# Axelate Roadmap - -> Strategic execution roadmap as of 2026-04-23. -> Planning document only. It is not a setup guide and it does not mean every listed feature already exists in the repository today. - -## Mission - -Build Axelate into the trusted Windows-first AI workstation and creator distribution platform. - -That means: - -- reliable desktop core first -- curated distribution second -- managed execution third - -## Overall Difficulty - -Overall product ambition: `9/10`. - -Phase difficulty: - -- workstation core: `6/10` -- package system and curated marketplace: `8/10` -- managed runtime and platform operations: `9-10/10` - -## Core Constraints - -These constraints are not optional. - -- Windows-first until the model is proven -- workstation before marketplace -- curated packages before public upload chaos -- local and BYOK before managed cloud complexity -- trust and permissions before growth hacks -- clear product identity before feature expansion - -## Phase 0: Product Reset - -### Goal - -Remove identity confusion and define one honest product direction. - -### Work - -- consolidate documentation into English canonical docs -- define the product as a Windows AI workstation, not a generic chat client -- define future business logic before adding new platform layers -- remove or demote legacy positioning that implies a marketplace already exists - -### Exit Criteria - -- one clear product statement exists -- one current-state document exists -- one roadmap exists -- future work can be judged against the workstation thesis - -### Status - -In progress and partially completed. - -## Phase 1: Workstation Core - -### Goal - -Turn the current shell into a reliable daily-use Windows AI workstation. - -### Workstream A: Desktop Reliability - -- stabilize startup, shutdown, and state restore -- make runtime status deterministic after restart -- make selection state and model settings persistent and obvious -- improve window, tray, and shell consistency - -### Workstream B: AI Provider Layer - -- keep OpenRouter path stable -- normalize provider/model capabilities cleanly -- make text and image routing explicit -- keep web access as an optional capability toggle -- keep custom model support first-class - -### Workstream C: Chat and Session System - -- keep streaming fast and predictable -- preserve chat history correctly -- keep summary compaction hidden and reliable -- make request isolation and cancellation robust -- improve file and multimodal handling only where it is already justified - -### Workstream D: Local Runtime Orchestration - -- make install and update flows trustworthy -- improve start, stop, health, and log visibility -- keep hardware-aware resolution readable and debuggable -- keep ComfyUI out of the core promise until it is truly product-ready - -### Workstream E: MCP Foundation - -- add real MCP client support behind clean adapters -- make permissions explicit per server and per tool -- expose connection state in the desktop UI -- avoid hidden or automatic unsafe execution - -### Exit Criteria - -- a new user can install the app and complete a first useful workflow -- local and cloud model routing feels coherent -- logs, monitoring, and repair tools explain failures -- provider settings are understandable -- MCP works as a feature, not as a science experiment - -### Why This Phase Matters - -If this phase fails, the marketplace should not launch. - -## Phase 2: Package System Foundation - -### Goal - -Create the technical base for creator-distributed packages without pretending the marketplace is already live. - -### Work - -- define package manifest format -- define package permission model -- define settings schema model for packages -- define install, update, rollback, and uninstall contracts -- define signing flow for official builds -- define entitlement sync contract for future commercial packages - -### Packaging Modes - -The package system must support three modes from the start: - -- local -- managed -- hybrid - -### Exit Criteria - -- package manifests are versioned and validated -- package lifecycle is deterministic -- packages can be installed and removed safely -- package permissions are visible to the user -- the desktop understands package metadata without ad-hoc code paths - -### Why This Phase Matters - -Without a real package model, a marketplace is just marketing. - -## Phase 3: Curated Marketplace MVP - -### Goal - -Launch a controlled creator marketplace with real purchases and real entitlements, but only for reviewed packages. - -### Workstream A: Website and Commerce - -- landing page -- download flow -- pricing -- account creation -- billing -- purchase history -- entitlement management - -### Workstream B: Creator Program - -- creator onboarding -- package submission flow -- review rules -- screenshots and listing metadata -- pricing controls -- support and refund policy - -### Workstream C: Desktop Integration - -- account sign-in -- entitlement sync -- marketplace browsing inside desktop -- install from owned entitlements -- update and rollback from official channel - -### Trust Rules - -- no public self-serve upload at first -- no anonymous package publishing -- no claims of perfect IP protection for local packages -- no unsafe execution path hidden behind one click - -### Exit Criteria - -- users can buy and install reviewed packages -- creators can submit and update packages -- entitlements sync into the desktop reliably -- refund and payout operations are operationally manageable - -### Difficulty - -`8/10` - -## Phase 4: Managed and Hybrid Runtime Platform - -### Goal - -Support creators who need stronger protection and platform-hosted execution. - -### Work - -- define managed runtime API contract -- define secure relay and session authentication -- define usage metering -- define revocation and expiration -- define managed logs and diagnostics -- define creator-side deployment requirements -- optionally host official managed execution for creators - -### Operational Requirements - -- incident handling -- security response -- cost controls -- rate limiting -- abuse prevention -- auditability - -### Exit Criteria - -- managed packages can be sold and enforced -- creator logic does not need to ship locally when not appropriate -- platform can meter and bill usage without trust collapse -- users understand whether a package runs local, remote, or hybrid - -### Difficulty - -`9-10/10` - -## Phase 5: Open Core Transition - -### Goal - -Make the desktop core auditable and contribution-friendly while keeping the commercial platform defensible. - -### Work - -- split open desktop core from closed platform services -- publish package spec and SDKs -- choose final open-core license -- formalize contribution rules -- document official build and signing policy - -### Exit Criteria - -- external contributors can work on the desktop core safely -- official commercial backend stays private and operationally controlled -- forks do not confuse official trust guarantees - -## What Is Explicitly Not A Priority - -Not now: - -- mobile apps -- cross-platform perfection -- social feeds -- open upload marketplace -- enterprise-first sales motion -- feature racing against every chat client -- turning Axelate into a generic unsafe script runner - -## Go / No-Go Checkpoints - -### Checkpoint 1: After Phase 1 - -Question: - -- is the workstation core reliable enough that people would use it weekly without the marketplace? - -If no: - -- stop expanding scope -- fix reliability, UX clarity, and trust - -### Checkpoint 2: Before Phase 3 - -Question: - -- do we have a safe package model, a review process, and entitlement sync that is understandable to users? - -If no: - -- do not launch a marketplace - -### Checkpoint 3: Before Phase 4 - -Question: - -- can we run commercial managed infrastructure without burning margin or collapsing trust? - -If no: - -- keep the business focused on local and hybrid packages first - -## Success Metrics - -### Workstation Metrics - -- install to first successful workflow -- runtime install success rate -- runtime recovery success rate -- chat success rate -- crash-free sessions - -### Marketplace Metrics - -- package conversion rate -- paid package install completion rate -- creator retention -- refund rate -- payout accuracy - -### Managed Platform Metrics - -- gross margin after infra cost -- entitlement verification reliability -- incident frequency -- abuse rate -- package uptime and latency - -## Final Roadmap Rule - -Axelate should only earn the right to become a marketplace after it becomes a trusted workstation. - -That sequencing is the roadmap. diff --git a/docs/en/VISION.md b/docs/en/VISION.md deleted file mode 100644 index caab243a..00000000 --- a/docs/en/VISION.md +++ /dev/null @@ -1,354 +0,0 @@ -# Axelate Vision - -> Strategic direction as of 2026-04-23. -> Planning document only. Use `CURRENT_STATE.md`, `GETTING_STARTED.md`, and `DEVELOPMENT_WORKFLOW.md` for the repository as it works today. - -## Product Name - -- Short name: Axelate -- Working full name: Axelate Workstation Platform -- Category: Windows-first AI workstation and creator marketplace -- Product sentence: Axelate is a secure desktop control plane for local AI runtimes, BYOK cloud models, MCP tools, and packaged creator apps. - -## Why This Product Should Exist - -The AI desktop market is crowded with chat clients, local model launchers, agent shells, and web dashboards. What is still weak on Windows is the layer that combines all of the following in one consistent product: - -- local runtime install, update, start, stop, and health status -- cloud model access through user-owned keys -- MCP tool access with explicit permissions -- creator-distributed AI packages with billing and updates -- backend-owned secrets, signing, entitlements, and managed execution when local delivery is not enough - -Axelate should exist to be that layer. - -The product should not compete as "another chat UI". It should compete as the trusted operating surface for practical AI work on Windows. - -## Product Thesis - -Axelate wins only if it stays narrow and honest: - -- one desktop shell for local and cloud AI work -- one package system for creator tools and paid workflows -- one trust model for permissions, secrets, updates, and entitlements - -Axelate loses if it tries to become: - -- a generic social marketplace -- a pure web dashboard clone -- a random unsafe script runner -- a giant everything-app before the workstation core is reliable - -## Target Users - -- Power users who switch between local runtimes and cloud models. -- Creators who want to sell AI-powered tools without building their own launcher, updater, billing system, and entitlement service. -- Small studios that need one desktop surface for text, image, automation, and tool-backed AI workflows. - -## Core Product Definition - -### 1. Desktop App - -The desktop app is the main product. - -It should provide: - -- local engine lifecycle management -- cloud provider selection and model routing -- MCP server configuration and permission prompts -- package install, update, rollback, and uninstall -- backend-owned credential storage -- logs, health checks, and repair actions - -The desktop app should stay Windows-first until the model is proven. - -### 2. Website - -The website is not optional. It is the public business surface. - -It should handle: - -- landing pages and positioning -- download distribution -- pricing -- account creation -- billing and subscriptions -- package discovery -- creator onboarding -- creator payouts -- docs, legal pages, and trust material - -Recommended first public structure: - -- `/` product landing page -- `/download` installer distribution -- `/pricing` plans and marketplace fees -- `/marketplace` searchable package catalog -- `/creators` creator program and publishing rules -- `/account` purchases, entitlements, devices, billing -- `/trust` security model, signing, package review -- `/docs` later, once the public protocol and package spec stabilize - -### 3. Creator Platform - -Creators should be able to ship AI products in three package modes: - -- `Local package` - - shipped to the user machine - - best for tools that can run locally with acceptable IP exposure - - supports versioning, signing, updates, rollback, and local settings schemas -- `Managed package` - - sensitive logic stays on creator or platform infrastructure - - desktop acts as authenticated client and orchestrator - - best for protected commercial workflows and premium automations -- `Hybrid package` - - local UI and setup, remote execution for sensitive steps - - best for mixed local/cloud tools - -This packaging model is the bridge between the desktop shell and the business. - -## Business Logic - -### User Flow - -1. User installs Axelate desktop. -2. User signs in or continues in local-only mode. -3. User adds cloud provider keys or installs local runtimes. -4. User browses packages on the website or inside the desktop marketplace. -5. User purchases or claims a package entitlement. -6. Desktop syncs entitlements and installs the package. -7. Package runs in local, managed, or hybrid mode. -8. Updates, permissions, logs, and billing stay visible in one place. - -### Creator Flow - -1. Creator applies for creator access. -2. Creator creates a package listing with category, screenshots, pricing, support terms, and manifest. -3. Creator chooses `local`, `managed`, or `hybrid`. -4. Creator uploads signed assets or registers the managed runtime endpoint. -5. Platform runs validation, malware checks, schema checks, and policy review. -6. Approved package becomes visible in the marketplace. -7. Purchases create entitlements and payout records. -8. Updates go through version review and staged rollout. - -### Platform Flow - -The platform owned by Axelate should be responsible for: - -- account and identity -- package signing and trust chain -- entitlement issuance -- billing and payouts -- abuse prevention -- marketplace discovery and ranking -- package review -- optional managed runtime orchestration - -The platform should not promise impossible guarantees. - -Local packages are convenient and monetizable, but they are not undecompilable. -Managed packages are the correct answer for creators who need stronger protection. - -## Revenue Model - -### Owner Revenue - -Axelate should have three revenue lines. - -#### 1. Marketplace fee - -- recommended default: `15%` platform fee on creator software revenue -- payout target: creator receives `85%` before payment processor and tax adjustments - -This is simple, legible, and competitive enough for an early curated marketplace. - -#### 2. Pro subscription for end users - -The desktop should remain useful for free local and BYOK usage. - -Paid `Axelate Pro` should unlock platform features, not basic trust: - -- encrypted cloud backup of settings and entitlements -- multi-device sync -- advanced package rollback history -- premium diagnostics and recovery tools -- early access to verified package releases - -This should be a modest subscription, not the core profit engine. - -#### 3. Managed runtime margin - -For managed packages, Axelate can charge for platform-hosted orchestration: - -- entitlement checks -- secure relay -- execution control -- usage metering -- storage and logs - -This can be billed as: - -- pass-through infra cost plus a platform margin -- or a fixed platform fee charged to creators - -The product should start with pass-through plus margin. It is easier to explain and less risky. - -### Creator Revenue - -Creators should be able to earn through: - -- one-time purchases -- subscriptions -- paid upgrades -- seat-based licenses later -- managed workflow subscriptions - -Creators should control their own list prices. -The platform should only control fee policy, refund windows, and content rules. - -## Open vs Closed Strategy - -The best long-term model is `open core + closed commercial platform`. - -### What Should Be Open - -- desktop core -- package manifest specification -- public SDKs -- MCP and provider adapters that are part of the core client -- documentation for package and entitlement integration - -Selected license for the open core: `Apache-2.0`. - -Reason: - -- trust matters for a BYOK desktop product -- contributors are more likely to help if the core is auditable -- forks do not destroy the business if the marketplace, signing, billing, and brand stay controlled - -### What Should Stay Closed - -- official marketplace backend -- billing and payout services -- entitlement service -- signing infrastructure -- abuse detection -- managed runtime orchestration -- official ranking and recommendation logic - -### Can Everyone Modify It - -For the open core: - -- yes, anyone can read, fork, modify, and submit changes -- no, modified forks do not automatically become official builds - -For the official platform: - -- no, only the owner and approved maintainers can change the production marketplace and commercial backend - -For creator packages: - -- creators may choose open packages -- creators may choose closed local packages -- creators may choose managed packages where sensitive logic never ships - -This is the practical trust and business split. - -## Product Rules - -- Do not promise perfect DRM for local packages. -- Do not make the chat tab the center of the brand. -- Do not force cloud accounts for local-only usage. -- Do not mix unsafe arbitrary execution with the curated marketplace path. -- Do not build a creator marketplace before the workstation core is stable. - -## What Axelate Should Actually Build - -### Phase 1: Workstation Core - -Ship a reliable Windows desktop with: - -- strong provider routing -- strong streaming chat and image flows -- local runtime management -- MCP client support -- package manifest and install model -- health checks, logs, and repair tools - -This phase proves product value to users. - -### Phase 2: Curated Creator Marketplace - -Ship: - -- website -- creator onboarding -- package listing flow -- package signing and review -- entitlement sync into desktop - -This phase proves creator demand. - -### Phase 3: Managed Package Layer - -Ship: - -- managed package runtime contract -- billing for managed subscriptions -- creator payout automation -- usage metering -- optional owner-hosted execution plane - -This phase proves defensible business value. - -## Reality Check - -This product is real and implementable, but only under these conditions: - -- Windows-first, not cross-platform from day one -- curated marketplace, not open upload chaos -- open core for trust, closed platform for monetization -- local plus BYOK first, managed cloud second -- creator distribution and entitlement first, full enterprise later - -If Axelate tries to launch as: - -- chat app -- launcher -- package manager -- agent platform -- public marketplace -- managed cloud - -all at once, it will become diffuse and weak. - -If Axelate launches first as: - -- the trusted Windows AI workstation for running local and paid AI tools - -then the marketplace and managed layer become believable. - -## Execution Difficulty - -This product is feasible, but it is not cheap or simple to execute well. - -Overall ambition difficulty: `9/10`. - -Phase difficulty: - -- workstation core only: `6/10` -- package system and curated marketplace: `8/10` -- managed runtime, billing, payouts, signing, and abuse control: `9-10/10` - -Why the score is high: - -- the desktop product alone requires stable runtime orchestration, provider routing, permissions, logs, recovery, and packaging -- the marketplace requires trust, policy review, entitlement sync, billing, and payouts -- the managed layer requires production backend operations, metering, security, incident handling, and revocation - -The product becomes realistic only if it is built in phases and only if each phase proves value before the next one starts. - -## Final Product Statement - -Axelate should become the Windows-first AI workstation and creator distribution platform: a desktop product where users run local engines, connect cloud models, install verified MCP-enabled packages, and buy creator-built AI tools from one trusted surface; with an open desktop core for trust and a closed commercial platform for billing, signing, entitlements, payouts, and managed execution. diff --git a/docs/examples/integrations/python-ai-tool/README.md b/docs/examples/integrations/python-ai-tool/README.md new file mode 100644 index 00000000..ab475446 --- /dev/null +++ b/docs/examples/integrations/python-ai-tool/README.md @@ -0,0 +1,22 @@ +# Python AI Tool + +Minimal Axelate integration example. + +It demonstrates: + +- `axelate-module.toml` +- launcher-provided environment variables +- `/v1/modules/{moduleId}/settings` +- `/v1/modules/{moduleId}/stage` +- `/v1/ai/text` +- custom settings UI + +## Try It + +From the Axelate repository: + +```bash +npm run integration:doctor -- docs/examples/integrations/python-ai-tool +``` + +Then import this folder in the launcher integrations screen and launch it. diff --git a/docs/examples/integrations/python-ai-tool/axelate-module.toml b/docs/examples/integrations/python-ai-tool/axelate-module.toml new file mode 100644 index 00000000..44d5e448 --- /dev/null +++ b/docs/examples/integrations/python-ai-tool/axelate-module.toml @@ -0,0 +1,15 @@ +api_version = "1" +id = "python-ai-tool" +name = "Python AI Tool" +version = "0.1.0" +description = "Example integration that calls Axelate AI and stores settings." +author = "Axelate" +type = "service" +icon = "⚙" +readme = "README.md" +settings_ui = "settings-ui/index.html" + +[runtime] +kind = "python" +version = "3.11" +entry = "src/main.py" diff --git a/docs/examples/integrations/python-ai-tool/settings-ui/axelate-settings-bridge.js b/docs/examples/integrations/python-ai-tool/settings-ui/axelate-settings-bridge.js new file mode 100644 index 00000000..f81b1f9f --- /dev/null +++ b/docs/examples/integrations/python-ai-tool/settings-ui/axelate-settings-bridge.js @@ -0,0 +1,88 @@ +const CHANNEL = 'axelate:module-settings'; + +export class AxelateSettingsBridge { + constructor({ target = window.parent, allowedOrigin = window.location.origin } = {}) { + this.target = target; + this.allowedOrigin = allowedOrigin; + this.pending = new Map(); + this.context = null; + this.settings = {}; + window.addEventListener('message', (event) => this.handleMessage(event)); + } + + ready() { + this.target.postMessage({ channel: CHANNEL, type: 'module-ready' }, this.allowedOrigin); + } + + rendered() { + this.target.postMessage({ channel: CHANNEL, type: 'module-rendered' }, this.allowedOrigin); + } + + waitForHost() { + return new Promise((resolve) => { + if (this.context !== null) { + resolve({ context: this.context, settings: this.settings }); + return; + } + + this.pending.set('host-ready', { resolve }); + }); + } + + saveSettings(settings) { + return this.request('saveSettings', settings).then((savedSettings) => { + this.settings = savedSettings; + return savedSettings; + }); + } + + request(method, payload) { + const requestId = crypto.randomUUID(); + this.target.postMessage( + { channel: CHANNEL, requestId, method, payload }, + this.allowedOrigin, + ); + + return new Promise((resolve, reject) => { + this.pending.set(requestId, { resolve, reject }); + }); + } + + handleMessage(event) { + if (event.origin !== this.allowedOrigin || event.source !== this.target) { + return; + } + + const payload = event.data; + if (payload?.channel !== CHANNEL) { + return; + } + + if (payload.type === 'host-ready') { + this.context = payload.context; + this.settings = payload.settings ?? {}; + const waiter = this.pending.get('host-ready'); + if (waiter) { + this.pending.delete('host-ready'); + waiter.resolve({ context: this.context, settings: this.settings }); + } + return; + } + + if (typeof payload.requestId !== 'string') { + return; + } + + const pending = this.pending.get(payload.requestId); + if (!pending) { + return; + } + + this.pending.delete(payload.requestId); + if (payload.ok) { + pending.resolve(payload.result); + } else { + pending.reject(new Error(payload.error ?? 'Settings bridge request failed.')); + } + } +} diff --git a/docs/examples/integrations/python-ai-tool/settings-ui/index.html b/docs/examples/integrations/python-ai-tool/settings-ui/index.html new file mode 100644 index 00000000..82e8dbfa --- /dev/null +++ b/docs/examples/integrations/python-ai-tool/settings-ui/index.html @@ -0,0 +1,75 @@ + + + + + + Python AI Tool Settings + + + + + + + + diff --git a/docs/examples/integrations/python-ai-tool/src/main.py b/docs/examples/integrations/python-ai-tool/src/main.py new file mode 100644 index 00000000..991d587d --- /dev/null +++ b/docs/examples/integrations/python-ai-tool/src/main.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.parse +import urllib.request + +LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"} + + +def required_env(name: str) -> str: + value = os.environ.get(name) + if value is None or value.strip() == "": + raise RuntimeError(f"Missing required Axelate integration env var: {name}") + return value + + +def validate_base_url(value: str) -> str: + parsed = urllib.parse.urlparse(value) + if parsed.scheme not in {"http", "https"} or parsed.hostname not in LOOPBACK_HOSTS: + raise ValueError("AXELATE_HTTP_API_BASE must be an http(s) loopback URL.") + return value.rstrip("/") + + +BASE_URL = validate_base_url(required_env("AXELATE_HTTP_API_BASE")) +TOKEN = required_env("AXELATE_HTTP_API_TOKEN") +MODULE_ID = required_env("AXELATE_MODULE_ID") +MODULE_PATH_ID = urllib.parse.quote(MODULE_ID, safe="") + + +def request(method: str, path: str, payload: dict | None = None) -> dict: + data = None if payload is None else json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + f"{BASE_URL}{path}", + data=data, + method=method, + headers={ + "Authorization": f"Bearer {TOKEN}", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=120) as response: + body = response.read().decode("utf-8") + return json.loads(body) if body.strip() else {} + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", errors="replace") + raise RuntimeError(f"{method} {path} failed with HTTP {error.code}: {body}") from error + except urllib.error.URLError as error: + raise RuntimeError(f"{method} {path} failed: {error.reason}") from error + + +def main() -> None: + settings = request("GET", f"/v1/modules/{MODULE_PATH_ID}/settings").get("settings", {}) + prompt = settings.get("prompt") or "Write a short status update." + + request( + "POST", + f"/v1/modules/{MODULE_PATH_ID}/stage", + {"stage": "ai.request", "label": "Calling Axelate AI", "progress": 0.5}, + ) + + result = request( + "POST", + "/v1/ai/text", + { + "prompt": prompt, + "sessionId": MODULE_ID, + }, + ) + + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/sdk/browser/axelate-settings-bridge.js b/docs/examples/sdk/browser/axelate-settings-bridge.js new file mode 100644 index 00000000..0ec13563 --- /dev/null +++ b/docs/examples/sdk/browser/axelate-settings-bridge.js @@ -0,0 +1,104 @@ +const CHANNEL = 'axelate:module-settings'; + +export class AxelateSettingsBridge { + constructor({ target = window.parent, allowedOrigin = window.location.origin } = {}) { + this.target = target; + this.allowedOrigin = allowedOrigin; + this.pending = new Map(); + this.context = null; + this.settings = {}; + window.addEventListener('message', (event) => this.handleMessage(event)); + } + + ready() { + this.target.postMessage({ channel: CHANNEL, type: 'module-ready' }, this.allowedOrigin); + } + + rendered() { + this.target.postMessage({ channel: CHANNEL, type: 'module-rendered' }, this.allowedOrigin); + } + + waitForHost() { + return new Promise((resolve) => { + if (this.context !== null) { + resolve({ context: this.context, settings: this.settings }); + return; + } + + this.pending.set('host-ready', { resolve }); + }); + } + + getSettings() { + return this.request('getSettings'); + } + + saveSettings(settings) { + return this.request('saveSettings', settings).then((savedSettings) => { + this.settings = savedSettings; + return savedSettings; + }); + } + + notify(payload) { + return this.request('notify', payload); + } + + request(method, payload) { + const requestId = crypto.randomUUID(); + this.target.postMessage( + { + channel: CHANNEL, + requestId, + method, + payload, + }, + this.allowedOrigin, + ); + + return new Promise((resolve, reject) => { + this.pending.set(requestId, { resolve, reject }); + }); + } + + handleMessage(event) { + if (event.origin !== this.allowedOrigin || event.source !== this.target) { + return; + } + + const payload = event.data; + if (payload?.channel !== CHANNEL) { + return; + } + + if (payload.type === 'host-ready') { + this.context = payload.context; + this.settings = payload.settings ?? {}; + const waiter = this.pending.get('host-ready'); + if (waiter) { + this.pending.delete('host-ready'); + waiter.resolve({ + context: this.context, + settings: this.settings, + }); + } + return; + } + + if (typeof payload.requestId !== 'string') { + return; + } + + const pending = this.pending.get(payload.requestId); + if (!pending) { + return; + } + + this.pending.delete(payload.requestId); + if (payload.ok) { + pending.resolve(payload.result); + } else { + pending.reject(new Error(payload.error ?? 'Settings bridge request failed.')); + } + } +} diff --git a/docs/examples/sdk/javascript/axelate-client.mjs b/docs/examples/sdk/javascript/axelate-client.mjs new file mode 100644 index 00000000..0b5916a2 --- /dev/null +++ b/docs/examples/sdk/javascript/axelate-client.mjs @@ -0,0 +1,96 @@ +export class AxelateClient { + constructor(env = globalThis.process?.env ?? {}) { + this.baseUrl = validateBaseUrl(requiredEnv(env, 'AXELATE_HTTP_API_BASE')).replace(/\/$/u, ''); + this.token = requiredEnv(env, 'AXELATE_HTTP_API_TOKEN'); + this.moduleId = requiredEnv(env, 'AXELATE_MODULE_ID'); + } + + async request(method, path, payload) { + const response = await fetch(`${this.baseUrl}${path}`, { + method, + headers: { + Authorization: `Bearer ${this.token}`, + 'Content-Type': 'application/json', + }, + body: payload === undefined ? undefined : JSON.stringify(payload), + }); + + const body = await readResponseBody(response); + if (!response.ok) { + const message = + body && typeof body === 'object' && 'error' in body + ? body.error + : `Axelate request failed: ${response.status}`; + throw new Error(String(message)); + } + + return body; + } + + settings() { + return this.request('GET', `/v1/modules/${encodeURIComponent(this.moduleId)}/settings`).then( + (body) => body.settings ?? {}, + ); + } + + saveSettings(settings) { + return this.request( + 'PUT', + `/v1/modules/${encodeURIComponent(this.moduleId)}/settings`, + settings, + ); + } + + stage(stage, label, progress) { + const payload = { stage, label }; + if (progress !== undefined) { + payload.progress = progress; + } + return this.request('POST', `/v1/modules/${encodeURIComponent(this.moduleId)}/stage`, payload); + } + + aiText(prompt, options = {}) { + return this.request('POST', '/v1/ai/text', { + prompt, + sessionId: this.moduleId, + ...options, + }); + } +} + +function requiredEnv(env, name) { + const value = String(env[name] ?? ''); + if (value.trim().length === 0) { + throw new Error(`Missing required Axelate integration env var: ${name}`); + } + + return value; +} + +function validateBaseUrl(value) { + const url = new URL(value); + const allowedHosts = new Set(['localhost', '127.0.0.1', '[::1]']); + if (!['http:', 'https:'].includes(url.protocol) || !allowedHosts.has(url.hostname)) { + throw new Error('AXELATE_HTTP_API_BASE must be an http(s) loopback URL.'); + } + + return value; +} + +async function readResponseBody(response) { + if (response.status === 204) { + return {}; + } + + const contentType = response.headers.get('content-type') ?? ''; + const text = await response.text(); + if (text.length === 0) { + return {}; + } + + if (contentType.includes('application/json')) { + return JSON.parse(text); + } + + return { text }; +} diff --git a/docs/examples/sdk/python/axelate_sdk.py b/docs/examples/sdk/python/axelate_sdk.py new file mode 100644 index 00000000..8abe009d --- /dev/null +++ b/docs/examples/sdk/python/axelate_sdk.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"} + + +class AxelateApiError(RuntimeError): + def __init__(self, method: str, path: str, status: int | None, body: str, message: str) -> None: + self.method = method + self.path = path + self.status = status + self.body = body + super().__init__(f"{method} {path} failed: {message}") + + +class AxelateClient: + def __init__(self) -> None: + self.base_url = validate_base_url(required_env("AXELATE_HTTP_API_BASE")).rstrip("/") + self.token = required_env("AXELATE_HTTP_API_TOKEN") + self.module_id = required_env("AXELATE_MODULE_ID") + self.encoded_module_id = urllib.parse.quote(self.module_id, safe="") + + def request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + data = None if payload is None else json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + f"{self.base_url}{path}", + data=data, + method=method, + headers={ + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(request, timeout=120) as response: + body = response.read().decode("utf-8") + return {} if body == "" else json.loads(body) + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", errors="replace") + raise AxelateApiError( + method, + path, + error.code, + body, + extract_error_message(body) or error.reason, + ) from error + except urllib.error.URLError as error: + raise AxelateApiError(method, path, None, "", str(error.reason)) from error + + def settings(self) -> dict[str, Any]: + payload = self.request("GET", f"/v1/modules/{self.encoded_module_id}/settings") + if "error" in payload: + raise AxelateApiError("GET", "/settings", None, json.dumps(payload), str(payload["error"])) + return payload.get("settings", {}) + + def save_settings(self, settings: dict[str, Any]) -> dict[str, Any]: + return self.request( + "PUT", + f"/v1/modules/{self.encoded_module_id}/settings", + settings, + ) + + def stage(self, stage: str, label: str, progress: float | None = None) -> dict[str, Any]: + payload: dict[str, Any] = {"stage": stage, "label": label} + if progress is not None: + payload["progress"] = progress + return self.request("POST", f"/v1/modules/{self.encoded_module_id}/stage", payload) + + def ai_text(self, prompt: str, **options: Any) -> dict[str, Any]: + """Run text generation. sessionId defaults to module_id and can be overridden.""" + session_id = options.pop("sessionId", self.module_id) + payload = {"prompt": prompt, "sessionId": session_id, **options} + return self.request("POST", "/v1/ai/text", payload) + + +def required_env(name: str) -> str: + value = os.environ.get(name) + if value is None or value.strip() == "": + raise ValueError(f"Missing required Axelate integration env var: set {name}.") + return value + + +def validate_base_url(value: str) -> str: + parsed = urllib.parse.urlparse(value) + if parsed.scheme not in {"http", "https"}: + raise ValueError("AXELATE_HTTP_API_BASE must use http or https.") + if parsed.hostname not in LOOPBACK_HOSTS: + raise ValueError("AXELATE_HTTP_API_BASE must point to localhost, 127.0.0.1, or ::1.") + return value + + +def extract_error_message(body: str) -> str | None: + try: + parsed = json.loads(body) + except json.JSONDecodeError: + return body or None + if isinstance(parsed, dict): + error = parsed.get("error") or parsed.get("message") + if isinstance(error, str): + return error + return body or None diff --git a/docs/localization/en/ARCHITECTURE.md b/docs/localization/en/ARCHITECTURE.md new file mode 100644 index 00000000..df884fd8 --- /dev/null +++ b/docs/localization/en/ARCHITECTURE.md @@ -0,0 +1,116 @@ +# Axelate Architecture + +> Practical map of the current repository. Use this before changing backend +> contracts, integrations, runtime lifecycle, or cross-platform behavior. + +## Runtime Shape + +Axelate is a Tauri 2 desktop app: + +- `src/` is the TypeScript frontend. +- `src-tauri/` is the Rust backend and Tauri host. +- Rust commands are exported to TypeScript through Specta bindings in + `src/shared/types/bindings.ts`. +- The current binding stack is `specta` `2.0.0-rc.25`, + `tauri-specta` `2.0.0-rc.25`, and `specta-typescript` `0.0.12`. +- Runtime assets, built-in module manifests, and locales live under + `src-tauri/resources/`. + +The frontend should render state and orchestrate user flow. The backend should +own domain rules, filesystem access, secrets, process lifecycle, downloads, and +persisted state. + +## Frontend + +Important frontend areas: + +- `src/app/`: application bootstrap, shell wiring, and top-level events. +- `src/features/`: user-facing feature modules such as chat, AI catalog, + downloads, console, monitoring, settings, and the home overview placeholder. +- `src/infrastructure/`: adapters for i18n, logging, navigation, and Tauri IPC. +- `src/shared/`: shared API wrappers, shell helpers, UI utilities, config, and + generated backend types. + +Rules for frontend changes: + +- Keep user-facing text in `src-tauri/resources/locales/`. +- Call backend commands through the existing Tauri provider and generated + bindings where available. +- Do not store provider secrets in frontend-owned state. +- Surface provider, engine, module, and download errors through notifications or + status UI, not as assistant chat messages. + +## Backend + +Important backend areas: + +- `src-tauri/src/api/`: Tauri commands and frontend-facing request/response + boundaries. +- `src-tauri/src/domain/`: AI, engine, module, integration API, monitoring, and + system domain logic. +- `src-tauri/src/infrastructure/`: config, filesystem, crypto, logging, + persistence, and platform adapters. +- `src-tauri/src/models/`: shared data structures exported to the frontend. +- `src-tauri/src/app/`: window, tray, startup, and application lifecycle glue. + +Rules for backend changes: + +- Keep frontend-facing contracts stable and typed. +- Regenerate bindings after changing exported commands or types. +- Keep OS-specific behavior behind platform adapters or `cfg(...)` gates. +- Prefer typed errors over string-only failures. +- Avoid adding product gates or activation logic unless the real backend system + exists. + +## Integration Runtime + +Local integrations are installed modules that can be imported from folders, +archives, repositories, or trusted URLs. The launcher owns: + +- install/import flow +- module manifest discovery +- start, stop, status, and cleanup requests +- settings-session tokens for module-owned settings UIs +- local integration API tokens for script-runtime integrations + +Current local integrations are code the user chose to run. They are not the same +as reviewed or signed packages yet. Permission prompts, signing, verified +publisher state, and remote managed execution are future layers documented in +the roadmap and trust model. + +## Contracts + +Use this sequence when changing a frontend-visible backend contract: + +1. Update Rust command/type definitions. +2. Run `npm --prefix src run bindings:sync`. +3. Update TypeScript callers. +4. Run `npm --prefix src run typecheck`. + +If bindings are out of date, `npm --prefix src run bindings:check` should fail. + +Dynamic JSON fields such as provider payloads, module settings, config schemas, +and chat content are exported as TypeScript `unknown`. Treat that as an +intentional trust boundary: narrow the value at the frontend use site, or replace +the Rust field with a typed DTO when the shape becomes stable. + +## Cross-Platform Rule + +The app is Windows-first today, but new architecture should keep Linux and macOS +viable: + +- avoid hardcoded path separators and drive-letter assumptions +- avoid `.exe` assumptions outside platform-specific code +- model GitHub release parsing by OS, architecture, accelerator, and archive + format +- degrade unavailable platform features in the UI instead of failing late + +## Related Docs + +- [Getting Started](GETTING_STARTED.md) +- [User Guide](USER_GUIDE.md) +- [Development Workflow](DEVELOPMENT_WORKFLOW.md) +- [Integration API](INTEGRATION_API.md) +- [Integration Development](INTEGRATION_DEVELOPMENT.md) +- [Custom Integrations](CUSTOM_INTEGRATIONS.md) +- [Trust Model](TRUST_MODEL.md) diff --git a/docs/en/CURRENT_STATE.md b/docs/localization/en/CURRENT_STATE.md similarity index 76% rename from docs/en/CURRENT_STATE.md rename to docs/localization/en/CURRENT_STATE.md index 474ab419..4545dee4 100644 --- a/docs/en/CURRENT_STATE.md +++ b/docs/localization/en/CURRENT_STATE.md @@ -1,6 +1,6 @@ # Axelate Current State -> Repository-grounded snapshot as of 2026-04-23. +> Repository-grounded snapshot as of 2026-05-06. > 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). @@ -20,11 +20,11 @@ Today the repository is closest to: Today the repository is not yet: -- a real creator marketplace +- a reviewed package distribution layer - a full package distribution platform - a managed runtime platform - a mature MCP-first workstation -- a finished public product with a stable long-term business model +- a finished public product with stable distribution and operations ## Current Stack @@ -33,7 +33,7 @@ Confirmed by the repository: - backend: Rust - desktop runtime: Tauri v2 - frontend: vanilla TypeScript -- shared contract generation: Specta +- shared contract generation: Specta rc.25 with generated TypeScript bindings - async runtime: Tokio - HTTP client: reqwest - target operating system: Windows-first @@ -43,7 +43,7 @@ Confirmed repository posture: - Rust owns domain logic and secure state - TypeScript owns desktop composition and UI orchestration - Tauri commands are used as thin adapters -- frontend-visible bindings are generated from Rust types +- frontend-visible bindings are generated from Rust types and validated by the exporter ## Current Repository Shape @@ -52,7 +52,7 @@ Top-level areas: - `.github/` workflow runner, scripts, automation support - `src/` frontend app and shell - `src-tauri/` Rust backend, domain logic, config, commands -- `docs/` canonical English product documentation +- `docs/localization/en/` canonical English product documentation Current backend top-level areas: @@ -68,10 +68,9 @@ Current frontend feature areas: - `chat/` - `console/` - `downloads/` -- `home-overview/` - `monitoring/` - `settings/` -- shared shell and app composition layers +- shared shell, templates, and app composition layers ## Current User-Facing Surfaces @@ -83,13 +82,13 @@ The repository clearly contains code for these surfaces: - downloads - console logs - monitoring -- home overview placeholder +- home page placeholder in the shared shell/templates - shared shell, sidebar, window, modal flow Important nuance: - the shell has carried marketplace ambitions and related wording -- the current frontend feature set is still centered on workstation behavior, not marketplace commerce +- the current frontend feature set is still centered on workstation behavior, not public package distribution - the home overview is still a placeholder surface, not a finished dashboard ## Current AI Layer @@ -157,7 +156,7 @@ The local module catalog currently includes these known entries. - type: local - capability: image -- engine: `stable-diffusion.cpp` +- engine: `sdcpp` - role: lightweight local image generation ### 3. `comfyui` @@ -173,11 +172,20 @@ Important current interpretation: - it is not a stable cornerstone of the current product definition - it should be treated as placeholder or future integration, not as core value today -### 4. `sample-integration` +### 4. Imported custom integrations -- type: script +- source: user-imported folder, archive, or supported GitHub URL +- manifest: `axelate-module.toml` +- runtime: `python`, `node`, `bun`, or `binary` - role: external workflow integration +Important current interpretation: + +- there is no bundled `sample-integration` entry in the current resource catalog +- imported integrations are discovered from the user's integrations directory +- imported integrations are local code chosen by the user, not reviewed + marketplace packages + ### Confirmed Runtime Responsibilities The backend currently handles real runtime concerns: @@ -206,7 +214,8 @@ Current limitation: - the repository now moves to `Apache-2.0` for the desktop open core - the legal and packaging split between open core and closed platform is still incomplete -- the commercial backend, signing, billing, entitlements, and managed execution layers are not separated in this repository yet +- package signing, ownership sync, verified distribution, and managed execution + layers are not separated in this repository yet ## Current Strengths @@ -247,28 +256,27 @@ OpenRouter is a strong accelerator for the current stage, but it also means: - provider abstraction is not yet the main product story - business differentiation cannot come from model catalog alone -### 3. Placeholder and Legacy Surfaces +### 3. Placeholder and Unfinished Surfaces The repository still contains surfaces or ideas that are ahead of the stable product: -- marketplace ambitions without actual commerce backend - home overview placeholder - ComfyUI presence without product-ready positioning -- legacy wording and shell assumptions carried from earlier product framing +- old wording and shell assumptions carried from earlier product framing -### 4. Incomplete Commercial Foundation +### 4. Incomplete Package Trust Foundation What does not exist yet as a finished system: - package signing service -- entitlement service -- billing -- payouts -- creator onboarding +- verified package distribution - managed runtime orchestration - trust and review pipeline for third-party packages +- permission prompts for package capabilities +- signed update and rollback flow -Without these, Axelate cannot honestly claim to be a creator marketplace today. +Without these, Axelate cannot honestly claim to be a trusted package platform +today. ### 5. Incomplete Trust Story On The Surface @@ -295,7 +303,8 @@ Based on the repository and current capabilities, Axelate should currently be de - weaker than a finished platform business It already has enough substance to become a serious product if scope stays narrow. -It does not yet have enough commercial infrastructure to expand safely into a public creator marketplace. +It does not yet have enough trust infrastructure to expand safely into public +package distribution. ## What The Project Should Mean Right Now @@ -307,7 +316,7 @@ That is the current truth. The project should not yet describe itself as: -- a mature creator marketplace +- a mature package distribution platform - a fully open ecosystem - a trusted managed execution platform - a finished MCP operating layer @@ -318,8 +327,24 @@ Current GitHub automation: - strict CI runs on `main` and `nightly` - Dependabot opens dependency update pull requests against `nightly` +- Dependabot security updates, secret scanning, and push protection are enabled +- CodeQL scans TypeScript/JavaScript and Rust on protected branch pushes, weekly schedule, and manual dispatch +- dependency review runs on pull requests only when npm or Cargo dependency files change +- scheduled security audit runs `npm audit` and `cargo audit` +- CodeRabbit reviews pull requests targeting `nightly` and `main` - release builds run when a `v*` tag is pushed - release tags must match all project manifest versions +- release tags matching `v*` are protected against deletion and non-fast-forward updates + +Current branch and merge settings: + +- `nightly` is the default branch +- `main` and `nightly` are protected +- protected branches require the frontend and backend strict CI checks +- protected branches require linear history and resolved conversations +- protected branches reject force-push and branch deletion +- human approval and CODEOWNERS review are not required during the solo-maintainer phase +- squash merge is enabled; merge commits and rebase merges are disabled The repository is still alpha-stage. `nightly` is where active development lands; `main` should stay release-ready. @@ -331,6 +356,6 @@ The correct interpretation is: - keep building the workstation core - remove identity confusion -- treat package commerce as phase two +- treat public package distribution as phase two - treat managed execution as phase three - do not reopen scope until the desktop core is reliable and coherent diff --git a/docs/localization/en/CUSTOM_INTEGRATIONS.md b/docs/localization/en/CUSTOM_INTEGRATIONS.md new file mode 100644 index 00000000..40df20d7 --- /dev/null +++ b/docs/localization/en/CUSTOM_INTEGRATIONS.md @@ -0,0 +1,89 @@ +# Custom Integrations + +Axelate integrations are folders with an `axelate-module.toml` manifest. The +launcher can import a folder, a local archive, or a GitHub repository/archive URL. +Archives may be `.zip`, `.tar.gz`, `.tgz`, or `.7z`. + +For a guided development flow, use [Integration Development](INTEGRATION_DEVELOPMENT.md). +For a new integration, prefer the scaffold first: + +```bash +npm run integration:new -- ./my-integration --id my-integration --name "My Integration" +npm run integration:doctor -- ./my-integration +``` + +Use `--runtime node` or `--runtime bun` for JavaScript integrations. + +## Minimal Layout + +```text +my-integration/ + axelate-module.toml + README.md + requirements.txt + src/ + main.py + settings-ui/ + index.html +``` + +## Manifest + +```toml +api_version = "1" +id = "my-integration" +name = "My Integration" +version = "0.1.0" +description = "Connects my product to Axelate." +author = "Your Name" +type = "service" +icon = "⚙" +readme = "README.md" +settings_ui = "settings-ui/index.html" + +[runtime] +kind = "python" +version = "3.11" +entry = "src/main.py" +dependencies = "requirements.txt" +``` + +Rules: + +- `id` may contain only letters, numbers, `-`, and `_`. +- `type` should be `service` for launcher integrations. +- `runtime.entry` and `runtime.dependencies` are paths relative to the module + folder. +- Do not ship `.venv`, `node_modules`, caches, logs, or downloaded runtimes. + +## Launcher API + +Launcher-managed script-runtime integrations receive: + +- `AXELATE_INTEGRATION_API_VERSION` +- `AXELATE_HTTP_API_BASE` +- `AXELATE_HTTP_API_TOKEN` +- `AXELATE_RUNTIME_DIR` +- `AXELATE_MODULE_DIR` +- `AXELATE_MODULE_RUNTIME_DIR` +- `AXELATE_MODULE_LOG_DIR` +- `AXELATE_MODULE_ID` + +Use the local HTTP API from [INTEGRATION_API.md](./INTEGRATION_API.md) to call AI, +read and save integration settings, report stages, and control integration +status. Store integration-owned runtime files under `AXELATE_MODULE_RUNTIME_DIR` +and logs under `AXELATE_MODULE_LOG_DIR`; do not write generated files into the +imported integration folder. + +## Current Trust Limits + +Custom integrations are local code imported by the user. The current launcher can +validate the manifest, isolate settings/runtime/log folders, issue scoped local +API tokens, and remove imported files. It does not yet provide marketplace +signing, verified publisher identity, install-time permission review, or managed +remote execution. Treat manually imported integrations as code you chose to run. + +## Example + +Use [Axelate Telegram Parser](https://github.com/F0RLE/Axelate-telegram-parser) +as a working integration structure. diff --git a/docs/en/DEVELOPMENT_WORKFLOW.md b/docs/localization/en/DEVELOPMENT_WORKFLOW.md similarity index 57% rename from docs/en/DEVELOPMENT_WORKFLOW.md rename to docs/localization/en/DEVELOPMENT_WORKFLOW.md index 26a88a75..460132af 100644 --- a/docs/en/DEVELOPMENT_WORKFLOW.md +++ b/docs/localization/en/DEVELOPMENT_WORKFLOW.md @@ -16,13 +16,16 @@ Branch model: - `main` is the release-ready branch - dependency update pull requests target `nightly` - merge to `main` only after CI is green and the change is ready to release +- protected branches require strict frontend/backend checks, linear history, resolved conversations, and no force-push or deletion +- protected branches currently do not require a second human approval because the repository is in a solo-maintainer phase +- pull requests use squash merge; merge commits and rebase merges are disabled The repository currently splits responsibilities this way: - `.github/scripts/workflow.mjs`: root task runner for setup, dev, build, release, and verification - `src/`: vanilla TypeScript frontend, shell, tests, and frontend tooling - `src-tauri/`: Rust backend, domain logic, secure state, and build pipeline -- `docs/en/`: current docs plus separate planning docs +- `docs/localization/en/`: current English docs plus separate planning docs Rust toolchain policy: @@ -68,6 +71,33 @@ Recommended use: - `typecheck`: read-only binding validation plus TypeScript checks - `verify`: full local release gate before handoff, release work, or a pull request +## Manual Smoke Check + +Before merging a large PR to `nightly` or promoting `nightly` toward `main`, run +the smallest manual desktop pass that touches the real Tauri runtime: + +- startup and shutdown: launch with `npm run dev`, close the app, relaunch, and + confirm the previous UI state restores without stale modals or stuck loading + states +- chat text flow: send a normal message, cancel a streaming response, retry or + regenerate the last turn, and confirm provider errors appear as toasts rather + than persistent assistant messages +- chat image flow: send an image-generation request, cancel one in progress, and + confirm generated images restore from history with image actions intact +- provider settings: save, validate, remove, and relaunch after deleting an API + key; the key should stay removed after restart +- local modules: open version selection, download a CPU or GPU package, start, + stop, restart, remove, and confirm the selection modal refreshes after disk + changes +- integrations: import a folder or archive, run it, open settings, delete it + from the launcher, then delete or restore the folder externally and confirm + the integrations modal refreshes +- console and downloads: filter log levels, pause/resume/cancel an active + download, and confirm controls stay clickable under hover + +If one of these checks fails, fix the product flow first and only update docs if +the intended behavior changed. + Current dev-server behavior: - the Tauri dev flow expects the frontend on `http://localhost:1420` @@ -86,18 +116,33 @@ npm run release - `tauri:build`: desktop app build - `release`: full verification plus release bundle build -## CI And Releases +## Automation, CI, And Releases -GitHub Actions currently has two repository workflows: +GitHub Actions currently has these repository workflows: - `Strict CI`: runs on pushes and pull requests for `main` and `nightly`, plus manual dispatch +- `CodeQL`: runs code scanning for TypeScript/JavaScript and Rust on pushes to `main` or `nightly`, weekly schedule, and manual dispatch +- `Dependency Review`: reviews dependency changes on pull requests to `main` and `nightly` when npm or Cargo dependency files change +- `Security Audit`: runs scheduled and manual `npm audit` plus `cargo audit` - `Release Build`: runs on pushed `v*` tags, plus manual dispatch for an existing tag +Protected branches require the `Frontend Strict Check` and `Backend Strict Check` jobs from `Strict CI`. +CodeRabbit is the normal advisory review signal on pull requests. +CodeQL and scheduled security audits run outside the normal PR path to avoid slowing down solo development. + The release workflow builds the Windows Tauri bundles, verifies release hardening, writes `SHA256SUMS.txt`, and attaches checksums to the GitHub release. The release tag must match the versions in `package.json`, `src/package.json`, and `src-tauri/Cargo.toml`. See [Releases](RELEASES.md) for the release checklist. +Repository review automation: + +- CodeRabbit reviews pull requests targeting `nightly` and `main` +- CodeRabbit is configured for a low-noise solo-maintainer workflow and should prioritize correctness, security, data loss, user-flow regressions, and missing tests +- CodeRabbit labeling is advisory; labels are not auto-applied +- generated bindings, lockfiles, build output, caches, and dependency directories are excluded from CodeRabbit review noise where configured +- GitHub secret scanning, push protection, Dependabot alerts, and Dependabot security updates are enabled in repository settings + Cleanup command: ```bash @@ -126,6 +171,14 @@ Frontend bindings are generated from Rust. The intended workflow is: If bindings are out of date, `typecheck` and `verify` should fail instead of silently rewriting files. +Current Specta policy: + +- the binding stack is `specta` `2.0.0-rc.25`, `tauri-specta` `2.0.0-rc.25`, and `specta-typescript` `0.0.12` +- `serde_json::Value` is exported to TypeScript as `unknown` because it is dynamic JSON, not a stable typed contract +- Rust integer shapes that cross the Tauri JSON boundary are exported as TypeScript `number`; do not introduce frontend `bigint` unless the IPC path is changed deliberately +- floating-point DTO fields use lossless-float generation so metrics and settings remain typed as numbers on the frontend +- validate binding changes with `npm run bindings:check`, `npm run typecheck`, and the smallest relevant Rust/frontend tests + ## Git Hooks `npm run setup` configures `core.hooksPath` to use `.github/.husky`. @@ -150,7 +203,9 @@ The current doctor flow checks WebView2 through the Windows registry and resolve Use these as current truth: +- [User Guide](USER_GUIDE.md) - [Getting Started](GETTING_STARTED.md) +- [Architecture](ARCHITECTURE.md) - [Releases](RELEASES.md) - [Current State](CURRENT_STATE.md) - [Trust Model](TRUST_MODEL.md) diff --git a/docs/en/GETTING_STARTED.md b/docs/localization/en/GETTING_STARTED.md similarity index 97% rename from docs/en/GETTING_STARTED.md rename to docs/localization/en/GETTING_STARTED.md index 5176b252..aa8bcf5b 100644 --- a/docs/en/GETTING_STARTED.md +++ b/docs/localization/en/GETTING_STARTED.md @@ -8,8 +8,8 @@ For day-to-day contributor work after setup, continue with [Development Workflow ## Requirements -- Node.js 20+ -- npm 10+ +- Node.js 26.1.0+ +- npm 11+ - Rust via `rustup` (`rust-toolchain.toml` pins the tested version) - Windows: Visual Studio Build Tools, Windows SDK, and WebView2 Runtime @@ -134,7 +134,7 @@ That gate includes: - prerequisite check - Rust format, clippy, check, and tests - frontend dependency presence check -- frontend bindings check, format check, typecheck, lint, tests, build, and size budget +- frontend bindings check, format check, typecheck, lint, tests, and bundle build If `verify` is red, the repository is not ready for release work. @@ -164,7 +164,9 @@ npm run clear ## Related Docs +- [User Guide](USER_GUIDE.md) - [Development Workflow](DEVELOPMENT_WORKFLOW.md) +- [Architecture](ARCHITECTURE.md) - [Releases](RELEASES.md) - [Current State](CURRENT_STATE.md) - [Trust Model](TRUST_MODEL.md) diff --git a/docs/localization/en/INTEGRATION_API.md b/docs/localization/en/INTEGRATION_API.md new file mode 100644 index 00000000..4ae458a8 --- /dev/null +++ b/docs/localization/en/INTEGRATION_API.md @@ -0,0 +1,304 @@ +# 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. + +For scaffolding, validation, and examples, start with +[Integration Development](INTEGRATION_DEVELOPMENT.md). + +## Runtime Contract + +Axelate starts a local API server on `127.0.0.1` when the launcher starts. The +server is available only on the local machine and requires a launcher-issued +runtime token. + +Launcher-managed script-runtime integration processes receive these environment +variables: + +- `AXELATE_INTEGRATION_API_VERSION`: local launcher integration API version, + currently `1` +- `AXELATE_HTTP_API_BASE`: local base URL, for example `http://127.0.0.1:3000` +- `AXELATE_HTTP_API_TOKEN`: bearer token issued by `apply_process_env` through + `issue_module_api_token` and scoped to this integration. It authorizes shared + endpoints and only this integration's own `/v1/modules/{moduleId}/...` routes; + `ensure_module_route_owner` rejects other module routes with `403`. +- `AXELATE_RUNTIME_DIR`: shared launcher runtime directory +- `AXELATE_MODULE_DIR`: read-only integration installation directory +- `AXELATE_MODULE_RUNTIME_DIR`: writable runtime directory reserved for the integration +- `AXELATE_MODULE_LOG_DIR`: writable log directory reserved for the integration +- `AXELATE_MODULE_ID`: current integration id + +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. + +Script integrations declare their runtime in `axelate-module.toml`. + +```toml +[runtime] +kind = "python" # python | node | bun | binary +version = "3.11" +entry = "src/main.py" +dependencies = "requirements.txt" +``` + +The launcher installs dependencies into its managed runtime under +`AxelateData/System/Runtime//envs//`. +Python uses `uv` and `requirements.txt`. Node and Bun use the declared package +manager and a package manifest outside the integration directory. Integrations must not +ship or write `.venv`, `node_modules`, caches, logs, or downloaded runtime +dependencies inside the integration directory. + +## Authentication + +Every endpoint except `GET /v1/health` requires bearer-token authentication: + +```http +Authorization: Bearer +``` + +## Client Rules + +- Treat `AXELATE_HTTP_API_BASE` and `AXELATE_HTTP_API_TOKEN` as runtime values. +- Do not hardcode the port. The launcher can choose any free port in its local + range. +- Do not store the token permanently. It changes between launcher processes. +- Send and receive JSON. +- Use `/v1` endpoints only; unversioned endpoints are not public API. +- Read and save integration settings through `/v1/modules/{moduleId}/settings`. + Do not read or write Axelate's internal `module_settings.json` directly. +- Write temporary files, caches, and generated state to `AXELATE_MODULE_RUNTIME_DIR`. + Write logs to `AXELATE_MODULE_LOG_DIR`. +- If a request specifies an AI `provider`, the launcher validates and uses that + provider for the request only. It does not change the user's visual card + selection. Omit `provider` to use the active launcher selection. + +## Quick Start + +### curl + +```bash +curl -X POST "$AXELATE_HTTP_API_BASE/v1/ai/text" \ + -H "Authorization: Bearer $AXELATE_HTTP_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"provider":"llamacpp","prompt":"Write a short status update"}' +``` + +### PowerShell + +```powershell +$headers = @{ + Authorization = "Bearer $env:AXELATE_HTTP_API_TOKEN" +} + +Invoke-RestMethod ` + -Method Post ` + -Uri "$env:AXELATE_HTTP_API_BASE/v1/ai/text" ` + -Headers $headers ` + -ContentType "application/json" ` + -Body (@{ + provider = "llamacpp" + prompt = "Write a short status update" + } | ConvertTo-Json) +``` + +### JavaScript + +```js +const baseUrl = process.env.AXELATE_HTTP_API_BASE; +const token = process.env.AXELATE_HTTP_API_TOKEN; +const moduleId = process.env.AXELATE_MODULE_ID; + +const response = await fetch(`${baseUrl}/v1/ai/text`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + provider: 'llamacpp', + prompt: 'Write a short status update', + }), +}); + +const result = await response.json(); + +const modulePathId = encodeURIComponent(moduleId); +const settingsResponse = await fetch(`${baseUrl}/v1/modules/${modulePathId}/settings`, { + headers: { Authorization: `Bearer ${token}` }, +}); +const { settings } = await settingsResponse.json(); +``` + +### Python + +```python +import os +import urllib.parse +import requests + +base_url = os.environ["AXELATE_HTTP_API_BASE"] +token = os.environ["AXELATE_HTTP_API_TOKEN"] +module_id = os.environ["AXELATE_MODULE_ID"] +module_path_id = urllib.parse.quote(module_id, safe="") +headers = {"Authorization": f"Bearer {token}"} + +response = requests.post( + f"{base_url}/v1/ai/text", + headers=headers, + json={ + "provider": "llamacpp", + "prompt": "Write a short status update", + }, + timeout=120, +) +result = response.json() + +settings = requests.get( + f"{base_url}/v1/modules/{module_path_id}/settings", + headers=headers, + timeout=30, +).json()["settings"] +``` + +## Endpoints + +### Health + +`GET /v1/health` + +Does not require authentication. Returns whether the local API server is alive. + +### Integrations + +`GET /v1/modules` + +Returns installed integrations with launcher status, category, install state, and +metadata. A launcher-wide token can see all installed integrations. A +module-scoped token only sees the integration that received the token. + +`GET /v1/modules/{moduleId}/status` + +Returns one integration status. + +`GET /v1/modules/{moduleId}/context` + +Returns the stable runtime context for an installed integration. + +```json +{ + "ok": true, + "apiVersion": "1", + "moduleId": "my-integration", + "moduleDir": "{AXELATE_DATA_DIR}/System/Integrations/my-integration", + "runtimeDir": "{AXELATE_DATA_DIR}/System/Runtime", + "moduleRuntimeDir": "{AXELATE_DATA_DIR}/System/Runtime/Integrations/my-integration", + "moduleLogDir": "{AXELATE_DATA_DIR}/System/Logs/Integrations/my-integration", + "httpApiBase": "http://127.0.0.1:3000" +} +``` + +`moduleDir` is for reading shipped integration files. Runtime output belongs in +`moduleRuntimeDir`, not in the integration folder. Treat these paths as +platform-specific strings and use path utilities such as `path.join` and +`path.sep` instead of hardcoded separators. + +`GET /v1/modules/{moduleId}/settings` + +Returns the JSON settings object owned by the integration. + +`PUT /v1/modules/{moduleId}/settings` + +Replaces the integration settings object. + +```json +{ + "chatId": "12345", + "enabled": true +} +``` + +`PATCH /v1/modules/{moduleId}/settings` + +Merges the request JSON object into the existing integration settings object. + +`POST /v1/modules/{moduleId}/stage` + +Reports the current user-visible stage of a running integration. The launcher +emits `module-stage-changed` for UI surfaces and writes the stage to logs. + +```json +{ + "stage": "parser.fetch", + "label": "Fetching external data", + "details": { "topics": 3 }, + "progress": 0.35 +} +``` + +`stage` is a stable machine-readable stage id. `label` is the human-readable +current action. `details` and `progress` are optional. + +`POST /v1/modules/{moduleId}/start` + +Starts an integration or long-running module script through the launcher module +controller. + +`POST /v1/modules/{moduleId}/stop` + +Stops the running module script. + +`POST /v1/modules/{moduleId}/restart` + +Restarts the module script. + +### AI Text + +`POST /v1/ai/text` + +Runs the selected or requested text AI provider through the same backend path as +launcher chat. + +```json +{ + "prompt": "Summarize this message", + "sessionId": "my-integration", + "provider": "gpt", + "model": "gpt-5.5", + "messages": [{ "role": "user", "content": "Optional chat history" }], + "thinkingLevel": "medium", + "maxTokens": 1024, + "webSearch": { "enabled": false } +} +``` + +`provider` and `model` are optional. When omitted, the launcher uses the active +`ai_text` module selection and its selected model. + +When `provider` is provided, the launcher validates that provider and runs this +request against it without changing the user's active `ai_text` selection. + +### AI Image + +`POST /v1/ai/image` + +Runs image generation through the selected or requested image AI provider. + +```json +{ + "prompt": "Pixel art launcher icon", + "provider": "gpt-image", + "model": "openai/gpt-5-image", + "width": 1024, + "height": 1024, + "steps": 30 +} +``` + +`provider` and `model` are optional. When omitted, the launcher uses the active +`ai_image` module selection and its selected model. + +When `provider` is provided, the launcher validates that provider and runs this +request against it without changing the user's active `ai_image` selection. diff --git a/docs/localization/en/INTEGRATION_DEVELOPMENT.md b/docs/localization/en/INTEGRATION_DEVELOPMENT.md new file mode 100644 index 00000000..21d60324 --- /dev/null +++ b/docs/localization/en/INTEGRATION_DEVELOPMENT.md @@ -0,0 +1,141 @@ +# Integration Development + +> Build a product integration that uses Axelate AI, settings, logs, and runtime +> folders without depending on launcher internals. + +## Fast Path + +Create a starter integration: + +```bash +npm run integration:new -- ./my-integration --id my-integration --name "My Integration" +npm run integration:doctor -- ./my-integration +``` + +Then import the folder in the launcher integrations screen and launch it. +Use `--runtime node` or `--runtime bun` when the integration entrypoint is +JavaScript instead of Python. + +For an existing app, keep the app code in your integration folder, declare its +entrypoint in `axelate-module.toml`, and use the launcher-provided environment +variables at process start. Store generated state in +`AXELATE_MODULE_RUNTIME_DIR`, call `/v1/ai/text` or `/v1/ai/image` through the +local API, then run `integration:doctor` before importing the folder. + +## Repository Helpers + +- `npm run integration:new -- ` creates a minimal Python integration. + Add `--runtime node` or `--runtime bun` for JavaScript runtimes. +- `npm run integration:doctor -- ` validates `axelate-module.toml`, + entry files, settings UI, dependency paths, and common generated folders that + should not be shipped. +- `docs/examples/integrations/python-ai-tool/` is the smallest working example. +- `docs/examples/sdk/python/axelate_sdk.py` and + `docs/examples/sdk/javascript/axelate-client.mjs` are small copyable + client helpers for the local HTTP API. +- `docs/examples/sdk/browser/axelate-settings-bridge.js` is a copyable + helper for custom settings UI iframe messaging. + +These helpers are developer tools. The runtime contract is still the local HTTP +API documented in [Integration API](INTEGRATION_API.md). + +## Integration Layout + +```text +my-integration/ + axelate-module.toml + README.md + src/ + main.py + settings-ui/ + index.html +``` + +The manifest must declare a launcher-managed runtime: + +```toml +api_version = "1" +id = "my-integration" +name = "My Integration" +version = "0.1.0" +type = "service" +settings_ui = "settings-ui/index.html" + +[runtime] +kind = "python" +version = "3.11" +entry = "src/main.py" +``` + +Supported runtime kinds are `python`, `node`, `bun`, and `binary`. + +## Runtime Contract + +When Axelate launches a script-runtime integration it sets: + +- `AXELATE_INTEGRATION_API_VERSION` +- `AXELATE_HTTP_API_BASE` +- `AXELATE_HTTP_API_TOKEN` +- `AXELATE_MODULE_ID` +- `AXELATE_MODULE_DIR` +- `AXELATE_RUNTIME_DIR` +- `AXELATE_MODULE_RUNTIME_DIR` +- `AXELATE_MODULE_LOG_DIR` + +Use those values at process start. Do not hardcode ports or data paths. + +## Calling AI + +Minimal Python call: + +```python +from axelate_sdk import AxelateClient + +client = AxelateClient() +settings = client.settings() +reply = client.ai_text(settings.get("prompt", "Write a short status update.")) +print(reply) +``` + +Minimal JavaScript call: + +```js +import { AxelateClient } from './axelate-client.mjs'; + +const client = new AxelateClient(); +const settings = await client.settings(); +const reply = await client.aiText(settings.prompt ?? 'Write a short status update.'); +console.log(reply); +``` + +## Settings UI + +If `settings_ui` points to an HTML file or a directory with `index.html`, the +launcher opens it in a sandboxed settings host. + +The iframe protocol is: + +- post `{ channel: "axelate:module-settings", type: "module-ready" }` +- wait for `host-ready`, which includes `settings` and `context` +- post `module-rendered` when the UI is ready +- save settings with a message whose `method` is `saveSettings` + +Use `docs/examples/integrations/python-ai-tool/settings-ui/index.html` and +`docs/examples/integrations/python-ai-tool/settings-ui/axelate-settings-bridge.js` +as the current reference. + +## Development Loop + +1. Scaffold or copy the example. +2. Run `integration:doctor`. +3. Import the folder in Axelate. +4. Launch the card. +5. Check integration logs from the launcher console/logs UI. +6. Keep generated data in `AXELATE_MODULE_RUNTIME_DIR`. +7. Keep shipped source clean: no `.venv`, `node_modules`, caches, logs, or + downloaded runtimes. + +## Trust Rule + +Imported integrations are local code chosen by the user. They are not reviewed, +signed, or sandboxed packages yet. diff --git a/docs/en/RELEASES.md b/docs/localization/en/RELEASES.md similarity index 69% rename from docs/en/RELEASES.md rename to docs/localization/en/RELEASES.md index 33c6e9c1..43002805 100644 --- a/docs/en/RELEASES.md +++ b/docs/localization/en/RELEASES.md @@ -10,6 +10,7 @@ - Dependabot targets `nightly`. - Release tags should be created from the commit that is meant to ship. - Release tags must point to a commit that is already reachable from `main`. +- Release tags matching `v*` are protected by a repository ruleset against deletion and non-fast-forward updates. ## CI @@ -21,7 +22,16 @@ - pull requests targeting `nightly` - manual dispatch from GitHub Actions -The CI gate checks frontend linting, formatting, type/build, bundle size, tests, Rust clippy, Rust check, Rust tests, and audit reporting. +The CI gate checks frontend linting, formatting, type/build, tests, Rust clippy, +Rust check, Rust tests, and audit reporting. Frontend size reporting exists as a +local task, but it is not part of the required release gate. + +Additional release-relevant automation: + +- `CodeQL` scans TypeScript/JavaScript and Rust code on protected branch pushes, weekly schedule, and manual dispatch. +- `Dependency Review` checks dependency changes on PRs to `main` and `nightly` when npm or Cargo dependency files change. +- `Security Audit` runs `npm audit --audit-level=high` and `cargo audit` on a weekly schedule and manual dispatch. +- CodeRabbit reviews PRs and is configured as advisory automation for the current solo-maintainer workflow. ## Local Release Check diff --git a/docs/localization/en/ROADMAP.md b/docs/localization/en/ROADMAP.md new file mode 100644 index 00000000..6c171b4d --- /dev/null +++ b/docs/localization/en/ROADMAP.md @@ -0,0 +1,605 @@ +# Axelate Roadmap + +> Product execution roadmap as of 2026-05-06. +> Planning document only. It is not a setup guide and it does not mean every +> listed feature already exists in the repository today. + +## Mission + +Build Axelate into a trusted Windows-first AI workstation and local integration +runtime. + +That means: + +- reliable desktop core first +- local integrations second +- trusted package and managed execution layers later + +Business and operations planning live outside this repository roadmap in the +private Axelate notes. + +## Overall Difficulty + +Overall product ambition: `8/10`. + +Phase difficulty: + +- workstation core: `6/10` +- local integrations and SDKs: `6/10` +- trusted package layer: `7/10` +- managed or hybrid execution layer: `9/10` + +## Core Constraints + +These constraints are not optional. + +- Windows-first until the model is proven +- workstation before distribution +- local and BYOK before managed cloud complexity +- trust and permissions before growth +- clear product identity before feature expansion + +## Market Read + +The market does not need another generic AI chat client. Local AI users already +combine tools such as Ollama, LM Studio, Jan, AnythingLLM, Open WebUI, Pinokio, +ComfyUI, and Dify-like workflow builders. The repeated demand is for a reliable +control plane that makes those pieces usable without manual setup, broken +downloads, hidden GPU usage, unclear logs, unsafe scripts, or provider-specific +lock-in. + +The strongest opening for Axelate is: + +> A desktop AI launcher and runtime for integrations: one app that installs +> engines, manages local and BYOK cloud providers, owns secrets, exposes a +> stable local API, and lets user-added tools run against shared AI capability. + +Axelate should not try to win by being the best chat UI. It should win by being +the dependable desktop layer underneath AI-powered tools. + +## What Users Are Asking For + +Signals from local AI communities and competing products point to these +priorities: + +- simple install, update, resume, stop, and repair flows for local runtimes +- OpenAI-compatible local API support so existing clients can point at the app +- local-first privacy with optional cloud routing for frontier models +- hardware-aware model and engine selection across CUDA, Vulkan, ROCm, Metal, + CPU, x64, arm64, and future cross-platform targets +- reliable idle behavior that does not leak memory or keep GPU/CPU busy after + work completes +- logs, status, and diagnostics that explain failures without forcing users into + terminals +- workflow tools, RAG, MCP, settings, and state management rather than only a + prompt box +- one-click integration install from folders, archives, and trusted URLs, with + clear permissions and uninstall behavior + +These are product requirements, not nice-to-have polish. If they are weak, +users will fall back to the existing toolchain. + +## Competitive Position + +Axelate should learn from existing products without copying their identity: + +- Ollama proves that a simple local model API can become ecosystem plumbing. +- LM Studio proves that a polished desktop model runner can reach non-expert + users and still serve developers through APIs. +- Jan proves that local desktop AI, extensions, local API servers, and MCP can + coexist in one open product. +- AnythingLLM proves demand for private workspaces, RAG, agents, and practical + tools on top of local and cloud providers. +- Pinokio proves demand for one-click local AI app installation, but also shows + why arbitrary script execution needs a stronger trust model. +- Dify-like products prove demand for workflow and agent builders, but Axelate + should stay desktop/runtime-first before trying to become a full web platform. + +The defensible position is not "Axelate replaces all of them." The defensible +position is "Axelate is the desktop runtime and integration layer that makes +AI-powered tools easier to install, run, observe, and trust." + +## Stack Fit + +The current stack fits this direction. + +- Tauri 2 plus Rust is the right foundation for process lifecycle, downloads, + archive extraction, local HTTP APIs, secure storage, hardware probing, logs, + and cross-platform adapters. +- Tokio and reqwest fit long-running async work such as streaming, downloads, + release fetching, and provider calls. +- Specta-generated TypeScript bindings reduce frontend/backend contract drift + and should remain mandatory for Tauri commands. +- Vanilla TypeScript is acceptable for the current desktop UI, but the frontend + needs strict component and controller discipline as settings, permissions, + integrations, and package surfaces grow. +- The backend should remain the source of truth for secrets, runtime state, + provider routing, module lifecycle, and persistent state. + +The main stack risk is not the backend. The main risk is frontend and package +surface complexity growing faster than the product architecture. If the UI starts +carrying runtime truth or ad-hoc module behavior, the project will become hard to +stabilize. + +## Ordered Execution Plan + +This is ordered by the best mix of importance, simplicity, and dependency +sequence. + +### 1. Fix Current-State Truth And Developer Docs + +Difficulty: `1/10` + +Work: + +- keep `CURRENT_STATE.md`, `ROADMAP.md`, `TRUST_MODEL.md`, and + `INTEGRATION_API.md` aligned with actual behavior +- remove stale claims when backend behavior changes +- document the exact local API, environment variables, runtime directories, and + settings ownership rules +- keep examples runnable + +Exit criteria: + +- a developer can read the docs and build one integration without asking how + tokens, settings, logs, and runtime folders work + +### 2. Make Runtime Reliability Boring + +Difficulty: `3/10` to `5/10` + +Work: + +- resumable downloads +- deterministic start, stop, cancel, and restart +- health checks and repair actions +- clear release selection for OS, architecture, accelerator, and archive type +- clear errors for missing engines, missing modules, bad archives, and GitHub + release failures +- no memory or GPU growth while idle + +Exit criteria: + +- install, start, stop, restart, delete, and restart-after-app-relaunch work + repeatedly without stale UI state or duplicate backend actions + +### 3. Make Integrations First-Class Locally + +Difficulty: `4/10` to `6/10` + +Work: + +- stable manifest validation +- folder, archive, and GitHub URL import +- predictable uninstall and external-folder-missing behavior +- clear runtime, cache, log, and settings ownership +- integration template generator +- minimal "hello Axelate" integration sample +- practical example integration such as Telegram or Discord summarizer/parser + +Exit criteria: + +- a user can add, configure, run, stop, remove, and re-add an integration without + touching project internals + +### 4. Add An OpenAI-Compatible Gateway + +Difficulty: `5/10` to `7/10` + +Work: + +- `/v1/models` +- `/v1/chat/completions` +- `/v1/responses` +- `/v1/images/generations` +- streaming compatibility where practical +- compatibility tests against common OpenAI clients +- clear mapping from Axelate providers, engines, sessions, and settings to + OpenAI-compatible request fields + +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 + +Difficulty: `5/10` to `7/10` + +Work: + +- TypeScript SDK +- Python SDK +- helpers for chat, image, settings, stage reporting, and module control +- typed errors +- examples that match the integration template +- version compatibility checks using `AXELATE_INTEGRATION_API_VERSION` + +Exit criteria: + +- integration authors can build useful tools without hand-writing local HTTP + plumbing + +### 6. Make Trust And Permissions Visible + +Difficulty: `6/10` to `8/10` + +Work: + +- module permissions in the manifest +- local, managed, and hybrid mode labels +- install-time permission review +- verified/signed package state +- visible module 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 + +### 7. Add MCP Foundation + +Difficulty: `7/10` to `8/10` + +Work: + +- MCP server registry +- connection state +- tool discovery +- user approval for server and tool access +- 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 + +### 8. Prepare Package Signing And Update Trust + +Difficulty: `7/10` to `9/10` + +Work: + +- signed package metadata +- verified publisher metadata +- update channels +- rollback metadata +- local verification before install/update +- clear official vs manual package state + +Exit criteria: + +- the desktop can distinguish trusted official packages from manual local + imports + +### 9. Add Trusted Package Discovery And Ownership + +Difficulty: `8/10` to `9/10` + +Work: + +- reviewed package discovery surface +- ownership metadata +- ownership sync contract +- reviewed package install/update flow +- revocation and rollback behavior + +Exit criteria: + +- reviewed packages can be discovered, installed, updated, and revoked + predictably. + +### 10. Build Managed And Hybrid Runtime Support + +Difficulty: `9/10` to `10/10` + +Work: + +- managed runtime API contract +- secure relay +- usage metering +- package ownership enforcement +- revocation +- managed logs and diagnostics +- deployment requirements + +Exit criteria: + +- protected workflows can run without shipping all sensitive logic locally, and + users can understand what runs local vs remote + +### Ordering Rule + +Do not start a later layer if an earlier layer is still failing in normal use. +The product earns the right to add platform complexity only after the desktop +runtime and local integration path are reliable. + +## Phase 0: Product Reset + +### Goal + +Remove identity confusion and define one honest product direction. + +### Work + +- consolidate documentation into English canonical docs +- define the product as a Windows AI workstation, not a generic chat client +- define future platform boundaries before adding new layers +- remove or demote old positioning that implies distribution features already + exist + +### Exit Criteria + +- one clear product statement exists +- one current-state document exists +- one roadmap exists +- future work can be judged against the workstation thesis + +### Status + +In progress and partially completed. + +## Phase 1: Workstation Core + +### Goal + +Turn the current shell into a reliable daily-use Windows AI workstation. + +### Workstream A: Desktop Reliability + +- stabilize startup, shutdown, and state restore +- make runtime status deterministic after restart +- make selection state and model settings persistent and obvious +- improve window, tray, and shell consistency + +### Workstream B: AI Provider Layer + +- keep OpenRouter path stable +- normalize provider/model capabilities cleanly +- make text and image routing explicit +- keep web access as an optional capability toggle +- keep custom model support first-class + +### Workstream C: Chat and Session System + +- keep streaming fast and predictable +- preserve chat history correctly +- keep summary compaction hidden and reliable +- make request isolation and cancellation robust +- improve file and multimodal handling only where it is already justified + +### Workstream D: Local Runtime Orchestration + +- make install and update flows trustworthy +- improve start, stop, health, and log visibility +- keep hardware-aware resolution readable and debuggable +- keep ComfyUI out of the core promise until it is truly product-ready + +### Exit Criteria + +- a new user can install the app and complete a first useful workflow +- local and cloud model routing feels coherent +- logs, monitoring, and repair tools explain failures +- provider settings are understandable +- local integrations can run through the launcher without manual path hacks + +### Why This Phase Matters + +If this phase fails, later package and platform layers should not launch. + +## Phase 2: Integration And Package Foundation + +### Goal + +Create the technical base for user-installed integrations and future reviewed +packages. + +### Work + +- define package manifest format +- define package permission model +- define settings schema model for packages +- define install, update, rollback, and uninstall contracts +- define signing flow for official builds +- define ownership sync contract for future reviewed packages + +### Packaging Modes + +The package system must support three modes from the start: + +- local +- managed +- hybrid + +### Exit Criteria + +- package manifests are versioned and validated +- package lifecycle is deterministic +- packages can be installed and removed safely +- package permissions are visible to the user +- the desktop understands package metadata without ad-hoc code paths + +### Why This Phase Matters + +Without a real package model, package discovery is just marketing. + +## Phase 3: Trusted Discovery And Ownership + +### Goal + +Add a controlled discovery and ownership layer for reviewed packages. Business +and operations planning is intentionally kept outside this repository roadmap. + +### Workstream A: Public Product Surface + +- landing page +- download flow +- trust explanation +- package discovery +- account and ownership flow only when needed for verified packages + +### Workstream B: Reviewed Package Intake + +- package submission flow +- review rules +- screenshots and listing metadata +- quality and support metadata + +### Workstream C: Desktop Integration + +- ownership sync +- reviewed package browsing inside desktop +- install from owned or claimed packages +- update and rollback from official channel + +### Trust Rules + +- no public self-serve upload at first +- no claims of perfect IP protection for local packages +- no unsafe execution path hidden behind one click + +### Exit Criteria + +- users can install reviewed packages through a trusted flow +- packages can be submitted and updated through a review path +- ownership state syncs into the desktop reliably + +### Difficulty + +`8/10` + +## Phase 4: Managed And Hybrid Runtime Support + +### Goal + +Support packages that need stronger protection and platform-hosted execution. + +### Work + +- define managed runtime API contract +- define secure relay and session authentication +- define usage metering +- define revocation and expiration +- define managed logs and diagnostics +- define deployment requirements +- optionally host official managed execution + +### Operational Requirements + +- incident handling +- security response +- cost controls +- rate limiting +- abuse prevention +- auditability + +### Exit Criteria + +- sensitive logic does not need to ship locally when not appropriate +- platform can meter usage without trust collapse +- users understand whether a package runs local, remote, or hybrid + +### Difficulty + +`9-10/10` + +## Phase 5: Open Core And Platform Boundary + +### Goal + +Make the desktop core auditable and contribution-friendly while keeping future +platform services clearly separated. + +### Work + +- split open desktop core from platform services +- publish package spec and SDKs +- choose final open-core license +- formalize contribution rules +- document official build and signing policy + +### Exit Criteria + +- external contributors can work on the desktop core safely +- platform services stay operationally controlled +- forks do not confuse official trust guarantees + +## What Is Explicitly Not A Priority + +Not now: + +- mobile apps +- cross-platform perfection +- social feeds +- open upload marketplace +- enterprise-first sales motion +- feature racing against every chat client +- turning Axelate into a generic unsafe script runner + +## Go / No-Go Checkpoints + +### Checkpoint 1: After Phase 1 + +Question: + +- is the workstation core reliable enough that people would use it weekly without + package discovery? + +If no: + +- stop expanding scope +- fix reliability, UX clarity, and trust + +### Checkpoint 2: Before Phase 3 + +Question: + +- do we have a safe package model, a review process, and ownership sync that is + understandable to users? + +If no: + +- do not launch package discovery + +### Checkpoint 3: Before Phase 4 + +Question: + +- can we run managed infrastructure without collapsing trust or reliability? + +If no: + +- keep the product focused on local and hybrid packages first + +## Success Metrics + +### Workstation Metrics + +- install to first successful workflow +- runtime install success rate +- runtime recovery success rate +- chat success rate +- crash-free sessions + +### Package Metrics + +- reviewed package install completion rate +- ownership sync reliability +- rollback success rate +- package update success rate + +### Managed Runtime Metrics + +- ownership verification reliability +- incident frequency +- abuse rate +- package uptime and latency + +## Final Roadmap Rule + +Axelate should only earn the right to become a package platform after it becomes +a trusted workstation. + +That sequencing is the roadmap. diff --git a/docs/en/TRUST_MODEL.md b/docs/localization/en/TRUST_MODEL.md similarity index 100% rename from docs/en/TRUST_MODEL.md rename to docs/localization/en/TRUST_MODEL.md diff --git a/docs/localization/en/USER_GUIDE.md b/docs/localization/en/USER_GUIDE.md new file mode 100644 index 00000000..ebf4b071 --- /dev/null +++ b/docs/localization/en/USER_GUIDE.md @@ -0,0 +1,97 @@ +# Axelate User Guide + +> Short guide for using the current desktop app. This is not a developer setup +> guide; for source builds use [Getting Started](GETTING_STARTED.md). + +## What Axelate Does Today + +Axelate is a Windows-first AI workstation. It can: + +- use BYOK (Bring Your Own Key) cloud AI providers through the launcher UI +- run chat and image requests +- manage local AI engines such as `llamacpp` and `sdcpp` +- import local integrations from folders, archives, or supported URLs +- show downloads, runtime logs, settings, and system monitoring in one shell + +It is not yet a reviewed package store, a managed remote execution platform, or +a finished MCP control layer. + +## First Launch + +On first launch: + +1. Open Settings. +2. Choose the UI language and theme. +3. Add the provider key you want to use. +4. Select an AI provider and model. +5. Optionally install a local engine for text or image generation. + +Provider keys are stored through the backend secure-storage path. The UI should +show whether a key exists without exposing the full secret. + +## Chat And Images + +Use the chat surface for text conversations and image attachments. If a provider +or local engine fails, the error should appear as a notification or status +message, not as a fake assistant reply. + +For image generation, select an image-capable provider or local image engine in +the AI settings surface before sending the request. + +## Local Engines + +The current built-in local engines are: + +- `llamacpp` for text generation +- `sdcpp` for image generation +- `comfyui` as a future/experimental image workflow entry + +Engine downloads and starts are backend-owned. Use the launcher controls to +install, launch, stop, delete, and inspect logs instead of editing runtime files +by hand. + +## Integrations + +Custom integrations are local projects with an `axelate-module.toml` manifest. +The launcher can import: + +- a folder +- a local archive +- a supported GitHub repository or archive URL + +Imported integrations are code you chose to run. They are not reviewed or signed +packages yet. Use the card actions to launch, stop, open, or delete an +integration. + +## Data And Logs + +Axelate keeps runtime data under its application data directory, split by +purpose: + +- integration install folders +- integration runtime folders +- integration logs +- engine runtime folders +- launcher logs and settings + +Prefer launcher actions for deleting engines or integrations. Manual deletion +can leave stale UI state until the launcher refreshes its module list. + +## Troubleshooting + +If something fails: + +- check the notification/status message first +- open the console/logs surface +- stop and launch the engine or integration again +- verify that required provider keys still exist +- delete and reinstall a broken local engine only after checking logs + +For source-development problems, use [Development Workflow](DEVELOPMENT_WORKFLOW.md). + +## Trust Limits + +Current local integrations are not sandboxed packages. Do not import projects you +would not normally run on your machine. + +For the full trust model, see [Trust Model](TRUST_MODEL.md). diff --git a/docs/localization/en/VISION.md b/docs/localization/en/VISION.md new file mode 100644 index 00000000..4575ebbc --- /dev/null +++ b/docs/localization/en/VISION.md @@ -0,0 +1,191 @@ +# Axelate Vision + +> Product direction as of 2026-05-06. +> Planning document only. Use `CURRENT_STATE.md`, `GETTING_STARTED.md`, and +> `DEVELOPMENT_WORKFLOW.md` for the repository as it works today. + +## Product Name + +- Short name: Axelate +- Working full name: Axelate Workstation Platform +- Current category: Windows-first AI workstation and local integration runtime +- Future category: trusted desktop control plane for local AI, BYOK cloud models, + MCP tools, and packaged AI integrations + +## Product Sentence + +Axelate is a desktop AI workstation that installs and controls local AI engines, +connects BYOK cloud providers, and lets user-installed integrations run against a +shared local AI runtime, settings, logs, and API surface. + +## Why This Product Should Exist + +The AI desktop market already has chat clients, model runners, agent shells, and +web dashboards. What is still weak is the layer that makes practical AI tools +easy to install, run, observe, repair, and trust on a user's machine. + +Axelate should exist to be that layer. + +The product should not compete as "another chat UI". It should compete as the +trusted operating surface for local and hybrid AI work. + +## Product Thesis + +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 trust model for secrets, permissions, runtime folders, updates, and future + verified packages + +Axelate loses if it tries to become: + +- a generic social marketplace +- a pure web dashboard clone +- a random unsafe script runner +- a giant everything-app before the workstation core is reliable + +## Target Users + +- Power users who switch between local runtimes and cloud models. +- Developers who want their tools to use local or BYOK AI without rebuilding + engine setup, provider routing, settings, logs, and runtime management. +- Small studios that need one desktop surface for text, image, automation, and + tool-backed AI workflows. + +## Core Product Definition + +### 1. Desktop Workstation + +The desktop app is the main product. + +It should provide: + +- local engine install, update, start, stop, and health status +- cloud provider selection and model routing +- OpenAI-compatible local API support where practical +- integration import from folders, archives, and trusted URLs +- backend-owned credential storage +- integration settings, runtime folders, and logs +- downloads, console logs, monitoring, and repair actions + +The workstation core should stay Windows-first until the product model is proven. + +### 2. Integration Runtime + +Integrations should be folders or packages with a manifest and runtime contract. + +The launcher should provide: + +- manifest validation +- runtime dependency setup +- scoped local API tokens +- per-integration settings +- per-integration runtime and log directories +- start, stop, restart, status, and stage reporting +- predictable import, update, removal, and external-folder-missing behavior + +This layer is the product wedge. It turns Axelate from a model launcher into a +workstation platform. + +### 3. Trust Surface + +Local integrations are useful, but they are not automatically trusted. + +Axelate should make boundaries visible: + +- backend vs frontend +- local vs remote +- installed vs verified +- manual import vs official package +- allowed vs denied permissions +- user-owned secrets vs integration-owned state + +The current repository does not yet ship full package signing, publisher +verification, package review, or managed execution. Those belong to later +platform layers. + +## Immediate Focus + +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 + +Package discovery, account-backed ownership, and managed execution should not +lead the roadmap until the workstation and local integration path are reliable. + +## Product Rules + +- Do not make the chat tab the center of the brand. +- Do not force cloud accounts for local-only workflows. +- Do not imply that manually imported integrations are verified. +- Do not promise perfect DRM for local packages. +- Do not mix unsafe arbitrary execution with future curated package flows. +- Do not build package distribution before the workstation core is stable. + +## Phase Direction + +### Phase 1: Workstation Core + +Ship a reliable Windows desktop with: + +- stable provider routing +- stable streaming chat and image flows +- local runtime management +- integration import and lifecycle +- local API and SDK foundations +- health checks, logs, and repair tools + +This phase proves product value to users and developers. + +### Phase 2: Trusted Package Layer + +Ship: + +- 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. + +### Phase 3: Platform Layer + +Only after the workstation and package trust model are stable, add: + +- package ownership sync +- curated package discovery +- publisher onboarding +- managed or hybrid execution contracts +- operational services around verified packages + +This phase proves platform value beyond the desktop app. + +## Reality Check + +This product is real and implementable, but only under these conditions: + +- workstation before marketplace +- local and BYOK before managed cloud complexity +- trust and permissions before growth +- integration runtime before public package distribution +- open core for desktop trust, controlled platform services later + +If Axelate launches first as a reliable desktop AI workstation for running local +engines and user-installed integrations, the later platform layers become +believable. + +## Final Product Statement + +Axelate should become the trusted desktop AI workstation for running local +engines, BYOK cloud models, and user-installed AI integrations from one reliable +surface, with future verified packages and managed workflows added only after +the workstation core earns trust. diff --git a/docs/localization/ru/INTEGRATION_DEVELOPMENT.md b/docs/localization/ru/INTEGRATION_DEVELOPMENT.md new file mode 100644 index 00000000..103ff974 --- /dev/null +++ b/docs/localization/ru/INTEGRATION_DEVELOPMENT.md @@ -0,0 +1,142 @@ +# Разработка интеграций + +> Как подключить свой продукт к Axelate, использовать AI лаунчера, настройки, +> логи и runtime-папки без доступа к внутренним файлам приложения. + +## Быстрый старт + +Создать шаблон интеграции: + +```bash +npm run integration:new -- ./my-integration --id my-integration --name "My Integration" +npm run integration:doctor -- ./my-integration +``` + +После этого импортируй папку на странице интеграций в лаунчере и запусти +карточку. +Если entrypoint интеграции на JavaScript, используй `--runtime node` или +`--runtime bun`. + +Для существующего приложения оставь код приложения внутри папки интеграции, +укажи entrypoint в `axelate-module.toml` и читай переменные окружения лаунчера +при старте процесса. Сгенерированное состояние пиши в +`AXELATE_MODULE_RUNTIME_DIR`, AI вызывай через `/v1/ai/text` или `/v1/ai/image`, +после этого запускай `integration:doctor` и импортируй папку. + +## Инструменты в репозитории + +- `npm run integration:new -- ` создает минимальную Python-интеграцию. + Для JavaScript runtime добавь `--runtime node` или `--runtime bun`. +- `npm run integration:doctor -- ` проверяет `axelate-module.toml`, + entry-файлы, settings UI, dependency paths и типичные сгенерированные папки, + которые нельзя поставлять. +- `docs/examples/integrations/python-ai-tool/` - минимальный рабочий пример. +- `docs/examples/sdk/python/axelate_sdk.py` и + `docs/examples/sdk/javascript/axelate-client.mjs` - маленькие helper + клиенты, которые можно скопировать в свой проект. +- `docs/examples/sdk/browser/axelate-settings-bridge.js` - helper для + iframe-протокола custom settings UI. + +Главный контракт все равно описан в [Integration API](../en/INTEGRATION_API.md) +(англ., в `docs/localization/en/INTEGRATION_API.md`): это локальный HTTP API лаунчера. + +## Структура интеграции + +```text +my-integration/ + axelate-module.toml + README.md + src/ + main.py + settings-ui/ + index.html +``` + +Минимальный manifest: + +```toml +api_version = "1" +id = "my-integration" +name = "My Integration" +version = "0.1.0" +type = "service" +settings_ui = "settings-ui/index.html" + +[runtime] +kind = "python" +version = "3.11" +entry = "src/main.py" +``` + +Поддерживаемые runtime: `python`, `node`, `bun`, `binary`. + +## Runtime-контракт + +Когда Axelate запускает script-runtime интеграцию, он передает: + +- `AXELATE_INTEGRATION_API_VERSION` +- `AXELATE_HTTP_API_BASE` +- `AXELATE_HTTP_API_TOKEN` +- `AXELATE_MODULE_ID` +- `AXELATE_MODULE_DIR` +- `AXELATE_RUNTIME_DIR` +- `AXELATE_MODULE_RUNTIME_DIR` +- `AXELATE_MODULE_LOG_DIR` + +Используй эти значения при старте процесса. Не хардкодь порт и пути. + +## Вызов AI + +Python: + +```python +from axelate_sdk import AxelateClient + +client = AxelateClient() +settings = client.settings() +reply = client.ai_text(settings.get("prompt", "Write a short status update.")) +print(reply) +``` + +JavaScript: + +```js +import { AxelateClient } from './axelate-client.mjs'; + +const client = new AxelateClient(); +const settings = await client.settings(); +const reply = await client.aiText(settings.prompt ?? 'Write a short status update.'); +console.log(reply); +``` + +## Settings UI + +Если `settings_ui` указывает на HTML-файл или папку с `index.html`, лаунчер +открывает его в sandboxed host. + +Протокол iframe: + +- отправить `{ channel: "axelate:module-settings", type: "module-ready" }` +- дождаться `host-ready`, где есть `settings` и `context` +- отправить `module-rendered`, когда интерфейс готов +- сохранить настройки сообщением с `method: "saveSettings"` + +Текущий пример: +`docs/examples/integrations/python-ai-tool/settings-ui/index.html` и +`docs/examples/integrations/python-ai-tool/settings-ui/axelate-settings-bridge.js`. + +## Цикл разработки + +1. Создай шаблон или скопируй пример. +2. Запусти `integration:doctor`. +3. Импортируй папку в Axelate. +4. Запусти карточку. +5. Смотри логи интеграции в лаунчере. +6. Runtime-файлы пиши в `AXELATE_MODULE_RUNTIME_DIR`. +7. Не поставляй `.venv`, `node_modules`, caches, logs и скачанные runtime. + +## Правило доверия + +Импортированные интеграции - это локальный код, который пользователь сам решил +запустить. Сейчас это не проверенные, не подписанные и не выполняемые в +песочнице пакеты. diff --git a/docs/localization/zh/INTEGRATION_DEVELOPMENT.md b/docs/localization/zh/INTEGRATION_DEVELOPMENT.md new file mode 100644 index 00000000..b2dccc25 --- /dev/null +++ b/docs/localization/zh/INTEGRATION_DEVELOPMENT.md @@ -0,0 +1,134 @@ +# 集成开发 + +> 将你的产品接入 Axelate,并使用启动器提供的 AI、设置、日志和运行时目录, +> 而不是依赖应用内部文件。 + +## 快速开始 + +创建一个集成模板: + +```bash +npm run integration:new -- ./my-integration --id my-integration --name "My Integration" +npm run integration:doctor -- ./my-integration +``` + +然后在 Axelate 的 Integrations 页面导入这个文件夹并启动卡片。 +如果集成入口是 JavaScript,可以使用 `--runtime node` 或 `--runtime bun`。 + +如果要接入已有应用,把应用代码放在集成目录里,在 `axelate-module.toml` +中声明入口文件,并在进程启动时读取启动器提供的环境变量。生成的状态写入 +`AXELATE_MODULE_RUNTIME_DIR`,通过 `/v1/ai/text` 或 `/v1/ai/image` 调用 AI, +然后运行 `integration:doctor`,再导入该文件夹。 + +## 仓库工具 + +- `npm run integration:new -- ` 创建一个最小 Python 集成。 + JavaScript runtime 可添加 `--runtime node` 或 `--runtime bun`。 +- `npm run integration:doctor -- ` 检查 `axelate-module.toml`、入口文件、 + settings UI、依赖路径,以及不应该随包发布的生成目录。 +- `docs/examples/integrations/python-ai-tool/` 是最小可运行示例。 +- `docs/examples/sdk/python/axelate_sdk.py` 和 + `docs/examples/sdk/javascript/axelate-client.mjs` 是可复制的小型客户端 helper。 +- `docs/examples/sdk/browser/axelate-settings-bridge.js` 是 custom settings + UI iframe 消息协议的可复制 helper。 + +真正的运行时契约仍然是 [Integration API](../en/INTEGRATION_API.md) 中描述的本地 +HTTP API。 + +## 集成结构 + +```text +my-integration/ + axelate-module.toml + README.md + src/ + main.py + settings-ui/ + index.html +``` + +最小 manifest: + +```toml +api_version = "1" +id = "my-integration" +name = "My Integration" +version = "0.1.0" +type = "service" +settings_ui = "settings-ui/index.html" + +[runtime] +kind = "python" +version = "3.11" +entry = "src/main.py" +``` + +支持的 runtime: `python`, `node`, `bun`, `binary`。 + +## 运行时契约 + +Axelate 启动 script-runtime 集成时会设置: + +- `AXELATE_INTEGRATION_API_VERSION` +- `AXELATE_HTTP_API_BASE` +- `AXELATE_HTTP_API_TOKEN` +- `AXELATE_MODULE_ID` +- `AXELATE_MODULE_DIR` +- `AXELATE_RUNTIME_DIR` +- `AXELATE_MODULE_RUNTIME_DIR` +- `AXELATE_MODULE_LOG_DIR` + +在进程启动时读取这些值。不要硬编码端口或数据路径。 + +## 调用 AI + +Python: + +```python +from axelate_sdk import AxelateClient + +client = AxelateClient() +settings = client.settings() +reply = client.ai_text(settings.get("prompt", "Write a short status update.")) +print(reply) +``` + +JavaScript: + +```js +import { AxelateClient } from './axelate-client.mjs'; + +const client = new AxelateClient(); +const settings = await client.settings(); +const reply = await client.aiText(settings.prompt ?? 'Write a short status update.'); +console.log(reply); +``` + +## Settings UI + +如果 `settings_ui` 指向 HTML 文件,或指向包含 `index.html` 的目录,启动器会在 +sandboxed settings host 中打开它。 + +iframe 协议: + +- 发送 `{ channel: "axelate:module-settings", type: "module-ready" }` +- 等待包含 `settings` 和 `context` 的 `host-ready` +- UI 准备完成后发送 `module-rendered` +- 使用 `method: "saveSettings"` 的消息保存设置 + +当前参考示例:`docs/examples/integrations/python-ai-tool/settings-ui/index.html` 和 +`docs/examples/integrations/python-ai-tool/settings-ui/axelate-settings-bridge.js`。 + +## 开发循环 + +1. 创建模板或复制示例。 +2. 运行 `integration:doctor`。 +3. 在 Axelate 中导入文件夹。 +4. 启动卡片。 +5. 在启动器的日志界面查看集成日志。 +6. 将运行时文件写入 `AXELATE_MODULE_RUNTIME_DIR`。 +7. 不要发布 `.venv`, `node_modules`, caches, logs 或下载的 runtime。 + +## 信任规则 + +导入的集成是用户选择运行的本地代码。目前它们不是经过审查、签名或沙箱隔离的包。 diff --git a/package.json b/package.json index 113e245d..4e6b4835 100644 --- a/package.json +++ b/package.json @@ -23,17 +23,22 @@ "bindings:check": "npm --prefix src run bindings:check", "test": "node .github/scripts/workflow.mjs test", "test:coverage": "node .github/scripts/workflow.mjs test:coverage", + "test:coverage:all": "node .github/scripts/workflow.mjs test:coverage:all", + "rust:test:coverage": "node .github/scripts/workflow.mjs rust:test:coverage", + "rust:test:coverage:lcov": "node .github/scripts/workflow.mjs rust:test:coverage:lcov", "test:watch": "node .github/scripts/workflow.mjs test:watch", "typecheck": "node .github/scripts/workflow.mjs typecheck", "doctor": "node .github/scripts/workflow.mjs doctor", "setup": "node .github/scripts/workflow.mjs setup", "verify": "node .github/scripts/workflow.mjs verify", "install-deps": "node .github/scripts/workflow.mjs install-deps", + "integration:doctor": "node .github/scripts/workflow.mjs integration:doctor", + "integration:new": "node .github/scripts/workflow.mjs integration:new", "update": "node .github/scripts/workflow.mjs update", "prepare": "node .github/scripts/workflow.mjs prepare", "check-size": "node .github/scripts/workflow.mjs check-size" }, "engines": { - "node": ">=20.0.0" + "node": ">=26.1.0" } } diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 48701262..8934eb4a 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.94.1" +channel = "1.95.0" components = ["clippy", "rustfmt"] profile = "minimal" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5deba578..b226bdf9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -146,9 +146,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" dependencies = [ "compression-codecs", "compression-core", @@ -300,7 +300,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axelate" -version = "0.1.5" +version = "0.2.0" dependencies = [ "aes-gcm", "async-trait", @@ -314,6 +314,7 @@ dependencies = [ "hex", "log", "machine-uid", + "notify", "num_cpus", "nvml-wrapper", "once_cell", @@ -467,6 +468,15 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -598,9 +608,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -713,9 +723,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.37" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" dependencies = [ "compression-core", "flate2", @@ -724,9 +734,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.31" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" [[package]] name = "concurrent-queue" @@ -1096,9 +1106,9 @@ dependencies = [ [[package]] name = "digest" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", "const-oid", @@ -1254,14 +1264,14 @@ checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" [[package]] name = "embed-resource" -version = "3.0.8" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "vswhom", "winreg", ] @@ -1381,23 +1391,9 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fax" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] name = "fdeflate" @@ -1420,13 +1416,12 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] @@ -1521,6 +1516,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.32" @@ -1999,9 +2003,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -2033,7 +2037,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -2097,9 +2101,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.10" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "typenum", ] @@ -2304,9 +2308,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -2344,7 +2348,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -2358,6 +2362,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.11.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -2383,16 +2407,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is-docker" version = "0.2.0" @@ -2497,9 +2511,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -2540,6 +2554,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2578,15 +2612,15 @@ dependencies = [ [[package]] name = "libbz2-rs-sys" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f" +checksum = "f8fc329e1457d97a9d58a4e2ca49e3be572431a7e096008efc2e3a3c19d428f4" [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libdbus-sys" @@ -2623,10 +2657,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.11.1", "libc", - "plain", - "redox_syscall 0.7.4", ] [[package]] @@ -2745,6 +2776,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2836,6 +2868,33 @@ dependencies = [ "memchr", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.1", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -3074,6 +3133,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-open-directory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -3145,9 +3215,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.3.4" +version = "5.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" dependencies = [ "dunce", "is-wsl", @@ -3273,7 +3343,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link 0.2.1", ] @@ -3296,7 +3366,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ - "digest 0.11.2", + "digest 0.11.3", "hmac", ] @@ -3393,21 +3463,15 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "plist" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml 0.38.4", + "quick-xml", "serde", "time", ] @@ -3577,18 +3641,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - -[[package]] -name = "quick-xml" -version = "0.39.2" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "memchr", ] @@ -3655,15 +3710,6 @@ dependencies = [ "bitflags 2.11.1", ] -[[package]] -name = "redox_syscall" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" -dependencies = [ - "bitflags 2.11.1", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -3822,9 +3868,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "zeroize", ] @@ -4093,11 +4139,12 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.18.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ "base64 0.22.1", + "bs58", "chrono", "hex", "indexmap 1.9.3", @@ -4112,9 +4159,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.18.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -4179,7 +4226,7 @@ checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -4201,7 +4248,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -4269,9 +4316,9 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -4310,7 +4357,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall 0.5.18", + "redox_syscall", "tracing", "wasm-bindgen", "web-sys", @@ -4345,9 +4392,9 @@ dependencies = [ [[package]] name = "specta" -version = "2.0.0-rc.24" +version = "2.0.0-rc.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f320c7dd82008b6958f43f6257c95319c407d1c17ade43686e50ea520c28bb26" +checksum = "38f9a30cbcbb7011f1da7d73483983bf838af123883e45f2b36ed76328df9c50" dependencies = [ "paste", "rustc_version", @@ -4357,9 +4404,9 @@ dependencies = [ [[package]] name = "specta-macros" -version = "2.0.0-rc.24" +version = "2.0.0-rc.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "153f185d0051a64d81977bab5012809d5c9d9db8792406a0997352e05494f711" +checksum = "2ce14957ecc2897f1f848b8255b6531d13ddf49cbcf506b7c2c9fb1d005593bb" dependencies = [ "Inflector", "proc-macro2", @@ -4369,30 +4416,28 @@ dependencies = [ [[package]] name = "specta-serde" -version = "0.0.11" +version = "0.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a46349e2c3fb3f3de4b78daa19632c32813262e1ab0966896b64ca91e0926e" +checksum = "ee8a72b755ddb8949fd8f17c5db43f0e8a806ea587d9bc602ee3f73240c00029" dependencies = [ "specta", "specta-macros", ] [[package]] -name = "specta-tags" -version = "0.0.0" +name = "specta-typescript" +version = "0.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3026b7e2f8c76dcab90ec27b7f3e6a0b7d646501a3a8aa5d739d09cb9ee59871" +checksum = "639404ee95557f2f8b7e4cb773ffefd45304c7ab8ba21ac83b69051595e083c0" dependencies = [ - "serde", - "serde_json", "specta", ] [[package]] -name = "specta-typescript" -version = "0.0.11" +name = "specta-util" +version = "0.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea586e1709619c9f4cb0c9115ff29c278f331c0450aff266c2913c639708b299" +checksum = "29b1fc02b446f7244a92924fe68c0555921209f1d342990cd1539e9138e69502" dependencies = [ "specta", ] @@ -4514,15 +4559,16 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.38.4" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +checksum = "a4deba334e1190ba7cb498327affa11e5ece10d26a30ab2f27fcf09504b8d8b6" dependencies = [ "libc", "memchr", "ntapi", "objc2-core-foundation", "objc2-io-kit", + "objc2-open-directory", "windows 0.62.2", ] @@ -4899,17 +4945,17 @@ dependencies = [ [[package]] name = "tauri-specta" -version = "2.0.0-rc.24" +version = "2.0.0-rc.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9191951c8d3aefce8fb9818271545c61b441a3c1095d8443637b36572e0346e4" +checksum = "ee080f36d2ac17ce2f3a82fb53f02d664e8345457de51b56dad3c394dacc41a2" dependencies = [ "heck 0.5.0", "serde", "serde_json", "specta", "specta-serde", - "specta-tags", "specta-typescript", + "specta-util", "tauri", "tauri-specta-macros", "thiserror 2.0.18", @@ -4917,9 +4963,9 @@ dependencies = [ [[package]] name = "tauri-specta-macros" -version = "2.0.0-rc.24" +version = "2.0.0-rc.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e23657d20f2b5508d5eca5ee6bb98d77e4c13127b9049e102b5db6a63bc73665" +checksum = "6a59dfdce06c98d8d211619bea5fdb39486d8a8c558e12b2d2ce255972320012" dependencies = [ "darling 0.23.0", "heck 0.5.0", @@ -4968,13 +5014,13 @@ dependencies = [ [[package]] name = "tauri-winres" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -5120,6 +5166,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" @@ -5209,7 +5270,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -5272,7 +5333,7 @@ dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -5281,7 +5342,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -5307,9 +5368,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "async-compression", "bitflags 2.11.1", @@ -5319,13 +5380,13 @@ dependencies = [ "http", "http-body", "http-body-util", - "iri-string", "pin-project-lite", "tokio", "tokio-util", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -5709,9 +5770,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -5722,9 +5783,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", @@ -5732,9 +5793,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5742,9 +5803,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -5755,9 +5816,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -5866,7 +5927,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", - "quick-xml 0.39.2", + "quick-xml", "quote", ] @@ -5881,9 +5942,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -6488,15 +6549,12 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -6779,9 +6837,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" dependencies = [ "async-broadcast", "async-executor", @@ -6806,7 +6864,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 0.7.15", + "winnow 1.0.3", "zbus_macros", "zbus_names", "zvariant", @@ -6814,9 +6872,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", @@ -6829,12 +6887,12 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.1" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "winnow 0.7.15", + "winnow 1.0.3", "zvariant", ] @@ -6860,9 +6918,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -7014,23 +7072,23 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.10.0" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" dependencies = [ "endi", "enumflags2", "serde", - "winnow 0.7.15", + "winnow 1.0.3", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.10.0" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", @@ -7041,13 +7099,13 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.3.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" dependencies = [ "proc-macro2", "quote", "serde", "syn 2.0.117", - "winnow 0.7.15", + "winnow 1.0.3", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d507c85b..a5db7570 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -4,11 +4,11 @@ # ============================================================================== [package] name = "axelate" -version = "0.1.5" +version = "0.2.0" description = "Axelate: Windows-first AI Workstation." authors = ["F0RLE"] edition = "2024" -rust-version = "1.94.1" +rust-version = "1.95.0" license = "Apache-2.0" repository = "https://github.com/F0RLE/Axelate" homepage = "https://github.com/F0RLE/Axelate" @@ -44,9 +44,9 @@ tauri-plugin-global-shortcut = "~2.3" tauri-plugin-clipboard-manager = "~2.3" # --- Frontend Bridge (Types & Serialization) --- -specta = { version = "2.0.0-rc.24", features = ["derive", "serde_json"] } -tauri-specta = { version = "2.0.0-rc.24", features = ["typescript", "derive"] } -specta-typescript = "0.0.11" +specta = { version = "2.0.0-rc.25", features = ["derive", "serde_json"] } +tauri-specta = { version = "2.0.0-rc.25", features = ["typescript", "derive"] } +specta-typescript = "0.0.12" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" toml = "1.1" @@ -58,7 +58,7 @@ futures-util = "0.3.32" scraper = "0.27.0" # --- System, Hardware & Low Level --- -sysinfo = "0.38.4" +sysinfo = "0.39.1" nvml-wrapper = "0.12.1" # NVIDIA Management Library wmi = "0.18.4" # Windows Management Instrumentation machine-uid = "0.6.0" # Hardware ID generation @@ -100,6 +100,7 @@ async-trait = "0.1.89" flate2 = "1.1.9" tar = "0.4.45" sevenz-rust2 = "0.21.0" +notify = "8.2.0" [target.'cfg(windows)'.dependencies] windows = { version = "0.62.2", features = [ diff --git a/src-tauri/resources/api_providers/image/gemini-image.json b/src-tauri/resources/api_providers/image/gemini-image.json index 2cc1ac17..949673fb 100644 --- a/src-tauri/resources/api_providers/image/gemini-image.json +++ b/src-tauri/resources/api_providers/image/gemini-image.json @@ -19,8 +19,8 @@ "contextWindow": 65536, "maxOutputTokens": 8192, "pricing": { - "input_per_1m": 2, - "output_per_1m": 12, + "input": 2, + "output": 12, "currency": "USD", "notes": "OpenRouter pricing" }, @@ -48,8 +48,8 @@ "contextWindow": 32768, "maxOutputTokens": 8192, "pricing": { - "input_per_1m": 0.3, - "output_per_1m": 2.5, + "input": 0.3, + "output": 2.5, "currency": "USD", "notes": "OpenRouter pricing" }, @@ -77,8 +77,8 @@ "contextWindow": 65536, "maxOutputTokens": 8192, "pricing": { - "input_per_1m": 0.5, - "output_per_1m": 3, + "input": 0.5, + "output": 3, "currency": "USD", "notes": "OpenRouter pricing" }, diff --git a/src-tauri/resources/api_providers/image/gpt-image.json b/src-tauri/resources/api_providers/image/gpt-image.json index c1d75042..1688b6c0 100644 --- a/src-tauri/resources/api_providers/image/gpt-image.json +++ b/src-tauri/resources/api_providers/image/gpt-image.json @@ -19,8 +19,8 @@ "contextWindow": 272000, "maxOutputTokens": 8192, "pricing": { - "input_per_1m": 8, - "output_per_1m": 15, + "input": 8, + "output": 15, "currency": "USD", "notes": "OpenRouter pricing" }, @@ -48,8 +48,8 @@ "contextWindow": 400000, "maxOutputTokens": 8192, "pricing": { - "input_per_1m": 10, - "output_per_1m": 10, + "input": 10, + "output": 10, "currency": "USD", "notes": "OpenRouter pricing" }, @@ -77,8 +77,8 @@ "contextWindow": 400000, "maxOutputTokens": 8192, "pricing": { - "input_per_1m": 2.5, - "output_per_1m": 2, + "input": 2.5, + "output": 2, "currency": "USD", "notes": "OpenRouter pricing" }, diff --git a/src-tauri/resources/api_providers/image/seedream-image.json b/src-tauri/resources/api_providers/image/seedream-image.json index ce943cfd..b7e474cf 100644 --- a/src-tauri/resources/api_providers/image/seedream-image.json +++ b/src-tauri/resources/api_providers/image/seedream-image.json @@ -19,8 +19,8 @@ "contextWindow": 4096, "maxOutputTokens": 4096, "pricing": { - "input_per_1m": 0.04, - "output_per_1m": 0, + "input": 0.04, + "output": 0, "currency": "USD", "notes": "$0.04 / image" }, diff --git a/src-tauri/resources/api_providers/text/claude.json b/src-tauri/resources/api_providers/text/claude.json index 5f045968..8ec8b5ea 100644 --- a/src-tauri/resources/api_providers/text/claude.json +++ b/src-tauri/resources/api_providers/text/claude.json @@ -16,8 +16,8 @@ "contextWindow": 1000000, "maxOutputTokens": 128000, "pricing": { - "input_per_1m": 5, - "output_per_1m": 25, + "input": 5, + "output": 25, "currency": "USD" }, "stats": { @@ -44,8 +44,8 @@ "contextWindow": 1000000, "maxOutputTokens": 128000, "pricing": { - "input_per_1m": 3, - "output_per_1m": 15, + "input": 3, + "output": 15, "currency": "USD" }, "stats": { @@ -72,8 +72,8 @@ "contextWindow": 200000, "maxOutputTokens": 64000, "pricing": { - "input_per_1m": 1, - "output_per_1m": 5, + "input": 1, + "output": 5, "currency": "USD" }, "stats": { diff --git a/src-tauri/resources/api_providers/text/deepseek.json b/src-tauri/resources/api_providers/text/deepseek.json index 0f414348..528b88a3 100644 --- a/src-tauri/resources/api_providers/text/deepseek.json +++ b/src-tauri/resources/api_providers/text/deepseek.json @@ -16,8 +16,8 @@ "contextWindow": 1048576, "maxOutputTokens": 65536, "pricing": { - "input_per_1m": 1.74, - "output_per_1m": 3.48, + "input": 1.74, + "output": 3.48, "currency": "USD" }, "stats": { @@ -44,8 +44,8 @@ "contextWindow": 1048576, "maxOutputTokens": 65536, "pricing": { - "input_per_1m": 0.14, - "output_per_1m": 0.28, + "input": 0.14, + "output": 0.28, "currency": "USD" }, "stats": { diff --git a/src-tauri/resources/api_providers/text/gemini.json b/src-tauri/resources/api_providers/text/gemini.json index b2666e54..474fd221 100644 --- a/src-tauri/resources/api_providers/text/gemini.json +++ b/src-tauri/resources/api_providers/text/gemini.json @@ -16,8 +16,8 @@ "contextWindow": 1048576, "maxOutputTokens": 65536, "pricing": { - "input_per_1m": 2, - "output_per_1m": 12, + "input": 2, + "output": 12, "currency": "USD" }, "stats": { @@ -46,8 +46,8 @@ "contextWindow": 1048576, "maxOutputTokens": 65500, "pricing": { - "input_per_1m": 0.5, - "output_per_1m": 3, + "input": 0.5, + "output": 3, "currency": "USD" }, "stats": { @@ -76,8 +76,8 @@ "contextWindow": 1048576, "maxOutputTokens": 65500, "pricing": { - "input_per_1m": 0.25, - "output_per_1m": 1.5, + "input": 0.25, + "output": 1.5, "currency": "USD" }, "stats": { diff --git a/src-tauri/resources/api_providers/text/gpt.json b/src-tauri/resources/api_providers/text/gpt.json index 7287da1e..ed1bb613 100644 --- a/src-tauri/resources/api_providers/text/gpt.json +++ b/src-tauri/resources/api_providers/text/gpt.json @@ -16,8 +16,8 @@ "contextWindow": 1050000, "maxOutputTokens": 128000, "pricing": { - "input_per_1m": 5, - "output_per_1m": 30, + "input": 5, + "output": 30, "currency": "USD" }, "stats": { @@ -44,8 +44,8 @@ "contextWindow": 1050000, "maxOutputTokens": 128000, "pricing": { - "input_per_1m": 30, - "output_per_1m": 180, + "input": 30, + "output": 180, "currency": "USD" }, "stats": { @@ -72,8 +72,8 @@ "contextWindow": 400000, "maxOutputTokens": 8192, "pricing": { - "input_per_1m": 0.75, - "output_per_1m": 4.5, + "input": 0.75, + "output": 4.5, "currency": "USD" }, "stats": { @@ -100,8 +100,8 @@ "contextWindow": 400000, "maxOutputTokens": 4096, "pricing": { - "input_per_1m": 0.2, - "output_per_1m": 1.25, + "input": 0.2, + "output": 1.25, "currency": "USD" }, "stats": { diff --git a/src-tauri/resources/config/local_modules.json b/src-tauri/resources/config/local_modules.json index 740c03b0..135f9a21 100644 --- a/src-tauri/resources/config/local_modules.json +++ b/src-tauri/resources/config/local_modules.json @@ -62,7 +62,7 @@ "descKey": "ui.launcher.engine.comfyui.desc", "name": "ComfyUI", "desc": "Node-based image workflow engine for maximum quality and control.", - "icon": "🧩", + "icon": "🕸️", "type": "local", "dlType": "release", "comingSoon": true, diff --git a/src-tauri/resources/locales/en.json b/src-tauri/resources/locales/en.json index 088eec40..5de2ecf4 100644 --- a/src-tauri/resources/locales/en.json +++ b/src-tauri/resources/locales/en.json @@ -64,7 +64,7 @@ "ui.chat.context_used": "used", "ui.chat.context_remaining": "remaining", "ui.chat.context_unknown": "unknown", - "ui.chat.error.local_model_memory": "Not enough memory to start the local model. Reduce context size or GPU layers, or use a smaller model.", + "ui.chat.error.local_model_memory": "Not enough memory to start the local model. Reduce context size or change Compute Device to CPU, or use a smaller model.", "ui.chat.error.local_model_system_memory": "Not enough system memory to start the local model. Close other apps or use a smaller model.", "ui.chat.error.image_vram": "Not enough GPU memory to generate the image. Lower image size, steps, or batch size, or use a smaller model.", "ui.chat.error.local_image_engine_connection": "Local image engine stopped or closed the connection while generating. Restart the image engine and lower image size, steps, or batch size if it happens again.", @@ -75,19 +75,20 @@ "ui.chat.image_open_folder_failed": "Failed to open image folder", "ui.chat.open_image_folder": "Open image folder", "ui.chat.close_image_preview": "Close image preview", + "ui.chat.previous_image": "Previous image", + "ui.chat.next_image": "Next image", "ui.chat.image_preview": "Image preview", "ui.chat.save_image": "Save Image", "ui.chat.image_generating": "Rendering image", "ui.chat.streaming_text": "Model is typing...", "ui.chat.thinking": "Thinking...", "ui.chat.image_ready": "Generated image", - "ui.chat.image_cancel": "Cancel", "ui.chat.image_cancelled": "Image generation cancelled", "ui.chat.regenerate_failed": "Failed to regenerate response", "ui.ai.communication_failure": "Communication failure", "ui.ai.no_api_key": "API key missing", + "ui.ai.no_model_selected": "No AI model selected", "ui.ai.no_provider": "No AI module running. Please launch a module first.", - "ui.ai.performance_mode_active": "Performance mode active", "ui.ai.provider_activation_failed": "Provider activation failed", "ui.claude.model.46sonnet.desc": "Anthropic's most capable Sonnet-class model yet, with frontier performance across coding, agents, and professional work", "ui.claude.model.haiku.desc": "Anthropic's fastest and most efficient model with near-frontier quality for low-latency and high-volume workloads", @@ -168,6 +169,23 @@ "ui.launcher.models.ai_desc": "AI runtimes and providers for text, image, and code generation.", "ui.launcher.models.services": "Integrations", "ui.launcher.models.services_desc": "Local tools, automation flows, and external project integrations.", + "ui.launcher.integrations.import.add": "Add", + "ui.launcher.integrations.import.archive": "Archive", + "ui.launcher.integrations.import.archive_title": "Choose integration archive", + "ui.launcher.integrations.import.card_desc": "Import a custom integration from a folder, archive, or repository link.", + "ui.launcher.integrations.import.card_title": "Add integration", + "ui.launcher.integrations.import.error": "Integration import failed", + "ui.launcher.integrations.import.folder": "Folder", + "ui.launcher.integrations.import.folder_title": "Choose integration folder", + "ui.launcher.integrations.import.guide_short": "Create an axelate-module.toml, runtime entry, optional settings UI, then import it here.", + "ui.launcher.integrations.import.guide_title": "Integration guide", + "ui.launcher.integrations.import.open": "Open", + "ui.launcher.integrations.import.open_title": "Choose integration folder or archive", + "ui.launcher.integrations.import.success": "Integration added", + "ui.launcher.integrations.import.url": "Link", + "ui.launcher.integrations.import.url_desc": "Paste a GitHub repository or direct archive URL.", + "ui.launcher.integrations.import.url_placeholder": "Repository or archive URL", + "ui.launcher.integrations.import.url_title": "Add integration URL", "ui.launcher.models_subtitle": "Choose an engine or integration to start work", "ui.launcher.module.delete": "Delete", "ui.launcher.module.download": "Download", @@ -202,8 +220,8 @@ "ui.launcher.settings.monitor_ram": "RAM", "ui.launcher.settings.monitor_title": "Monitoring Management", "ui.launcher.settings.monitor_vram": "VRAM", - "ui.launcher.settings.taskbar_desc": "Configure tab visibility", - "ui.launcher.settings.taskbar_title": "Taskbar Management", + "ui.launcher.settings.taskbar_desc": "Configure sidebar page visibility", + "ui.launcher.settings.taskbar_title": "Sidebar Management", "ui.launcher.web.app_title": "Axelate", "ui.launcher.web.chat": "Chat", "ui.launcher.web.chat_clear": "Clear chat", @@ -220,6 +238,7 @@ "ui.launcher.web.copy_code": "Copy code", "ui.launcher.web.copy_failed": "Failed to copy code", "ui.launcher.web.delete_model_error": "Delete model error", + "ui.launcher.web.download_control_error": "Download control failed", "ui.launcher.web.download_error": "Download error", "ui.launcher.web.download_url_empty": "Download URL is empty", "ui.launcher.web.downloaded": "Downloaded", @@ -232,9 +251,8 @@ "ui.launcher.web.home": "Home", "ui.launcher.web.home_title": "Main Menu", "ui.launcher.web.information": "Information", - "ui.launcher.web.logs_general": "General", + "ui.launcher.web.logs_general": "Platform", "ui.launcher.web.main_menu": "Main Menu", - "ui.launcher.web.marketplace": "Market", "ui.connectivity.offline_title": "No internet connection", "ui.connectivity.offline_text": "Local modules still work. Cloud AI, downloads and external pages are unavailable.", "ui.launcher.web.models_title": "AI Engines & Integrations", @@ -278,8 +296,8 @@ "ui.settings.internet_access_hint": "Lets the model use web tools when needed. It does not force every reply to search.", "ui.settings.context_short": "Ctx", "ui.settings.free": "Free", - "ui.settings.price_input": "In", - "ui.settings.price_output": "Out", + "ui.settings.price_input": "Input", + "ui.settings.price_output": "Output", "ui.settings.key_reveal_error": "Failed to reveal stored key", "ui.settings.model_stats": "Model Stats", "ui.settings.image_stats.control": "Control", @@ -294,9 +312,6 @@ "ui.settings.engine.browse": "Browse", "ui.settings.engine.config_unavailable": "Engine config unavailable (Tauri not connected)", "ui.settings.engine.context_size": "Context Window", - "ui.settings.engine.compute_mode": "Compute Device", - "ui.settings.engine.compute_mode_cpu": "CPU", - "ui.settings.engine.compute_mode_gpu": "GPU", "ui.settings.engine.core_config": "Core Config", "ui.settings.engine.extra_args": "Extra Arguments", "ui.settings.engine.extra_args.add_all": "Add all", @@ -311,16 +326,20 @@ "ui.settings.engine.extra_args.recommended": "Recommended", "ui.settings.engine.extra_args.remove": "Remove", "ui.settings.engine.generation_presets": "Generation Presets", + "ui.settings.engine.generation_settings": "Generation Settings", "ui.settings.engine.group_batch": "Batch & Seed", "ui.settings.engine.group_sampling": "Sampling", "ui.settings.engine.group_size": "Image Size", "ui.settings.engine.model_not_selected": "Model not selected", + "ui.settings.engine.model_profiles": "Model Profiles", "ui.settings.engine.model_path": "Model Path (*.gguf, *.safetensors)", "ui.settings.engine.image_model_path": "Main Image Model (*.gguf, *.safetensors)", - "ui.settings.engine.image_model_path_hint": "Put the main diffusion model here: a regular SD model or a qwen-image*.gguf file.", - "ui.settings.engine.extra_args_hint": "Advanced startup flags only. Qwen Image companion files are auto-detected next to the selected model or can be passed here.", - "ui.settings.engine.performance_mode": "Performance Mode", - "ui.settings.engine.performance_mode_title": "Close launcher during generation", + "ui.settings.engine.image_model_path_hint": "Main diffusion model file.", + "ui.settings.engine.extra_args_hint": "Advanced startup flags appended to sd.cpp.", + "ui.settings.engine.profile_save": "Save Current", + "ui.settings.engine.profile_save_button": "Save", + "ui.settings.engine.profile_save_desc": "Store model, generation settings, and startup flags.", + "ui.settings.engine.profile_select_model_first": "Select a model first", "ui.settings.engine.thinking_level": "Thinking Level", "ui.settings.engine.thinking_level_desc": "Controls how much the model reasons before answering.", "ui.settings.engine.max_output_tokens": "Max Output Tokens", @@ -330,8 +349,10 @@ "ui.settings.engine.sd_clip_skip": "Clip Skip", "ui.settings.engine.sd_denoising_strength": "Denoising strength", "ui.settings.engine.sd_height": "Height (px)", - "ui.settings.engine.sd_negative_prompt": "Negative Prompt Prefix", - "ui.settings.engine.sd_positive_prompt": "Positive Prompt Prefix", + "ui.settings.engine.sd_negative_prompt": "Negative Prompt", + "ui.settings.engine.sd_negative_prompt_placeholder": "Things to avoid: blurry, low quality, watermark, distortion.", + "ui.settings.engine.sd_positive_prompt": "Positive Prompt", + "ui.settings.engine.sd_positive_prompt_placeholder": "Describe the image style, subject, lighting, and details.", "ui.settings.engine.sd_sampler": "Sampler", "ui.settings.engine.sd_scheduler": "Scheduler", "ui.settings.engine.sd_seed": "Seed", @@ -361,7 +382,7 @@ "ui.settings.thinking.off": "Off", "ui.settings.toggle_visibility": "Toggle password visibility", "ui.launcher.modules.modal.btn_remove": "Hide", - "ui.launcher.modules.modal.btn_select": "Select", + "ui.launcher.modules.modal.btn_select": "Launch", "ui.launcher.engine.llamacpp.name": "llama.cpp", "ui.launcher.engine.llamacpp.desc": "Universal LLM engine. CUDA, Vulkan, CPU.", "ui.launcher.engine.sdcpp.name": "Stable Diffusion.cpp", @@ -369,5 +390,131 @@ "ui.launcher.engine.comfyui.name": "ComfyUI", "ui.launcher.engine.comfyui.desc": "Node-based image workflow engine for maximum quality and control.", "ui.gpt.model.gpt55.desc": "Frontier model for complex professional workloads with stronger reasoning, higher reliability, and improved token efficiency", - "ui.gpt.model.gpt55pro.desc": "High-capability model optimized for deep reasoning and accuracy on complex, high-stakes workloads" + "ui.gpt.model.gpt55pro.desc": "High-capability model optimized for deep reasoning and accuracy on complex, high-stakes workloads", + "ui.settings.engine.sdcpp_flags.title": "Manual sd.cpp flags", + "ui.settings.engine.sdcpp_flags.subtitle": "Startup flags appended to sd-server.", + "ui.settings.engine.sdcpp_flag.threads_8": "Set worker thread count.", + "ui.settings.engine.sdcpp_flag.model_path": "Override the main model path.", + "ui.settings.engine.sdcpp_flag.diffusion_model_path": "Set diffusion model path.", + "ui.settings.engine.sdcpp_flag.vae_path": "Set VAE model path.", + "ui.settings.engine.sdcpp_flag.taesd_path": "Set TAESD model path.", + "ui.settings.engine.sdcpp_flag.control_net_path": "Set ControlNet model path.", + "ui.settings.engine.sdcpp_flag.embd_dir_path": "Load textual inversion embeddings.", + "ui.settings.engine.sdcpp_flag.stacked_id_embd_dir_path": "Load stacked ID embeddings.", + "ui.settings.engine.sdcpp_flag.input_id_images_dir_path": "Load input ID images.", + "ui.settings.engine.sdcpp_flag.lora_model_dir_path": "Directory containing LoRA models.", + "ui.settings.engine.sdcpp_flag.vae_decode_only": "Decode a latent image with VAE only.", + "ui.settings.engine.sdcpp_flag.vae_encode_only": "Encode an image into latent space.", + "ui.settings.engine.sdcpp_flag.control_image_path": "Image used by ControlNet.", + "ui.settings.engine.sdcpp_flag.output_path": "Set output image path.", + "ui.settings.engine.sdcpp_flag.output_video_path": "Set output video path.", + "ui.settings.engine.sdcpp_flag.init_img_path": "Use an initial image for img2img.", + "ui.settings.engine.sdcpp_flag.mask_path": "Use a mask image for inpainting.", + "ui.settings.engine.sdcpp_flag.ref_image_path": "Use a reference image.", + "ui.settings.engine.sdcpp_flag.clip_l_path": "Set CLIP-L model path.", + "ui.settings.engine.sdcpp_flag.clip_g_path": "Set CLIP-G model path.", + "ui.settings.engine.sdcpp_flag.clip_vision_path": "Set CLIP-Vision model path.", + "ui.settings.engine.sdcpp_flag.t5xxl_path": "Set T5-XXL model path.", + "ui.settings.engine.sdcpp_flag.llm_path": "Set LLM model path.", + "ui.settings.engine.sdcpp_flag.diffusion_fa": "Enable flash attention for diffusion.", + "ui.settings.engine.sdcpp_flag.fa": "Enable flash attention globally.", + "ui.settings.engine.sdcpp_flag.no_fallback": "Disable fallback execution paths.", + "ui.settings.engine.sdcpp_flag.mmap": "Memory-map model weights from disk.", + "ui.settings.engine.sdcpp_flag.no_mmap": "Disable memory mapping.", + "ui.settings.engine.sdcpp_flag.offload_to_cpu": "Offload model work to CPU.", + "ui.settings.engine.sdcpp_flag.clip_on_cpu": "Run CLIP on CPU.", + "ui.settings.engine.sdcpp_flag.vae_on_cpu": "Run VAE on CPU.", + "ui.settings.engine.sdcpp_flag.vae_tiling": "Use tiled VAE decoding to reduce VRAM usage.", + "ui.settings.engine.sdcpp_flag.free_params_immediately": "Free model params after load.", + "ui.settings.engine.sdcpp_flag.keep_clip_on_cpu": "Keep CLIP weights on CPU.", + "ui.settings.engine.sdcpp_flag.keep_control_net_cpu": "Keep ControlNet weights on CPU.", + "ui.settings.engine.sdcpp_flag.keep_vae_on_cpu": "Keep VAE weights on CPU.", + "ui.settings.engine.sdcpp_flag.control_net_cpu": "Run ControlNet on CPU when ControlNet is used.", + "ui.settings.engine.sdcpp_flag.canny": "Apply Canny preprocessing for ControlNet.", + "ui.settings.engine.sdcpp_flag.color": "Apply color preprocessing or colored output.", + "ui.settings.engine.sdcpp_flag.cpu_params": "Keep parameters in regular CPU memory.", + "ui.settings.engine.sdcpp_flag.normalize_input": "Normalize input image values.", + "ui.settings.engine.sdcpp_flag.upscale_model_path": "Set ESRGAN upscale model path.", + "ui.settings.engine.sdcpp_flag.upscale_repeats_2": "Repeat upscaling passes.", + "ui.settings.engine.sdcpp_flag.type_q8_0": "Set weight precision/type.", + "ui.settings.engine.sdcpp_flag.rng_cuda": "Prefer CUDA RNG on NVIDIA systems.", + "ui.settings.engine.sdcpp_flag.sampling_method_euler_a": "Set sampling method.", + "ui.settings.engine.sdcpp_flag.schedule_karras": "Set scheduler.", + "ui.settings.engine.sdcpp_flag.prediction_v": "Set prediction mode.", + "ui.settings.engine.sdcpp_flag.clip_skip_2": "Skip final CLIP layers.", + "ui.settings.engine.sdcpp_flag.cfg_scale_7": "Set classifier-free guidance scale.", + "ui.settings.engine.sdcpp_flag.guidance_3_5": "Set guidance scale.", + "ui.settings.engine.sdcpp_flag.eta_0": "Set DDIM eta.", + "ui.settings.engine.sdcpp_flag.steps_30": "Set default sampling steps.", + "ui.settings.engine.sdcpp_flag.strength_0_75": "Set img2img denoise strength.", + "ui.settings.engine.sdcpp_flag.pm_style_strength_20": "Set PhotoMaker style strength.", + "ui.settings.engine.sdcpp_flag.control_strength_0_9": "Set ControlNet strength.", + "ui.settings.engine.sdcpp_flag.width_1024": "Set default output width.", + "ui.settings.engine.sdcpp_flag.height_1024": "Set default output height.", + "ui.settings.engine.sdcpp_flag.batch_count_1": "Set number of batches.", + "ui.settings.engine.sdcpp_flag.video_frames_16": "Set generated video frame count.", + "ui.settings.engine.sdcpp_flag.fps_24": "Set generated video FPS.", + "ui.settings.engine.sdcpp_flag.motion_bucket_id_127": "Set SVD motion bucket.", + "ui.settings.engine.sdcpp_flag.augmentation_level_0": "Set SVD augmentation level.", + "ui.settings.engine.sdcpp_flag.sample_start_0": "Set sample start value.", + "ui.settings.engine.sdcpp_flag.sample_end_1": "Set sample end value.", + "ui.settings.engine.sdcpp_flag.slg_scale_0": "Set skip-layer guidance scale.", + "ui.settings.engine.sdcpp_flag.skip_layers_7_8_9": "Set skip-layer guidance layers.", + "ui.settings.engine.sdcpp_flag.skip_layer_start_0_01": "Set skip-layer start ratio.", + "ui.settings.engine.sdcpp_flag.skip_layer_end_0_2": "Set skip-layer end ratio.", + "ui.settings.engine.sdcpp_flag.seed_42": "Set generation seed.", + "ui.settings.engine.sdcpp_flag.negative_prompt_text": "Set default negative prompt.", + "ui.settings.engine.sdcpp_flag.prompt_text": "Set default positive prompt.", + "ui.settings.engine.sdcpp_flag.cfg_negative_prompt_text": "Set CFG negative prompt.", + "ui.settings.engine.sdcpp_flag.vae_tile_size_32x32": "Set VAE tile size.", + "ui.settings.engine.sdcpp_flag.vae_tile_overlap_0_5": "Set VAE tile overlap.", + "ui.settings.engine.sdcpp_flag.vae_relative_tile_size_0_5x0_5": "Set relative VAE tile size.", + "ui.settings.engine.sdcpp_flag.clip_g_layers_0": "Set CLIP-G layer count.", + "ui.settings.engine.sdcpp_flag.clip_l_layers_0": "Set CLIP-L layer count.", + "ui.settings.engine.sdcpp_flag.t5xxl_layers_0": "Set T5-XXL layer count.", + "ui.settings.engine.sdcpp_flag.diffusion_model_layers_0": "Set diffusion layer count.", + "ui.settings.engine.sdcpp_flag.vae_layers_0": "Set VAE layer count.", + "ui.settings.engine.sdcpp_flag.verbose": "Enable verbose logging.", + "ui.settings.engine.sdcpp_flag.quiet": "Reduce logging output.", + "ui.settings.engine.sdcpp_flag.preview_none": "Disable preview image output.", + "ui.settings.engine.sdcpp_flag.preview_path_path": "Set preview image path.", + "ui.settings.engine.sdcpp_flag.diffusion_on_cpu": "Run diffusion model on CPU.", + "ui.settings.engine.sdcpp_flag.vae_on_gpu": "Run VAE on GPU when possible.", + "ui.settings.engine.sdcpp_flag.clip_on_gpu": "Run CLIP on GPU when possible.", + "ui.settings.engine.sdcpp_flag.control_net_on_gpu": "Run ControlNet on GPU.", + "ui.settings.engine.sdcpp_flag.chroma_disable_ds": "Disable Chroma downsampling.", + "ui.settings.engine.sdcpp_flag.chroma_enable_t5_mask": "Enable Chroma T5 mask.", + "ui.settings.engine.sdcpp_flag.chroma_t5_mask_pad_1": "Set Chroma T5 mask padding.", + "ui.settings.engine.sdcpp_flag.flow_shift_3": "Set flow shift value.", + "ui.settings.engine.sdcpp_flag.timestep_shift_250": "Set shifted timestep value.", + "ui.settings.engine.sdcpp_flag.diffusion_cpu_params": "Keep diffusion params on CPU.", + "ui.settings.engine.sdcpp_flag.vae_cpu_params": "Keep VAE params on CPU.", + "ui.settings.engine.sdcpp_flag.clip_cpu_params": "Keep CLIP params on CPU.", + "ui.settings.engine.sdcpp_flag.control_net_cpu_params": "Keep ControlNet params on CPU.", + "ui.settings.engine.sdcpp_flag.rng_std_default": "Use standard RNG.", + "ui.settings.engine.sdcpp_flag.sampler_rng_cuda": "Use CUDA RNG specifically for the sampler.", + "ui.settings.engine.sdcpp_flag.load_id_weights_path": "Load ID weights file.", + "ui.settings.engine.sdcpp_flag.photo_maker_path": "Set PhotoMaker model path.", + "ui.settings.engine.sdcpp_flag.photo_maker_vae_path": "Set PhotoMaker VAE path.", + "ui.settings.engine.sdcpp_flag.style_strength_20": "Set PhotoMaker style strength.", + "ui.settings.engine.sdcpp_flag.taesd_decode": "Use TAESD decoder.", + "ui.settings.engine.sdcpp_flag.taesd_encode": "Use TAESD encoder.", + "ui.settings.engine.compute_mode": "Compute Device", + "ui.settings.engine.compute_gpu": "GPU", + "ui.settings.engine.compute_cpu": "CPU", + "ui.settings.engine.compute_mode_hint": "Choose whether this engine starts on the GPU or CPU.", + "ui.download.select_package": "Select package", + "ui.download.package_subtitle": "Choose package and version", + "ui.download.compute_target": "Compute target", + "ui.download.gpu_package": "GPU", + "ui.download.cpu_package": "CPU", + "ui.download.both_packages": "CPU + GPU", + "ui.download.unavailable": "Unavailable", + "ui.download.version": "Version", + "ui.download.latest_suffix": "(latest)", + "ui.download.date_unknown": "date unknown", + "ui.download.no_release_options": "No compatible release packages found", + "ui.download.loading_versions": "Loading versions...", + "ui.download.load_versions_error": "Failed to load release versions", + "ui.common.cancel": "Cancel" } diff --git a/src-tauri/resources/locales/ru.json b/src-tauri/resources/locales/ru.json index 0c40c6c6..c7506f54 100644 --- a/src-tauri/resources/locales/ru.json +++ b/src-tauri/resources/locales/ru.json @@ -64,7 +64,7 @@ "ui.chat.context_used": "использовано", "ui.chat.context_remaining": "осталось", "ui.chat.context_unknown": "неизвестно", - "ui.chat.error.local_model_memory": "Недостаточно памяти для запуска локальной модели. Уменьшите размер контекста или число GPU layers, либо выберите модель поменьше.", + "ui.chat.error.local_model_memory": "Недостаточно памяти для запуска локальной модели. Уменьшите размер контекста, переключите режим вычислений или выберите модель поменьше.", "ui.chat.error.local_model_system_memory": "Недостаточно системной памяти для запуска локальной модели. Закройте лишние приложения или выберите модель поменьше.", "ui.chat.error.image_vram": "Недостаточно видеопамяти для генерации изображения. Уменьшите размер изображения, steps или batch size, либо выберите модель поменьше.", "ui.chat.error.local_image_engine_connection": "Локальный движок изображений остановился или закрыл соединение во время генерации. Перезапустите движок и уменьшите размер изображения, steps или batch size, если ошибка повторится.", @@ -75,19 +75,20 @@ "ui.chat.image_open_folder_failed": "Не удалось открыть папку с изображением", "ui.chat.open_image_folder": "Открыть папку с изображением", "ui.chat.close_image_preview": "Закрыть просмотр изображения", + "ui.chat.previous_image": "Предыдущее изображение", + "ui.chat.next_image": "Следующее изображение", "ui.chat.image_preview": "Просмотр изображения", "ui.chat.save_image": "Сохранить изображение", - "ui.chat.image_generating": "Рендеринг изображения", + "ui.chat.image_generating": "Создание изображения", "ui.chat.streaming_text": "Модель печатает...", "ui.chat.thinking": "Думает...", "ui.chat.image_ready": "Изображение готово", - "ui.chat.image_cancel": "Отменить", "ui.chat.image_cancelled": "Генерация изображения отменена", "ui.chat.regenerate_failed": "Не удалось повторить ответ", "ui.ai.communication_failure": "Ошибка соединения", "ui.ai.no_api_key": "API ключ отсутствует", + "ui.ai.no_model_selected": "AI-модель не выбрана", "ui.ai.no_provider": "Нет активного AI-модуля. Сначала выберите и запустите модуль.", - "ui.ai.performance_mode_active": "Режим производительности активен", "ui.ai.provider_activation_failed": "Не удалось активировать провайдер", "ui.claude.model.46sonnet.desc": "Самая сильная Sonnet-модель Anthropic с фронтирной производительностью для кодинга, агентов и профессиональной работы", "ui.claude.model.haiku.desc": "Самая быстрая и экономичная модель Anthropic с near-frontier качеством для задач с низкой задержкой и большим объемом запросов", @@ -172,6 +173,23 @@ "ui.launcher.models.ai_desc": "ИИ-рантаймы и провайдеры для генерации текста, изображений и кода.", "ui.launcher.models.services": "Интеграции", "ui.launcher.models.services_desc": "Локальные инструменты, автоматизация и внешние интеграции проекта.", + "ui.launcher.integrations.import.add": "Добавить", + "ui.launcher.integrations.import.archive": "Архив", + "ui.launcher.integrations.import.archive_title": "Выберите архив интеграции", + "ui.launcher.integrations.import.card_desc": "Импортируйте свою интеграцию из папки, архива или ссылки на репозиторий.", + "ui.launcher.integrations.import.card_title": "Добавить интеграцию", + "ui.launcher.integrations.import.error": "Не удалось импортировать интеграцию", + "ui.launcher.integrations.import.folder": "Папка", + "ui.launcher.integrations.import.folder_title": "Выберите папку интеграции", + "ui.launcher.integrations.import.guide_short": "Создайте axelate-module.toml, runtime entry, опциональный settings UI и импортируйте модуль здесь.", + "ui.launcher.integrations.import.guide_title": "Гайд по интеграциям", + "ui.launcher.integrations.import.open": "Открыть", + "ui.launcher.integrations.import.open_title": "Выберите папку или архив интеграции", + "ui.launcher.integrations.import.success": "Интеграция добавлена", + "ui.launcher.integrations.import.url": "Ссылка", + "ui.launcher.integrations.import.url_desc": "Вставьте ссылку на GitHub-репозиторий или прямой URL архива.", + "ui.launcher.integrations.import.url_placeholder": "URL репозитория или архива", + "ui.launcher.integrations.import.url_title": "Добавить интеграцию по ссылке", "ui.launcher.models_subtitle": "Выберите движок или интеграцию для работы", "ui.launcher.module.delete": "Удалить", "ui.launcher.module.download": "Скачать", @@ -203,8 +221,8 @@ "ui.launcher.settings.monitor_ram": "RAM", "ui.launcher.settings.monitor_title": "Управление мониторингом", "ui.launcher.settings.monitor_vram": "VRAM", - "ui.launcher.settings.taskbar_desc": "Настройка видимости вкладок", - "ui.launcher.settings.taskbar_title": "Управление панелью задач", + "ui.launcher.settings.taskbar_desc": "Настройка видимости страниц в боковой панели", + "ui.launcher.settings.taskbar_title": "Управление боковой панелью", "ui.launcher.web.app_title": "Axelate", "ui.launcher.web.chat": "Чат", "ui.launcher.web.chat_clear": "Очистить чат", @@ -221,6 +239,7 @@ "ui.launcher.web.copy_code": "Копировать код", "ui.launcher.web.copy_failed": "Не удалось скопировать код", "ui.launcher.web.delete_model_error": "Ошибка удаления модели", + "ui.launcher.web.download_control_error": "Не удалось управлять загрузкой", "ui.launcher.web.download_error": "Ошибка загрузки", "ui.launcher.web.download_url_empty": "URL загрузки пуст", "ui.launcher.web.downloaded": "Скачано", @@ -233,9 +252,8 @@ "ui.launcher.web.home": "Главное меню", "ui.launcher.web.home_title": "Главное меню", "ui.launcher.web.information": "Информация", - "ui.launcher.web.logs_general": "Общие", + "ui.launcher.web.logs_general": "Платформа", "ui.launcher.web.main_menu": "Главное меню", - "ui.launcher.web.marketplace": "Маркет", "ui.connectivity.offline_title": "Нет подключения к интернету", "ui.connectivity.offline_text": "Локальные модули продолжают работать. Облачный ИИ, загрузки и внешние страницы недоступны.", "ui.launcher.web.models_title": "Движки и интеграции", @@ -295,9 +313,6 @@ "ui.settings.engine.browse": "Обзор", "ui.settings.engine.config_unavailable": "Конфигурация движка недоступна (Tauri не подключен)", "ui.settings.engine.context_size": "Размер контекстного окна", - "ui.settings.engine.compute_mode": "Устройство вычислений", - "ui.settings.engine.compute_mode_cpu": "Процессор", - "ui.settings.engine.compute_mode_gpu": "Видеокарта", "ui.settings.engine.core_config": "Основная конфигурация", "ui.settings.engine.extra_args": "Дополнительные аргументы", "ui.settings.engine.extra_args.add_all": "Добавить все", @@ -312,16 +327,20 @@ "ui.settings.engine.extra_args.recommended": "Рекомендуемые", "ui.settings.engine.extra_args.remove": "Удалить", "ui.settings.engine.generation_presets": "Пресеты генерации", + "ui.settings.engine.generation_settings": "Настройки генерации", "ui.settings.engine.group_batch": "Пакет и Seed", "ui.settings.engine.group_sampling": "Сэмплинг", "ui.settings.engine.group_size": "Размер изображения", "ui.settings.engine.model_not_selected": "Модель не выбрана", + "ui.settings.engine.model_profiles": "Профили моделей", "ui.settings.engine.model_path": "Путь к модели (*.gguf, *.safetensors)", "ui.settings.engine.image_model_path": "Основная модель изображения (*.gguf, *.safetensors)", - "ui.settings.engine.image_model_path_hint": "Сюда ставится основная diffusion-модель: обычная SD-модель или qwen-image*.gguf.", - "ui.settings.engine.extra_args_hint": "Только для продвинутых флагов запуска. Файлы-компаньоны Qwen Image определяются рядом с выбранной моделью или передаются здесь.", - "ui.settings.engine.performance_mode": "Режим производительности", - "ui.settings.engine.performance_mode_title": "Закрывать лаунчер во время генерации", + "ui.settings.engine.image_model_path_hint": "Основной файл diffusion-модели.", + "ui.settings.engine.extra_args_hint": "Продвинутые флаги запуска для sd.cpp.", + "ui.settings.engine.profile_save": "Сохранить текущую", + "ui.settings.engine.profile_save_button": "Сохранить", + "ui.settings.engine.profile_save_desc": "Сохранить модель, настройки генерации и флаги запуска.", + "ui.settings.engine.profile_select_model_first": "Сначала выберите модель", "ui.settings.engine.thinking_level": "Уровень размышления", "ui.settings.engine.thinking_level_desc": "Управляет тем, насколько глубоко модель размышляет перед ответом.", "ui.settings.engine.max_output_tokens": "Максимум токенов ответа", @@ -331,8 +350,10 @@ "ui.settings.engine.sd_clip_skip": "Clip Skip", "ui.settings.engine.sd_denoising_strength": "Сила денойзинга", "ui.settings.engine.sd_height": "Высота (px)", - "ui.settings.engine.sd_negative_prompt": "Префикс негативного промпта", - "ui.settings.engine.sd_positive_prompt": "Префикс позитивного промпта", + "ui.settings.engine.sd_negative_prompt": "Негативный промпт", + "ui.settings.engine.sd_negative_prompt_placeholder": "Что исключить: мыло, низкое качество, водяные знаки, искажения.", + "ui.settings.engine.sd_positive_prompt": "Позитивный промпт", + "ui.settings.engine.sd_positive_prompt_placeholder": "Опишите стиль, объект, свет и детали изображения.", "ui.settings.engine.sd_sampler": "Сэмплер", "ui.settings.engine.sd_scheduler": "Планировщик", "ui.settings.engine.sd_seed": "Seed", @@ -362,7 +383,7 @@ "ui.settings.thinking.off": "Выкл", "ui.settings.toggle_visibility": "Показать/скрыть пароль", "ui.launcher.modules.modal.btn_remove": "Убрать", - "ui.launcher.modules.modal.btn_select": "Выбрать", + "ui.launcher.modules.modal.btn_select": "Запустить", "ui.launcher.engine.llamacpp.name": "llama.cpp", "ui.launcher.engine.llamacpp.desc": "Универсальный LLM движок. CUDA, Vulkan, CPU.", "ui.launcher.engine.sdcpp.name": "Stable Diffusion.cpp", @@ -370,5 +391,131 @@ "ui.launcher.engine.comfyui.name": "ComfyUI", "ui.launcher.engine.comfyui.desc": "Нодовый движок workflow для изображений с упором на максимальное качество и контроль.", "ui.gpt.model.gpt55.desc": "Фронтирная модель для сложных профессиональных задач с более сильным reasoning, высокой надёжностью и лучшей токен-эффективностью", - "ui.gpt.model.gpt55pro.desc": "Модель повышенной мощности для глубокого reasoning и точности в сложных high-stakes задачах" + "ui.gpt.model.gpt55pro.desc": "Модель повышенной мощности для глубокого reasoning и точности в сложных high-stakes задачах", + "ui.settings.engine.sdcpp_flags.title": "Ручные флаги sd.cpp", + "ui.settings.engine.sdcpp_flags.subtitle": "Параметры запуска, которые добавляются к sd-server.", + "ui.settings.engine.sdcpp_flag.threads_8": "Задаёт количество рабочих потоков.", + "ui.settings.engine.sdcpp_flag.model_path": "Переопределяет путь основной модели.", + "ui.settings.engine.sdcpp_flag.diffusion_model_path": "Задаёт путь diffusion-модели.", + "ui.settings.engine.sdcpp_flag.vae_path": "Задаёт путь модели VAE.", + "ui.settings.engine.sdcpp_flag.taesd_path": "Задаёт путь модели TAESD.", + "ui.settings.engine.sdcpp_flag.control_net_path": "Задаёт путь модели ControlNet.", + "ui.settings.engine.sdcpp_flag.embd_dir_path": "Загружает textual inversion embeddings.", + "ui.settings.engine.sdcpp_flag.stacked_id_embd_dir_path": "Загружает stacked ID embeddings.", + "ui.settings.engine.sdcpp_flag.input_id_images_dir_path": "Загружает входные ID-изображения.", + "ui.settings.engine.sdcpp_flag.lora_model_dir_path": "Папка с моделями LoRA.", + "ui.settings.engine.sdcpp_flag.vae_decode_only": "Только декодирует latent-изображение через VAE.", + "ui.settings.engine.sdcpp_flag.vae_encode_only": "Кодирует изображение в latent-пространство.", + "ui.settings.engine.sdcpp_flag.control_image_path": "Изображение для ControlNet.", + "ui.settings.engine.sdcpp_flag.output_path": "Задаёт путь выходного изображения.", + "ui.settings.engine.sdcpp_flag.output_video_path": "Задаёт путь выходного видео.", + "ui.settings.engine.sdcpp_flag.init_img_path": "Использует начальное изображение для img2img.", + "ui.settings.engine.sdcpp_flag.mask_path": "Использует маску для inpainting.", + "ui.settings.engine.sdcpp_flag.ref_image_path": "Использует референсное изображение.", + "ui.settings.engine.sdcpp_flag.clip_l_path": "Задаёт путь модели CLIP-L.", + "ui.settings.engine.sdcpp_flag.clip_g_path": "Задаёт путь модели CLIP-G.", + "ui.settings.engine.sdcpp_flag.clip_vision_path": "Задаёт путь модели CLIP-Vision.", + "ui.settings.engine.sdcpp_flag.t5xxl_path": "Задаёт путь модели T5-XXL.", + "ui.settings.engine.sdcpp_flag.llm_path": "Задаёт путь LLM-модели.", + "ui.settings.engine.sdcpp_flag.diffusion_fa": "Включает flash attention для diffusion.", + "ui.settings.engine.sdcpp_flag.fa": "Включает flash attention глобально.", + "ui.settings.engine.sdcpp_flag.no_fallback": "Отключает fallback-пути выполнения.", + "ui.settings.engine.sdcpp_flag.mmap": "Использует mmap для весов модели с диска.", + "ui.settings.engine.sdcpp_flag.no_mmap": "Отключает memory mapping.", + "ui.settings.engine.sdcpp_flag.offload_to_cpu": "Переносит часть работы модели на CPU.", + "ui.settings.engine.sdcpp_flag.clip_on_cpu": "Запускает CLIP на CPU.", + "ui.settings.engine.sdcpp_flag.vae_on_cpu": "Запускает VAE на CPU.", + "ui.settings.engine.sdcpp_flag.vae_tiling": "Включает tiled VAE decoding для экономии VRAM.", + "ui.settings.engine.sdcpp_flag.free_params_immediately": "Освобождает параметры модели после загрузки.", + "ui.settings.engine.sdcpp_flag.keep_clip_on_cpu": "Оставляет веса CLIP на CPU.", + "ui.settings.engine.sdcpp_flag.keep_control_net_cpu": "Оставляет веса ControlNet на CPU.", + "ui.settings.engine.sdcpp_flag.keep_vae_on_cpu": "Оставляет веса VAE на CPU.", + "ui.settings.engine.sdcpp_flag.control_net_cpu": "Запускает ControlNet на CPU при использовании ControlNet.", + "ui.settings.engine.sdcpp_flag.canny": "Применяет Canny preprocessing для ControlNet.", + "ui.settings.engine.sdcpp_flag.color": "Включает color preprocessing или цветной вывод.", + "ui.settings.engine.sdcpp_flag.cpu_params": "Держит параметры в обычной CPU-памяти.", + "ui.settings.engine.sdcpp_flag.normalize_input": "Нормализует значения входного изображения.", + "ui.settings.engine.sdcpp_flag.upscale_model_path": "Задаёт путь ESRGAN upscale-модели.", + "ui.settings.engine.sdcpp_flag.upscale_repeats_2": "Повторяет проходы апскейла.", + "ui.settings.engine.sdcpp_flag.type_q8_0": "Задаёт тип/точность весов.", + "ui.settings.engine.sdcpp_flag.rng_cuda": "Использует CUDA RNG на NVIDIA.", + "ui.settings.engine.sdcpp_flag.sampling_method_euler_a": "Задаёт метод сэмплинга.", + "ui.settings.engine.sdcpp_flag.schedule_karras": "Задаёт scheduler.", + "ui.settings.engine.sdcpp_flag.prediction_v": "Задаёт режим prediction.", + "ui.settings.engine.sdcpp_flag.clip_skip_2": "Пропускает последние слои CLIP.", + "ui.settings.engine.sdcpp_flag.cfg_scale_7": "Задаёт CFG scale.", + "ui.settings.engine.sdcpp_flag.guidance_3_5": "Задаёт guidance scale.", + "ui.settings.engine.sdcpp_flag.eta_0": "Задаёт DDIM eta.", + "ui.settings.engine.sdcpp_flag.steps_30": "Задаёт число шагов сэмплинга по умолчанию.", + "ui.settings.engine.sdcpp_flag.strength_0_75": "Задаёт силу denoise для img2img.", + "ui.settings.engine.sdcpp_flag.pm_style_strength_20": "Задаёт силу стиля PhotoMaker.", + "ui.settings.engine.sdcpp_flag.control_strength_0_9": "Задаёт силу ControlNet.", + "ui.settings.engine.sdcpp_flag.width_1024": "Задаёт ширину вывода по умолчанию.", + "ui.settings.engine.sdcpp_flag.height_1024": "Задаёт высоту вывода по умолчанию.", + "ui.settings.engine.sdcpp_flag.batch_count_1": "Задаёт количество batch-ов.", + "ui.settings.engine.sdcpp_flag.video_frames_16": "Задаёт число кадров видео.", + "ui.settings.engine.sdcpp_flag.fps_24": "Задаёт FPS видео.", + "ui.settings.engine.sdcpp_flag.motion_bucket_id_127": "Задаёт SVD motion bucket.", + "ui.settings.engine.sdcpp_flag.augmentation_level_0": "Задаёт уровень SVD augmentation.", + "ui.settings.engine.sdcpp_flag.sample_start_0": "Задаёт начальное значение sample.", + "ui.settings.engine.sdcpp_flag.sample_end_1": "Задаёт конечное значение sample.", + "ui.settings.engine.sdcpp_flag.slg_scale_0": "Задаёт skip-layer guidance scale.", + "ui.settings.engine.sdcpp_flag.skip_layers_7_8_9": "Задаёт слои skip-layer guidance.", + "ui.settings.engine.sdcpp_flag.skip_layer_start_0_01": "Задаёт старт skip-layer ratio.", + "ui.settings.engine.sdcpp_flag.skip_layer_end_0_2": "Задаёт конец skip-layer ratio.", + "ui.settings.engine.sdcpp_flag.seed_42": "Задаёт seed генерации.", + "ui.settings.engine.sdcpp_flag.negative_prompt_text": "Задаёт негативный промпт по умолчанию.", + "ui.settings.engine.sdcpp_flag.prompt_text": "Задаёт позитивный промпт по умолчанию.", + "ui.settings.engine.sdcpp_flag.cfg_negative_prompt_text": "Задаёт CFG negative prompt.", + "ui.settings.engine.sdcpp_flag.vae_tile_size_32x32": "Задаёт размер VAE tile.", + "ui.settings.engine.sdcpp_flag.vae_tile_overlap_0_5": "Задаёт перекрытие VAE tile.", + "ui.settings.engine.sdcpp_flag.vae_relative_tile_size_0_5x0_5": "Задаёт относительный размер VAE tile.", + "ui.settings.engine.sdcpp_flag.clip_g_layers_0": "Задаёт число слоёв CLIP-G.", + "ui.settings.engine.sdcpp_flag.clip_l_layers_0": "Задаёт число слоёв CLIP-L.", + "ui.settings.engine.sdcpp_flag.t5xxl_layers_0": "Задаёт число слоёв T5-XXL.", + "ui.settings.engine.sdcpp_flag.diffusion_model_layers_0": "Задаёт число diffusion-слоёв.", + "ui.settings.engine.sdcpp_flag.vae_layers_0": "Задаёт число VAE-слоёв.", + "ui.settings.engine.sdcpp_flag.verbose": "Включает подробные логи.", + "ui.settings.engine.sdcpp_flag.quiet": "Уменьшает объём логов.", + "ui.settings.engine.sdcpp_flag.preview_none": "Отключает preview-изображение.", + "ui.settings.engine.sdcpp_flag.preview_path_path": "Задаёт путь preview-изображения.", + "ui.settings.engine.sdcpp_flag.diffusion_on_cpu": "Запускает diffusion-модель на CPU.", + "ui.settings.engine.sdcpp_flag.vae_on_gpu": "Запускает VAE на GPU, если возможно.", + "ui.settings.engine.sdcpp_flag.clip_on_gpu": "Запускает CLIP на GPU, если возможно.", + "ui.settings.engine.sdcpp_flag.control_net_on_gpu": "Запускает ControlNet на GPU.", + "ui.settings.engine.sdcpp_flag.chroma_disable_ds": "Отключает Chroma downsampling.", + "ui.settings.engine.sdcpp_flag.chroma_enable_t5_mask": "Включает Chroma T5 mask.", + "ui.settings.engine.sdcpp_flag.chroma_t5_mask_pad_1": "Задаёт padding для Chroma T5 mask.", + "ui.settings.engine.sdcpp_flag.flow_shift_3": "Задаёт flow shift.", + "ui.settings.engine.sdcpp_flag.timestep_shift_250": "Задаёт смещение timestep.", + "ui.settings.engine.sdcpp_flag.diffusion_cpu_params": "Держит diffusion params на CPU.", + "ui.settings.engine.sdcpp_flag.vae_cpu_params": "Держит VAE params на CPU.", + "ui.settings.engine.sdcpp_flag.clip_cpu_params": "Держит CLIP params на CPU.", + "ui.settings.engine.sdcpp_flag.control_net_cpu_params": "Держит ControlNet params на CPU.", + "ui.settings.engine.sdcpp_flag.rng_std_default": "Использует стандартный RNG.", + "ui.settings.engine.sdcpp_flag.sampler_rng_cuda": "Использует CUDA RNG именно для sampler.", + "ui.settings.engine.sdcpp_flag.load_id_weights_path": "Загружает файл ID weights.", + "ui.settings.engine.sdcpp_flag.photo_maker_path": "Задаёт путь модели PhotoMaker.", + "ui.settings.engine.sdcpp_flag.photo_maker_vae_path": "Задаёт путь VAE для PhotoMaker.", + "ui.settings.engine.sdcpp_flag.style_strength_20": "Задаёт силу стиля PhotoMaker.", + "ui.settings.engine.sdcpp_flag.taesd_decode": "Использует TAESD decoder.", + "ui.settings.engine.sdcpp_flag.taesd_encode": "Использует TAESD encoder.", + "ui.settings.engine.compute_mode": "Устройство вычислений", + "ui.settings.engine.compute_gpu": "GPU", + "ui.settings.engine.compute_cpu": "CPU", + "ui.settings.engine.compute_mode_hint": "Выбери, запускать движок на видеокарте или процессоре.", + "ui.download.select_package": "Выбор пакета", + "ui.download.package_subtitle": "Выбери пакет и версию", + "ui.download.compute_target": "Тип пакета", + "ui.download.gpu_package": "GPU", + "ui.download.cpu_package": "CPU", + "ui.download.both_packages": "CPU + GPU", + "ui.download.unavailable": "Недоступно", + "ui.download.version": "Версия", + "ui.download.latest_suffix": "(последняя)", + "ui.download.date_unknown": "дата неизвестна", + "ui.download.no_release_options": "Совместимые пакеты не найдены", + "ui.download.loading_versions": "Загрузка версий...", + "ui.download.load_versions_error": "Не удалось загрузить версии", + "ui.common.cancel": "Отмена" } diff --git a/src-tauri/resources/locales/zh.json b/src-tauri/resources/locales/zh.json index f7a89435..326885fe 100644 --- a/src-tauri/resources/locales/zh.json +++ b/src-tauri/resources/locales/zh.json @@ -64,7 +64,7 @@ "ui.chat.context_used": "已用", "ui.chat.context_remaining": "剩余", "ui.chat.context_unknown": "未知", - "ui.chat.error.local_model_memory": "启动本地模型的内存不足。请降低上下文大小或 GPU layers,或改用更小的模型。", + "ui.chat.error.local_model_memory": "启动本地模型的内存不足。请降低上下文大小或将计算设备切换为 CPU,或改用更小的模型。", "ui.chat.error.local_model_system_memory": "启动本地模型的系统内存不足。请关闭其他应用,或改用更小的模型。", "ui.chat.error.image_vram": "生成图片所需的显存不足。请降低图片尺寸、steps 或 batch size,或改用更小的模型。", "ui.chat.error.local_image_engine_connection": "本地图像引擎在生成时停止或关闭了连接。请重启图像引擎;如果再次发生,请降低图片尺寸、steps 或 batch size。", @@ -75,19 +75,20 @@ "ui.chat.image_open_folder_failed": "无法打开图片文件夹", "ui.chat.open_image_folder": "打开图片文件夹", "ui.chat.close_image_preview": "关闭图片预览", + "ui.chat.previous_image": "上一张图片", + "ui.chat.next_image": "下一张图片", "ui.chat.image_preview": "图片预览", "ui.chat.save_image": "保存图片", "ui.chat.image_generating": "正在渲染图片", "ui.chat.streaming_text": "模型正在输入...", "ui.chat.thinking": "正在思考...", "ui.chat.image_ready": "图片已生成", - "ui.chat.image_cancel": "取消", "ui.chat.image_cancelled": "图片生成已取消", "ui.chat.regenerate_failed": "重新生成回复失败", "ui.ai.communication_failure": "通信失败", "ui.ai.no_api_key": "缺少 API 密钥", + "ui.ai.no_model_selected": "未选择 AI 模型", "ui.ai.no_provider": "当前没有运行中的 AI 模块。请先选择并启动一个模块。", - "ui.ai.performance_mode_active": "性能模式已启用", "ui.ai.provider_activation_failed": "提供商激活失败", "ui.claude.model.46sonnet.desc": "Anthropic 最强的 Sonnet 系列模型之一,适合编码、代理与专业工作场景", "ui.claude.model.haiku.desc": "Anthropic 速度最快、成本最低的模型之一,适合低延迟与高吞吐场景并保持接近前沿的能力", @@ -168,6 +169,23 @@ "ui.launcher.models.ai_desc": "用于文本、图像和代码生成的 AI 运行时与提供商。", "ui.launcher.models.services": "集成", "ui.launcher.models.services_desc": "本地工具、自动化流程和项目外部集成。", + "ui.launcher.integrations.import.add": "添加", + "ui.launcher.integrations.import.archive": "归档", + "ui.launcher.integrations.import.archive_title": "选择集成归档", + "ui.launcher.integrations.import.card_desc": "从文件夹、归档或仓库链接导入自定义集成。", + "ui.launcher.integrations.import.card_title": "添加集成", + "ui.launcher.integrations.import.error": "集成导入失败", + "ui.launcher.integrations.import.folder": "文件夹", + "ui.launcher.integrations.import.folder_title": "选择集成文件夹", + "ui.launcher.integrations.import.guide_short": "创建 axelate-module.toml、runtime entry、可选 settings UI,然后在此导入。", + "ui.launcher.integrations.import.guide_title": "集成指南", + "ui.launcher.integrations.import.open": "打开", + "ui.launcher.integrations.import.open_title": "选择集成文件夹或归档", + "ui.launcher.integrations.import.success": "集成已添加", + "ui.launcher.integrations.import.url": "链接", + "ui.launcher.integrations.import.url_desc": "粘贴 GitHub 仓库或直接归档 URL。", + "ui.launcher.integrations.import.url_placeholder": "仓库或归档 URL", + "ui.launcher.integrations.import.url_title": "通过 URL 添加集成", "ui.launcher.models_subtitle": "选择引擎或集成以开始工作", "ui.launcher.module.delete": "删除", "ui.launcher.module.download": "下载", @@ -199,8 +217,8 @@ "ui.launcher.settings.monitor_ram": "内存", "ui.launcher.settings.monitor_title": "监控管理", "ui.launcher.settings.monitor_vram": "显存", - "ui.launcher.settings.taskbar_desc": "配置选项卡可见性", - "ui.launcher.settings.taskbar_title": "任务栏管理", + "ui.launcher.settings.taskbar_desc": "配置侧边栏页面可见性", + "ui.launcher.settings.taskbar_title": "侧边栏管理", "ui.launcher.web.app_title": "Axelate", "ui.launcher.web.chat": "聊天", "ui.launcher.web.chat_clear": "清除聊天", @@ -217,6 +235,7 @@ "ui.launcher.web.copy_code": "复制代码", "ui.launcher.web.copy_failed": "复制代码失败", "ui.launcher.web.delete_model_error": "删除模型错误", + "ui.launcher.web.download_control_error": "下载控制失败", "ui.launcher.web.download_error": "下载错误", "ui.launcher.web.download_url_empty": "下载 URL 为空", "ui.launcher.web.downloaded": "已下载", @@ -229,9 +248,8 @@ "ui.launcher.web.home": "首页", "ui.launcher.web.home_title": "主菜单", "ui.launcher.web.information": "信息", - "ui.launcher.web.logs_general": "常规", + "ui.launcher.web.logs_general": "平台", "ui.launcher.web.main_menu": "主菜单", - "ui.launcher.web.marketplace": "市场", "ui.connectivity.offline_title": "网络连接不可用", "ui.connectivity.offline_text": "本地模块仍可继续工作。云 AI、下载和外部页面暂时不可用。", "ui.launcher.web.models_title": "AI 引擎与集成", @@ -291,9 +309,6 @@ "ui.settings.engine.browse": "浏览", "ui.settings.engine.config_unavailable": "引擎配置不可用(Tauri 未连接)", "ui.settings.engine.context_size": "上下文窗口", - "ui.settings.engine.compute_mode": "计算设备", - "ui.settings.engine.compute_mode_cpu": "CPU", - "ui.settings.engine.compute_mode_gpu": "GPU", "ui.settings.engine.core_config": "核心配置", "ui.settings.engine.extra_args": "附加参数", "ui.settings.engine.extra_args.add_all": "全部添加", @@ -308,16 +323,20 @@ "ui.settings.engine.extra_args.recommended": "推荐", "ui.settings.engine.extra_args.remove": "移除", "ui.settings.engine.generation_presets": "生成预设", + "ui.settings.engine.generation_settings": "生成设置", "ui.settings.engine.group_batch": "批量与种子", "ui.settings.engine.group_sampling": "采样", "ui.settings.engine.group_size": "图像尺寸", "ui.settings.engine.model_not_selected": "未选择模型", + "ui.settings.engine.model_profiles": "模型配置", "ui.settings.engine.model_path": "模型路径 (*.gguf, *.safetensors)", "ui.settings.engine.image_model_path": "主图像模型路径 (*.gguf, *.safetensors)", - "ui.settings.engine.image_model_path_hint": "这里放主要 diffusion 模型:普通 SD 模型或 qwen-image*.gguf。", - "ui.settings.engine.extra_args_hint": "这里只放高级启动参数。Qwen Image 配套文件会从所选模型旁自动检测,也可以在这里传入。", - "ui.settings.engine.performance_mode": "性能模式", - "ui.settings.engine.performance_mode_title": "生成期间关闭启动器", + "ui.settings.engine.image_model_path_hint": "主 diffusion 模型文件。", + "ui.settings.engine.extra_args_hint": "传给 sd.cpp 的高级启动参数。", + "ui.settings.engine.profile_save": "保存当前", + "ui.settings.engine.profile_save_button": "保存", + "ui.settings.engine.profile_save_desc": "保存模型、生成设置和启动参数。", + "ui.settings.engine.profile_select_model_first": "请先选择模型", "ui.settings.engine.thinking_level": "思考强度", "ui.settings.engine.thinking_level_desc": "控制模型在回答前进行多少推理。", "ui.settings.engine.max_output_tokens": "最大输出 Token", @@ -327,8 +346,10 @@ "ui.settings.engine.sd_clip_skip": "Clip Skip", "ui.settings.engine.sd_denoising_strength": "去噪强度", "ui.settings.engine.sd_height": "高度 (px)", - "ui.settings.engine.sd_negative_prompt": "负向提示词前缀", - "ui.settings.engine.sd_positive_prompt": "正向提示词前缀", + "ui.settings.engine.sd_negative_prompt": "负向提示词", + "ui.settings.engine.sd_negative_prompt_placeholder": "要避免的内容:模糊、低质量、水印、变形。", + "ui.settings.engine.sd_positive_prompt": "正向提示词", + "ui.settings.engine.sd_positive_prompt_placeholder": "描述图像风格、主体、光照和细节。", "ui.settings.engine.sd_sampler": "采样器", "ui.settings.engine.sd_scheduler": "调度器", "ui.settings.engine.sd_seed": "随机种子", @@ -358,7 +379,7 @@ "ui.settings.thinking.off": "关闭", "ui.settings.toggle_visibility": "切换密码显示", "ui.launcher.modules.modal.btn_remove": "收起", - "ui.launcher.modules.modal.btn_select": "选择", + "ui.launcher.modules.modal.btn_select": "启动", "ui.launcher.engine.llamacpp.name": "llama.cpp", "ui.launcher.engine.llamacpp.desc": "通用LLM引擎。CUDA、Vulkan、CPU。", "ui.launcher.engine.sdcpp.name": "Stable Diffusion.cpp", @@ -366,5 +387,131 @@ "ui.launcher.engine.comfyui.name": "ComfyUI", "ui.launcher.engine.comfyui.desc": "面向最高画质与控制力的节点式图像工作流引擎。", "ui.gpt.model.gpt55.desc": "面向复杂专业工作负载的前沿模型,具备更强推理、更高可靠性和更好的高难任务 token 效率", - "ui.gpt.model.gpt55pro.desc": "面向复杂高风险工作负载的高能力模型,优化深度推理与准确性" + "ui.gpt.model.gpt55pro.desc": "面向复杂高风险工作负载的高能力模型,优化深度推理与准确性", + "ui.settings.engine.sdcpp_flags.title": "手动 sd.cpp 参数", + "ui.settings.engine.sdcpp_flags.subtitle": "追加到 sd-server 的启动参数。", + "ui.settings.engine.sdcpp_flag.threads_8": "设置工作线程数量。", + "ui.settings.engine.sdcpp_flag.model_path": "覆盖主模型路径。", + "ui.settings.engine.sdcpp_flag.diffusion_model_path": "设置 diffusion 模型路径。", + "ui.settings.engine.sdcpp_flag.vae_path": "设置 VAE 模型路径。", + "ui.settings.engine.sdcpp_flag.taesd_path": "设置 TAESD 模型路径。", + "ui.settings.engine.sdcpp_flag.control_net_path": "设置 ControlNet 模型路径。", + "ui.settings.engine.sdcpp_flag.embd_dir_path": "加载 textual inversion 嵌入。", + "ui.settings.engine.sdcpp_flag.stacked_id_embd_dir_path": "加载 stacked ID 嵌入。", + "ui.settings.engine.sdcpp_flag.input_id_images_dir_path": "加载输入 ID 图像。", + "ui.settings.engine.sdcpp_flag.lora_model_dir_path": "包含 LoRA 模型的目录。", + "ui.settings.engine.sdcpp_flag.vae_decode_only": "仅用 VAE 解码 latent 图像。", + "ui.settings.engine.sdcpp_flag.vae_encode_only": "将图像编码到 latent 空间。", + "ui.settings.engine.sdcpp_flag.control_image_path": "ControlNet 使用的图像。", + "ui.settings.engine.sdcpp_flag.output_path": "设置输出图像路径。", + "ui.settings.engine.sdcpp_flag.output_video_path": "设置输出视频路径。", + "ui.settings.engine.sdcpp_flag.init_img_path": "为 img2img 使用初始图像。", + "ui.settings.engine.sdcpp_flag.mask_path": "为 inpainting 使用蒙版图像。", + "ui.settings.engine.sdcpp_flag.ref_image_path": "使用参考图像。", + "ui.settings.engine.sdcpp_flag.clip_l_path": "设置 CLIP-L 模型路径。", + "ui.settings.engine.sdcpp_flag.clip_g_path": "设置 CLIP-G 模型路径。", + "ui.settings.engine.sdcpp_flag.clip_vision_path": "设置 CLIP-Vision 模型路径。", + "ui.settings.engine.sdcpp_flag.t5xxl_path": "设置 T5-XXL 模型路径。", + "ui.settings.engine.sdcpp_flag.llm_path": "设置 LLM 模型路径。", + "ui.settings.engine.sdcpp_flag.diffusion_fa": "为 diffusion 启用 flash attention。", + "ui.settings.engine.sdcpp_flag.fa": "全局启用 flash attention。", + "ui.settings.engine.sdcpp_flag.no_fallback": "禁用 fallback 执行路径。", + "ui.settings.engine.sdcpp_flag.mmap": "从磁盘内存映射模型权重。", + "ui.settings.engine.sdcpp_flag.no_mmap": "禁用内存映射。", + "ui.settings.engine.sdcpp_flag.offload_to_cpu": "将模型工作卸载到 CPU。", + "ui.settings.engine.sdcpp_flag.clip_on_cpu": "在 CPU 上运行 CLIP。", + "ui.settings.engine.sdcpp_flag.vae_on_cpu": "在 CPU 上运行 VAE。", + "ui.settings.engine.sdcpp_flag.vae_tiling": "使用分块 VAE 解码以减少显存占用。", + "ui.settings.engine.sdcpp_flag.free_params_immediately": "加载后立即释放模型参数。", + "ui.settings.engine.sdcpp_flag.keep_clip_on_cpu": "将 CLIP 权重保留在 CPU。", + "ui.settings.engine.sdcpp_flag.keep_control_net_cpu": "将 ControlNet 权重保留在 CPU。", + "ui.settings.engine.sdcpp_flag.keep_vae_on_cpu": "将 VAE 权重保留在 CPU。", + "ui.settings.engine.sdcpp_flag.control_net_cpu": "使用 ControlNet 时在 CPU 上运行。", + "ui.settings.engine.sdcpp_flag.canny": "为 ControlNet 应用 Canny 预处理。", + "ui.settings.engine.sdcpp_flag.color": "应用颜色预处理或彩色输出。", + "ui.settings.engine.sdcpp_flag.cpu_params": "将参数保存在普通 CPU 内存中。", + "ui.settings.engine.sdcpp_flag.normalize_input": "归一化输入图像值。", + "ui.settings.engine.sdcpp_flag.upscale_model_path": "设置 ESRGAN 放大模型路径。", + "ui.settings.engine.sdcpp_flag.upscale_repeats_2": "重复放大处理。", + "ui.settings.engine.sdcpp_flag.type_q8_0": "设置权重精度/类型。", + "ui.settings.engine.sdcpp_flag.rng_cuda": "在 NVIDIA 系统上优先使用 CUDA RNG。", + "ui.settings.engine.sdcpp_flag.sampling_method_euler_a": "设置采样方法。", + "ui.settings.engine.sdcpp_flag.schedule_karras": "设置调度器。", + "ui.settings.engine.sdcpp_flag.prediction_v": "设置 prediction 模式。", + "ui.settings.engine.sdcpp_flag.clip_skip_2": "跳过最后的 CLIP 层。", + "ui.settings.engine.sdcpp_flag.cfg_scale_7": "设置 CFG 比例。", + "ui.settings.engine.sdcpp_flag.guidance_3_5": "设置 guidance 比例。", + "ui.settings.engine.sdcpp_flag.eta_0": "设置 DDIM eta。", + "ui.settings.engine.sdcpp_flag.steps_30": "设置默认采样步数。", + "ui.settings.engine.sdcpp_flag.strength_0_75": "设置 img2img 去噪强度。", + "ui.settings.engine.sdcpp_flag.pm_style_strength_20": "设置 PhotoMaker 风格强度。", + "ui.settings.engine.sdcpp_flag.control_strength_0_9": "设置 ControlNet 强度。", + "ui.settings.engine.sdcpp_flag.width_1024": "设置默认输出宽度。", + "ui.settings.engine.sdcpp_flag.height_1024": "设置默认输出高度。", + "ui.settings.engine.sdcpp_flag.batch_count_1": "设置批次数量。", + "ui.settings.engine.sdcpp_flag.video_frames_16": "设置生成视频帧数。", + "ui.settings.engine.sdcpp_flag.fps_24": "设置生成视频 FPS。", + "ui.settings.engine.sdcpp_flag.motion_bucket_id_127": "设置 SVD motion bucket。", + "ui.settings.engine.sdcpp_flag.augmentation_level_0": "设置 SVD augmentation 级别。", + "ui.settings.engine.sdcpp_flag.sample_start_0": "设置 sample 起始值。", + "ui.settings.engine.sdcpp_flag.sample_end_1": "设置 sample 结束值。", + "ui.settings.engine.sdcpp_flag.slg_scale_0": "设置 skip-layer guidance 比例。", + "ui.settings.engine.sdcpp_flag.skip_layers_7_8_9": "设置 skip-layer guidance 层。", + "ui.settings.engine.sdcpp_flag.skip_layer_start_0_01": "设置 skip-layer 起始比例。", + "ui.settings.engine.sdcpp_flag.skip_layer_end_0_2": "设置 skip-layer 结束比例。", + "ui.settings.engine.sdcpp_flag.seed_42": "设置生成 seed。", + "ui.settings.engine.sdcpp_flag.negative_prompt_text": "设置默认负向提示词。", + "ui.settings.engine.sdcpp_flag.prompt_text": "设置默认正向提示词。", + "ui.settings.engine.sdcpp_flag.cfg_negative_prompt_text": "设置 CFG 负向提示词。", + "ui.settings.engine.sdcpp_flag.vae_tile_size_32x32": "设置 VAE tile 大小。", + "ui.settings.engine.sdcpp_flag.vae_tile_overlap_0_5": "设置 VAE tile 重叠。", + "ui.settings.engine.sdcpp_flag.vae_relative_tile_size_0_5x0_5": "设置相对 VAE tile 大小。", + "ui.settings.engine.sdcpp_flag.clip_g_layers_0": "设置 CLIP-G 层数。", + "ui.settings.engine.sdcpp_flag.clip_l_layers_0": "设置 CLIP-L 层数。", + "ui.settings.engine.sdcpp_flag.t5xxl_layers_0": "设置 T5-XXL 层数。", + "ui.settings.engine.sdcpp_flag.diffusion_model_layers_0": "设置 diffusion 层数。", + "ui.settings.engine.sdcpp_flag.vae_layers_0": "设置 VAE 层数。", + "ui.settings.engine.sdcpp_flag.verbose": "启用详细日志。", + "ui.settings.engine.sdcpp_flag.quiet": "减少日志输出。", + "ui.settings.engine.sdcpp_flag.preview_none": "禁用预览图输出。", + "ui.settings.engine.sdcpp_flag.preview_path_path": "设置预览图路径。", + "ui.settings.engine.sdcpp_flag.diffusion_on_cpu": "在 CPU 上运行 diffusion 模型。", + "ui.settings.engine.sdcpp_flag.vae_on_gpu": "尽可能在 GPU 上运行 VAE。", + "ui.settings.engine.sdcpp_flag.clip_on_gpu": "尽可能在 GPU 上运行 CLIP。", + "ui.settings.engine.sdcpp_flag.control_net_on_gpu": "在 GPU 上运行 ControlNet。", + "ui.settings.engine.sdcpp_flag.chroma_disable_ds": "禁用 Chroma 下采样。", + "ui.settings.engine.sdcpp_flag.chroma_enable_t5_mask": "启用 Chroma T5 mask。", + "ui.settings.engine.sdcpp_flag.chroma_t5_mask_pad_1": "设置 Chroma T5 mask padding。", + "ui.settings.engine.sdcpp_flag.flow_shift_3": "设置 flow shift 值。", + "ui.settings.engine.sdcpp_flag.timestep_shift_250": "设置 timestep shift 值。", + "ui.settings.engine.sdcpp_flag.diffusion_cpu_params": "将 diffusion 参数保留在 CPU。", + "ui.settings.engine.sdcpp_flag.vae_cpu_params": "将 VAE 参数保留在 CPU。", + "ui.settings.engine.sdcpp_flag.clip_cpu_params": "将 CLIP 参数保留在 CPU。", + "ui.settings.engine.sdcpp_flag.control_net_cpu_params": "将 ControlNet 参数保留在 CPU。", + "ui.settings.engine.sdcpp_flag.rng_std_default": "使用标准 RNG。", + "ui.settings.engine.sdcpp_flag.sampler_rng_cuda": "专门为 sampler 使用 CUDA RNG。", + "ui.settings.engine.sdcpp_flag.load_id_weights_path": "加载 ID weights 文件。", + "ui.settings.engine.sdcpp_flag.photo_maker_path": "设置 PhotoMaker 模型路径。", + "ui.settings.engine.sdcpp_flag.photo_maker_vae_path": "设置 PhotoMaker VAE 路径。", + "ui.settings.engine.sdcpp_flag.style_strength_20": "设置 PhotoMaker 风格强度。", + "ui.settings.engine.sdcpp_flag.taesd_decode": "使用 TAESD 解码器。", + "ui.settings.engine.sdcpp_flag.taesd_encode": "使用 TAESD 编码器。", + "ui.settings.engine.compute_mode": "计算设备", + "ui.settings.engine.compute_gpu": "GPU", + "ui.settings.engine.compute_cpu": "CPU", + "ui.settings.engine.compute_mode_hint": "选择此引擎使用 GPU 还是 CPU 启动。", + "ui.download.select_package": "选择包", + "ui.download.package_subtitle": "选择包和版本", + "ui.download.compute_target": "计算目标", + "ui.download.gpu_package": "GPU", + "ui.download.cpu_package": "CPU", + "ui.download.both_packages": "CPU + GPU", + "ui.download.unavailable": "不可用", + "ui.download.version": "版本", + "ui.download.latest_suffix": "(最新)", + "ui.download.date_unknown": "日期未知", + "ui.download.no_release_options": "未找到兼容的发布包", + "ui.download.loading_versions": "正在加载版本...", + "ui.download.load_versions_error": "无法加载发布版本", + "ui.common.cancel": "取消" } diff --git a/src-tauri/resources/module_settings_host/host.js b/src-tauri/resources/module_settings_host/host.js index 49857a4c..c1c386c3 100644 --- a/src-tauri/resources/module_settings_host/host.js +++ b/src-tauri/resources/module_settings_host/host.js @@ -1,43 +1,42 @@ -const BRIDGE_CHANNEL = "axelate:module-settings"; -const HOST_CHANNEL = "axelate:module-settings-host"; +const BRIDGE_CHANNEL = 'axelate:module-settings'; +const HOST_CHANNEL = 'axelate:module-settings-host'; const SETTINGS_LOAD_TIMEOUT_MS = 2500; const MODULE_BOOT_TIMEOUT_MS = 5000; const STRINGS = { en: { - loading: "Loading module settings…", - loadingModule: "Loading module settings…", - failed: "Failed to load the module or integration settings UI.", - invalidSavePayload: "Settings payload must be a plain object.", - moduleBootTimedOut: - "The module or integration settings UI did not finish loading.", + loading: 'Loading module settings…', + loadingModule: 'Loading module settings…', + failed: 'Failed to load the module or integration settings UI.', + invalidSavePayload: 'Settings payload must be a plain object.', + moduleBootTimedOut: 'The module or integration settings UI did not finish loading.', }, ru: { - loading: "Загрузка интерфейса настроек модуля…", - loadingModule: "Загрузка интерфейса настроек модуля…", - failed: "Не удалось загрузить интерфейс настроек этого модуля или интеграции.", - invalidSavePayload: "Настройки должны передаваться как обычный объект.", - moduleBootTimedOut: - "Интерфейс настроек этого модуля или интеграции не завершил загрузку.", + loading: 'Загрузка интерфейса настроек модуля…', + loadingModule: 'Загрузка интерфейса настроек модуля…', + failed: 'Не удалось загрузить интерфейс настроек этого модуля или интеграции.', + invalidSavePayload: 'Настройки должны передаваться как обычный объект.', + moduleBootTimedOut: 'Интерфейс настроек этого модуля или интеграции не завершил загрузку.', }, zh: { - loading: "正在加载模块设置界面…", - loadingModule: "正在加载模块设置界面…", - failed: "无法加载该模块或集成的设置界面。", - invalidSavePayload: "设置载荷必须是普通对象。", - moduleBootTimedOut: "该模块或集成的设置界面未能完成加载。", + loading: '正在加载模块设置界面…', + loadingModule: '正在加载模块设置界面…', + failed: '无法加载该模块或集成的设置界面。', + invalidSavePayload: '设置载荷必须是普通对象。', + moduleBootTimedOut: '该模块或集成的设置界面未能完成加载。', }, }; const params = new URLSearchParams(globalThis.location.search); -const language = normalizeLanguage(params.get("language")); +const language = normalizeLanguage(params.get('language')); const strings = STRINGS[language] ?? STRINGS.en; const sessionPrefix = resolveSessionPrefix(globalThis.location.pathname); +const hostOrigin = globalThis.location.origin; const elements = { - frame: document.getElementById("module-frame"), - overlay: document.getElementById("overlay"), - overlayMessage: document.getElementById("overlay-message"), + frame: document.getElementById('module-frame'), + overlay: document.getElementById('overlay'), + overlayMessage: document.getElementById('overlay-message'), }; const state = { @@ -52,7 +51,7 @@ const state = { }; document.documentElement.dataset.theme = state.context.launcher.theme; -setOverlay("loading", strings.loadingModule); +setOverlay('loading', strings.loadingModule); void bootstrap().catch((error) => { showFatalError(error); @@ -60,8 +59,8 @@ void bootstrap().catch((error) => { async function bootstrap() { ensureElements(); - postHostStatus("host-ready"); - globalThis.addEventListener("message", handleModuleMessage); + postHostStatus('host-ready'); + globalThis.addEventListener('message', handleModuleMessage); const settingsLoad = loadSettingsSafe(); mountModuleFrame(); await settingsLoad; @@ -73,7 +72,7 @@ function ensureElements() { !(elements.overlay instanceof HTMLElement) || !(elements.overlayMessage instanceof HTMLElement) ) { - throw new Error("Host UI elements are missing"); + throw new Error('Host UI elements are missing'); } } @@ -81,52 +80,51 @@ function buildContext(searchParams) { return { bridgeVersion: 1, module: { - id: searchParams.get("moduleId") ?? "", - name: - searchParams.get("name") ?? searchParams.get("moduleId") ?? "", - category: searchParams.get("category") ?? "", - type: searchParams.get("type") ?? "", - settingsUi: searchParams.get("settingsUi"), + id: searchParams.get('moduleId') ?? '', + name: searchParams.get('name') ?? searchParams.get('moduleId') ?? '', + category: searchParams.get('category') ?? '', + type: searchParams.get('type') ?? '', + settingsUi: searchParams.get('settingsUi'), }, launcher: { language, - theme: normalizeTheme(searchParams.get("theme")), + theme: normalizeTheme(searchParams.get('theme')), }, }; } function normalizeLanguage(rawLanguage) { - const normalized = String(rawLanguage ?? "en") + const normalized = String(rawLanguage ?? 'en') .trim() .toLowerCase(); - if (normalized.startsWith("ru")) { - return "ru"; + if (normalized.startsWith('ru')) { + return 'ru'; } - if (normalized.startsWith("zh")) { - return "zh"; + if (normalized.startsWith('zh')) { + return 'zh'; } - return "en"; + return 'en'; } function normalizeTheme(rawTheme) { - return String(rawTheme ?? "dark") + return String(rawTheme ?? 'dark') .trim() - .toLowerCase() === "light" - ? "light" - : "dark"; + .toLowerCase() === 'light' + ? 'light' + : 'dark'; } function resolveSessionPrefix(pathname) { const match = pathname.match(/^\/session\/([^/]+)/); - return match === null ? "" : `/session/${match[1]}`; + return match === null ? '' : `/session/${match[1]}`; } async function loadSettingsSafe() { try { state.settings = await withTimeout( - requestJson("/api/settings"), + requestJson('/api/settings'), SETTINGS_LOAD_TIMEOUT_MS, - "Settings load timed out.", + 'Settings load timed out.', ); } catch { state.settings = {}; @@ -142,16 +140,16 @@ function mountModuleFrame() { return; } - elements.frame.addEventListener("load", () => { + elements.frame.addEventListener('load', () => { state.frameLoaded = true; applyEmbeddedModuleChrome(); revealModuleWhenReady(); }); - elements.frame.addEventListener("error", () => { + elements.frame.addEventListener('error', () => { showFatalError(new Error(strings.failed)); }); armModuleBootTimeout(); - elements.frame.src = buildUrl("/module/"); + elements.frame.src = buildUrl('/module/'); } function handleModuleMessage(event) { @@ -159,19 +157,23 @@ function handleModuleMessage(event) { return; } + if (event.origin !== hostOrigin || event.source !== elements.frame.contentWindow) { + return; + } + const payload = event.data; if (!isBridgePayload(payload)) { return; } - if (payload.type === "module-ready") { + if (payload.type === 'module-ready') { state.moduleReady = true; postHostReadyWhenPossible(); revealModuleWhenReady(); return; } - if (payload.type === "module-rendered") { + if (payload.type === 'module-rendered') { state.moduleReady = true; state.moduleRendered = true; postHostReadyWhenPossible(); @@ -179,10 +181,7 @@ function handleModuleMessage(event) { return; } - if ( - typeof payload.requestId !== "string" || - typeof payload.method !== "string" - ) { + if (typeof payload.requestId !== 'string' || typeof payload.method !== 'string') { return; } @@ -190,29 +189,22 @@ function handleModuleMessage(event) { } function isBridgePayload(payload) { - return ( - typeof payload === "object" && - payload !== null && - payload.channel === BRIDGE_CHANNEL - ); + return typeof payload === 'object' && payload !== null && payload.channel === BRIDGE_CHANNEL; } function postHostReady() { - if ( - !(elements.frame instanceof HTMLIFrameElement) || - elements.frame.contentWindow === null - ) { + if (!(elements.frame instanceof HTMLIFrameElement) || elements.frame.contentWindow === null) { return; } elements.frame.contentWindow.postMessage( { channel: BRIDGE_CHANNEL, - type: "host-ready", + type: 'host-ready', context: state.context, settings: state.settings, }, - "*", + hostOrigin, ); } @@ -275,30 +267,28 @@ async function processModuleRequest(request) { async function resolveRequest(request) { switch (request.method) { - case "getContext": + case 'getContext': return state.context; - case "getSettings": + case 'getSettings': return state.settings; - case "saveSettings": + case 'saveSettings': return await saveSettings(request.payload); // Keep optional bridge hooks compatible without letting the host own module UX. - case "markDirty": + case 'markDirty': return { dirty: true }; - case "notify": + case 'notify': return { shown: true }; default: - throw new Error( - `Unsupported custom settings method: ${request.method}`, - ); + throw new Error(`Unsupported custom settings method: ${request.method}`); } } async function saveSettings(payload) { const normalizedSettings = normalizeSettingsPayload(payload); - const savedSettings = await requestJson("/api/settings", { - method: "POST", + const savedSettings = await requestJson('/api/settings', { + method: 'POST', headers: { - "content-type": "application/json", + 'content-type': 'application/json', }, body: JSON.stringify(normalizedSettings), }); @@ -316,18 +306,15 @@ function normalizeSettingsPayload(payload) { } function isPlainObject(value) { - return typeof value === "object" && value !== null && !Array.isArray(value); + return typeof value === 'object' && value !== null && !Array.isArray(value); } function postBridgeResponse(message) { - if ( - !(elements.frame instanceof HTMLIFrameElement) || - elements.frame.contentWindow === null - ) { + if (!(elements.frame instanceof HTMLIFrameElement) || elements.frame.contentWindow === null) { return; } - elements.frame.contentWindow.postMessage(message, "*"); + elements.frame.contentWindow.postMessage(message, hostOrigin); } function setOverlay(stateName, message) { @@ -349,7 +336,7 @@ function hideOverlay() { } elements.overlay.hidden = true; - postHostStatus("module-rendered"); + postHostStatus('module-rendered'); } function applyEmbeddedModuleChrome() { @@ -368,12 +355,12 @@ function applyEmbeddedModuleChrome() { return; } - const styleId = "axelate-embedded-module-settings-style"; + const styleId = 'axelate-embedded-module-settings-style'; if (frameDocument.getElementById(styleId) !== null) { return; } - const style = frameDocument.createElement("style"); + const style = frameDocument.createElement('style'); style.id = styleId; style.textContent = ` :root { @@ -434,10 +421,10 @@ function applyEmbeddedModuleChrome() { styleHost.appendChild(style); frameDocument.documentElement?.dataset && - (frameDocument.documentElement.dataset.axelateEmbedded = "true"); + (frameDocument.documentElement.dataset.axelateEmbedded = 'true'); } -function postHostStatus(type, message = "") { +function postHostStatus(type, message = '') { if (globalThis.parent === globalThis) { return; } @@ -448,22 +435,22 @@ function postHostStatus(type, message = "") { type, message, }, - "*", + hostOrigin, ); } async function requestJson(path, init = {}) { const response = await fetch(buildUrl(path), { - cache: "no-store", + cache: 'no-store', ...init, }); const bodyText = await response.text(); - const parsedBody = bodyText === "" ? {} : safeParseJson(bodyText); + const parsedBody = bodyText === '' ? {} : safeParseJson(bodyText); if (!response.ok) { const errorMessage = - isPlainObject(parsedBody) && typeof parsedBody.message === "string" + isPlainObject(parsedBody) && typeof parsedBody.message === 'string' ? parsedBody.message : strings.failed; throw new Error(errorMessage); @@ -500,19 +487,13 @@ function safeParseJson(input) { } function buildUrl(path) { - const normalizedPath = path.startsWith("/") ? path : `/${path}`; - const scopedPath = - sessionPrefix === "" - ? normalizedPath - : `${sessionPrefix}${normalizedPath}`; - - const url = new URL( - scopedPath, - `${globalThis.location.protocol}//${globalThis.location.host}`, - ); - if (normalizedPath === "/module/") { - url.searchParams.set("embedded", "1"); - url.searchParams.set("host", "axelate"); + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + const scopedPath = sessionPrefix === '' ? normalizedPath : `${sessionPrefix}${normalizedPath}`; + + const url = new URL(scopedPath, `${globalThis.location.protocol}//${globalThis.location.host}`); + if (normalizedPath === '/module/') { + url.searchParams.set('embedded', '1'); + url.searchParams.set('host', 'axelate'); } return url.toString(); @@ -521,9 +502,7 @@ function buildUrl(path) { function showFatalError(error) { clearModuleBootTimeout(); const message = - error instanceof Error && error.message.trim() !== "" - ? error.message - : strings.failed; - setOverlay("error", message); - postHostStatus("host-error", message); + error instanceof Error && error.message.trim() !== '' ? error.message : strings.failed; + setOverlay('error', message); + postHostStatus('host-error', message); } diff --git a/src-tauri/src/api/ai/mod.rs b/src-tauri/src/api/ai/mod.rs index df81da11..85476996 100644 --- a/src-tauri/src/api/ai/mod.rs +++ b/src-tauri/src/api/ai/mod.rs @@ -1,22 +1,19 @@ -use crate::app::window::{create_main_window, show_and_focus_window}; use crate::domain::ai::{ self, ChatSessionManager, ai_service, ai_service::{ChatRequest, ChatResponse}, }; use crate::domain::ai::{StreamEvent, StreamSink}; use crate::domain::engine::manager::EngineManager; -use crate::domain::engine::types::Capability; use crate::domain::system::config_service::ConfigService; use crate::errors::AppError; -use crate::infrastructure::config::ui_state::UiStateService; use crate::infrastructure::crypto::secure_storage::SecureStorage; use base64::{Engine as _, engine::general_purpose::STANDARD}; use dashmap::DashMap; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Arc; +use tauri::State; use tauri::ipc::Channel; -use tauri::{Manager, State, Window}; use tokio::sync::oneshot; #[cfg(target_os = "windows")] @@ -197,16 +194,6 @@ impl StreamSink for TauriStreamSink { } } -#[derive(Clone)] -struct BackgroundImageGenerationContext { - sessions: Arc, - config_service: Arc, - engine_manager: Arc, - image_generation_state: Arc, - settings_service: crate::infrastructure::config::settings::SettingsService, - ui_state_service: UiStateService, -} - fn ensure_request_id(request: &mut ChatRequest) -> String { let request_id = request .request_id @@ -244,15 +231,7 @@ fn configured_provider_secret_service( )); } - default_secret_service_for_provider(provider) -} - -fn default_secret_service_for_provider(provider: &str) -> Option { - if is_local_provider(provider) { - return None; - } - - Some("openrouter_api_key".to_string()) + None } async fn load_stored_provider_api_key( @@ -282,122 +261,6 @@ pub(crate) async fn fill_chat_request_api_key( Ok(()) } -fn build_background_image_generation_context( - sessions: &State<'_, Arc>, - config_service: &State<'_, Arc>, - engine_manager: &State<'_, Arc>, - image_generation_state: &State<'_, Arc>, - settings_service: &State<'_, crate::infrastructure::config::settings::SettingsService>, - ui_state_service: &State<'_, UiStateService>, -) -> BackgroundImageGenerationContext { - BackgroundImageGenerationContext { - sessions: Arc::clone(sessions.inner()), - config_service: Arc::clone(config_service.inner()), - engine_manager: Arc::clone(engine_manager.inner()), - image_generation_state: Arc::clone(image_generation_state.inner()), - settings_service: settings_service.inner().clone(), - ui_state_service: ui_state_service.inner().clone(), - } -} - -fn spawn_background_image_generation( - app_handle: tauri::AppHandle, - request: ai::ImageGenerationRequest, - context: BackgroundImageGenerationContext, -) { - tauri::async_runtime::spawn(async move { - crate::app::tray::set_background_generation_active(&app_handle, "Generating image..."); - let result = ai_service::process_image_request( - request, - &context.sessions, - &context.config_service, - &context.engine_manager, - &context.image_generation_state, - &context.settings_service, - ) - .await; - - if let Err(error) = &result { - tracing::error!("Background image generation failed: {error}"); - } - - reveal_chat_window_after_background_generation(&context.ui_state_service).await; - crate::app::tray::clear_background_generation(&app_handle); - restore_or_create_main_window(&app_handle); - }); -} - -async fn reveal_chat_window_after_background_generation(ui_state_service: &UiStateService) { - let mut ui_state = ui_state_service.get_ui_state().await.unwrap_or_default(); - ui_state.last_page = Some("chat".to_string()); - ui_state.pending_chat_reveal = true; - let _ = ui_state_service.save_ui_state(&ui_state).await; -} - -fn restore_or_create_main_window(app_handle: &tauri::AppHandle) { - if let Some(window) = app_handle.get_webview_window("main") { - show_and_focus_window(&window); - } else if let Some(window) = create_main_window(app_handle) { - show_and_focus_window(&window); - } -} - -async fn cancel_comfyui_job( - provider: &str, - image_generation_state: &crate::domain::ai::ImageGenerationState, -) -> Result<(), AppError> { - if let Some(job) = image_generation_state.cancel(provider).await { - let client = reqwest::Client::new(); - let response = client - .post(format!("{}/interrupt", job.base_url.trim_end_matches('/'))) - .send() - .await?; - - if !response.status().is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(AppError::External { - request_id: None, - message: format!("Failed to interrupt ComfyUI job: {body}"), - }); - } - } - - Ok(()) -} - -async fn cancel_sdcpp_job( - provider: &str, - engine_manager: &EngineManager, - image_generation_state: &crate::domain::ai::ImageGenerationState, -) -> Result<(), AppError> { - let mut should_stop_engine = true; - - if let Some(job) = image_generation_state.cancel(provider).await - && let Some(job_id) = job.prompt_id - { - let client = reqwest::Client::new(); - let response = client - .post(format!( - "{}/sdcpp/v1/jobs/{job_id}/cancel", - job.base_url.trim_end_matches('/') - )) - .send() - .await?; - - should_stop_engine = !response.status().is_success(); - if should_stop_engine && response.status().as_u16() != 409 { - let body = response.text().await.unwrap_or_default(); - tracing::warn!("Failed to cancel stable-diffusion.cpp job via native API: {body}"); - } - } - - if should_stop_engine { - engine_manager.stop_slot(Capability::Image).await?; - } - - Ok(()) -} - fn read_image_generation_preview_file(path: &Path) -> Option { let metadata = match std::fs::metadata(path) { Ok(metadata) => metadata, @@ -438,22 +301,57 @@ fn read_image_generation_preview_file(path: &Path) -> Option &'static str { - match mime_type.to_ascii_lowercase().as_str() { - mime if mime.contains("jpeg") || mime.contains("jpg") => "jpg", - mime if mime.contains("webp") => "webp", - mime if mime.contains("gif") => "gif", - _ => "png", +fn image_extension_for_mime_type(mime_type: &str) -> Result<&'static str, AppError> { + match mime_type.trim().to_ascii_lowercase().as_str() { + "image/png" => Ok("png"), + "image/jpeg" | "image/jpg" => Ok("jpg"), + "image/webp" => Ok("webp"), + "image/gif" => Ok("gif"), + "image/bmp" => Ok("bmp"), + "image/avif" => Ok("avif"), + _ => Err(AppError::Validation(format!( + "Unsupported image type: {mime_type}" + ))), } } -fn decode_chat_image_payload(base64_data: &str) -> Result, AppError> { +fn decode_chat_image_payload(base64_data: &str, mime_type: &str) -> Result, AppError> { let payload = base64_data .split_once(',') .map_or(base64_data, |(_, data)| data); - STANDARD + let bytes = STANDARD .decode(payload) - .map_err(|error| AppError::Validation(format!("Invalid image data: {error}"))) + .map_err(|error| AppError::Validation(format!("Invalid image data: {error}")))?; + + validate_chat_image_signature(&bytes, mime_type)?; + Ok(bytes) +} + +fn validate_chat_image_signature(bytes: &[u8], mime_type: &str) -> Result<(), AppError> { + let mime = mime_type.trim().to_ascii_lowercase(); + let valid = match mime.as_str() { + "image/png" => bytes.starts_with(b"\x89PNG\r\n\x1a\n"), + "image/jpeg" | "image/jpg" => bytes.starts_with(&[0xFF, 0xD8, 0xFF]), + "image/webp" => { + bytes.len() >= 12 && bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") + } + "image/gif" => bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a"), + "image/bmp" => bytes.starts_with(b"BM"), + "image/avif" => { + bytes.len() >= 12 + && bytes.get(4..8) == Some(b"ftyp") + && (bytes.get(8..12) == Some(b"avif") || bytes.get(8..12) == Some(b"avis")) + } + _ => false, + }; + + if valid { + Ok(()) + } else { + Err(AppError::Validation( + "Image data does not match the declared image type".to_string(), + )) + } } fn build_chat_image_file_path(target_dir: &Path, ext: &str) -> PathBuf { @@ -608,7 +506,7 @@ pub async fn clear_chat_history( sessions: State<'_, Arc>, ) -> Result<(), AppError> { sessions.clear_chat_history(session_id); - let _ = sessions.force_save().await; + sessions.force_save().await?; Ok(()) } @@ -632,7 +530,7 @@ pub async fn rewind_last_turn( sessions: State<'_, Arc>, ) -> Result, AppError> { let removed = sessions.rewind_last_turn(session_id); - let _ = sessions.force_save().await; + sessions.force_save().await?; Ok(removed) } @@ -671,34 +569,6 @@ pub async fn generate_image( .await } -#[tauri::command] -#[specta::specta] -#[allow(clippy::too_many_arguments)] -/// Starts image generation as a detached backend task and restores the window on completion. -pub async fn generate_image_background( - app: tauri::AppHandle, - _window: Window, - request: ai::ImageGenerationRequest, - sessions: State<'_, Arc>, - config_service: State<'_, Arc>, - engine_manager: State<'_, Arc>, - image_generation_state: State<'_, Arc>, - settings_service: State<'_, crate::infrastructure::config::settings::SettingsService>, - ui_state_service: State<'_, UiStateService>, -) -> Result<(), AppError> { - let context = build_background_image_generation_context( - &sessions, - &config_service, - &engine_manager, - &image_generation_state, - &settings_service, - &ui_state_service, - ); - spawn_background_image_generation(app, request, context); - - Ok(()) -} - #[tauri::command] #[specta::specta] /// Cancels the current image generation request for the selected provider. @@ -707,14 +577,7 @@ pub async fn cancel_image_generation( engine_manager: State<'_, Arc>, image_generation_state: State<'_, Arc>, ) -> Result<(), AppError> { - if provider == "comfyui" { - return cancel_comfyui_job(&provider, &image_generation_state).await; - } - if matches!(provider.as_str(), "sdcpp" | "stable-diffusion") { - return cancel_sdcpp_job(&provider, &engine_manager, &image_generation_state).await; - } - - engine_manager.stop_slot(Capability::Image).await + ai::cancel_image_provider_generation(&provider, &engine_manager, &image_generation_state).await } #[tauri::command] @@ -724,7 +587,8 @@ pub async fn get_image_generation_preview( engine_manager: State<'_, Arc>, image_generation_state: State<'_, Arc>, ) -> Result, AppError> { - let log_progress = image_generation_state.latest_progress("sdcpp").await; + let active_job = image_generation_state.active_job().await; + let log_progress = active_job.as_ref().and_then(|job| job.progress.clone()); let merged_progress = log_progress.as_ref().and_then(|snapshot| snapshot.progress); let step = log_progress.as_ref().and_then(|snapshot| snapshot.step); let total = log_progress.as_ref().and_then(|snapshot| snapshot.total); @@ -733,9 +597,10 @@ pub async fn get_image_generation_preview( .and_then(|snapshot| snapshot.speed.clone()); let has_status = merged_progress.is_some() || step.is_some() || total.is_some() || speed.is_some(); + let has_active_job = active_job.as_ref().is_some_and(|job| !job.cancelled); let Some(path) = engine_manager.active_image_preview_path().await else { - return Ok(if has_status { + return Ok(if has_status || has_active_job { Some(ImageGenerationPreview { data_url: String::new(), updated_at_ms: log_progress @@ -766,7 +631,7 @@ pub async fn get_image_generation_preview( preview.total = total; preview.speed.clone_from(&speed); preview.eta_relative = None; - } else if has_status { + } else if has_status || has_active_job { preview = Some(ImageGenerationPreview { data_url: String::new(), updated_at_ms: log_progress @@ -799,9 +664,9 @@ pub fn save_chat_image_default( ) -> Result { let target_dir = chat_image_root_dir()?; std::fs::create_dir_all(&target_dir)?; - let bytes = decode_chat_image_payload(&base64_data)?; - let file_path = - build_chat_image_file_path(&target_dir, image_extension_for_mime_type(&mime_type)); + let extension = image_extension_for_mime_type(&mime_type)?; + let bytes = decode_chat_image_payload(&base64_data, &mime_type)?; + let file_path = build_chat_image_file_path(&target_dir, extension); std::fs::write(&file_path, bytes)?; Ok(SavedChatImage { @@ -1010,28 +875,14 @@ fn create_stream_sink( }) } -fn is_local_provider(provider: &str) -> bool { - !matches!( - provider, - "gpt" - | "gemini" - | "gemini-image" - | "gpt-image" - | "seedream-image" - | "openai" - | "openrouter" - | "anthropic" - | "mistral" - | "claude" - | "deepseek" - ) -} - #[cfg(test)] #[allow(clippy::expect_used)] mod tests { - use super::resolve_existing_path_within_root; + use super::{ + decode_chat_image_payload, image_extension_for_mime_type, resolve_existing_path_within_root, + }; use crate::errors::AppError; + use base64::{Engine as _, engine::general_purpose::STANDARD}; #[test] fn resolve_existing_path_within_root_allows_file_inside_root() { @@ -1065,4 +916,33 @@ mod tests { matches!(error, AppError::Validation(message) if message.contains("outside chat image directory")) ); } + + #[test] + fn image_extension_for_mime_type_rejects_unsupported_types() { + let error = image_extension_for_mime_type("image/svg+xml") + .expect_err("svg should not be saved from chat"); + + assert!( + matches!(error, AppError::Validation(message) if message.contains("Unsupported image type")) + ); + } + + #[test] + fn decode_chat_image_payload_accepts_matching_png_signature() { + let png = STANDARD.encode(b"\x89PNG\r\n\x1a\npayload"); + let decoded = decode_chat_image_payload(&png, "image/png").expect("png should decode"); + + assert!(decoded.starts_with(b"\x89PNG\r\n\x1a\n")); + } + + #[test] + fn decode_chat_image_payload_rejects_mismatched_signature() { + let fake_png = STANDARD.encode(b"not a png"); + let error = decode_chat_image_payload(&fake_png, "image/png") + .expect_err("invalid image signature must be rejected"); + + assert!( + matches!(error, AppError::Validation(message) if message.contains("does not match")) + ); + } } diff --git a/src-tauri/src/api/engine/mod.rs b/src-tauri/src/api/engine/mod.rs index ba6de1a7..a4aaa640 100644 --- a/src-tauri/src/api/engine/mod.rs +++ b/src-tauri/src/api/engine/mod.rs @@ -8,12 +8,13 @@ use crate::domain::engine::config::{ build_default_engine_config, merge_user_engine_config, normalize_engine_config, }; use crate::domain::engine::manager::EngineManager; +use crate::domain::engine::manager::canonical_engine_id; use crate::domain::engine::types::{ - Capability, EngineConfig, EngineDefinition, EngineState, EngineStatus, + Capability, EngineComputeMode, EngineConfig, EngineDefinition, EngineState, EngineStatus, }; use crate::errors::AppError; use crate::infrastructure::config::engine_settings::{ - load_engine_config_map, save_engine_config_map, + EngineConfigMap, load_engine_config_map, save_engine_config_map, }; use tauri::State; @@ -24,6 +25,49 @@ pub struct EngineSettingsPayload { pub config: EngineConfig, } +fn engine_config_for_definition(def: &EngineDefinition, saved: &EngineConfigMap) -> EngineConfig { + let mut config = saved.get(&def.id).map_or_else( + || build_default_engine_config(def), + |config| merge_user_engine_config(def, config), + ); + let installed_modes = crate::domain::engine::detector::installed_compute_modes(&def.id); + if installed_modes.len() == 1 + && let Some(mode) = installed_modes.first().copied() + { + config.compute_mode = mode; + } + config +} + +fn engine_settings_payload_for_definition( + def: &EngineDefinition, + saved: &EngineConfigMap, +) -> EngineSettingsPayload { + EngineSettingsPayload { + config: engine_config_for_definition(def, saved), + } +} + +fn normalize_config_for_save(def: &EngineDefinition, mut config: EngineConfig) -> EngineConfig { + config.engine_id = canonical_engine_id(&config.engine_id); + merge_user_engine_config(def, &normalize_engine_config(config)) +} + +fn mark_engine_definitions_installed( + defs: &mut [EngineDefinition], + mut is_installed: impl FnMut(&EngineDefinition) -> bool, + mut installed_compute_modes: impl FnMut(&EngineDefinition) -> Vec, +) { + for def in defs { + def.installed = def.managed_externally || is_installed(def); + def.installed_compute_modes = if def.installed && !def.managed_externally { + installed_compute_modes(def) + } else { + Vec::new() + }; + } +} + #[tauri::command] #[specta::specta] /// Starts a local engine. Hot-swaps if another engine is active. @@ -68,6 +112,24 @@ pub fn check_engine_installed(engine_id: String, binary_name: Option) -> crate::domain::engine::detector::is_engine_installed(&engine_id, binary_name.as_deref()) } +#[tauri::command] +#[specta::specta] +/// Deletes an Axelate-managed engine from local storage. +#[allow(clippy::needless_pass_by_value)] // Tauri commands require owned params +pub async fn delete_engine( + engine_id: String, + engine_manager: State<'_, Arc>, +) -> Result<(), AppError> { + let engine_id = canonical_engine_id(&engine_id); + if engine_manager.is_engine_running(&engine_id).await { + return Err(AppError::Validation(format!( + "Cannot delete engine '{engine_id}' while it is running" + ))); + } + + crate::domain::engine::detector::delete_installed_engine(&engine_id).await +} + #[tauri::command] #[specta::specta] /// Returns all registered engine definitions with real-time installation status. @@ -76,13 +138,11 @@ pub async fn get_engine_definitions( ) -> Result, AppError> { let mut defs = engine_manager.list_definitions().await; // Populate `installed` at request time — no extra round-trip needed from frontend - for def in &mut defs { - def.installed = if def.managed_externally { - true - } else { - crate::domain::engine::detector::is_engine_installed(&def.id, def.binary.as_deref()) - }; - } + mark_engine_definitions_installed( + &mut defs, + |def| crate::domain::engine::detector::is_engine_installed(&def.id, def.binary.as_deref()), + |def| crate::domain::engine::detector::installed_compute_modes(&def.id), + ); Ok(defs) } @@ -93,17 +153,14 @@ pub async fn get_engine_config( engine_id: String, engine_manager: State<'_, Arc>, ) -> Result { + let engine_id = canonical_engine_id(&engine_id); let def = engine_manager .get_definition(&engine_id) .await .ok_or_else(|| AppError::Config(format!("Unknown engine: {engine_id}")))?; let saved = load_engine_config_map().await?; - if let Some(config) = saved.get(&engine_id) { - return Ok(merge_user_engine_config(&def, config)); - } - - Ok(build_default_engine_config(&def)) + Ok(engine_config_for_definition(&def, &saved)) } #[tauri::command] @@ -113,19 +170,14 @@ pub async fn get_engine_settings_payload( engine_id: String, engine_manager: State<'_, Arc>, ) -> Result { + let engine_id = canonical_engine_id(&engine_id); let def = engine_manager .get_definition(&engine_id) .await .ok_or_else(|| AppError::Config(format!("Unknown engine: {engine_id}")))?; let saved = load_engine_config_map().await?; - let config = if let Some(config) = saved.get(&engine_id) { - merge_user_engine_config(&def, config) - } else { - build_default_engine_config(&def) - }; - - Ok(EngineSettingsPayload { config }) + Ok(engine_settings_payload_for_definition(&def, &saved)) } #[tauri::command] @@ -135,13 +187,144 @@ pub async fn set_engine_config( config: crate::domain::engine::types::EngineConfig, engine_manager: State<'_, Arc>, ) -> Result<(), AppError> { + let mut config = config; + config.engine_id = canonical_engine_id(&config.engine_id); let def = engine_manager .get_definition(&config.engine_id) .await .ok_or_else(|| AppError::Config(format!("Unknown engine: {}", config.engine_id)))?; - let mut map = load_engine_config_map().await.unwrap_or_default(); - let normalized = merge_user_engine_config(&def, &normalize_engine_config(config)); + let mut map = load_engine_config_map().await?; + let normalized = normalize_config_for_save(&def, config); map.insert(normalized.engine_id.clone(), normalized); save_engine_config_map(&map).await } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + use crate::domain::engine::types::EngineComputeMode; + + fn sample_definition(id: &str) -> EngineDefinition { + EngineDefinition { + id: id.to_string(), + name: format!("{id} engine"), + desc: String::new(), + icon: String::new(), + capabilities: vec![Capability::Text], + binary: Some(format!("{id}-server")), + repo_url: None, + version: "1.0.0".to_string(), + default_port: 8081, + default_context_size: 8192, + config_schema: None, + installed: false, + installed_compute_modes: Vec::new(), + managed_externally: false, + } + } + + fn saved_config(engine_id: &str) -> EngineConfig { + EngineConfig { + engine_id: engine_id.to_string(), + compute_mode: EngineComputeMode::Cpu, + context_size: 2048, + model_path: Some("C:/models/model.gguf".to_string()), + extra_args: vec!["--threads".to_string(), "8".to_string()], + } + } + + #[test] + fn engine_config_for_definition_uses_defaults_when_no_saved_config_exists() { + let def = sample_definition("sdcpp"); + let config = engine_config_for_definition(&def, &EngineConfigMap::default()); + + assert_eq!(config.engine_id, "sdcpp"); + assert_eq!(config.compute_mode, EngineComputeMode::Gpu); + assert_eq!(config.context_size, 8192); + assert_eq!(config.model_path, None); + assert!(config.extra_args.is_empty()); + } + + #[test] + fn engine_config_for_definition_merges_saved_config_and_normalizes_llamacpp() { + let def = sample_definition("llamacpp"); + let mut saved = EngineConfigMap::default(); + saved.insert(def.id.clone(), saved_config("llamacpp")); + + let config = engine_config_for_definition(&def, &saved); + + assert_eq!(config.compute_mode, EngineComputeMode::Cpu); + assert_eq!(config.context_size, 4096); + assert_eq!(config.model_path.as_deref(), Some("C:/models/model.gguf")); + assert_eq!(config.extra_args, vec!["--threads", "8"]); + } + + #[test] + fn engine_settings_payload_wraps_the_resolved_config() { + let def = sample_definition("sdcpp"); + let mut saved = EngineConfigMap::default(); + saved.insert(def.id.clone(), saved_config("sdcpp")); + + let payload = engine_settings_payload_for_definition(&def, &saved); + + assert_eq!(payload.config.engine_id, "sdcpp"); + assert_eq!(payload.config.compute_mode, EngineComputeMode::Cpu); + } + + #[test] + fn normalize_config_for_save_uses_definition_id_before_persisting() { + let def = sample_definition("sdcpp"); + let normalized = normalize_config_for_save(&def, saved_config("sdcpp")); + + assert_eq!(normalized.engine_id, "sdcpp"); + assert_eq!(normalized.compute_mode, EngineComputeMode::Cpu); + assert_eq!(normalized.context_size, 2048); + } + + #[test] + fn mark_engine_definitions_installed_keeps_external_engines_available() { + let external = EngineDefinition { + managed_externally: true, + binary: None, + ..sample_definition("external") + }; + let mut defs = vec![sample_definition("missing"), external]; + + mark_engine_definitions_installed(&mut defs, |def| def.id == "missing", |_| Vec::new()); + + assert!(defs.iter().all(|def| def.installed)); + } + + #[test] + fn mark_engine_definitions_installed_marks_missing_local_engines_uninstalled() { + let mut defs = vec![sample_definition("missing")]; + + mark_engine_definitions_installed(&mut defs, |_| false, |_| Vec::new()); + + assert!(defs.iter().all(|def| !def.installed)); + } + + #[test] + fn engine_settings_payload_serializes_as_expected() { + let payload = EngineSettingsPayload { + config: EngineConfig { + engine_id: "cloud".to_string(), + compute_mode: EngineComputeMode::Gpu, + context_size: 4096, + model_path: None, + extra_args: vec![], + }, + }; + let json = serde_json::to_value(&payload).unwrap(); + + assert_eq!( + json.get("config") + .and_then(|config| config.get("engine_id")) + .and_then(serde_json::Value::as_str), + Some("cloud") + ); + } +} diff --git a/src-tauri/src/api/license/mod.rs b/src-tauri/src/api/license/mod.rs deleted file mode 100644 index 49115be6..00000000 --- a/src-tauri/src/api/license/mod.rs +++ /dev/null @@ -1,42 +0,0 @@ -// use tauri::command; - -use crate::domain::license; -use crate::domain::license::types::LicenseStatus; -use crate::errors::AppError; -use crate::models::LicenseStatusResponse; - -#[tauri::command] -#[specta::specta] -/// Retrieves current license activation status -#[allow(clippy::missing_const_for_fn)] // Wrapper around const verify() function -pub fn get_license_status() -> Result { - let status = license::verify(); - Ok(LicenseStatusResponse { - status, - email: None, // In real app, load from storage - }) -} - -#[tauri::command] -#[specta::specta] -/// Activates a license key with optional email -pub async fn activate_license( - key: String, - email: Option, -) -> Result { - license::activate(&key, email) -} - -#[tauri::command] -#[specta::specta] -/// Deactivates the current license -pub async fn deactivate_license() -> Result<(), AppError> { - license::deactivate() -} - -#[tauri::command] -#[specta::specta] -/// Checks if a specific feature is enabled by the current license -pub fn check_feature(feature: &str) -> Result { - Ok(license::has_feature(feature)) -} diff --git a/src-tauri/src/api/mod.rs b/src-tauri/src/api/mod.rs index 72bf82c0..7b2bf8f0 100644 --- a/src-tauri/src/api/mod.rs +++ b/src-tauri/src/api/mod.rs @@ -2,8 +2,6 @@ pub mod ai; /// Engine lifecycle commands (start, stop, status) pub mod engine; -/// License management commands -pub mod license; /// Module management commands (download, control) pub mod modules; /// Secure storage commands diff --git a/src-tauri/src/api/modules/downloader.rs b/src-tauri/src/api/modules/downloader.rs index e302d04f..35a57be8 100644 --- a/src-tauri/src/api/modules/downloader.rs +++ b/src-tauri/src/api/modules/downloader.rs @@ -1,7 +1,37 @@ use crate::domain::modules::downloader; +use crate::domain::modules::downloader::DownloadRequest; use crate::errors::AppError; +use std::path::Path; use tauri::AppHandle; +fn resume_request_for_module( + module_id: &str, + request: Option, +) -> Result { + request + .ok_or_else(|| AppError::NotFound(format!("No paused download metadata for {module_id}"))) +} + +fn list_regular_file_names(path: &Path) -> Result, AppError> { + if !path.exists() { + return Err(AppError::NotFound( + "Module directory does not exist".to_string(), + )); + } + + let entries = std::fs::read_dir(path)?; + let mut files = Vec::new(); + for entry in entries { + let entry = entry?; + if entry.file_type()?.is_file() { + files.push(entry.file_name().to_string_lossy().to_string()); + } + } + files.sort(); + + Ok(files) +} + #[tauri::command] #[specta::specta] /// Downloads and verifies a module from a Git repository @@ -12,6 +42,7 @@ pub async fn download_module( repo_url: String, expected_hash: Option, dl_type: Option, + release_selection: Option, ) -> Result { downloader::download_module( app, @@ -20,10 +51,53 @@ pub async fn download_module( repo_url, expected_hash, dl_type, + release_selection, ) .await } +#[tauri::command] +#[specta::specta] +/// Lists compatible release versions and CPU/GPU package choices for a module. +pub async fn get_release_download_options( + module_id: String, + repo_url: String, +) -> Result { + downloader::get_release_download_options(&module_id, &repo_url).await +} + +#[tauri::command] +#[specta::specta] +/// Imports an integration from a local folder containing `axelate-module.toml`. +pub async fn import_integration_folder(path: String) -> Result { + downloader::import_integration_folder(&std::path::PathBuf::from(path)).await +} + +#[tauri::command] +#[specta::specta] +/// Imports an integration from a local `.zip`, `.tar.gz`, `.tgz`, or `.7z` archive. +pub async fn import_integration_archive(app: AppHandle, path: String) -> Result { + downloader::import_integration_archive(app, std::path::PathBuf::from(path)).await +} + +#[tauri::command] +#[specta::specta] +/// Imports an integration from a local folder or archive, auto-detected by path type. +pub async fn import_integration_path(app: AppHandle, path: String) -> Result { + downloader::import_integration_path(app, std::path::PathBuf::from(path)).await +} + +#[tauri::command] +#[specta::specta] +/// Downloads and imports an integration from a repository or archive URL. +pub async fn import_integration_url( + app: AppHandle, + downloader: tauri::State<'_, downloader::DownloaderService>, + source_url: String, +) -> Result { + downloader::import_integration_url(app, &downloader, source_url).await +} + #[tauri::command] #[specta::specta] /// Resumes a paused module download using backend-owned request metadata. @@ -32,9 +106,7 @@ pub async fn resume_download( downloader: tauri::State<'_, downloader::DownloaderService>, module_id: String, ) -> Result { - let request = downloader.get_request(&module_id).ok_or_else(|| { - AppError::NotFound(format!("No paused download metadata for {module_id}")) - })?; + let request = resume_request_for_module(&module_id, downloader.get_request(&module_id))?; downloader::download_module( app, @@ -43,6 +115,7 @@ pub async fn resume_download( request.repo_url, request.expected_hash, request.dl_type, + request.release_selection, ) .await } @@ -79,24 +152,7 @@ pub async fn list_module_files(module_id: &str) -> Result, AppError> downloader::validate_module_id(module_id)?; let path = downloader::get_module_path(module_id); - if !path.exists() { - return Err(AppError::NotFound( - "Module directory does not exist".to_string(), - )); - } - - let entries = std::fs::read_dir(path)?; - - let mut files = Vec::new(); - for entry in entries.flatten() { - if let Ok(file_type) = entry.file_type() - && file_type.is_file() - { - files.push(entry.file_name().to_string_lossy().to_string()); - } - } - - Ok(files) + list_regular_file_names(&path) } #[tauri::command] @@ -132,3 +188,67 @@ pub fn pause_download( ) -> bool { downloader.pause(&module_id) } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + use crate::domain::modules::github_releases::{ReleaseComputeTarget, ReleaseDownloadSelection}; + + fn request() -> DownloadRequest { + DownloadRequest { + repo_url: "https://github.com/example/module".to_string(), + expected_hash: Some("sha256".to_string()), + dl_type: Some("release".to_string()), + release_selection: Some(ReleaseDownloadSelection { + tag_name: Some("v1.0.0".to_string()), + compute_target: ReleaseComputeTarget::Cpu, + }), + } + } + + #[test] + fn resume_request_for_module_returns_stored_request() { + let request = request(); + let resolved = resume_request_for_module("llamacpp", Some(request.clone())).unwrap(); + + assert_eq!(resolved.repo_url, request.repo_url); + assert_eq!(resolved.expected_hash, request.expected_hash); + assert_eq!(resolved.dl_type, request.dl_type); + assert_eq!( + resolved + .release_selection + .map(|selection| selection.tag_name), + Some(Some("v1.0.0".to_string())) + ); + } + + #[test] + fn resume_request_for_module_reports_missing_metadata() { + let error = resume_request_for_module("llamacpp", None).unwrap_err(); + + assert!(matches!(error, AppError::NotFound(_))); + assert!(error.to_string().contains("llamacpp")); + } + + #[test] + fn list_regular_file_names_returns_sorted_files_only() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("zeta.toml"), "z").unwrap(); + std::fs::write(temp.path().join("alpha.toml"), "a").unwrap(); + std::fs::create_dir(temp.path().join("nested")).unwrap(); + + let files = list_regular_file_names(temp.path()).unwrap(); + + assert_eq!(files, vec!["alpha.toml", "zeta.toml"]); + } + + #[test] + fn list_regular_file_names_reports_missing_directory() { + let temp = tempfile::tempdir().unwrap(); + let error = list_regular_file_names(&temp.path().join("missing")).unwrap_err(); + + assert!(matches!(error, AppError::NotFound(_))); + } +} diff --git a/src-tauri/src/api/secure/mod.rs b/src-tauri/src/api/secure/mod.rs index 1d507f2b..d06633dd 100644 --- a/src-tauri/src/api/secure/mod.rs +++ b/src-tauri/src/api/secure/mod.rs @@ -28,7 +28,7 @@ fn is_frontend_readable_secret(service: &str) -> bool { fn ensure_frontend_managed_secret(service: &str) -> Result { let normalized = normalize_service_name(service); if !is_frontend_managed_secret(&normalized) { - return Err(AppError::Validation(format!( + return Err(AppError::FrontendSecretForbidden(format!( "Secret is not allowed through the frontend secure API: {normalized}" ))); } @@ -41,9 +41,21 @@ fn ensure_frontend_managed_secret(service: &str) -> Result { /// Saves anAPI key securely to system credential storage pub async fn save_secure_key(service: String, key: String) -> Result<(), AppError> { let service = ensure_frontend_managed_secret(&service)?; + if key.trim().is_empty() { + return SecureStorage::remove_key_async(service).await; + } + SecureStorage::save_key_async(service, key).await } +#[tauri::command] +#[specta::specta] +/// Removes a frontend-managed secret from system credential storage +pub async fn remove_secure_key(service: String) -> Result<(), AppError> { + let service = ensure_frontend_managed_secret(&service)?; + SecureStorage::remove_key_async(service).await +} + #[tauri::command] #[specta::specta] /// Retrieves a frontend-managed secret from system credential storage @@ -98,14 +110,14 @@ mod tests { fn frontend_secret_policy_allows_only_expected_service_names() { assert!(is_frontend_managed_secret("openrouter_api_key")); assert!(is_frontend_managed_secret("ai_session_id")); - assert!(!is_frontend_managed_secret("license_data")); + assert!(!is_frontend_managed_secret("internal_service_token")); assert!(is_frontend_readable_secret("ai_session_id")); assert!(is_frontend_readable_secret("openrouter_api_key")); } #[tokio::test] async fn get_secure_key_rejects_non_frontend_secret_reads() { - let err = get_secure_key("license_data".to_string()) + let err = get_secure_key("internal_service_token".to_string()) .await .unwrap_err(); @@ -117,13 +129,25 @@ mod tests { #[tokio::test] async fn has_secure_key_rejects_non_frontend_secret_names() { - let err = has_secure_key("license_data".to_string()) + let err = has_secure_key("internal_service_token".to_string()) + .await + .unwrap_err(); + + assert!(matches!( + err, + AppError::FrontendSecretForbidden(message) if message.contains("frontend secure API") + )); + } + + #[tokio::test] + async fn remove_secure_key_rejects_non_frontend_secret_names() { + let err = remove_secure_key("internal_service_token".to_string()) .await .unwrap_err(); assert!(matches!( err, - AppError::Validation(message) if message.contains("frontend secure API") + AppError::FrontendSecretForbidden(message) if message.contains("frontend secure API") )); } } diff --git a/src-tauri/src/api/settings/mod.rs b/src-tauri/src/api/settings/mod.rs index b7de88c5..77f902aa 100644 --- a/src-tauri/src/api/settings/mod.rs +++ b/src-tauri/src/api/settings/mod.rs @@ -51,6 +51,7 @@ pub async fn get_module_settings( settings_service: tauri::State<'_, settings::SettingsService>, module_id: String, ) -> Result, AppError> { + crate::domain::modules::downloader::validate_module_id(&module_id)?; settings_service.get_module_settings(&module_id).await } @@ -63,6 +64,7 @@ pub async fn save_module_settings( module_id: String, settings: HashMap, ) -> Result<(), AppError> { + crate::domain::modules::downloader::validate_module_id(&module_id)?; settings_service .save_module_settings(&module_id, &settings) .await @@ -74,3 +76,17 @@ pub async fn save_module_settings( pub fn get_system_language() -> Result { Ok(settings::get_language_sync()) } + +#[cfg(test)] +mod tests { + use super::get_system_language; + + #[test] + fn get_system_language_returns_supported_code() { + let result = get_system_language(); + assert!(result.is_ok()); + let lang = result.ok().unwrap_or_default(); + + assert!(matches!(lang.as_str(), "en" | "ru" | "zh")); + } +} diff --git a/src-tauri/src/api/settings/window_settings.rs b/src-tauri/src/api/settings/window_settings.rs index de9181a7..cb9d77bb 100644 --- a/src-tauri/src/api/settings/window_settings.rs +++ b/src-tauri/src/api/settings/window_settings.rs @@ -85,7 +85,7 @@ pub async fn save_zoom_level( ui_service: tauri::State<'_, ui_state::UiStateService>, zoom: f64, ) -> Result<(), AppError> { - let mut state = ui_service.get_ui_state().await.unwrap_or_default(); + let mut state = ui_service.get_ui_state().await?; if (state.zoom_level - zoom).abs() < f64::EPSILON { return Ok(()); } @@ -102,7 +102,7 @@ async fn persist_zoom_for_window( window_settings::SCALING_MIN_ZOOM, window_settings::SCALING_MAX_ZOOM, ); - let mut state = ui_service.get_ui_state().await.unwrap_or_default(); + let mut state = ui_service.get_ui_state().await?; let previous_zoom = state.zoom_level; state.zoom_level = zoom; @@ -161,7 +161,7 @@ pub async fn get_resolution_zoom( window: tauri::WebviewWindow, ui_service: tauri::State<'_, ui_state::UiStateService>, ) -> Result { - let state = ui_service.get_ui_state().await.unwrap_or_default(); + let state = ui_service.get_ui_state().await?; let res_key = res_key_from_window(&window).unwrap_or_else(|| "unknown".to_string()); Ok(resolve_zoom(&state, &res_key)) } @@ -210,15 +210,14 @@ pub async fn get_window_policy( } // Get current zoom from state to calculate effective dimensions - let zoom = ui_service - .get_ui_state() - .await - .map(|s| s.zoom_level) - .unwrap_or(1.0); + let zoom = ui_service.get_ui_state().await?.zoom_level; let win_size = window .inner_size() - .unwrap_or_default() + .map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to read window inner size: {error}"), + })? .to_logical::(scale_factor); let effective_w = u32::try_from((win_size.width / zoom).round() as i64).unwrap_or(1920); @@ -231,3 +230,82 @@ pub async fn get_window_policy( effective_h, )) } + +#[cfg(test)] +mod tests { + use super::get_window_config; + use super::resolve_zoom; + use crate::infrastructure::config::window_settings::{ + BP_COMPACT, BP_LARGE, BP_MEDIUM, SCALING_MAX_ZOOM, SCALING_MIN_ZOOM, + THRESHOLD_SMALL_SCREEN_HEIGHT, THRESHOLD_SMALL_SCREEN_WIDTH, THRESHOLD_WARNING_HEIGHT, + THRESHOLD_WARNING_WIDTH, + }; + use crate::models::UIState; + + fn assert_zoom_eq(actual: f64, expected: f64) { + assert!( + (actual - expected).abs() < f64::EPSILON, + "expected zoom {expected}, got {actual}" + ); + } + + #[test] + fn resolve_zoom_prefers_saved_resolution_zoom() { + let mut state = UIState { + zoom_level: 1.15, + ..UIState::default() + }; + state.resolution_zoom.insert("1920x1080".to_string(), 1.35); + + assert_zoom_eq(resolve_zoom(&state, "1920x1080"), 1.35); + } + + #[test] + fn resolve_zoom_falls_back_to_global_zoom_then_default() { + let global = UIState { + zoom_level: 1.2, + ..UIState::default() + }; + let invalid_global = UIState { + zoom_level: 0.0, + ..UIState::default() + }; + + assert_zoom_eq(resolve_zoom(&global, "missing"), 1.2); + assert_zoom_eq(resolve_zoom(&invalid_global, "missing"), 1.0); + } + + #[test] + fn resolve_zoom_ignores_non_positive_resolution_zoom_and_clamps_bounds() { + let mut state = UIState { + zoom_level: 1.1, + ..UIState::default() + }; + state.resolution_zoom.insert("invalid".to_string(), -2.0); + state.resolution_zoom.insert("too-low".to_string(), 0.01); + state.resolution_zoom.insert("too-high".to_string(), 99.0); + + assert_zoom_eq(resolve_zoom(&state, "invalid"), 1.1); + assert_zoom_eq(resolve_zoom(&state, "too-low"), SCALING_MIN_ZOOM); + assert_zoom_eq(resolve_zoom(&state, "too-high"), SCALING_MAX_ZOOM); + } + + #[test] + fn get_window_config_exposes_scaling_bounds() { + let config = get_window_config(); + + assert_eq!(config.breakpoints.compact, BP_COMPACT); + assert_eq!(config.breakpoints.medium, BP_MEDIUM); + assert_eq!(config.breakpoints.large, BP_LARGE); + assert_eq!(config.thresholds.warning_width, THRESHOLD_WARNING_WIDTH); + assert_eq!(config.thresholds.warning_height, THRESHOLD_WARNING_HEIGHT); + assert_eq!( + config.thresholds.small_screen_width, + THRESHOLD_SMALL_SCREEN_WIDTH + ); + assert_eq!( + config.thresholds.small_screen_height, + THRESHOLD_SMALL_SCREEN_HEIGHT + ); + } +} diff --git a/src-tauri/src/api/system/bootstrap.rs b/src-tauri/src/api/system/bootstrap.rs index 461fdf53..a903060b 100644 --- a/src-tauri/src/api/system/bootstrap.rs +++ b/src-tauri/src/api/system/bootstrap.rs @@ -30,7 +30,10 @@ pub async fn get_app_bootstrap_data( ) -> Result { tracing::debug!("[Bootstrap] Collecting application data..."); - let ui_state = ui_service.get_ui_state().await.unwrap_or_default(); + let ui_state = ui_service.get_ui_state().await.unwrap_or_else(|error| { + tracing::warn!("Failed to load UI state during bootstrap, using defaults: {error}"); + UIState::default() + }); let window_config = window_settings::get_window_config(); let system_language = settings::get_language_sync(); diff --git a/src-tauri/src/api/system/health.rs b/src-tauri/src/api/system/health.rs index cd5fddcd..de8f5a03 100644 --- a/src-tauri/src/api/system/health.rs +++ b/src-tauri/src/api/system/health.rs @@ -7,3 +7,17 @@ use crate::errors::AppError; pub fn get_health() -> Result { Ok(services::health::check()) } + +#[cfg(test)] +mod tests { + use super::get_health; + + #[test] + fn get_health_returns_ok_status() { + let result = get_health(); + assert!(result.is_ok()); + let status = result.ok().unwrap_or_default(); + + assert_eq!(status, "ok"); + } +} diff --git a/src-tauri/src/api/system/logs.rs b/src-tauri/src/api/system/logs.rs index 8360ab89..3b1d37db 100644 --- a/src-tauri/src/api/system/logs.rs +++ b/src-tauri/src/api/system/logs.rs @@ -1,3 +1,5 @@ +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}; @@ -67,11 +69,21 @@ pub fn get_logs(since: f64) -> Result, AppError> { Ok(logs::get_frontend_logs_since(since)) } +#[tauri::command] +#[specta::specta] +/// Retrieves log entries for a single console view since a given timestamp. +#[allow(clippy::needless_pass_by_value)] +pub fn get_console_logs(view_id: String, since: f64) -> Result, AppError> { + let view_id = canonical_console_view_id(&view_id); + Ok(logs::get_frontend_logs_for_view(&view_id, since)) +} + #[tauri::command] #[specta::specta] /// Clears all stored log entries pub fn clear_logs() -> Result<(), AppError> { logs::clear_logs(); + clear_all_console_log_files(crate::utils::paths::LOG_DIR.as_path())?; Ok(()) } @@ -80,9 +92,10 @@ pub fn clear_logs() -> Result<(), AppError> { /// Clears log entries and files for a single console view. #[allow(clippy::needless_pass_by_value)] pub fn clear_console_logs(view_id: String) -> Result<(), AppError> { - let target = resolve_console_log_target(&view_id); - logs::clear_logs_for_view(&view_id); - clear_console_log_target(&view_id, &target)?; + let canonical_view_id = canonical_console_view_id(&view_id); + let target = resolve_console_log_target(&canonical_view_id); + logs::clear_logs_for_view(&canonical_view_id); + clear_console_log_target(&canonical_view_id, &target)?; Ok(()) } @@ -122,16 +135,27 @@ pub async fn get_console_overview( ui_state_service: State<'_, crate::infrastructure::config::ui_state::UiStateService>, ) -> Result { let engine_state = engine_manager.state().await; - let ui_state = ui_state_service.get_ui_state().await.unwrap_or_default(); + let engine_definitions = engine_manager.list_definitions().await; + let ui_state = ui_state_service + .get_ui_state() + .await + .unwrap_or_else(|error| { + tracing::warn!("Failed to load UI state for console overview, using defaults: {error}"); + UIState::default() + }); let logs = logger::get_frontend_logs_since(0.0); - Ok(ConsoleOverviewBuilder::build(&engine_state, &ui_state, &logs).await) + Ok(ConsoleOverviewBuilder::build(&engine_state, &engine_definitions, &ui_state, &logs).await) } #[tauri::command] #[specta::specta] /// Adds a single log entry to the log store pub fn add_log(msg: &str, source: &str, level: &str) -> Result<(), AppError> { - logs::add_log(msg, source, level); + if source.trim().eq_ignore_ascii_case("frontend") { + trace_frontend_log(level, msg); + } else { + logs::add_log(msg, source, level); + } Ok(()) } /// Batch log entry from frontend @@ -148,11 +172,28 @@ pub struct BatchLogEntry { /// Adds multiple log entries in batch from frontend pub fn log_batch(logs: Vec) -> Result<(), AppError> { for log in logs { - logs::add_log(&log.message, "Frontend", &log.level); + trace_frontend_log(&log.level, &log.message); } Ok(()) } +fn trace_frontend_log(level: &str, message: &str) { + let normalized_level = level.trim().to_ascii_lowercase(); + match normalized_level.as_str() { + "error" => tracing::error!(target: "frontend", message = message), + "warn" | "warning" => tracing::warn!(target: "frontend", message = message), + "debug" => { + logs::add_log(message, "frontend", &normalized_level); + tracing::debug!(target: "frontend", message = message); + } + "trace" => { + logs::add_log(message, "frontend", &normalized_level); + tracing::trace!(target: "frontend", message = message); + } + _ => tracing::info!(target: "frontend", message = message), + } +} + const fn describe_status(status: ConsoleRuntimeStatus) -> &'static str { match status { ConsoleRuntimeStatus::Running => "Running", @@ -162,13 +203,6 @@ const fn describe_status(status: ConsoleRuntimeStatus) -> &'static str { } } -fn canonical_engine_id(engine_id: &str) -> &str { - match engine_id { - "stable-diffusion" => "sdcpp", - value => value, - } -} - fn resolve_console_log_target(view_id: &str) -> PathBuf { if let Some(engine_id) = view_id.strip_prefix("engine:") { return crate::utils::paths::ENGINE_LOGS_DIR.join(canonical_engine_id(engine_id)); @@ -181,6 +215,14 @@ fn resolve_console_log_target(view_id: &str) -> PathBuf { crate::utils::paths::LOG_DIR.clone() } +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"))?; @@ -216,19 +258,56 @@ fn clear_log_file(path: &Path) -> Result<(), AppError> { Ok(()) } +fn clear_all_console_log_files(root: &Path) -> Result<(), AppError> { + if !root.exists() { + return Ok(()); + } + + for entry in fs::read_dir(root)? { + let path = entry?.path(); + if path.is_dir() { + clear_all_console_log_files(&path)?; + continue; + } + + if path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("log")) + { + clear_log_file(&path)?; + } + } + + Ok(()) +} + impl ConsoleOverviewBuilder { async fn build( engine_state: &crate::domain::engine::types::EngineState, + engine_definitions: &[EngineDefinition], ui_state: &UIState, logs: &[LogEntry], ) -> ConsoleOverview { + let registry_engine_labels = Self::collect_registry_engine_labels(engine_definitions); let module_labels = Self::collect_module_labels(&ui_state.selected_modules); let module_ids = Self::collect_module_ids(logs, &module_labels); - let engine_labels = Self::collect_engine_labels(engine_state); + 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, &engine_labels, &module_labels, &module_ids) - .await; + let status_items = Self::build_status_items( + engine_state, + ®istry_engine_labels, + &engine_labels, + &module_labels, + &module_ids, + ) + .await; ConsoleOverview { views, @@ -236,12 +315,40 @@ impl ConsoleOverviewBuilder { } } + 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 - .values() - .map(|module| (module.id.clone(), module.name.clone())) + .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() } @@ -262,18 +369,32 @@ impl ConsoleOverviewBuilder { fn collect_engine_labels( state: &crate::domain::engine::types::EngineState, ) -> BTreeMap { - match state { - crate::domain::engine::types::EngineState::Ready { slots } => slots - .iter() - .map(|slot| { - ( - canonical_engine_id(&slot.engine.id).to_string(), - slot.engine.name.clone(), - ) - }) - .collect(), - _ => BTreeMap::new(), + 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( @@ -286,10 +407,10 @@ impl ConsoleOverviewBuilder { let mut view_labels = BTreeSet::new(); views.push(ConsoleLogView { id: "general".to_string(), - label: "General".to_string(), + label: "Platform".to_string(), }); view_ids.insert("general".to_string()); - view_labels.insert(Self::normalize_view_label("General")); + view_labels.insert(Self::normalize_view_label("Platform")); for (id, label) in engine_labels { Self::push_unique_view( @@ -344,11 +465,29 @@ impl ConsoleOverviewBuilder { 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); + 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, @@ -384,6 +523,7 @@ impl ConsoleOverviewBuilder { fn build_engine_status_items( state: &crate::domain::engine::types::EngineState, + registry_engine_labels: &BTreeMap, ) -> Vec { use crate::domain::engine::types::EngineState; @@ -397,21 +537,21 @@ impl ConsoleOverviewBuilder { }], EngineState::Starting { engine_id } => vec![ConsoleStatusItem { id: format!("engine:{}", canonical_engine_id(engine_id)), - label: ConsoleLabelFormatter::format_module_label(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: ConsoleLabelFormatter::format_module_label(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: ConsoleLabelFormatter::format_module_label(engine_id), + label: Self::engine_label_for_id(engine_id, registry_engine_labels), kind: "engine".to_string(), status: ConsoleRuntimeStatus::Failed, detail: message.clone(), @@ -423,7 +563,7 @@ impl ConsoleOverviewBuilder { 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).to_string()) + .or_insert_with(|| canonical_engine_id(&slot.engine.id)) .clone(); let detail = ConsoleLabelFormatter::format_capability(slot.capability); items @@ -446,6 +586,16 @@ impl ConsoleOverviewBuilder { } } } + + 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<()> { @@ -496,3 +646,321 @@ impl ConsoleLabelFormatter { } } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::{ + ConsoleLabelFormatter, ConsoleOverviewBuilder, ConsoleRuntimeStatus, + canonical_console_view_id, canonical_engine_id, clear_all_console_log_files, + clear_console_log_target, resolve_console_log_target, + }; + use crate::domain::engine::types::EngineDefinition; + use crate::domain::engine::types::{Capability, EngineState, EngineStatus, SlotStatus}; + use crate::infrastructure::logging::LogEntry; + use crate::models::{SelectedModule, UIState}; + use std::collections::HashMap; + use std::fs; + + fn selected_module(id: &str, name: &str, type_: &str) -> SelectedModule { + SelectedModule { + id: id.to_string(), + name: name.to_string(), + name_key: None, + icon: "box".to_string(), + type_: type_.to_string(), + desc_key: None, + desc: String::new(), + } + } + + fn log_entry(module_id: Option<&str>) -> LogEntry { + LogEntry { + timestamp: 1.0, + source: "test".to_string(), + level: "info".to_string(), + message: "message".to_string(), + module_id: module_id.map(str::to_string), + display_time: None, + normalized_level: None, + scope: None, + summary_message: None, + source_label: None, + source_class: None, + page: None, + action: None, + expected: None, + } + } + + fn engine_log_entry(source: &str) -> LogEntry { + LogEntry { + source: source.to_string(), + ..log_entry(None) + } + } + + fn engine_definition(id: &str, name: &str) -> EngineDefinition { + EngineDefinition { + id: id.to_string(), + name: name.to_string(), + desc: String::new(), + icon: String::new(), + capabilities: vec![Capability::Text], + binary: None, + repo_url: None, + version: "1.0.0".to_string(), + default_port: 8081, + default_context_size: 4096, + config_schema: None, + installed: false, + installed_compute_modes: Vec::new(), + managed_externally: false, + } + } + + #[test] + fn canonicalizes_engine_ids_and_console_view_ids() { + 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_console_view_id("engine:sdcpp"), "engine:sdcpp"); + assert_eq!( + canonical_console_view_id(" module:example "), + "module:example" + ); + } + + #[test] + fn formats_console_labels_and_capabilities() { + assert_eq!( + ConsoleLabelFormatter::format_module_label("axelate-open-webui"), + "Open Webui" + ); + assert_eq!(ConsoleLabelFormatter::format_module_label("--"), ""); + assert_eq!( + ConsoleLabelFormatter::format_capability(Capability::Text), + "text" + ); + assert_eq!( + ConsoleLabelFormatter::format_capability(Capability::Image), + "image" + ); + assert_eq!( + ConsoleLabelFormatter::format_capability(Capability::Vision), + "vision" + ); + } + + #[test] + fn resolves_console_log_targets_by_view_kind() { + let engine_target = resolve_console_log_target("engine:sdcpp"); + let module_target = resolve_console_log_target("module:comfyui"); + let general_target = resolve_console_log_target("general"); + + assert!(engine_target.ends_with("sdcpp")); + assert!(module_target.ends_with("comfyui")); + assert_ne!(general_target, module_target); + } + + #[test] + fn clears_general_and_nested_console_logs_only() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let general = root.join("axelate.log"); + let nested_dir = root.join("nested"); + let nested_log = nested_dir.join("module.log"); + let nested_txt = nested_dir.join("keep.txt"); + fs::create_dir_all(&nested_dir).unwrap(); + fs::write(&general, "general").unwrap(); + fs::write(&nested_log, "module").unwrap(); + fs::write(&nested_txt, "text").unwrap(); + + clear_console_log_target("general", root).unwrap(); + assert_eq!(fs::read_to_string(&general).unwrap(), ""); + assert_eq!(fs::read_to_string(&nested_log).unwrap(), "module"); + + clear_all_console_log_files(root).unwrap(); + assert_eq!(fs::read_to_string(&nested_log).unwrap(), ""); + assert_eq!(fs::read_to_string(&nested_txt).unwrap(), "text"); + } + + #[test] + fn clear_console_log_target_ignores_missing_targets_and_non_log_files() { + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("missing"); + let target = temp.path().join("target"); + let text_file = target.join("keep.txt"); + fs::create_dir_all(&target).unwrap(); + fs::write(&text_file, "keep").unwrap(); + + clear_console_log_target("module:missing", &missing).unwrap(); + clear_console_log_target("module:target", &target).unwrap(); + + assert_eq!(fs::read_to_string(text_file).unwrap(), "keep"); + } + + #[tokio::test] + async fn console_overview_deduplicates_views_and_reports_engine_states() { + let mut ui_state = UIState::default(); + ui_state.selected_modules.insert( + "services".to_string(), + selected_module("comfyui", "ComfyUI", "service"), + ); + ui_state.selected_modules.insert( + "ai_text".to_string(), + selected_module("sdcpp", "Stable Diffusion.cpp", "local"), + ); + ui_state.selected_modules.insert( + "ai_image".to_string(), + selected_module("cloud", "Cloud", "api"), + ); + let logs = vec![log_entry(Some("comfyui")), log_entry(Some("unknown"))]; + let engine = EngineStatus { + id: "sdcpp".to_string(), + name: "Stable Diffusion.cpp".to_string(), + capabilities: vec![Capability::Image], + endpoint: "http://127.0.0.1:7860".to_string(), + healthy: true, + }; + let state = EngineState::Ready { + slots: vec![ + SlotStatus { + capability: Capability::Image, + engine: engine.clone(), + }, + SlotStatus { + capability: Capability::Vision, + engine, + }, + ], + }; + + let engine_definitions = vec![engine_definition("sdcpp", "Stable Diffusion.cpp")]; + let overview = + ConsoleOverviewBuilder::build(&state, &engine_definitions, &ui_state, &logs).await; + let views = overview + .views + .iter() + .map(|view| view.id.as_str()) + .collect::>(); + let engine_status = overview + .status_items + .iter() + .find(|item| item.id == "engine:sdcpp") + .unwrap(); + + assert_eq!(views, vec!["general", "engine:sdcpp", "module:comfyui"]); + assert!(matches!( + engine_status.status, + ConsoleRuntimeStatus::Running + )); + assert_eq!(engine_status.detail, "image, vision"); + } + + #[tokio::test] + async fn console_overview_names_logged_engines_from_registry_definitions() { + let logs = vec![engine_log_entry("custom_engine")]; + let engine_definitions = vec![engine_definition("custom-engine", "Custom Engine")]; + + let overview = ConsoleOverviewBuilder::build( + &EngineState::Idle, + &engine_definitions, + &UIState::default(), + &logs, + ) + .await; + + let view = overview + .views + .iter() + .find(|view| view.id == "engine:custom-engine"); + assert!( + view.is_some(), + "logged custom engine should create a console view" + ); + let view = view.unwrap(); + + assert_eq!(view.label, "Custom Engine"); + } + + #[tokio::test] + async fn console_overview_builds_status_rows_for_non_ready_states() { + let cases = [ + ( + EngineState::Idle, + "engine:idle", + ConsoleRuntimeStatus::Stopped, + "No active engines", + ), + ( + EngineState::Starting { + engine_id: "llama-cpp".to_string(), + }, + "engine:llama-cpp", + ConsoleRuntimeStatus::Starting, + "Starting…", + ), + ( + EngineState::Swapping { + from: "old".to_string(), + to: "new".to_string(), + }, + "engine:new", + ConsoleRuntimeStatus::Starting, + "Switching from old", + ), + ( + EngineState::Error { + engine_id: "bad".to_string(), + message: "boom".to_string(), + }, + "engine:bad", + ConsoleRuntimeStatus::Failed, + "boom", + ), + ]; + + for (state, expected_id, expected_status, expected_detail) in cases { + let overview = ConsoleOverviewBuilder::build( + &state, + &[engine_definition("new", "New Engine")], + &UIState::default(), + &Vec::::new(), + ) + .await; + let item = overview.status_items.first().unwrap(); + assert_eq!(item.id, expected_id); + assert!( + std::mem::discriminant(&item.status) == std::mem::discriminant(&expected_status) + ); + assert_eq!(item.detail, expected_detail); + } + } + + #[test] + fn module_label_collection_excludes_api_modules() { + let mut modules = HashMap::new(); + modules.insert( + "services".to_string(), + selected_module("service-module", "Service Module", "service"), + ); + modules.insert( + "ai_text".to_string(), + selected_module("local-engine", "Local Engine", "local"), + ); + modules.insert( + "ai_image".to_string(), + selected_module("api-engine", "API Engine", "api"), + ); + + let module_labels = ConsoleOverviewBuilder::collect_module_labels(&modules); + let engine_labels = ConsoleOverviewBuilder::collect_selected_engine_labels(&modules); + + assert!(module_labels.contains_key("service-module")); + assert!(engine_labels.contains_key("local-engine")); + assert!(!engine_labels.contains_key("api-engine")); + } +} diff --git a/src-tauri/src/app/tray.rs b/src-tauri/src/app/tray.rs index 95af5bc0..5809bd10 100644 --- a/src-tauri/src/app/tray.rs +++ b/src-tauri/src/app/tray.rs @@ -201,15 +201,11 @@ pub fn setup_system_tray(app: &tauri::App) -> Result<(), Box>() .map(|s| std::sync::Arc::clone(&*s)); if let Some(sessions) = sessions_arc { - std::thread::spawn(move || { - if let Err(error) = sessions.save_to_disk() { - tracing::error!( - "Failed to save chat history during shutdown: {error:?}" - ); - } else { - tracing::info!("AI history flushed successfully during shutdown."); - } - }); + if let Err(error) = sessions.save_to_disk() { + tracing::error!("Failed to save chat history during shutdown: {error:?}"); + } else { + tracing::info!("AI history flushed successfully during shutdown."); + } } app.exit(0); diff --git a/src-tauri/src/app/window.rs b/src-tauri/src/app/window.rs index 69ed355a..94cf53a6 100644 --- a/src-tauri/src/app/window.rs +++ b/src-tauri/src/app/window.rs @@ -123,7 +123,7 @@ pub fn create_main_window(app: &tauri::AppHandle) -> Option, } +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) + .filter(|session_id| !session_id.is_empty()) +} + pub(super) async fn prepare_chat_dispatch( request: &ChatRequest, sessions: &ChatSessionManager, @@ -29,8 +42,16 @@ pub(super) async fn prepare_chat_dispatch( local_engine_access: LocalEngineAccess, ) -> Result { let mut messages_context = request.messages.clone(); - if let Some(session_id) = &request.session_id { + if let Some(session_id) = normalize_session_id(request.session_id.as_deref()) { messages_context = sessions.merge_request_messages(session_id, &request.messages); + if !request.messages.is_empty() { + if let Err(error) = sessions.force_save().await { + tracing::warn!( + session_id, + "Failed to persist incoming chat messages before dispatch: {error}" + ); + } + } } let mut base_url = "https://openrouter.ai/api/v1".to_string(); @@ -44,6 +65,7 @@ pub(super) async fn prepare_chat_dispatch( engine_manager, settings_service, local_engine_access, + &messages_context, ) .await? { @@ -81,11 +103,11 @@ pub(super) async fn persist_successful_response( session_id: Option<&str>, message_id: String, response: &Result, -) { +) -> Result<(), crate::errors::AppError> { if let Ok(response) = response && response.ok && let Some(reply) = &response.reply - && let Some(session_id) = session_id + && let Some(session_id) = normalize_session_id(session_id) { sessions.append_response( session_id, @@ -93,7 +115,15 @@ pub(super) async fn persist_successful_response( reply, response.thought_signature.clone(), ); + if let Err(error) = sessions.force_save().await { + tracing::warn!( + session_id, + "Failed to persist successful chat response: {error}" + ); + } } + + Ok(()) } pub(super) async fn active_local_engine_status( @@ -105,7 +135,9 @@ pub(super) async fn active_local_engine_status( crate::domain::engine::types::EngineState::Ready { slots } => slots .into_iter() .find(|slot| { - slot.capability == capability && slot.engine.id == provider && slot.engine.healthy + slot.capability == capability + && canonical_engine_id(&slot.engine.id) == canonical_engine_id(provider) + && slot.engine.healthy }) .map(|slot| slot.engine) .ok_or_else(|| { @@ -123,17 +155,14 @@ pub(super) async fn build_engine_config( definition: &crate::domain::engine::types::EngineDefinition, ) -> Result { let saved = load_engine_config_map().await?; - Ok(saved.get(&definition.id).map_or_else( + let canonical_id = canonical_engine_id(&definition.id); + Ok(saved.get(&canonical_id).map_or_else( || build_default_engine_config(definition), |config| merge_user_engine_config(definition, config), )) } -pub(super) fn resolve_local_text_model_id( - request_model: &str, - model_path: Option<&str>, - provider: &str, -) -> String { +pub(super) fn resolve_local_text_model_id(request_model: &str, model_path: Option<&str>) -> String { let requested = request_model.trim(); if !requested.is_empty() && requested != "default" { return requested.to_string(); @@ -146,7 +175,7 @@ pub(super) fn resolve_local_text_model_id( } } - provider.to_string() + "default".to_string() } async fn resolve_local_engine_request( @@ -155,6 +184,7 @@ async fn resolve_local_engine_request( engine_manager: &crate::domain::engine::manager::EngineManager, settings_service: &crate::infrastructure::config::settings::SettingsService, local_engine_access: LocalEngineAccess, + prepared_messages_context: &[ChatMessage], ) -> Result, crate::errors::AppError> { let Some(definition) = engine_manager.get_definition(&request.provider).await else { return Ok(None); @@ -178,15 +208,9 @@ async fn resolve_local_engine_request( } let local_context_size = usize::try_from(config.context_size.max(4096)).unwrap_or(4096); - let local_model_for_context = config - .model_path - .clone() - .unwrap_or_else(|| request.model.clone()); - let effective_model = resolve_local_text_model_id( - &request.model, - config.model_path.as_deref(), - &request.provider, - ); + let effective_model = + resolve_local_text_model_id(&request.model, config.model_path.as_deref()); + let local_model_for_context = effective_model.clone(); super::ai_service::stop_conflicting_local_engine( engine_manager, @@ -195,9 +219,9 @@ async fn resolve_local_engine_request( .await?; let status = engine_manager.start(config).await?; - let mut messages_context = request.messages.clone(); + let mut messages_context = prepared_messages_context.to_vec(); - if let Some(session_id) = &request.session_id { + if let Some(session_id) = normalize_session_id(request.session_id.as_deref()) { messages_context = sessions.build_local_context( session_id, local_context_size, @@ -228,17 +252,14 @@ async fn resolve_local_engine_request( ) .await?; let base_url = format!("{}/v1", status.endpoint); - let effective_model = - resolve_local_text_model_id(&request.model, None, &request.provider); let config = build_engine_config(&definition).await?; + let effective_model = + resolve_local_text_model_id(&request.model, config.model_path.as_deref()); let local_context_size = usize::try_from(config.context_size.max(4096)).unwrap_or(4096); - let local_model_for_context = config - .model_path - .clone() - .unwrap_or_else(|| request.model.clone()); - let mut messages_context = request.messages.clone(); + let local_model_for_context = effective_model.clone(); + let mut messages_context = prepared_messages_context.to_vec(); - if let Some(session_id) = &request.session_id { + if let Some(session_id) = normalize_session_id(request.session_id.as_deref()) { messages_context = sessions.build_local_context( session_id, local_context_size, @@ -285,10 +306,11 @@ async fn prepend_local_system_prompt( return Ok(()); } }; - let key = format!("{provider}_system_prompt"); + let canonical_provider = canonical_engine_id(provider); + let canonical_key = format!("{canonical_provider}_system_prompt"); let prompt = settings .extra_settings - .get(&key) + .get(&canonical_key) .map(String::as_str) .unwrap_or_default() .trim(); @@ -322,46 +344,75 @@ fn resolve_cloud_provider_request( .iter() .find(|provider| provider.id == request.provider) { - if let Some(url) = &provider.base_url { - base_url.clone_from(url); - } + let custom_models = config_service.load_custom_models().ok(); + let resolution = resolve_cloud_provider_values( + &request.provider, + &request.model, + "https://openrouter.ai/api/v1", + 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; + } +} - 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); - } +fn resolve_cloud_provider_values( + provider_id: &str, + request_model: &str, + default_base_url: &str, + provider: &crate::models::config::ApiProvider, + custom_models: Option<&crate::models::custom_models::CustomModelConfig>, +) -> CloudProviderResolution { + let base_url = provider + .base_url + .clone() + .unwrap_or_else(|| default_base_url.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) + 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()) { - *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(); - } + tracing::info!("Resolved API model ID: {effective_model} -> {api_model}"); + effective_model = api_model.clone(); } } - if let Ok(custom_models) = config_service.load_custom_models() + if let Some(custom_models) = custom_models && let Some(custom) = custom_models .models .iter() - .find(|model| model.id == *effective_model && model.provider_id == request.provider) + .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(); + effective_model = custom.base_model_id.clone(); + } + + CloudProviderResolution { + base_url, + effective_model, + model_max_tokens, } } @@ -372,3 +423,158 @@ fn clamp_max_tokens(request_limit: Option, model_limit: Option) -> Opt (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(), + )])), + } + } + + #[test] + fn normalize_session_id_rejects_blank_values() { + assert_eq!(normalize_session_id(None), None); + assert_eq!(normalize_session_id(Some("")), None); + assert_eq!(normalize_session_id(Some(" ")), None); + } + + #[test] + fn normalize_session_id_trims_valid_values() { + assert_eq!(normalize_session_id(Some(" session-1 ")), Some("session-1")); + } + + #[test] + fn resolve_local_text_model_id_prefers_explicit_model() { + assert_eq!( + resolve_local_text_model_id("custom-model.gguf", Some("C:/models/default.gguf")), + "custom-model.gguf" + ); + } + + #[test] + fn resolve_local_text_model_id_uses_model_file_name_for_default_request() { + assert_eq!( + resolve_local_text_model_id("default", Some("C:/models/chat-model.gguf")), + "chat-model.gguf" + ); + } + + #[test] + fn resolve_local_text_model_id_uses_default_when_model_is_not_known() { + 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", + &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", + &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_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", + &provider(), + Some(&custom_models), + ); + + assert_eq!(resolution.effective_model, "ft:gpt:custom"); + assert_eq!(resolution.model_max_tokens, Some(16_384)); + } +} diff --git a/src-tauri/src/domain/ai/ai_service.rs b/src-tauri/src/domain/ai/ai_service.rs index 4713eb5b..ab5d3801 100644 --- a/src-tauri/src/domain/ai/ai_service.rs +++ b/src-tauri/src/domain/ai/ai_service.rs @@ -8,7 +8,8 @@ use std::sync::Arc; use super::ai_dispatch::{ - LocalEngineAccess, PreparedChatDispatch, persist_successful_response, prepare_chat_dispatch, + LocalEngineAccess, PreparedChatDispatch, normalize_session_id, persist_successful_response, + prepare_chat_dispatch, }; use super::session::ChatSessionManager; use super::streaming::{AiProvider, OpenAiCompatibleProvider, StreamEvent, StreamSink}; @@ -16,13 +17,15 @@ pub use super::types::{ ChatMessage, ChatReply, ChatRequest, ChatResponse, ChatSession, TokenUsage, }; -const AI_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90); +const CLOUD_AI_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90); +const LOCAL_AI_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(30); struct PreparedRequestExecution { provider: OpenAiCompatibleProvider, effective_request: ChatRequest, request_id: String, message_id: String, + timeout: std::time::Duration, } struct ChatStreamExecutionOptions { @@ -204,7 +207,7 @@ async fn process_chat_request_with_local_engine_access( execute_prepared_request( execution, sessions, - session_id.as_deref(), + normalize_session_id(session_id.as_deref()), move |execution| async move { execution .provider @@ -255,7 +258,7 @@ async fn process_chat_request_non_stream_with_local_engine_access( execute_prepared_request( execution, sessions, - session_id.as_deref(), + normalize_session_id(session_id.as_deref()), |execution| async move { execution .provider @@ -307,6 +310,7 @@ async fn prepare_request_execution( } }) .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let timeout = timeout_for_base_url(&base_url); tracing::info!( "[AI] Starting request {} (msg {}) for model {}", request_id, @@ -319,9 +323,18 @@ async fn prepare_request_execution( effective_request, request_id, message_id, + timeout, }) } +fn timeout_for_base_url(base_url: &str) -> std::time::Duration { + if crate::domain::ai::streaming::is_local_base_url(base_url) { + LOCAL_AI_REQUEST_TIMEOUT + } else { + CLOUD_AI_REQUEST_TIMEOUT + } +} + async fn execute_prepared_request( execution: PreparedRequestExecution, sessions: &ChatSessionManager, @@ -336,26 +349,24 @@ where { let message_id = execution.message_id.clone(); let request_id = execution.request_id.clone(); - let response = tokio::time::timeout(AI_REQUEST_TIMEOUT, run(execution)).await; + let timeout = execution.timeout; + let response = tokio::time::timeout(timeout, run(execution)).await; let response = if let Ok(result) = response { result } else { on_timeout(&message_id); - Err(timeout_error(request_id)) + Err(timeout_error(request_id, timeout)) }; - persist_successful_response(sessions, session_id, message_id, &response).await; + persist_successful_response(sessions, session_id, message_id, &response).await?; response } -fn timeout_error(request_id: String) -> crate::errors::AppError { +fn timeout_error(request_id: String, timeout: std::time::Duration) -> crate::errors::AppError { crate::errors::AppError::Internal { request_id: Some(request_id), - message: format!( - "AI Request timed out after {} seconds.", - AI_REQUEST_TIMEOUT.as_secs() - ), + message: format!("AI Request timed out after {} seconds.", timeout.as_secs()), } } @@ -449,7 +460,6 @@ pub async fn validate_api_key( return Ok(!data.is_empty()); } - // Legacy Gemini fallback if body.get("models").is_some() { return Ok(true); } @@ -514,8 +524,10 @@ pub async fn process_image_request_without_engine_autostart( mod tests { #![allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] use super::*; - use crate::domain::ai::image_service::{ + use crate::domain::ai::image_comfyui::{ normalize_comfyui_sampler, normalize_comfyui_scheduler, parse_comfyui_checkpoint_list, + }; + use crate::domain::ai::image_settings::{ resolve_f32_setting, resolve_string_setting, resolve_u32_setting, }; use crate::models::AppSettings; @@ -565,6 +577,19 @@ mod tests { ); } + #[test] + fn local_chat_requests_get_longer_timeout_than_cloud_requests() { + assert_eq!( + timeout_for_base_url("http://127.0.0.1:8080/v1"), + LOCAL_AI_REQUEST_TIMEOUT + ); + assert_eq!( + timeout_for_base_url("https://openrouter.ai/api/v1"), + CLOUD_AI_REQUEST_TIMEOUT + ); + assert!(LOCAL_AI_REQUEST_TIMEOUT > CLOUD_AI_REQUEST_TIMEOUT); + } + #[test] fn test_count_tokens_longer_text() { let text = "The quick brown fox jumps over the lazy dog"; @@ -640,7 +665,7 @@ mod tests { extra_settings.insert("custom_sd_steps".to_string(), "30".to_string()); extra_settings.insert("sdcpp_steps".to_string(), "20".to_string()); extra_settings.insert( - "custom_sd_positiveprompt".to_string(), + "custom_sd_positive_prompt".to_string(), "portrait".to_string(), ); @@ -650,17 +675,17 @@ mod tests { }; assert_eq!( - resolve_u32_setting(&settings, "custom_sd", "sdcpp", "steps"), + resolve_u32_setting(&settings, "custom_sd", "steps"), Some(30) ); assert_eq!( - resolve_string_setting(&settings, "custom_sd", "sdcpp", "positive_prompt"), + resolve_string_setting(&settings, "custom_sd", "positive_prompt"), Some("portrait".to_string()) ); } #[test] - fn test_resolve_image_setting_falls_back_to_provider_id() { + fn test_resolve_image_setting_does_not_read_provider_key_when_settings_key_differs() { let mut extra_settings = HashMap::new(); extra_settings.insert("sdcpp_cfg_scale".to_string(), "8.5".to_string()); extra_settings.insert("sdcpp_negative_prompt".to_string(), "blurry".to_string()); @@ -671,12 +696,12 @@ mod tests { }; assert_eq!( - resolve_f32_setting(&settings, "custom_sd", "sdcpp", "cfg_scale"), - Some(8.5) + resolve_f32_setting(&settings, "custom_sd", "cfg_scale"), + None ); assert_eq!( - resolve_string_setting(&settings, "custom_sd", "sdcpp", "negative_prompt"), - Some("blurry".to_string()) + resolve_string_setting(&settings, "custom_sd", "negative_prompt"), + None ); } diff --git a/src-tauri/src/domain/ai/custom_model_service.rs b/src-tauri/src/domain/ai/custom_model_service.rs index d298e986..16acd67a 100644 --- a/src-tauri/src/domain/ai/custom_model_service.rs +++ b/src-tauri/src/domain/ai/custom_model_service.rs @@ -53,7 +53,7 @@ pub struct CustomModelManager; impl CustomModelManager { /// Retrieves all configured custom models. pub fn get_all() -> Result, AppError> { - let config = CustomModelConfigRepository::load().unwrap_or_default(); + let config = CustomModelConfigRepository::load()?; Ok(config.models) } @@ -64,7 +64,7 @@ impl CustomModelManager { name: String, base_model_id: String, ) -> Result<(), AppError> { - let mut config = CustomModelConfigRepository::load().unwrap_or_default(); + let mut config = CustomModelConfigRepository::load()?; // Idempotency check if config @@ -92,7 +92,7 @@ impl CustomModelManager { /// Removes a custom model by its ID. pub fn remove(id: &str) -> Result<(), AppError> { - let mut config = CustomModelConfigRepository::load().unwrap_or_default(); + let mut config = CustomModelConfigRepository::load()?; config.models.retain(|m| m.id != id); CustomModelConfigRepository::save(&config) } diff --git a/src-tauri/src/domain/ai/image_cloud.rs b/src-tauri/src/domain/ai/image_cloud.rs new file mode 100644 index 00000000..ebe615d5 --- /dev/null +++ b/src-tauri/src/domain/ai/image_cloud.rs @@ -0,0 +1,48 @@ +//! Cloud image generation through OpenRouter-compatible image models. + +use std::time::Duration; + +use super::image_http::{build_image_client, parse_image_response_body}; +use super::image_payload::build_cloud_image_payload; +use super::image_provider_adapter; +use super::image_response::parse_openrouter_generated_images; +use super::types::ImageGenerationRequest; +use crate::errors::AppError; +use crate::infrastructure::crypto::secure_storage::SecureStorage; + +pub(super) fn is_cloud_image_provider(provider: &str) -> bool { + image_provider_adapter::is_cloud_image_provider(provider) +} + +pub(super) async fn process_cloud_image_request( + request: &ImageGenerationRequest, +) -> Result, AppError> { + let api_key = SecureStorage::get_key_async("openrouter_api_key".to_string()) + .await? + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| AppError::Validation("OpenRouter API key is missing".to_string()))?; + + let client = build_image_client(Duration::from_mins(3))?; + let response = client + .post("https://openrouter.ai/api/v1/chat/completions") + .header(reqwest::header::AUTHORIZATION, format!("Bearer {api_key}")) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .json(&build_cloud_image_payload(request)) + .send() + .await + .map_err(|error| AppError::External { + request_id: None, + message: format!("Cloud image request failed: {error}"), + })?; + + let body = parse_image_response_body(response).await?; + let images = parse_openrouter_generated_images(&body); + if images.is_empty() { + return Err(AppError::External { + request_id: None, + message: "Cloud image provider returned no images".to_string(), + }); + } + + Ok(images) +} diff --git a/src-tauri/src/domain/ai/image_comfyui.rs b/src-tauri/src/domain/ai/image_comfyui.rs new file mode 100644 index 00000000..fe296a36 --- /dev/null +++ b/src-tauri/src/domain/ai/image_comfyui.rs @@ -0,0 +1,595 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use std::time::{Duration, Instant}; + +use super::image_http::build_image_client; +use super::image_settings::resolve_string_setting; +use super::types::ImageGenerationRequest; +use crate::domain::ai::ImageGenerationState; +use crate::errors::AppError; +use crate::infrastructure::config::settings::SettingsService; +use crate::models::AppSettings; + +struct ImageRequestSettingsContext { + settings: AppSettings, + settings_key: String, +} + +struct ComfyUiRequestContext { + base_url: String, + checkpoint: String, + sampler: String, + scheduler: String, + seed: u64, + steps: u32, + cfg_scale: f32, + width: u32, + height: u32, + batch_size: u32, + negative_prompt: String, + prompt_id: String, + client_id: String, +} + +pub(super) async fn process_comfyui_request( + request: &ImageGenerationRequest, + image_generation_state: &ImageGenerationState, + settings_service: &SettingsService, +) -> Result, AppError> { + let settings_context = load_image_request_settings_context(request, settings_service).await?; + let client = build_image_client(Duration::from_mins(2))?; + let comfyui = build_comfyui_request_context(request, &settings_context, &client).await?; + let workflow = build_comfyui_workflow( + &request.prompt, + &comfyui.negative_prompt, + &comfyui.checkpoint, + comfyui.seed, + comfyui.steps, + comfyui.cfg_scale, + comfyui.width, + comfyui.height, + comfyui.batch_size, + &comfyui.sampler, + &comfyui.scheduler, + ); + + image_generation_state + .begin( + &request.provider, + &comfyui.base_url, + Some(comfyui.prompt_id.clone()), + ) + .await; + + let mut active_prompt_id = comfyui.prompt_id.clone(); + let result = async { + let queue_body = queue_comfyui_prompt(&client, &comfyui, workflow).await?; + + if let Some(server_prompt_id) = queue_body.get("prompt_id").and_then(|value| value.as_str()) + && !server_prompt_id.trim().is_empty() + { + active_prompt_id = server_prompt_id.to_string(); + image_generation_state + .update_prompt_id(&request.provider, active_prompt_id.clone()) + .await; + } + + if let Some(message) = extract_comfyui_queue_error(&queue_body) { + return Err(AppError::External { + request_id: None, + message, + }); + } + + wait_for_comfyui_images( + &client, + &comfyui.base_url, + &request.provider, + &active_prompt_id, + image_generation_state, + ) + .await + } + .await; + + image_generation_state + .clear(&request.provider, Some(active_prompt_id.as_str())) + .await; + + result +} + +async fn load_image_request_settings_context( + request: &ImageGenerationRequest, + settings_service: &SettingsService, +) -> Result { + Ok(ImageRequestSettingsContext { + settings: settings_service.get_settings().await?, + settings_key: request + .settings_key + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| request.provider.clone()), + }) +} + +async fn build_comfyui_request_context( + request: &ImageGenerationRequest, + settings_context: &ImageRequestSettingsContext, + client: &reqwest::Client, +) -> Result { + let base_url = normalize_comfyui_base_url( + resolve_string_setting( + &settings_context.settings, + &settings_context.settings_key, + "base_url", + ) + .as_deref() + .unwrap_or("http://127.0.0.1:8188"), + ); + + Ok(ComfyUiRequestContext { + checkpoint: resolve_comfyui_checkpoint( + request, + &settings_context.settings, + &settings_context.settings_key, + &base_url, + client, + ) + .await?, + base_url, + sampler: normalize_comfyui_sampler(request.sampler.as_deref()), + scheduler: normalize_comfyui_scheduler(request.scheduler.as_deref()), + seed: normalize_comfyui_seed(request.seed), + steps: request.steps.unwrap_or(24), + cfg_scale: request.cfg_scale.unwrap_or(7.0), + width: request.width.unwrap_or(832), + height: request.height.unwrap_or(1216), + batch_size: request.batch_size.unwrap_or(1), + negative_prompt: request.negative_prompt.clone().unwrap_or_default(), + prompt_id: uuid::Uuid::new_v4().to_string(), + client_id: uuid::Uuid::new_v4().to_string(), + }) +} + +async fn resolve_comfyui_checkpoint( + request: &ImageGenerationRequest, + settings: &AppSettings, + settings_key: &str, + base_url: &str, + client: &reqwest::Client, +) -> Result { + if !request.model.trim().is_empty() && request.model != "default" { + return Ok(normalize_comfyui_checkpoint(&request.model)); + } + + if let Some(saved_checkpoint) = resolve_string_setting(settings, settings_key, "checkpoint") { + return Ok(normalize_comfyui_checkpoint(&saved_checkpoint)); + } + + let available_checkpoints = fetch_comfyui_checkpoints(client, base_url).await?; + if let Some(checkpoint) = available_checkpoints.first() { + return Ok(normalize_comfyui_checkpoint(checkpoint)); + } + + Err(AppError::Config( + "ComfyUI does not expose any checkpoints yet. Install a model in ComfyUI and try again." + .to_string(), + )) +} + +fn normalize_comfyui_base_url(raw: &str) -> String { + let trimmed = raw.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return "http://127.0.0.1:8188".to_string(); + } + + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + return trimmed.to_string(); + } + + format!("http://{trimmed}") +} + +fn normalize_comfyui_checkpoint(raw: &str) -> String { + raw.trim() + .replace('\\', "/") + .split('/') + .next_back() + .unwrap_or(raw) + .trim() + .to_string() +} + +pub(super) fn normalize_comfyui_sampler(value: Option<&str>) -> String { + match value.unwrap_or("euler").trim().to_lowercase().as_str() { + "euler a" | "euler_a" | "euler ancestral" | "euler_ancestral" => { + "euler_ancestral".to_string() + } + "euler" => "euler".to_string(), + "heun" => "heun".to_string(), + "heunpp2" => "heunpp2".to_string(), + "dpm2" | "dpm 2" | "dpm_2" => "dpm_2".to_string(), + "dpm2 a" | "dpm2_a" | "dpm 2 ancestral" | "dpm_2_ancestral" => { + "dpm_2_ancestral".to_string() + } + "lms" => "lms".to_string(), + "dpm fast" | "dpm_fast" => "dpm_fast".to_string(), + "dpm adaptive" | "dpm_adaptive" => "dpm_adaptive".to_string(), + "dpm++ 2s a" | "dpm++2s_a" | "dpmpp_2s_a" | "dpmpp_2s_ancestral" => { + "dpmpp_2s_ancestral".to_string() + } + "dpm++ sde" | "dpmpp_sde" => "dpmpp_sde".to_string(), + "dpm++ sde gpu" | "dpmpp_sde_gpu" => "dpmpp_sde_gpu".to_string(), + "dpm++ 2m" | "dpm++2m" | "dpmpp_2m" => "dpmpp_2m".to_string(), + "dpm++ 3m sde" | "dpm++3m sde" | "dpmpp_3m_sde" => "dpmpp_3m_sde".to_string(), + "dpm++ 3m sde gpu" | "dpm++3m sde gpu" | "dpmpp_3m_sde_gpu" => { + "dpmpp_3m_sde_gpu".to_string() + } + "ddpm" => "ddpm".to_string(), + "lcm" => "lcm".to_string(), + "ipndm" => "ipndm".to_string(), + "ipndm_v" => "ipndm_v".to_string(), + "deis" => "deis".to_string(), + "ddim" => "ddim".to_string(), + "uni pc" | "uni_pc" => "uni_pc".to_string(), + "uni pc bh2" | "uni_pc_bh2" => "uni_pc_bh2".to_string(), + other => other.to_string(), + } +} + +pub(super) fn normalize_comfyui_scheduler(value: Option<&str>) -> String { + match value.unwrap_or("karras").trim().to_lowercase().as_str() { + "default" | "auto" | "karras" => "karras".to_string(), + "normal" => "normal".to_string(), + "simple" => "simple".to_string(), + "sgm uniform" | "sgm_uniform" => "sgm_uniform".to_string(), + "exponential" => "exponential".to_string(), + "ddim uniform" | "ddim_uniform" => "ddim_uniform".to_string(), + "beta" => "beta".to_string(), + "linear quadratic" | "linear_quadratic" => "linear_quadratic".to_string(), + "kl optimal" | "kl_optimal" => "kl_optimal".to_string(), + other => other.to_string(), + } +} + +fn normalize_comfyui_seed(value: Option) -> u64 { + match value { + Some(seed) if seed >= 0 => u64::from(seed.unsigned_abs()), + _ => rand::random::(), + } +} + +async fn fetch_comfyui_checkpoints( + client: &reqwest::Client, + base_url: &str, +) -> Result, AppError> { + let response = client + .get(format!("{base_url}/models/checkpoints")) + .send() + .await + .map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to query ComfyUI checkpoints: {error}"), + })?; + + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(AppError::External { + request_id: None, + message: format!("ComfyUI checkpoints request failed: {body}"), + }); + } + + let payload: serde_json::Value = response.json().await.map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to parse ComfyUI checkpoint list: {error}"), + })?; + + Ok(parse_comfyui_checkpoint_list(&payload)) +} + +pub(super) fn parse_comfyui_checkpoint_list(payload: &serde_json::Value) -> Vec { + fn extract_checkpoint_name(value: &serde_json::Value) -> Option { + if let Some(name) = value.as_str() { + let trimmed = name.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + + let object = value.as_object()?; + for key in ["name", "filename", "path"] { + if let Some(candidate) = object.get(key).and_then(|entry| entry.as_str()) { + let trimmed = candidate.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + + None + } + + let values = if let Some(items) = payload.as_array() { + items.iter().collect::>() + } else if let Some(items) = payload.get("models").and_then(|value| value.as_array()) { + items.iter().collect::>() + } else if let Some(items) = payload.get("files").and_then(|value| value.as_array()) { + items.iter().collect::>() + } else { + Vec::new() + }; + + let mut seen = std::collections::HashSet::new(); + values + .into_iter() + .filter_map(extract_checkpoint_name) + .filter(|value| seen.insert(value.clone())) + .collect() +} + +#[allow(clippy::too_many_arguments)] +fn build_comfyui_workflow( + prompt: &str, + negative_prompt: &str, + checkpoint: &str, + seed: u64, + steps: u32, + cfg_scale: f32, + width: u32, + height: u32, + batch_size: u32, + sampler: &str, + scheduler: &str, +) -> serde_json::Value { + serde_json::json!({ + "3": { + "class_type": "KSampler", + "inputs": { + "cfg": cfg_scale, + "denoise": 1.0, + "latent_image": ["5", 0], + "model": ["4", 0], + "negative": ["7", 0], + "positive": ["6", 0], + "sampler_name": sampler, + "scheduler": scheduler, + "seed": seed, + "steps": steps + } + }, + "4": { + "class_type": "CheckpointLoaderSimple", + "inputs": { + "ckpt_name": checkpoint + } + }, + "5": { + "class_type": "EmptyLatentImage", + "inputs": { + "batch_size": batch_size, + "height": height, + "width": width + } + }, + "6": { + "class_type": "CLIPTextEncode", + "inputs": { + "clip": ["4", 1], + "text": prompt + } + }, + "7": { + "class_type": "CLIPTextEncode", + "inputs": { + "clip": ["4", 1], + "text": negative_prompt + } + }, + "8": { + "class_type": "VAEDecode", + "inputs": { + "samples": ["3", 0], + "vae": ["4", 2] + } + }, + "9": { + "class_type": "SaveImage", + "inputs": { + "filename_prefix": "Axelate", + "images": ["8", 0] + } + } + }) +} + +async fn queue_comfyui_prompt( + client: &reqwest::Client, + context: &ComfyUiRequestContext, + workflow: serde_json::Value, +) -> Result { + let response = client + .post(format!("{}/prompt", context.base_url)) + .json(&serde_json::json!({ + "prompt": workflow, + "client_id": context.client_id, + "prompt_id": context.prompt_id, + })) + .send() + .await + .map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to queue ComfyUI prompt: {error}"), + })?; + + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(AppError::External { + request_id: None, + message: format!("ComfyUI queue request failed: {body}"), + }); + } + + response.json().await.map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to parse ComfyUI queue response: {error}"), + }) +} + +async fn wait_for_comfyui_images( + client: &reqwest::Client, + base_url: &str, + provider: &str, + prompt_id: &str, + image_generation_state: &ImageGenerationState, +) -> Result, AppError> { + let deadline = Instant::now() + Duration::from_mins(10); + + loop { + if image_generation_state + .is_cancelled(provider, Some(prompt_id)) + .await + { + return Err(AppError::External { + request_id: None, + message: "Image generation cancelled".to_string(), + }); + } + + let response = client + .get(format!("{base_url}/history/{prompt_id}")) + .send() + .await + .map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to poll ComfyUI history: {error}"), + })?; + + if response.status().is_success() { + let history_body: serde_json::Value = + response.json().await.map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to parse ComfyUI history: {error}"), + })?; + + if let Some(entry) = history_body.get(prompt_id) { + let images = fetch_comfyui_history_images(client, base_url, entry).await?; + if !images.is_empty() { + return Ok(images); + } + } + } + + if Instant::now() >= deadline { + return Err(AppError::External { + request_id: None, + message: "ComfyUI image generation timed out".to_string(), + }); + } + + tokio::time::sleep(Duration::from_millis(700)).await; + } +} + +async fn fetch_comfyui_history_images( + client: &reqwest::Client, + base_url: &str, + history_entry: &serde_json::Value, +) -> Result, AppError> { + let mut images = Vec::new(); + let Some(outputs) = history_entry + .get("outputs") + .and_then(|value| value.as_object()) + else { + return Ok(images); + }; + + for node_output in outputs.values() { + let Some(node_images) = node_output.get("images").and_then(|value| value.as_array()) else { + continue; + }; + + for image_meta in node_images { + let Some(filename) = image_meta.get("filename").and_then(|value| value.as_str()) else { + continue; + }; + + let subfolder = image_meta + .get("subfolder") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let image_type = image_meta + .get("type") + .and_then(|value| value.as_str()) + .unwrap_or("output"); + let mut image_url = + reqwest::Url::parse(&format!("{base_url}/view")).map_err(|error| { + AppError::External { + request_id: None, + message: format!("Failed to build ComfyUI image URL: {error}"), + } + })?; + { + let mut query = image_url.query_pairs_mut(); + query.append_pair("filename", filename); + if !subfolder.is_empty() { + query.append_pair("subfolder", subfolder); + } + query.append_pair("type", image_type); + } + + let response = + client + .get(image_url) + .send() + .await + .map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to fetch ComfyUI image: {error}"), + })?; + + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(AppError::External { + request_id: None, + message: format!("ComfyUI image download failed: {body}"), + }); + } + + let mime_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("image/png") + .to_string(); + let bytes = response.bytes().await.map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to read ComfyUI image bytes: {error}"), + })?; + + images.push(format!( + "data:{mime_type};base64,{}", + STANDARD.encode(bytes) + )); + } + } + + Ok(images) +} + +fn extract_comfyui_queue_error(body: &serde_json::Value) -> Option { + if let Some(error_message) = body.get("error").and_then(|value| value.as_str()) { + return Some(format!("ComfyUI queue error: {error_message}")); + } + + let node_errors = body.get("node_errors")?; + if !node_errors.is_object() + || node_errors + .as_object() + .is_some_and(serde_json::Map::is_empty) + { + return None; + } + + Some(format!("ComfyUI node validation failed: {node_errors}")) +} diff --git a/src-tauri/src/domain/ai/image_generation_state.rs b/src-tauri/src/domain/ai/image_generation_state.rs index 1306d87c..dc01cf3b 100644 --- a/src-tauri/src/domain/ai/image_generation_state.rs +++ b/src-tauri/src/domain/ai/image_generation_state.rs @@ -7,10 +7,6 @@ use tokio::sync::Mutex; fn provider_matches(active: &str, candidate: &str) -> bool { active == candidate - || matches!( - (active, candidate), - ("sdcpp", "stable-diffusion") | ("stable-diffusion", "sdcpp") - ) } /// Latest progress parsed from a local image engine log line. @@ -91,6 +87,19 @@ impl ImageGenerationState { None } + /// Returns the currently active image job, when one exists. + pub async fn active_job(&self) -> Option { + self.inner.lock().await.clone() + } + + /// Returns whether a matching provider currently has an active image job. + pub async fn is_active(&self, provider: &str) -> bool { + let guard = self.inner.lock().await; + guard + .as_ref() + .is_some_and(|job| provider_matches(&job.provider, provider) && !job.cancelled) + } + /// Updates the prompt identifier for the current active job. pub async fn update_prompt_id(&self, provider: &str, prompt_id: String) { let mut guard = self.inner.lock().await; diff --git a/src-tauri/src/domain/ai/image_http.rs b/src-tauri/src/domain/ai/image_http.rs new file mode 100644 index 00000000..0a6f6a8d --- /dev/null +++ b/src-tauri/src/domain/ai/image_http.rs @@ -0,0 +1,34 @@ +//! Shared HTTP helpers for image generation adapters. + +use std::time::Duration; + +use crate::errors::AppError; + +pub(super) fn build_image_client(timeout: Duration) -> Result { + reqwest::Client::builder() + .timeout(timeout) + .pool_idle_timeout(Duration::from_secs(90)) + .pool_max_idle_per_host(4) + .build() + .map_err(|error| AppError::External { + request_id: None, + message: error.to_string(), + }) +} + +pub(super) async fn parse_image_response_body( + response: reqwest::Response, +) -> Result { + if !response.status().is_success() { + let err_text = response.text().await.unwrap_or_default(); + return Err(AppError::External { + request_id: None, + message: format!("Image generation failed: {err_text}"), + }); + } + + response.json().await.map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to parse image response: {error}"), + }) +} diff --git a/src-tauri/src/domain/ai/image_local.rs b/src-tauri/src/domain/ai/image_local.rs new file mode 100644 index 00000000..2fc9f102 --- /dev/null +++ b/src-tauri/src/domain/ai/image_local.rs @@ -0,0 +1,346 @@ +//! Local image engine dispatch for sd.cpp and OpenAI-compatible image APIs. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use super::ai_dispatch::{LocalEngineAccess, active_local_engine_status, build_engine_config}; +use super::ai_service::stop_conflicting_local_engine; +use super::image_http::{build_image_client, parse_image_response_body}; +use super::image_payload::{build_local_image_payload, build_sdcpp_native_image_payload}; +use super::image_provider_adapter::{LocalImageProtocol, local_image_protocol}; +use super::image_response::{ + ImageResponseFormat, parse_generated_images, parse_sdcpp_generated_images, + summarize_image_response_shape, +}; +use super::types::ImageGenerationRequest; +use crate::domain::ai::ImageGenerationState; +use crate::domain::engine::manager::{EngineManager, resolve_sdcpp_preview_path}; +use crate::domain::engine::types::{Capability, EngineDefinition}; +use crate::errors::AppError; + +struct PreparedImageDispatch { + base_url: String, + request_url: String, + protocol: LocalImageProtocol, + response_format: ImageResponseFormat, + preview_path: Option, +} + +pub(super) async fn process_local_image_request( + request: &ImageGenerationRequest, + engine_manager: &EngineManager, + image_generation_state: &ImageGenerationState, + local_engine_access: LocalEngineAccess, +) -> Result, AppError> { + let dispatch = + prepare_local_image_dispatch(request, engine_manager, local_engine_access).await?; + image_generation_state + .begin(&request.provider, &dispatch.base_url, None) + .await; + + let result = + execute_local_image_request(request, dispatch, engine_manager, image_generation_state) + .await; + image_generation_state.clear(&request.provider, None).await; + + result +} + +async fn prepare_local_image_dispatch( + request: &ImageGenerationRequest, + engine_manager: &EngineManager, + local_engine_access: LocalEngineAccess, +) -> Result { + let Some(definition) = engine_manager.get_definition(&request.provider).await else { + return Err(AppError::External { + request_id: None, + message: "Cloud image generation is not yet supported. Please use a local engine." + .into(), + }); + }; + + tracing::info!( + provider = %request.provider, + "Detected local engine for image generation" + ); + + let (base_url, preview_path) = + resolve_local_image_endpoint(request, engine_manager, local_engine_access, &definition) + .await?; + let protocol = local_image_protocol(&request.provider).ok_or_else(|| { + AppError::Validation(format!( + "Provider {} is not a local image engine", + request.provider + )) + })?; + let response_format = image_response_format(protocol); + let request_url = build_image_generation_url(&base_url, protocol); + + Ok(PreparedImageDispatch { + base_url, + request_url, + protocol, + response_format, + preview_path, + }) +} + +async fn resolve_local_image_endpoint( + request: &ImageGenerationRequest, + engine_manager: &EngineManager, + local_engine_access: LocalEngineAccess, + definition: &EngineDefinition, +) -> Result<(String, Option), AppError> { + match local_engine_access { + LocalEngineAccess::AutoStart => { + let mut config = build_engine_config(definition).await?; + + if !request.model.is_empty() && request.model != "default" { + config.model_path = Some(request.model.clone()); + } + + if config.model_path.as_deref() == Some("default") { + config.model_path = None; + } + + let preview_path = resolve_sdcpp_preview_path(&config.extra_args); + stop_conflicting_local_engine(engine_manager, Capability::Image).await?; + let status = engine_manager.start(config).await?; + + Ok((status.endpoint, preview_path)) + } + LocalEngineAccess::RequireRunning => { + let status = + active_local_engine_status(engine_manager, &request.provider, Capability::Image) + .await?; + let preview_path = engine_manager.active_image_preview_path().await; + Ok((status.endpoint, preview_path)) + } + } +} + +const fn image_response_format(protocol: LocalImageProtocol) -> ImageResponseFormat { + match protocol { + LocalImageProtocol::SdcppNative => ImageResponseFormat::SdApi, + LocalImageProtocol::OpenAiCompatible => ImageResponseFormat::OpenAiCompatible, + } +} + +fn build_image_generation_url(base_url: &str, protocol: LocalImageProtocol) -> String { + match protocol { + LocalImageProtocol::SdcppNative => format!("{base_url}/sdcpp/v1/img_gen"), + LocalImageProtocol::OpenAiCompatible => format!("{base_url}/v1/images/generations"), + } +} + +async fn execute_local_image_request( + request: &ImageGenerationRequest, + dispatch: PreparedImageDispatch, + engine_manager: &EngineManager, + image_generation_state: &ImageGenerationState, +) -> Result, AppError> { + let client = build_image_client(Duration::from_secs(999_999))?; + + if let Some(preview_path) = dispatch.preview_path.as_deref() { + clear_preview_file(preview_path).await; + } + + if dispatch.protocol == LocalImageProtocol::SdcppNative { + return execute_sdcpp_native_image_request( + request, + &dispatch, + engine_manager, + image_generation_state, + &client, + ) + .await; + } + + let payload = build_local_image_payload(request); + tracing::info!( + "Sending image generation request to {}", + dispatch.request_url + ); + let response = client + .post(&dispatch.request_url) + .json(&payload) + .send() + .await + .map_err(|error| AppError::External { + request_id: None, + message: format!( + "Local image engine request failed at {}: {error}. The engine may have stopped, closed the connection, or run out of memory while generating.", + dispatch.request_url + ), + })?; + + let body = parse_image_response_body(response).await?; + let images = parse_generated_images(&body, dispatch.response_format); + if images.is_empty() { + return Err(AppError::External { + request_id: None, + message: format!( + "Local image engine returned no images. Response shape was: {}", + summarize_image_response_shape(&body) + ), + }); + } + + Ok(images) +} + +async fn execute_sdcpp_native_image_request( + request: &ImageGenerationRequest, + dispatch: &PreparedImageDispatch, + engine_manager: &EngineManager, + image_generation_state: &ImageGenerationState, + client: &reqwest::Client, +) -> Result, AppError> { + let payload = build_sdcpp_native_image_payload(request); + tracing::info!( + "Submitting stable-diffusion.cpp native image job to {}", + dispatch.request_url + ); + let response = client + .post(&dispatch.request_url) + .json(&payload) + .send() + .await + .map_err(|error| AppError::External { + request_id: None, + message: format!( + "Local image engine request failed at {}: {error}. The engine may have stopped, closed the connection, or run out of memory while generating.", + dispatch.request_url + ), + })?; + + let body = parse_image_response_body(response).await?; + let job_id = extract_sdcpp_job_id(&body).ok_or_else(|| AppError::External { + request_id: None, + message: format!( + "stable-diffusion.cpp did not return a native job id. Response shape was: {}", + summarize_image_response_shape(&body) + ), + })?; + image_generation_state + .update_prompt_id(&request.provider, job_id.clone()) + .await; + + wait_for_sdcpp_native_images( + client, + &dispatch.base_url, + &request.provider, + &job_id, + engine_manager, + image_generation_state, + ) + .await +} + +fn extract_sdcpp_job_id(body: &serde_json::Value) -> Option { + body.get("id") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) +} + +async fn wait_for_sdcpp_native_images( + client: &reqwest::Client, + base_url: &str, + provider: &str, + job_id: &str, + engine_manager: &EngineManager, + image_generation_state: &ImageGenerationState, +) -> Result, AppError> { + let deadline = Instant::now() + Duration::from_secs(999_999); + let job_url = format!("{}/sdcpp/v1/jobs/{job_id}", base_url.trim_end_matches('/')); + + loop { + if image_generation_state + .is_cancelled(provider, Some(job_id)) + .await + { + return Err(AppError::External { + request_id: None, + message: "Image generation cancelled".to_string(), + }); + } + + let response = match client.get(&job_url).send().await { + Ok(response) => response, + Err(error) => { + let message = format!("Failed to poll stable-diffusion.cpp job: {error}"); + engine_manager + .stop_slot_after_error(Capability::Image, &message) + .await; + return Err(AppError::External { + request_id: None, + message, + }); + } + }; + let body = parse_image_response_body(response).await?; + let status = body + .get("status") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + + match status { + "completed" => { + let images = parse_sdcpp_generated_images(&body); + if !images.is_empty() { + return Ok(images); + } + return Err(AppError::External { + request_id: None, + message: format!( + "stable-diffusion.cpp completed without images. Response shape was: {}", + summarize_image_response_shape(&body) + ), + }); + } + "failed" | "cancelled" => { + return Err(AppError::External { + request_id: None, + message: extract_sdcpp_job_error(&body) + .unwrap_or_else(|| format!("stable-diffusion.cpp job {status}")), + }); + } + "queued" | "generating" => {} + _ => { + return Err(AppError::External { + request_id: None, + message: format!("stable-diffusion.cpp returned unknown job status: {status}"), + }); + } + } + + if Instant::now() >= deadline { + return Err(AppError::External { + request_id: None, + message: "stable-diffusion.cpp image generation timed out".to_string(), + }); + } + + tokio::time::sleep(Duration::from_millis(700)).await; + } +} + +fn extract_sdcpp_job_error(body: &serde_json::Value) -> Option { + body.get("error") + .and_then(|error| error.get("message")) + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) +} + +async fn clear_preview_file(path: &Path) { + if let Err(error) = tokio::fs::remove_file(path).await + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::debug!( + "Failed to clear stale preview file {}: {error}", + path.display() + ); + } +} diff --git a/src-tauri/src/domain/ai/image_payload.rs b/src-tauri/src/domain/ai/image_payload.rs new file mode 100644 index 00000000..11b4fb5c --- /dev/null +++ b/src-tauri/src/domain/ai/image_payload.rs @@ -0,0 +1,304 @@ +//! Image generation payload normalization. + +use super::types::ImageGenerationRequest; + +pub(super) fn build_sdcpp_native_image_payload( + request: &ImageGenerationRequest, +) -> serde_json::Value { + serde_json::json!({ + "prompt": request.prompt, + "negative_prompt": request.negative_prompt.clone().unwrap_or_default(), + "clip_skip": request.clip_skip.unwrap_or(-1), + "width": request.width.unwrap_or(512), + "height": request.height.unwrap_or(512), + "seed": request.seed.unwrap_or(-1), + "batch_count": request.batch_size.unwrap_or(1), + "strength": request.denoising_strength.unwrap_or(0.75), + "sample_params": { + "scheduler": normalize_sdcpp_scheduler(request.scheduler.as_deref()), + "sample_method": normalize_sdcpp_sampler(request.sampler.as_deref()), + "sample_steps": request.steps.unwrap_or(20), + "guidance": { + "txt_cfg": request.cfg_scale.unwrap_or(7.0) + } + }, + "output_format": "png", + "output_compression": 100 + }) +} + +pub(super) fn build_local_image_payload(request: &ImageGenerationRequest) -> serde_json::Value { + let sampler_name = request + .sampler + .clone() + .unwrap_or_else(|| "euler_a".to_string()); + let scheduler = request.scheduler.clone().unwrap_or_default(); + + serde_json::json!({ + "prompt": request.prompt, + "steps": request.steps.unwrap_or(20), + "cfg_scale": request.cfg_scale.unwrap_or(7.0), + "width": request.width.unwrap_or(512), + "height": request.height.unwrap_or(512), + "sampler_name": sampler_name, + "scheduler": scheduler, + "seed": request.seed.unwrap_or(-1), + "batch_size": request.batch_size.unwrap_or(1), + "clip_skip": request.clip_skip.unwrap_or(-1), + "negative_prompt": request.negative_prompt.clone().unwrap_or_default() + }) +} + +pub(super) fn build_cloud_image_payload(request: &ImageGenerationRequest) -> serde_json::Value { + let model = resolve_cloud_image_model(request); + let mut payload = serde_json::json!({ + "model": model, + "messages": [ + { + "role": "user", + "content": request.prompt + } + ], + "modalities": resolve_openrouter_modalities(model) + }); + + if let Some(session_id) = request.session_id.as_ref().map(|value| value.trim()) + && !session_id.is_empty() + && let Some(payload_object) = payload.as_object_mut() + { + payload_object.insert( + "session_id".to_string(), + serde_json::Value::String(session_id.to_string()), + ); + } + + if let Some(image_config) = build_openrouter_image_config(request) + && let Some(payload_object) = payload.as_object_mut() + { + payload_object.insert("image_config".to_string(), image_config); + } + + payload +} + +fn resolve_cloud_image_model(request: &ImageGenerationRequest) -> &str { + if !request.model.trim().is_empty() && request.model != "default" { + return request.model.as_str(); + } + + match request.provider.as_str() { + "gpt-image" => "openai/gpt-5-image", + "seedream-image" => "bytedance-seed/seedream-4.5", + _ => "google/gemini-3.1-flash-image-preview", + } +} + +fn resolve_openrouter_modalities(model: &str) -> &'static [&'static str] { + if supports_text_with_generated_images(model) { + &["image", "text"] + } else { + &["image"] + } +} + +fn supports_text_with_generated_images(model: &str) -> bool { + let normalized = model.trim().to_ascii_lowercase(); + + normalized.starts_with("google/gemini-") + || normalized.starts_with("openai/gpt-5-image") + || normalized.starts_with("openai/gpt-image") +} + +fn build_openrouter_image_config(request: &ImageGenerationRequest) -> Option { + let aspect_ratio = resolve_aspect_ratio(request.width, request.height)?; + Some(serde_json::json!({ + "aspect_ratio": aspect_ratio + })) +} + +fn resolve_aspect_ratio(width: Option, height: Option) -> Option<&'static str> { + let (width, height) = (width?, height?); + match (width, height) { + (1024, 1024) | (512, 512) => Some("1:1"), + (1152, 896) | (1216, 832) => Some("4:3"), + (896, 1152) | (832, 1216) => Some("3:4"), + (1344, 768) | (1536, 864) => Some("16:9"), + (768, 1344) | (864, 1536) => Some("9:16"), + _ => None, + } +} + +fn normalize_sdcpp_sampler(value: Option<&str>) -> String { + match value.unwrap_or("euler a").trim().to_lowercase().as_str() { + "dpm++ 2m" | "dpmpp_2m" => "dpm++2m".to_string(), + "dpm++ 2m v2" | "dpm++2m v2" | "dpm++2m_v2" | "dpmpp_2m_v2" => "dpm++2mv2".to_string(), + "dpm++ 2s a" | "dpmpp_2s_a" => "dpm++2s_a".to_string(), + "euler a" | "euler_a" => "euler_a".to_string(), + "er sde" | "er_sde" => "er_sde".to_string(), + "res 2s" | "res_2s" => "res_2s".to_string(), + "res multistep" | "res_multistep" => "res_multistep".to_string(), + "ddim trailing" | "ddim_trailing" => "ddim_trailing".to_string(), + other => other.replace(' ', "_"), + } +} + +fn normalize_sdcpp_scheduler(value: Option<&str>) -> String { + match value.unwrap_or("discrete").trim().to_lowercase().as_str() { + "sgm uniform" | "sgm_uniform" => "sgm_uniform".to_string(), + "kl optimal" | "kl_optimal" => "kl_optimal".to_string(), + "bong tangent" | "bong_tangent" => "bong_tangent".to_string(), + other => other.replace(' ', "_"), + } +} + +#[cfg(test)] +mod tests { + use super::{build_cloud_image_payload, build_sdcpp_native_image_payload}; + use crate::domain::ai::ImageGenerationRequest; + use serde_json::json; + + fn make_cloud_request(model: &str) -> ImageGenerationRequest { + ImageGenerationRequest { + provider: "gpt-image".to_string(), + prompt: "draw a cat".to_string(), + original_prompt: None, + model: model.to_string(), + settings_key: None, + session_id: None, + steps: None, + cfg_scale: None, + denoising_strength: None, + width: None, + height: None, + sampler: None, + seed: None, + clip_skip: None, + negative_prompt: None, + batch_size: None, + scheduler: None, + } + } + + #[test] + fn build_cloud_image_payload_uses_image_only_modalities_for_flux_models() { + let payload = + build_cloud_image_payload(&make_cloud_request("black-forest-labs/flux.2-max")); + + assert_eq!(payload.get("modalities"), Some(&json!(["image"]))); + } + + #[test] + fn build_cloud_image_payload_uses_image_only_modalities_for_seedream_models() { + let payload = build_cloud_image_payload(&make_cloud_request("bytedance-seed/seedream-4.5")); + + assert_eq!(payload.get("modalities"), Some(&json!(["image"]))); + } + + #[test] + fn build_cloud_image_payload_keeps_text_output_for_gemini_image_models() { + let payload = + build_cloud_image_payload(&make_cloud_request("google/gemini-3.1-flash-image-preview")); + + assert_eq!(payload.get("modalities"), Some(&json!(["image", "text"]))); + } + + #[test] + fn build_cloud_image_payload_keeps_text_output_for_gpt_image_models() { + let payload = build_cloud_image_payload(&make_cloud_request("openai/gpt-5-image-mini")); + + assert_eq!(payload.get("modalities"), Some(&json!(["image", "text"]))); + } + + #[test] + fn build_cloud_image_payload_uses_provider_specific_default_model() { + let payload = build_cloud_image_payload(&ImageGenerationRequest { + provider: "gpt-image".to_string(), + model: "default".to_string(), + ..make_cloud_request("default") + }); + + assert_eq!(payload.get("model"), Some(&json!("openai/gpt-5-image"))); + + let payload = build_cloud_image_payload(&ImageGenerationRequest { + provider: "seedream-image".to_string(), + model: String::new(), + ..make_cloud_request("") + }); + + assert_eq!( + payload.get("model"), + Some(&json!("bytedance-seed/seedream-4.5")) + ); + } + + #[test] + fn builds_native_sdcpp_image_payload() { + let payload = build_sdcpp_native_image_payload(&ImageGenerationRequest { + provider: "sdcpp".to_string(), + prompt: "draw a cat".to_string(), + original_prompt: None, + model: "default".to_string(), + settings_key: None, + session_id: None, + steps: Some(30), + cfg_scale: Some(8.5), + denoising_strength: Some(0.42), + width: Some(896), + height: Some(1152), + sampler: Some("Euler A".to_string()), + seed: Some(42), + clip_skip: Some(2), + negative_prompt: Some("blurry".to_string()), + batch_size: Some(1), + scheduler: Some("Karras".to_string()), + }); + + assert_eq!(payload.get("prompt"), Some(&json!("draw a cat"))); + assert_eq!(payload.get("width"), Some(&json!(896))); + assert_eq!( + payload.pointer("/sample_params/sample_steps"), + Some(&json!(30)) + ); + assert_eq!( + payload.pointer("/sample_params/sample_method"), + Some(&json!("euler_a")) + ); + assert_eq!( + payload.pointer("/sample_params/scheduler"), + Some(&json!("karras")) + ); + assert_eq!( + payload.pointer("/sample_params/guidance/txt_cfg"), + Some(&json!(8.5)) + ); + let strength = payload + .get("strength") + .and_then(serde_json::Value::as_f64) + .unwrap_or_default(); + assert!((strength - 0.42).abs() < 0.001); + } + + #[test] + fn normalizes_sdcpp_er_sde_sampler() { + assert_eq!( + build_sdcpp_native_image_payload(&ImageGenerationRequest { + sampler: Some("ER SDE".to_string()), + ..make_cloud_request("default") + }) + .pointer("/sample_params/sample_method"), + Some(&json!("er_sde")) + ); + } + + #[test] + fn normalizes_sdcpp_dpmpp_2m_v2_sampler_to_official_name() { + assert_eq!( + build_sdcpp_native_image_payload(&ImageGenerationRequest { + sampler: Some("DPM++ 2M v2".to_string()), + ..make_cloud_request("default") + }) + .pointer("/sample_params/sample_method"), + Some(&json!("dpm++2mv2")) + ); + } +} diff --git a/src-tauri/src/domain/ai/image_provider_adapter.rs b/src-tauri/src/domain/ai/image_provider_adapter.rs new file mode 100644 index 00000000..9dfd39f1 --- /dev/null +++ b/src-tauri/src/domain/ai/image_provider_adapter.rs @@ -0,0 +1,159 @@ +//! Image provider routing for cloud, local, and native engine protocols. + +use crate::domain::ai::ImageGenerationState; +use crate::domain::engine::manager::EngineManager; +use crate::domain::engine::types::Capability; +use crate::errors::AppError; + +const COMFYUI_PROVIDER_ID: &str = "comfyui"; +const SDCPP_PROVIDER_ID: &str = "sdcpp"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum LocalImageProtocol { + SdcppNative, + OpenAiCompatible, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ImageProviderRoute { + CloudOpenRouter, + ComfyUi, + Local(LocalImageProtocol), +} + +pub(super) fn route_for_image_provider(provider: &str) -> ImageProviderRoute { + match provider { + "gemini-image" | "gpt-image" | "seedream-image" => ImageProviderRoute::CloudOpenRouter, + COMFYUI_PROVIDER_ID => ImageProviderRoute::ComfyUi, + SDCPP_PROVIDER_ID => ImageProviderRoute::Local(LocalImageProtocol::SdcppNative), + _ => ImageProviderRoute::Local(LocalImageProtocol::OpenAiCompatible), + } +} + +pub(super) fn is_cloud_image_provider(provider: &str) -> bool { + route_for_image_provider(provider) == ImageProviderRoute::CloudOpenRouter +} + +pub(super) fn local_image_protocol(provider: &str) -> Option { + match route_for_image_provider(provider) { + ImageProviderRoute::Local(protocol) => Some(protocol), + ImageProviderRoute::CloudOpenRouter | ImageProviderRoute::ComfyUi => None, + } +} + +/// Cancels the active image-generation job for the selected provider. +pub async fn cancel_image_provider_generation( + provider: &str, + engine_manager: &EngineManager, + image_generation_state: &ImageGenerationState, +) -> Result<(), AppError> { + match route_for_image_provider(provider) { + ImageProviderRoute::ComfyUi => cancel_comfyui_job(provider, image_generation_state).await, + ImageProviderRoute::Local(LocalImageProtocol::SdcppNative) => { + cancel_sdcpp_job(provider, engine_manager, image_generation_state).await + } + ImageProviderRoute::Local(LocalImageProtocol::OpenAiCompatible) => { + image_generation_state.cancel(provider).await; + engine_manager.stop_slot(Capability::Image).await + } + ImageProviderRoute::CloudOpenRouter => { + image_generation_state.cancel(provider).await; + Ok(()) + } + } +} + +async fn cancel_comfyui_job( + provider: &str, + image_generation_state: &ImageGenerationState, +) -> Result<(), AppError> { + if let Some(job) = image_generation_state.cancel(provider).await { + let client = reqwest::Client::new(); + let response = client + .post(format!("{}/interrupt", job.base_url.trim_end_matches('/'))) + .send() + .await + .map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to interrupt ComfyUI job: {error}"), + })?; + + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(AppError::External { + request_id: None, + message: format!("Failed to interrupt ComfyUI job: {body}"), + }); + } + } + + Ok(()) +} + +async fn cancel_sdcpp_job( + provider: &str, + engine_manager: &EngineManager, + image_generation_state: &ImageGenerationState, +) -> Result<(), AppError> { + let mut should_stop_engine = true; + + if let Some(job) = image_generation_state.cancel(provider).await + && let Some(job_id) = job.prompt_id + { + let client = reqwest::Client::new(); + let response = client + .post(format!( + "{}/sdcpp/v1/jobs/{job_id}/cancel", + job.base_url.trim_end_matches('/') + )) + .send() + .await + .map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to cancel stable-diffusion.cpp job: {error}"), + })?; + + should_stop_engine = !response.status().is_success(); + if should_stop_engine && response.status().as_u16() != 409 { + let body = response.text().await.unwrap_or_default(); + tracing::warn!("Failed to cancel stable-diffusion.cpp job via native API: {body}"); + } + } + + if should_stop_engine { + engine_manager.stop_slot(Capability::Image).await?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + ImageProviderRoute, LocalImageProtocol, local_image_protocol, route_for_image_provider, + }; + + #[test] + fn routes_native_image_protocols_by_adapter() { + assert_eq!( + route_for_image_provider("sdcpp"), + ImageProviderRoute::Local(LocalImageProtocol::SdcppNative) + ); + assert_eq!( + local_image_protocol("custom-image-engine"), + Some(LocalImageProtocol::OpenAiCompatible) + ); + } + + #[test] + fn routes_non_local_image_adapters() { + assert_eq!( + route_for_image_provider("comfyui"), + ImageProviderRoute::ComfyUi + ); + assert_eq!( + route_for_image_provider("gpt-image"), + ImageProviderRoute::CloudOpenRouter + ); + } +} diff --git a/src-tauri/src/domain/ai/image_response.rs b/src-tauri/src/domain/ai/image_response.rs new file mode 100644 index 00000000..0c421acb --- /dev/null +++ b/src-tauri/src/domain/ai/image_response.rs @@ -0,0 +1,204 @@ +//! Image response normalization for local and cloud adapters. + +#[derive(Clone, Copy)] +pub(super) enum ImageResponseFormat { + SdApi, + OpenAiCompatible, +} + +pub(super) fn parse_generated_images( + body: &serde_json::Value, + response_format: ImageResponseFormat, +) -> Vec { + match response_format { + ImageResponseFormat::SdApi => parse_sdcpp_generated_images(body), + ImageResponseFormat::OpenAiCompatible => body + .get("data") + .and_then(|value| value.as_array()) + .into_iter() + .flat_map(|items| items.iter()) + .filter_map(|item| { + item.get("b64_json") + .and_then(|value| value.as_str()) + .map(|b64| format!("data:image/png;base64,{b64}")) + .or_else(|| { + item.get("url") + .and_then(|value| value.as_str()) + .map(str::to_string) + }) + }) + .collect(), + } +} + +pub(super) fn parse_sdcpp_generated_images(body: &serde_json::Value) -> Vec { + let output_format = body + .get("result") + .and_then(|value| value.get("output_format")) + .or_else(|| body.get("output_format")) + .and_then(serde_json::Value::as_str) + .unwrap_or("png"); + + parse_image_items(body.get("images"), output_format) + .into_iter() + .chain(parse_image_items( + body.get("result").and_then(|value| value.get("images")), + output_format, + )) + .chain( + body.get("result") + .and_then(|value| value.get("b64_json")) + .and_then(serde_json::Value::as_str) + .map(|b64| data_url_from_b64(output_format, b64)), + ) + .collect() +} + +fn parse_image_items(value: Option<&serde_json::Value>, output_format: &str) -> Vec { + value + .and_then(serde_json::Value::as_array) + .into_iter() + .flat_map(|items| items.iter()) + .filter_map(|item| { + item.as_str() + .map(|b64| data_url_from_b64(output_format, b64)) + .or_else(|| { + item.get("b64_json") + .and_then(serde_json::Value::as_str) + .map(|b64| data_url_from_b64(output_format, b64)) + }) + .or_else(|| { + item.get("url") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + }) + .collect() +} + +fn data_url_from_b64(output_format: &str, b64: &str) -> String { + let format = output_format + .trim() + .trim_start_matches('.') + .to_ascii_lowercase(); + let mime = match format.as_str() { + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + "gif" => "image/gif", + _ => "image/png", + }; + format!("data:{mime};base64,{b64}") +} + +pub(super) fn summarize_image_response_shape(body: &serde_json::Value) -> String { + let Some(object) = body.as_object() else { + return body + .as_str() + .map_or_else(|| body.to_string(), std::string::ToString::to_string); + }; + + object + .iter() + .map(|(key, value)| { + let kind = if value.is_array() { + "array" + } else if value.is_object() { + "object" + } else if value.is_string() { + "string" + } else if value.is_number() { + "number" + } else if value.is_boolean() { + "boolean" + } else { + "null" + }; + format!("{key}:{kind}") + }) + .collect::>() + .join(", ") +} + +pub(super) fn parse_openrouter_generated_images(body: &serde_json::Value) -> Vec { + body.get("choices") + .and_then(|value| value.as_array()) + .into_iter() + .flat_map(|items| items.iter()) + .filter_map(|item| item.get("message")) + .flat_map(extract_images_from_openrouter_message) + .collect() +} + +fn extract_images_from_openrouter_message(message: &serde_json::Value) -> Vec { + if let Some(images) = message.get("images").and_then(|value| value.as_array()) { + return images + .iter() + .filter_map(extract_openrouter_image_url) + .collect(); + } + + if let Some(content) = message.get("content").and_then(|value| value.as_array()) { + return content + .iter() + .filter_map(|item| { + item.get("image_url") + .and_then(|value| value.get("url")) + .and_then(|value| value.as_str()) + .map(str::to_string) + }) + .collect(); + } + + Vec::new() +} + +fn extract_openrouter_image_url(item: &serde_json::Value) -> Option { + item.get("image_url") + .and_then(|value| value.get("url")) + .and_then(|value| value.as_str()) + .map(str::to_string) + .or_else(|| { + item.get("imageUrl") + .and_then(|value| value.get("url")) + .and_then(|value| value.as_str()) + .map(str::to_string) + }) +} + +#[cfg(test)] +mod tests { + use super::{ImageResponseFormat, parse_generated_images}; + use serde_json::json; + + #[test] + fn parses_stable_diffusion_webui_style_images() { + let images = parse_generated_images( + &json!({ + "images": ["ZmFrZQ=="], + "parameters": {}, + "info": "{}" + }), + ImageResponseFormat::SdApi, + ); + + assert_eq!(images, vec!["data:image/png;base64,ZmFrZQ=="]); + } + + #[test] + fn parses_sdcpp_webui_result_images() { + let images = parse_generated_images( + &json!({ + "kind": "img_gen", + "result": { + "output_format": "webp", + "images": [ + { "b64_json": "ZmFrZQ==" } + ] + } + }), + ImageResponseFormat::SdApi, + ); + + assert_eq!(images, vec!["data:image/webp;base64,ZmFrZQ=="]); + } +} diff --git a/src-tauri/src/domain/ai/image_service.rs b/src-tauri/src/domain/ai/image_service.rs index 8e20e9d9..b5ac246b 100644 --- a/src-tauri/src/domain/ai/image_service.rs +++ b/src-tauri/src/domain/ai/image_service.rs @@ -1,59 +1,17 @@ -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use std::path::Path; -use std::time::{Duration, Instant}; - -use super::ai_dispatch::{LocalEngineAccess, active_local_engine_status, build_engine_config}; +use super::ai_dispatch::{LocalEngineAccess, normalize_session_id}; use super::ai_service::stop_conflicting_local_engine; +use super::image_cloud::{is_cloud_image_provider, process_cloud_image_request}; +use super::image_comfyui::process_comfyui_request; +use super::image_local::process_local_image_request; +use super::image_provider_adapter::{ImageProviderRoute, route_for_image_provider}; +use super::image_settings::apply_image_request_defaults; use super::session::ChatSessionManager; use super::types::{ChatMessage, ChatReply, ImageGenerationRequest, ImageGenerationResponse}; use crate::domain::ai::ImageGenerationState; -use crate::domain::engine::manager::{EngineManager, resolve_sdcpp_preview_path}; +use crate::domain::engine::manager::EngineManager; use crate::domain::engine::types::Capability; use crate::errors::AppError; use crate::infrastructure::config::settings::SettingsService; -use crate::infrastructure::crypto::secure_storage::SecureStorage; -use crate::models::AppSettings; - -struct PreparedImageDispatch { - base_url: String, - request_url: String, - api: LocalImageApi, - response_format: ImageResponseFormat, - preview_path: Option, -} - -struct ImageRequestSettingsContext { - settings: AppSettings, - settings_key: String, -} - -struct ComfyUiRequestContext { - base_url: String, - checkpoint: String, - sampler: String, - scheduler: String, - seed: u64, - steps: u32, - cfg_scale: f32, - width: u32, - height: u32, - batch_size: u32, - negative_prompt: String, - prompt_id: String, - client_id: String, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum LocalImageApi { - SdcppNative, - OpenAiCompatible, -} - -#[derive(Clone, Copy)] -enum ImageResponseFormat { - SdApi, - OpenAiCompatible, -} pub(super) async fn process_image_request( request: ImageGenerationRequest, @@ -100,29 +58,32 @@ async fn process_image_request_with_local_engine_access( local_engine_access: LocalEngineAccess, ) -> Result { let request = apply_image_request_defaults(request, settings_service).await?; - let _local_workload_guard = if is_cloud_image_provider(&request.provider) { - None - } else { - Some(engine_manager.acquire_local_workload().await) - }; + let images = { + let _local_workload_guard = if is_cloud_image_provider(&request.provider) { + None + } else { + Some(engine_manager.acquire_local_workload().await) + }; - let images = if request.provider == "comfyui" { - stop_conflicting_local_engine(engine_manager, Capability::Image).await?; - process_comfyui_request(&request, image_generation_state, settings_service).await? - } else if is_cloud_image_provider(&request.provider) { - process_cloud_image_request(&request).await? - } else { - let dispatch = - prepare_local_image_dispatch(&request, engine_manager, local_engine_access).await?; - image_generation_state - .begin(&request.provider, &dispatch.base_url, None) - .await; - let result = execute_local_image_request(&request, dispatch, image_generation_state).await; - image_generation_state.clear(&request.provider, None).await; - result? + match route_for_image_provider(&request.provider) { + ImageProviderRoute::ComfyUi => { + stop_conflicting_local_engine(engine_manager, Capability::Image).await?; + process_comfyui_request(&request, image_generation_state, settings_service).await? + } + ImageProviderRoute::CloudOpenRouter => process_cloud_image_request(&request).await?, + ImageProviderRoute::Local(_) => { + process_local_image_request( + &request, + engine_manager, + image_generation_state, + local_engine_access, + ) + .await? + } + } }; - if let Some(session_id) = request.session_id.as_deref() + if let Some(session_id) = normalize_session_id(request.session_id.as_deref()) && !images.is_empty() { let user_message = ChatMessage { @@ -150,6 +111,13 @@ async fn process_image_request_with_local_engine_access( &reply.role, None, ); + if let Err(error) = sessions.force_save().await { + tracing::warn!( + session_id, + image_count = images.len(), + "Failed to persist generated image transcript: {error}" + ); + } } Ok(ImageGenerationResponse { @@ -159,1279 +127,6 @@ async fn process_image_request_with_local_engine_access( }) } -async fn prepare_local_image_dispatch( - request: &ImageGenerationRequest, - engine_manager: &EngineManager, - local_engine_access: LocalEngineAccess, -) -> Result { - let Some(definition) = engine_manager.get_definition(&request.provider).await else { - return Err(AppError::External { - request_id: None, - message: "Cloud image generation is not yet supported. Please use a local engine." - .into(), - }); - }; - - tracing::info!( - provider = %request.provider, - "Detected local engine for image generation" - ); - - let (base_url, preview_path) = - resolve_local_image_endpoint(request, engine_manager, local_engine_access, &definition) - .await?; - let api = local_image_api(&request.provider); - let response_format = image_response_format(api); - let request_url = build_image_generation_url(&base_url, api); - - Ok(PreparedImageDispatch { - base_url, - request_url, - api, - response_format, - preview_path, - }) -} - -fn is_cloud_image_provider(provider: &str) -> bool { - matches!(provider, "gemini-image" | "gpt-image" | "seedream-image") -} - -fn local_image_api(provider: &str) -> LocalImageApi { - if matches!(provider, "sdcpp" | "stable-diffusion") { - LocalImageApi::SdcppNative - } else { - LocalImageApi::OpenAiCompatible - } -} - -async fn resolve_local_image_endpoint( - request: &ImageGenerationRequest, - engine_manager: &EngineManager, - local_engine_access: LocalEngineAccess, - definition: &crate::domain::engine::types::EngineDefinition, -) -> Result<(String, Option), AppError> { - match local_engine_access { - LocalEngineAccess::AutoStart => { - let mut config = build_engine_config(definition).await?; - - if !request.model.is_empty() && request.model != "default" { - config.model_path = Some(request.model.clone()); - } - - if config.model_path.as_deref() == Some("default") { - config.model_path = None; - } - - let preview_path = resolve_sdcpp_preview_path(&config.extra_args); - stop_conflicting_local_engine(engine_manager, Capability::Image).await?; - let status = engine_manager.start(config).await?; - - Ok((status.endpoint, preview_path)) - } - LocalEngineAccess::RequireRunning => { - let status = - active_local_engine_status(engine_manager, &request.provider, Capability::Image) - .await?; - let preview_path = engine_manager.active_image_preview_path().await; - Ok((status.endpoint, preview_path)) - } - } -} - -const fn image_response_format(api: LocalImageApi) -> ImageResponseFormat { - match api { - LocalImageApi::SdcppNative => ImageResponseFormat::SdApi, - LocalImageApi::OpenAiCompatible => ImageResponseFormat::OpenAiCompatible, - } -} - -fn build_image_generation_url(base_url: &str, api: LocalImageApi) -> String { - match api { - LocalImageApi::SdcppNative => format!("{base_url}/sdcpp/v1/img_gen"), - LocalImageApi::OpenAiCompatible => format!("{base_url}/v1/images/generations"), - } -} - -async fn execute_local_image_request( - request: &ImageGenerationRequest, - dispatch: PreparedImageDispatch, - image_generation_state: &ImageGenerationState, -) -> Result, AppError> { - let client = build_image_client(Duration::from_secs(999_999))?; - - if let Some(preview_path) = dispatch.preview_path.as_deref() { - clear_preview_file(preview_path).await; - } - - if dispatch.api == LocalImageApi::SdcppNative { - return execute_sdcpp_native_image_request( - request, - &dispatch, - image_generation_state, - &client, - ) - .await; - } - - let payload = build_local_image_payload(request); - tracing::info!( - "Sending image generation request to {}", - dispatch.request_url - ); - let response = client - .post(&dispatch.request_url) - .json(&payload) - .send() - .await - .map_err(|error| AppError::External { - request_id: None, - message: format!( - "Local image engine request failed at {}: {error}. The engine may have stopped, closed the connection, or run out of memory while generating.", - dispatch.request_url - ), - })?; - - let body = parse_image_response_body(response).await?; - let images = parse_generated_images(&body, dispatch.response_format); - if images.is_empty() { - return Err(AppError::External { - request_id: None, - message: format!( - "Local image engine returned no images. Response shape was: {}", - summarize_image_response_shape(&body) - ), - }); - } - - Ok(images) -} - -async fn execute_sdcpp_native_image_request( - request: &ImageGenerationRequest, - dispatch: &PreparedImageDispatch, - image_generation_state: &ImageGenerationState, - client: &reqwest::Client, -) -> Result, AppError> { - let payload = build_sdcpp_native_image_payload(request); - tracing::info!( - "Submitting stable-diffusion.cpp native image job to {}", - dispatch.request_url - ); - let response = client - .post(&dispatch.request_url) - .json(&payload) - .send() - .await - .map_err(|error| AppError::External { - request_id: None, - message: format!( - "Local image engine request failed at {}: {error}. The engine may have stopped, closed the connection, or run out of memory while generating.", - dispatch.request_url - ), - })?; - - let body = parse_image_response_body(response).await?; - let job_id = extract_sdcpp_job_id(&body).ok_or_else(|| AppError::External { - request_id: None, - message: format!( - "stable-diffusion.cpp did not return a native job id. Response shape was: {}", - summarize_image_response_shape(&body) - ), - })?; - image_generation_state - .update_prompt_id(&request.provider, job_id.clone()) - .await; - - wait_for_sdcpp_native_images( - client, - &dispatch.base_url, - &request.provider, - &job_id, - image_generation_state, - ) - .await -} - -fn extract_sdcpp_job_id(body: &serde_json::Value) -> Option { - body.get("id") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.trim().is_empty()) - .map(str::to_string) -} - -async fn wait_for_sdcpp_native_images( - client: &reqwest::Client, - base_url: &str, - provider: &str, - job_id: &str, - image_generation_state: &ImageGenerationState, -) -> Result, AppError> { - let deadline = Instant::now() + Duration::from_secs(999_999); - let job_url = format!("{}/sdcpp/v1/jobs/{job_id}", base_url.trim_end_matches('/')); - - loop { - if image_generation_state - .is_cancelled(provider, Some(job_id)) - .await - { - return Err(AppError::External { - request_id: None, - message: "Image generation cancelled".to_string(), - }); - } - - let response = client - .get(&job_url) - .send() - .await - .map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to poll stable-diffusion.cpp job: {error}"), - })?; - let body = parse_image_response_body(response).await?; - let status = body - .get("status") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - - match status { - "completed" => { - let images = parse_sdcpp_generated_images(&body); - if !images.is_empty() { - return Ok(images); - } - return Err(AppError::External { - request_id: None, - message: format!( - "stable-diffusion.cpp completed without images. Response shape was: {}", - summarize_image_response_shape(&body) - ), - }); - } - "failed" | "cancelled" => { - return Err(AppError::External { - request_id: None, - message: extract_sdcpp_job_error(&body) - .unwrap_or_else(|| format!("stable-diffusion.cpp job {status}")), - }); - } - "queued" | "generating" => {} - _ => { - return Err(AppError::External { - request_id: None, - message: format!("stable-diffusion.cpp returned unknown job status: {status}"), - }); - } - } - - if Instant::now() >= deadline { - return Err(AppError::External { - request_id: None, - message: "stable-diffusion.cpp image generation timed out".to_string(), - }); - } - - tokio::time::sleep(Duration::from_millis(700)).await; - } -} - -fn extract_sdcpp_job_error(body: &serde_json::Value) -> Option { - body.get("error") - .and_then(|error| error.get("message")) - .and_then(serde_json::Value::as_str) - .filter(|value| !value.trim().is_empty()) - .map(str::to_string) -} - -fn build_sdcpp_native_image_payload(request: &ImageGenerationRequest) -> serde_json::Value { - serde_json::json!({ - "prompt": request.prompt, - "negative_prompt": request.negative_prompt.clone().unwrap_or_default(), - "clip_skip": request.clip_skip.unwrap_or(-1), - "width": request.width.unwrap_or(512), - "height": request.height.unwrap_or(512), - "seed": request.seed.unwrap_or(-1), - "batch_count": request.batch_size.unwrap_or(1), - "sample_params": { - "scheduler": normalize_sdcpp_scheduler(request.scheduler.as_deref()), - "sample_method": normalize_sdcpp_sampler(request.sampler.as_deref()), - "sample_steps": request.steps.unwrap_or(20), - "strength": request.denoising_strength.unwrap_or(0.75), - "guidance": { - "txt_cfg": request.cfg_scale.unwrap_or(7.0) - } - }, - "output_format": "png", - "output_compression": 100 - }) -} - -fn build_local_image_payload(request: &ImageGenerationRequest) -> serde_json::Value { - let sampler_name = request - .sampler - .clone() - .unwrap_or_else(|| "euler_a".to_string()); - let scheduler = request.scheduler.clone().unwrap_or_default(); - - serde_json::json!({ - "prompt": request.prompt, - "steps": request.steps.unwrap_or(20), - "cfg_scale": request.cfg_scale.unwrap_or(7.0), - "width": request.width.unwrap_or(512), - "height": request.height.unwrap_or(512), - "sampler_name": sampler_name, - "scheduler": scheduler, - "seed": request.seed.unwrap_or(-1), - "batch_size": request.batch_size.unwrap_or(1), - "clip_skip": request.clip_skip.unwrap_or(-1), - "negative_prompt": request.negative_prompt.clone().unwrap_or_default() - }) -} - -async fn process_cloud_image_request( - request: &ImageGenerationRequest, -) -> Result, AppError> { - let api_key = SecureStorage::get_key_async("openrouter_api_key".to_string()) - .await? - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| AppError::Validation("OpenRouter API key is missing".to_string()))?; - - let client = build_image_client(Duration::from_secs(180))?; - let response = client - .post("https://openrouter.ai/api/v1/chat/completions") - .header(reqwest::header::AUTHORIZATION, format!("Bearer {api_key}")) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .json(&build_cloud_image_payload(request)) - .send() - .await - .map_err(|error| AppError::External { - request_id: None, - message: format!("Cloud image request failed: {error}"), - })?; - - let body = parse_image_response_body(response).await?; - let images = parse_openrouter_generated_images(&body); - if images.is_empty() { - return Err(AppError::External { - request_id: None, - message: "Cloud image provider returned no images".to_string(), - }); - } - - Ok(images) -} - -fn build_cloud_image_payload(request: &ImageGenerationRequest) -> serde_json::Value { - let model = resolve_cloud_image_model(request); - let mut payload = serde_json::json!({ - "model": model, - "messages": [ - { - "role": "user", - "content": request.prompt - } - ], - "modalities": resolve_openrouter_modalities(model) - }); - - if let Some(session_id) = request.session_id.as_ref().map(|value| value.trim()) - && !session_id.is_empty() - && let Some(payload_object) = payload.as_object_mut() - { - payload_object.insert( - "session_id".to_string(), - serde_json::Value::String(session_id.to_string()), - ); - } - - if let Some(image_config) = build_openrouter_image_config(request) { - if let Some(payload_object) = payload.as_object_mut() { - payload_object.insert("image_config".to_string(), image_config); - } - } - - payload -} - -fn resolve_cloud_image_model(request: &ImageGenerationRequest) -> &str { - if !request.model.trim().is_empty() && request.model != "default" { - return request.model.as_str(); - } - - match request.provider.as_str() { - "gpt-image" => "openai/gpt-5-image", - "seedream-image" => "bytedance-seed/seedream-4.5", - _ => "google/gemini-3.1-flash-image-preview", - } -} - -fn resolve_openrouter_modalities(model: &str) -> &'static [&'static str] { - if supports_text_with_generated_images(model) { - &["image", "text"] - } else { - &["image"] - } -} - -fn supports_text_with_generated_images(model: &str) -> bool { - let normalized = model.trim().to_ascii_lowercase(); - - normalized.starts_with("google/gemini-") - || normalized.starts_with("openai/gpt-5-image") - || normalized.starts_with("openai/gpt-image") -} - -fn build_openrouter_image_config(request: &ImageGenerationRequest) -> Option { - let aspect_ratio = resolve_aspect_ratio(request.width, request.height)?; - Some(serde_json::json!({ - "aspect_ratio": aspect_ratio - })) -} - -fn resolve_aspect_ratio(width: Option, height: Option) -> Option<&'static str> { - let (width, height) = (width?, height?); - match (width, height) { - (1024, 1024) | (512, 512) => Some("1:1"), - (1152, 896) | (1216, 832) => Some("4:3"), - (896, 1152) | (832, 1216) => Some("3:4"), - (1344, 768) | (1536, 864) => Some("16:9"), - (768, 1344) | (864, 1536) => Some("9:16"), - _ => None, - } -} - -fn build_image_client(timeout: Duration) -> Result { - reqwest::Client::builder() - .timeout(timeout) - .build() - .map_err(|error| AppError::External { - request_id: None, - message: error.to_string(), - }) -} - -async fn parse_image_response_body( - response: reqwest::Response, -) -> Result { - if !response.status().is_success() { - let err_text = response.text().await.unwrap_or_default(); - return Err(AppError::External { - request_id: None, - message: format!("Image generation failed: {err_text}"), - }); - } - - response.json().await.map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to parse image response: {error}"), - }) -} - -fn parse_generated_images( - body: &serde_json::Value, - response_format: ImageResponseFormat, -) -> Vec { - match response_format { - ImageResponseFormat::SdApi => parse_sdcpp_generated_images(body), - ImageResponseFormat::OpenAiCompatible => body - .get("data") - .and_then(|value| value.as_array()) - .into_iter() - .flat_map(|items| items.iter()) - .filter_map(|item| { - item.get("b64_json") - .and_then(|value| value.as_str()) - .map(|b64| format!("data:image/png;base64,{b64}")) - .or_else(|| { - item.get("url") - .and_then(|value| value.as_str()) - .map(str::to_string) - }) - }) - .collect(), - } -} - -fn parse_sdcpp_generated_images(body: &serde_json::Value) -> Vec { - let output_format = body - .get("result") - .and_then(|value| value.get("output_format")) - .or_else(|| body.get("output_format")) - .and_then(serde_json::Value::as_str) - .unwrap_or("png"); - - parse_image_items(body.get("images"), output_format) - .into_iter() - .chain(parse_image_items( - body.get("result").and_then(|value| value.get("images")), - output_format, - )) - .chain( - body.get("result") - .and_then(|value| value.get("b64_json")) - .and_then(serde_json::Value::as_str) - .map(|b64| data_url_from_b64(output_format, b64)), - ) - .collect() -} - -fn parse_image_items(value: Option<&serde_json::Value>, output_format: &str) -> Vec { - value - .and_then(serde_json::Value::as_array) - .into_iter() - .flat_map(|items| items.iter()) - .filter_map(|item| { - item.as_str() - .map(|b64| data_url_from_b64(output_format, b64)) - .or_else(|| { - item.get("b64_json") - .and_then(serde_json::Value::as_str) - .map(|b64| data_url_from_b64(output_format, b64)) - }) - .or_else(|| { - item.get("url") - .and_then(serde_json::Value::as_str) - .map(str::to_string) - }) - }) - .collect() -} - -fn data_url_from_b64(output_format: &str, b64: &str) -> String { - let format = output_format - .trim() - .trim_start_matches('.') - .to_ascii_lowercase(); - let mime = match format.as_str() { - "jpg" | "jpeg" => "image/jpeg", - "webp" => "image/webp", - "gif" => "image/gif", - _ => "image/png", - }; - format!("data:{mime};base64,{b64}") -} - -fn summarize_image_response_shape(body: &serde_json::Value) -> String { - let Some(object) = body.as_object() else { - return body - .as_str() - .map_or_else(|| body.to_string(), std::string::ToString::to_string); - }; - - object - .iter() - .map(|(key, value)| { - let kind = if value.is_array() { - "array" - } else if value.is_object() { - "object" - } else if value.is_string() { - "string" - } else if value.is_number() { - "number" - } else if value.is_boolean() { - "boolean" - } else { - "null" - }; - format!("{key}:{kind}") - }) - .collect::>() - .join(", ") -} - -fn parse_openrouter_generated_images(body: &serde_json::Value) -> Vec { - body.get("choices") - .and_then(|value| value.as_array()) - .into_iter() - .flat_map(|items| items.iter()) - .filter_map(|item| item.get("message")) - .flat_map(extract_images_from_openrouter_message) - .collect() -} - -fn extract_images_from_openrouter_message(message: &serde_json::Value) -> Vec { - if let Some(images) = message.get("images").and_then(|value| value.as_array()) { - return images - .iter() - .filter_map(extract_openrouter_image_url) - .collect(); - } - - if let Some(content) = message.get("content").and_then(|value| value.as_array()) { - return content - .iter() - .filter_map(|item| { - item.get("image_url") - .and_then(|value| value.get("url")) - .and_then(|value| value.as_str()) - .map(str::to_string) - }) - .collect(); - } - - Vec::new() -} - -fn extract_openrouter_image_url(item: &serde_json::Value) -> Option { - item.get("image_url") - .and_then(|value| value.get("url")) - .and_then(|value| value.as_str()) - .map(str::to_string) - .or_else(|| { - item.get("imageUrl") - .and_then(|value| value.get("url")) - .and_then(|value| value.as_str()) - .map(str::to_string) - }) -} - -async fn process_comfyui_request( - request: &ImageGenerationRequest, - image_generation_state: &ImageGenerationState, - settings_service: &SettingsService, -) -> Result, AppError> { - let settings_context = load_image_request_settings_context(request, settings_service).await?; - let client = build_image_client(Duration::from_secs(120))?; - let comfyui = build_comfyui_request_context(request, &settings_context, &client).await?; - let workflow = build_comfyui_workflow( - &request.prompt, - &comfyui.negative_prompt, - &comfyui.checkpoint, - comfyui.seed, - comfyui.steps, - comfyui.cfg_scale, - comfyui.width, - comfyui.height, - comfyui.batch_size, - &comfyui.sampler, - &comfyui.scheduler, - ); - - image_generation_state - .begin( - &request.provider, - &comfyui.base_url, - Some(comfyui.prompt_id.clone()), - ) - .await; - - let mut active_prompt_id = comfyui.prompt_id.clone(); - let result = async { - let queue_body = queue_comfyui_prompt(&client, &comfyui, workflow).await?; - - if let Some(server_prompt_id) = queue_body.get("prompt_id").and_then(|value| value.as_str()) - && !server_prompt_id.trim().is_empty() - { - active_prompt_id = server_prompt_id.to_string(); - image_generation_state - .update_prompt_id(&request.provider, active_prompt_id.clone()) - .await; - } - - if let Some(message) = extract_comfyui_queue_error(&queue_body) { - return Err(AppError::External { - request_id: None, - message, - }); - } - - wait_for_comfyui_images( - &client, - &comfyui.base_url, - &request.provider, - &active_prompt_id, - image_generation_state, - ) - .await - } - .await; - - image_generation_state - .clear(&request.provider, Some(active_prompt_id.as_str())) - .await; - - result -} - -async fn resolve_comfyui_checkpoint( - request: &ImageGenerationRequest, - settings: &AppSettings, - settings_key: &str, - base_url: &str, - client: &reqwest::Client, -) -> Result { - if !request.model.trim().is_empty() && request.model != "default" { - return Ok(normalize_comfyui_checkpoint(&request.model)); - } - - if let Some(saved_checkpoint) = - resolve_string_setting(settings, settings_key, &request.provider, "checkpoint") - { - return Ok(normalize_comfyui_checkpoint(&saved_checkpoint)); - } - - let available_checkpoints = fetch_comfyui_checkpoints(client, base_url).await?; - if let Some(checkpoint) = available_checkpoints.first() { - return Ok(normalize_comfyui_checkpoint(checkpoint)); - } - - Err(AppError::Config( - "ComfyUI does not expose any checkpoints yet. Install a model in ComfyUI and try again." - .to_string(), - )) -} - -fn normalize_comfyui_base_url(raw: &str) -> String { - let trimmed = raw.trim().trim_end_matches('/'); - if trimmed.is_empty() { - return "http://127.0.0.1:8188".to_string(); - } - - if trimmed.starts_with("http://") || trimmed.starts_with("https://") { - return trimmed.to_string(); - } - - format!("http://{trimmed}") -} - -fn normalize_comfyui_checkpoint(raw: &str) -> String { - raw.trim() - .replace('\\', "/") - .split('/') - .next_back() - .unwrap_or(raw) - .trim() - .to_string() -} - -pub(super) fn normalize_comfyui_sampler(value: Option<&str>) -> String { - match value.unwrap_or("euler").trim().to_lowercase().as_str() { - "euler a" | "euler_a" | "euler ancestral" | "euler_ancestral" => { - "euler_ancestral".to_string() - } - "euler" => "euler".to_string(), - "heun" => "heun".to_string(), - "heunpp2" => "heunpp2".to_string(), - "dpm2" | "dpm 2" | "dpm_2" => "dpm_2".to_string(), - "dpm2 a" | "dpm2_a" | "dpm 2 ancestral" | "dpm_2_ancestral" => { - "dpm_2_ancestral".to_string() - } - "lms" => "lms".to_string(), - "dpm fast" | "dpm_fast" => "dpm_fast".to_string(), - "dpm adaptive" | "dpm_adaptive" => "dpm_adaptive".to_string(), - "dpm++ 2s a" | "dpm++2s_a" | "dpmpp_2s_a" | "dpmpp_2s_ancestral" => { - "dpmpp_2s_ancestral".to_string() - } - "dpm++ sde" | "dpmpp_sde" => "dpmpp_sde".to_string(), - "dpm++ sde gpu" | "dpmpp_sde_gpu" => "dpmpp_sde_gpu".to_string(), - "dpm++ 2m" | "dpm++2m" | "dpmpp_2m" => "dpmpp_2m".to_string(), - "dpm++ 3m sde" | "dpm++3m sde" | "dpmpp_3m_sde" => "dpmpp_3m_sde".to_string(), - "dpm++ 3m sde gpu" | "dpm++3m sde gpu" | "dpmpp_3m_sde_gpu" => { - "dpmpp_3m_sde_gpu".to_string() - } - "ddpm" => "ddpm".to_string(), - "lcm" => "lcm".to_string(), - "ipndm" => "ipndm".to_string(), - "ipndm_v" => "ipndm_v".to_string(), - "deis" => "deis".to_string(), - "ddim" => "ddim".to_string(), - "uni pc" | "uni_pc" => "uni_pc".to_string(), - "uni pc bh2" | "uni_pc_bh2" => "uni_pc_bh2".to_string(), - other => other.to_string(), - } -} - -pub(super) fn normalize_comfyui_scheduler(value: Option<&str>) -> String { - match value.unwrap_or("karras").trim().to_lowercase().as_str() { - "default" | "auto" | "karras" => "karras".to_string(), - "normal" => "normal".to_string(), - "simple" => "simple".to_string(), - "sgm uniform" | "sgm_uniform" => "sgm_uniform".to_string(), - "exponential" => "exponential".to_string(), - "ddim uniform" | "ddim_uniform" => "ddim_uniform".to_string(), - "beta" => "beta".to_string(), - "linear quadratic" | "linear_quadratic" => "linear_quadratic".to_string(), - "kl optimal" | "kl_optimal" => "kl_optimal".to_string(), - other => other.to_string(), - } -} - -fn normalize_comfyui_seed(value: Option) -> u64 { - match value { - Some(seed) if seed >= 0 => u64::from(seed.unsigned_abs()), - _ => rand::random::(), - } -} - -async fn fetch_comfyui_checkpoints( - client: &reqwest::Client, - base_url: &str, -) -> Result, AppError> { - let response = client - .get(format!("{base_url}/models/checkpoints")) - .send() - .await - .map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to query ComfyUI checkpoints: {error}"), - })?; - - if !response.status().is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(AppError::External { - request_id: None, - message: format!("ComfyUI checkpoints request failed: {body}"), - }); - } - - let payload: serde_json::Value = response.json().await.map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to parse ComfyUI checkpoint list: {error}"), - })?; - - Ok(parse_comfyui_checkpoint_list(&payload)) -} - -pub(super) fn parse_comfyui_checkpoint_list(payload: &serde_json::Value) -> Vec { - fn extract_checkpoint_name(value: &serde_json::Value) -> Option { - if let Some(name) = value.as_str() { - let trimmed = name.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } - } - - let object = value.as_object()?; - for key in ["name", "filename", "path"] { - if let Some(candidate) = object.get(key).and_then(|entry| entry.as_str()) { - let trimmed = candidate.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } - } - } - - None - } - - let values = if let Some(items) = payload.as_array() { - items.iter().collect::>() - } else if let Some(items) = payload.get("models").and_then(|value| value.as_array()) { - items.iter().collect::>() - } else if let Some(items) = payload.get("files").and_then(|value| value.as_array()) { - items.iter().collect::>() - } else { - Vec::new() - }; - - let mut seen = std::collections::HashSet::new(); - values - .into_iter() - .filter_map(extract_checkpoint_name) - .filter(|value| seen.insert(value.clone())) - .collect() -} - -#[allow(clippy::too_many_arguments)] -fn build_comfyui_workflow( - prompt: &str, - negative_prompt: &str, - checkpoint: &str, - seed: u64, - steps: u32, - cfg_scale: f32, - width: u32, - height: u32, - batch_size: u32, - sampler: &str, - scheduler: &str, -) -> serde_json::Value { - serde_json::json!({ - "3": { - "class_type": "KSampler", - "inputs": { - "cfg": cfg_scale, - "denoise": 1.0, - "latent_image": ["5", 0], - "model": ["4", 0], - "negative": ["7", 0], - "positive": ["6", 0], - "sampler_name": sampler, - "scheduler": scheduler, - "seed": seed, - "steps": steps - } - }, - "4": { - "class_type": "CheckpointLoaderSimple", - "inputs": { - "ckpt_name": checkpoint - } - }, - "5": { - "class_type": "EmptyLatentImage", - "inputs": { - "batch_size": batch_size, - "height": height, - "width": width - } - }, - "6": { - "class_type": "CLIPTextEncode", - "inputs": { - "clip": ["4", 1], - "text": prompt - } - }, - "7": { - "class_type": "CLIPTextEncode", - "inputs": { - "clip": ["4", 1], - "text": negative_prompt - } - }, - "8": { - "class_type": "VAEDecode", - "inputs": { - "samples": ["3", 0], - "vae": ["4", 2] - } - }, - "9": { - "class_type": "SaveImage", - "inputs": { - "filename_prefix": "Axelate", - "images": ["8", 0] - } - } - }) -} - -async fn wait_for_comfyui_images( - client: &reqwest::Client, - base_url: &str, - provider: &str, - prompt_id: &str, - image_generation_state: &ImageGenerationState, -) -> Result, AppError> { - let deadline = Instant::now() + Duration::from_secs(600); - - loop { - if image_generation_state - .is_cancelled(provider, Some(prompt_id)) - .await - { - return Err(AppError::External { - request_id: None, - message: "Image generation cancelled".to_string(), - }); - } - - let response = client - .get(format!("{base_url}/history/{prompt_id}")) - .send() - .await - .map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to poll ComfyUI history: {error}"), - })?; - - if response.status().is_success() { - let history_body: serde_json::Value = - response.json().await.map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to parse ComfyUI history: {error}"), - })?; - - if let Some(entry) = history_body.get(prompt_id) { - let images = fetch_comfyui_history_images(client, base_url, entry).await?; - if !images.is_empty() { - return Ok(images); - } - } - } - - if Instant::now() >= deadline { - return Err(AppError::External { - request_id: None, - message: "ComfyUI image generation timed out".to_string(), - }); - } - - tokio::time::sleep(Duration::from_millis(700)).await; - } -} - -async fn fetch_comfyui_history_images( - client: &reqwest::Client, - base_url: &str, - history_entry: &serde_json::Value, -) -> Result, AppError> { - let mut images = Vec::new(); - let Some(outputs) = history_entry - .get("outputs") - .and_then(|value| value.as_object()) - else { - return Ok(images); - }; - - for node_output in outputs.values() { - let Some(node_images) = node_output.get("images").and_then(|value| value.as_array()) else { - continue; - }; - - for image_meta in node_images { - let Some(filename) = image_meta.get("filename").and_then(|value| value.as_str()) else { - continue; - }; - - let subfolder = image_meta - .get("subfolder") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - let image_type = image_meta - .get("type") - .and_then(|value| value.as_str()) - .unwrap_or("output"); - let mut image_url = - reqwest::Url::parse(&format!("{base_url}/view")).map_err(|error| { - AppError::External { - request_id: None, - message: format!("Failed to build ComfyUI image URL: {error}"), - } - })?; - { - let mut query = image_url.query_pairs_mut(); - query.append_pair("filename", filename); - if !subfolder.is_empty() { - query.append_pair("subfolder", subfolder); - } - query.append_pair("type", image_type); - } - - let response = - client - .get(image_url) - .send() - .await - .map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to fetch ComfyUI image: {error}"), - })?; - - if !response.status().is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(AppError::External { - request_id: None, - message: format!("ComfyUI image download failed: {body}"), - }); - } - - let mime_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("image/png") - .to_string(); - let bytes = response.bytes().await.map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to read ComfyUI image bytes: {error}"), - })?; - - images.push(format!( - "data:{mime_type};base64,{}", - STANDARD.encode(bytes) - )); - } - } - - Ok(images) -} - -fn extract_comfyui_queue_error(body: &serde_json::Value) -> Option { - if let Some(error_message) = body.get("error").and_then(|value| value.as_str()) { - return Some(format!("ComfyUI queue error: {error_message}")); - } - - let node_errors = body.get("node_errors")?; - if !node_errors.is_object() - || node_errors - .as_object() - .is_some_and(serde_json::Map::is_empty) - { - return None; - } - - Some(format!("ComfyUI node validation failed: {node_errors}")) -} - -async fn apply_image_request_defaults( - mut request: ImageGenerationRequest, - settings_service: &SettingsService, -) -> Result { - let settings_context = load_image_request_settings_context(&request, settings_service).await?; - apply_saved_image_defaults( - &mut request, - &settings_context.settings, - &settings_context.settings_key, - ); - - Ok(request) -} - -async fn load_image_request_settings_context( - request: &ImageGenerationRequest, - settings_service: &SettingsService, -) -> Result { - Ok(ImageRequestSettingsContext { - settings: settings_service.get_settings().await?, - settings_key: request - .settings_key - .clone() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| request.provider.clone()), - }) -} - -async fn build_comfyui_request_context( - request: &ImageGenerationRequest, - settings_context: &ImageRequestSettingsContext, - client: &reqwest::Client, -) -> Result { - let base_url = normalize_comfyui_base_url( - resolve_string_setting( - &settings_context.settings, - &settings_context.settings_key, - &request.provider, - "base_url", - ) - .as_deref() - .unwrap_or("http://127.0.0.1:8188"), - ); - - Ok(ComfyUiRequestContext { - checkpoint: resolve_comfyui_checkpoint( - request, - &settings_context.settings, - &settings_context.settings_key, - &base_url, - client, - ) - .await?, - base_url, - sampler: normalize_comfyui_sampler(request.sampler.as_deref()), - scheduler: normalize_comfyui_scheduler(request.scheduler.as_deref()), - seed: normalize_comfyui_seed(request.seed), - steps: request.steps.unwrap_or(24), - cfg_scale: request.cfg_scale.unwrap_or(7.0), - width: request.width.unwrap_or(832), - height: request.height.unwrap_or(1216), - batch_size: request.batch_size.unwrap_or(1), - negative_prompt: request.negative_prompt.clone().unwrap_or_default(), - prompt_id: uuid::Uuid::new_v4().to_string(), - client_id: uuid::Uuid::new_v4().to_string(), - }) -} - -async fn queue_comfyui_prompt( - client: &reqwest::Client, - context: &ComfyUiRequestContext, - workflow: serde_json::Value, -) -> Result { - let response = client - .post(format!("{}/prompt", context.base_url)) - .json(&serde_json::json!({ - "prompt": workflow, - "client_id": context.client_id, - "prompt_id": context.prompt_id, - })) - .send() - .await - .map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to queue ComfyUI prompt: {error}"), - })?; - - if !response.status().is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(AppError::External { - request_id: None, - message: format!("ComfyUI queue request failed: {body}"), - }); - } - - response.json().await.map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to parse ComfyUI queue response: {error}"), - }) -} - -fn apply_saved_image_defaults( - request: &mut ImageGenerationRequest, - settings: &AppSettings, - settings_key: &str, -) { - if let Some(prefix) = - resolve_string_setting(settings, settings_key, &request.provider, "positive_prompt") - { - request.prompt = format!("{prefix}, {}", request.prompt); - } - - request.negative_prompt = request.negative_prompt.take().or_else(|| { - resolve_string_setting(settings, settings_key, &request.provider, "negative_prompt") - }); - request.steps = request - .steps - .or_else(|| resolve_u32_setting(settings, settings_key, &request.provider, "steps")); - request.cfg_scale = request - .cfg_scale - .or_else(|| resolve_f32_setting(settings, settings_key, &request.provider, "cfg_scale")); - request.denoising_strength = request.denoising_strength.or_else(|| { - resolve_f32_setting( - settings, - settings_key, - &request.provider, - "denoising_strength", - ) - }); - request.width = request - .width - .or_else(|| resolve_u32_setting(settings, settings_key, &request.provider, "width")); - request.height = request - .height - .or_else(|| resolve_u32_setting(settings, settings_key, &request.provider, "height")); - request.sampler = request - .sampler - .take() - .or_else(|| resolve_string_setting(settings, settings_key, &request.provider, "sampler")); - request.seed = request - .seed - .or_else(|| resolve_i32_setting(settings, settings_key, &request.provider, "seed")); - request.batch_size = request - .batch_size - .or_else(|| resolve_u32_setting(settings, settings_key, &request.provider, "batch_size")); - request.scheduler = request - .scheduler - .take() - .or_else(|| resolve_string_setting(settings, settings_key, &request.provider, "scheduler")); - request.clip_skip = request - .clip_skip - .or_else(|| resolve_i32_setting(settings, settings_key, &request.provider, "clip_skip")); -} - -async fn clear_preview_file(path: &Path) { - if let Err(error) = tokio::fs::remove_file(path).await - && error.kind() != std::io::ErrorKind::NotFound - { - tracing::debug!( - "Failed to clear stale preview file {}: {error}", - path.display() - ); - } -} - fn build_generated_image_content(images: &[String]) -> serde_json::Value { serde_json::Value::Array( images @@ -1447,293 +142,3 @@ fn build_generated_image_content(images: &[String]) -> serde_json::Value { .collect(), ) } - -pub(super) fn resolve_string_setting( - settings: &AppSettings, - settings_key: &str, - provider_id: &str, - suffix: &str, -) -> Option { - resolve_setting_value(settings, settings_key, provider_id, suffix).and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) -} - -pub(super) fn resolve_u32_setting( - settings: &AppSettings, - settings_key: &str, - provider_id: &str, - suffix: &str, -) -> Option { - resolve_setting_value(settings, settings_key, provider_id, suffix) - .and_then(|value| value.parse::().ok()) -} - -fn resolve_i32_setting( - settings: &AppSettings, - settings_key: &str, - provider_id: &str, - suffix: &str, -) -> Option { - resolve_setting_value(settings, settings_key, provider_id, suffix) - .and_then(|value| value.parse::().ok()) -} - -pub(super) fn resolve_f32_setting( - settings: &AppSettings, - settings_key: &str, - provider_id: &str, - suffix: &str, -) -> Option { - resolve_setting_value(settings, settings_key, provider_id, suffix) - .and_then(|value| value.parse::().ok()) -} - -fn resolve_setting_value( - settings: &AppSettings, - settings_key: &str, - provider_id: &str, - suffix: &str, -) -> Option { - for key in build_setting_candidates(settings_key, suffix) { - if let Some(value) = settings.extra_settings.get(&key) { - return Some(value.clone()); - } - } - - if settings_key != provider_id { - for key in build_setting_candidates(provider_id, suffix) { - if let Some(value) = settings.extra_settings.get(&key) { - return Some(value.clone()); - } - } - } - - None -} - -fn build_setting_candidates(prefix: &str, suffix: &str) -> [String; 3] { - [ - format!("{prefix}_{suffix}"), - format!("{prefix}_{}", suffix.to_lowercase()), - format!("{prefix}_{}", suffix.replace('_', "")), - ] -} - -fn normalize_sdcpp_sampler(value: Option<&str>) -> String { - match value.unwrap_or("euler a").trim().to_lowercase().as_str() { - "euler a" | "euler_a" => "euler_a".to_string(), - "euler" => "euler".to_string(), - "heun" => "heun".to_string(), - "dpm2" => "dpm2".to_string(), - "dpm++ 2s a" | "dpm++2s_a" | "dpmpp_2s_a" => "dpm++2s_a".to_string(), - "dpm++ 2m" | "dpm++2m" | "dpmpp_2m" => "dpm++2m".to_string(), - "dpm++ 2m v2" | "dpm++2mv2" | "dpmpp_2mv2" => "dpm++2mv2".to_string(), - "ipndm" => "ipndm".to_string(), - "ipndm_v" => "ipndm_v".to_string(), - "er sde" | "er_sde" => "er_sde".to_string(), - "lcm" => "lcm".to_string(), - "ddim trailing" | "ddim_trailing" => "ddim_trailing".to_string(), - "tcd" => "tcd".to_string(), - "res multistep" | "res_multistep" => "res_multistep".to_string(), - "res 2s" | "res_2s" => "res_2s".to_string(), - other => other.to_string(), - } -} - -fn normalize_sdcpp_scheduler(value: Option<&str>) -> String { - match value.unwrap_or("discrete").trim().to_lowercase().as_str() { - "default" | "normal" | "discrete" => "discrete".to_string(), - "karras" => "karras".to_string(), - "exponential" => "exponential".to_string(), - "ays" => "ays".to_string(), - "gits" => "gits".to_string(), - "smoothstep" => "smoothstep".to_string(), - "sgm uniform" | "sgm_uniform" | "ddim_uniform" => "sgm_uniform".to_string(), - "simple" => "simple".to_string(), - "kl optimal" | "kl_optimal" => "kl_optimal".to_string(), - "lcm" => "lcm".to_string(), - "bong tangent" | "bong_tangent" => "bong_tangent".to_string(), - other => other.to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::{ - ImageResponseFormat, build_cloud_image_payload, build_sdcpp_native_image_payload, - parse_generated_images, - }; - use crate::domain::ai::ImageGenerationRequest; - use serde_json::json; - - fn make_cloud_request(model: &str) -> ImageGenerationRequest { - ImageGenerationRequest { - provider: "gpt-image".to_string(), - prompt: "draw a cat".to_string(), - original_prompt: None, - model: model.to_string(), - settings_key: None, - session_id: None, - steps: None, - cfg_scale: None, - denoising_strength: None, - width: None, - height: None, - sampler: None, - seed: None, - clip_skip: None, - negative_prompt: None, - batch_size: None, - scheduler: None, - } - } - - #[test] - fn build_cloud_image_payload_uses_image_only_modalities_for_flux_models() { - let payload = - build_cloud_image_payload(&make_cloud_request("black-forest-labs/flux.2-max")); - - assert_eq!(payload.get("modalities"), Some(&json!(["image"]))); - } - - #[test] - fn build_cloud_image_payload_uses_image_only_modalities_for_seedream_models() { - let payload = build_cloud_image_payload(&make_cloud_request("bytedance-seed/seedream-4.5")); - - assert_eq!(payload.get("modalities"), Some(&json!(["image"]))); - } - - #[test] - fn build_cloud_image_payload_keeps_text_output_for_gemini_image_models() { - let payload = - build_cloud_image_payload(&make_cloud_request("google/gemini-3.1-flash-image-preview")); - - assert_eq!(payload.get("modalities"), Some(&json!(["image", "text"]))); - } - - #[test] - fn build_cloud_image_payload_keeps_text_output_for_gpt_image_models() { - let payload = build_cloud_image_payload(&make_cloud_request("openai/gpt-5-image-mini")); - - assert_eq!(payload.get("modalities"), Some(&json!(["image", "text"]))); - } - - #[test] - fn build_cloud_image_payload_uses_provider_specific_default_model() { - let payload = build_cloud_image_payload(&ImageGenerationRequest { - provider: "gpt-image".to_string(), - model: "default".to_string(), - ..make_cloud_request("default") - }); - - assert_eq!(payload.get("model"), Some(&json!("openai/gpt-5-image"))); - - let payload = build_cloud_image_payload(&ImageGenerationRequest { - provider: "seedream-image".to_string(), - model: String::new(), - ..make_cloud_request("") - }); - - assert_eq!( - payload.get("model"), - Some(&json!("bytedance-seed/seedream-4.5")) - ); - } - - #[test] - fn parses_stable_diffusion_webui_style_images() { - let images = parse_generated_images( - &json!({ - "images": ["ZmFrZQ=="], - "parameters": {}, - "info": "{}" - }), - ImageResponseFormat::SdApi, - ); - - assert_eq!(images, vec!["data:image/png;base64,ZmFrZQ=="]); - } - - #[test] - fn builds_native_sdcpp_image_payload() { - let payload = build_sdcpp_native_image_payload(&ImageGenerationRequest { - provider: "sdcpp".to_string(), - prompt: "draw a cat".to_string(), - original_prompt: None, - model: "default".to_string(), - settings_key: None, - session_id: None, - steps: Some(30), - cfg_scale: Some(8.5), - denoising_strength: Some(0.42), - width: Some(896), - height: Some(1152), - sampler: Some("Euler A".to_string()), - seed: Some(42), - clip_skip: Some(2), - negative_prompt: Some("blurry".to_string()), - batch_size: Some(1), - scheduler: Some("Karras".to_string()), - }); - - assert_eq!(payload.get("prompt"), Some(&json!("draw a cat"))); - assert_eq!(payload.get("width"), Some(&json!(896))); - assert_eq!( - payload.pointer("/sample_params/sample_steps"), - Some(&json!(30)) - ); - assert_eq!( - payload.pointer("/sample_params/sample_method"), - Some(&json!("euler_a")) - ); - assert_eq!( - payload.pointer("/sample_params/scheduler"), - Some(&json!("karras")) - ); - assert_eq!( - payload.pointer("/sample_params/guidance/txt_cfg"), - Some(&json!(8.5)) - ); - let strength = payload - .pointer("/sample_params/strength") - .and_then(serde_json::Value::as_f64) - .unwrap_or_default(); - assert!((strength - 0.42).abs() < 0.001); - } - - #[test] - fn normalizes_sdcpp_er_sde_sampler() { - assert_eq!( - build_sdcpp_native_image_payload(&ImageGenerationRequest { - sampler: Some("ER SDE".to_string()), - ..make_cloud_request("default") - }) - .pointer("/sample_params/sample_method"), - Some(&json!("er_sde")) - ); - } - - #[test] - fn parses_sdcpp_webui_result_images() { - let images = parse_generated_images( - &json!({ - "kind": "img_gen", - "result": { - "output_format": "webp", - "images": [ - { "b64_json": "ZmFrZQ==" } - ] - } - }), - ImageResponseFormat::SdApi, - ); - - assert_eq!(images, vec!["data:image/webp;base64,ZmFrZQ=="]); - } -} diff --git a/src-tauri/src/domain/ai/image_settings.rs b/src-tauri/src/domain/ai/image_settings.rs new file mode 100644 index 00000000..e16fd2c9 --- /dev/null +++ b/src-tauri/src/domain/ai/image_settings.rs @@ -0,0 +1,135 @@ +//! Saved image generation settings resolution. + +use super::types::ImageGenerationRequest; +use crate::errors::AppError; +use crate::infrastructure::config::settings::SettingsService; +use crate::models::AppSettings; + +struct ImageRequestSettingsContext { + settings: AppSettings, + settings_key: String, +} + +pub(super) async fn apply_image_request_defaults( + mut request: ImageGenerationRequest, + settings_service: &SettingsService, +) -> Result { + let settings_context = load_image_request_settings_context(&request, settings_service).await?; + apply_saved_image_defaults( + &mut request, + &settings_context.settings, + &settings_context.settings_key, + ); + + Ok(request) +} + +async fn load_image_request_settings_context( + request: &ImageGenerationRequest, + settings_service: &SettingsService, +) -> Result { + Ok(ImageRequestSettingsContext { + settings: settings_service.get_settings().await?, + settings_key: request + .settings_key + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| request.provider.clone()), + }) +} + +fn apply_saved_image_defaults( + request: &mut ImageGenerationRequest, + settings: &AppSettings, + settings_key: &str, +) { + if let Some(prefix) = resolve_string_setting(settings, settings_key, "positive_prompt") { + request.prompt = format!("{prefix}, {}", request.prompt); + } + + request.negative_prompt = request + .negative_prompt + .take() + .or_else(|| resolve_string_setting(settings, settings_key, "negative_prompt")); + request.steps = request + .steps + .or_else(|| resolve_u32_setting(settings, settings_key, "steps")); + request.cfg_scale = request + .cfg_scale + .or_else(|| resolve_f32_setting(settings, settings_key, "cfg_scale")); + request.denoising_strength = request + .denoising_strength + .or_else(|| resolve_f32_setting(settings, settings_key, "denoising_strength")); + request.width = request + .width + .or_else(|| resolve_u32_setting(settings, settings_key, "width")); + request.height = request + .height + .or_else(|| resolve_u32_setting(settings, settings_key, "height")); + request.sampler = request + .sampler + .take() + .or_else(|| resolve_string_setting(settings, settings_key, "sampler")); + request.seed = request + .seed + .or_else(|| resolve_i32_setting(settings, settings_key, "seed")); + request.batch_size = request + .batch_size + .or_else(|| resolve_u32_setting(settings, settings_key, "batch_size")); + request.scheduler = request + .scheduler + .take() + .or_else(|| resolve_string_setting(settings, settings_key, "scheduler")); + request.clip_skip = request + .clip_skip + .or_else(|| resolve_i32_setting(settings, settings_key, "clip_skip")); +} + +pub(super) fn resolve_string_setting( + settings: &AppSettings, + settings_key: &str, + suffix: &str, +) -> Option { + resolve_setting_value(settings, settings_key, suffix).and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +pub(super) fn resolve_u32_setting( + settings: &AppSettings, + settings_key: &str, + suffix: &str, +) -> Option { + resolve_setting_value(settings, settings_key, suffix) + .and_then(|value| value.parse::().ok()) +} + +fn resolve_i32_setting(settings: &AppSettings, settings_key: &str, suffix: &str) -> Option { + resolve_setting_value(settings, settings_key, suffix) + .and_then(|value| value.parse::().ok()) +} + +pub(super) fn resolve_f32_setting( + settings: &AppSettings, + settings_key: &str, + suffix: &str, +) -> Option { + resolve_setting_value(settings, settings_key, suffix) + .and_then(|value| value.parse::().ok()) +} + +fn resolve_setting_value( + settings: &AppSettings, + settings_key: &str, + suffix: &str, +) -> Option { + settings + .extra_settings + .get(&format!("{settings_key}_{suffix}")) + .cloned() +} diff --git a/src-tauri/src/domain/ai/mod.rs b/src-tauri/src/domain/ai/mod.rs index 775bb757..cd54feba 100644 --- a/src-tauri/src/domain/ai/mod.rs +++ b/src-tauri/src/domain/ai/mod.rs @@ -3,9 +3,20 @@ mod ai_dispatch; pub mod ai_service; /// Custom model management service pub mod custom_model_service; +mod image_cloud; +mod image_comfyui; /// Shared state for active image-generation requests pub mod image_generation_state; +mod image_http; +mod image_local; +mod image_payload; +mod image_provider_adapter; +mod image_response; mod image_service; +mod image_settings; +mod provider_http; +mod provider_payload; +mod provider_response; /// Chat session persistence and management pub mod session; mod session_context; @@ -19,9 +30,9 @@ pub use ai_service::{ process_chat_request, validate_api_key, }; pub use image_generation_state::ImageGenerationState; +pub use image_provider_adapter::cancel_image_provider_generation; pub use session::ChatSessionManager; pub use streaming::{ - AiProvider, ChannelSink, NoopSink, OpenAiCompatibleProvider, OpenRouterProvider, StreamEvent, - StreamSink, + AiProvider, ChannelSink, NoopSink, OpenAiCompatibleProvider, StreamEvent, StreamSink, }; pub use types::{ImageGenerationRequest, ImageGenerationResponse, WebSearchOptions}; diff --git a/src-tauri/src/domain/ai/provider_http.rs b/src-tauri/src/domain/ai/provider_http.rs new file mode 100644 index 00000000..b4c14b57 --- /dev/null +++ b/src-tauri/src/domain/ai/provider_http.rs @@ -0,0 +1,45 @@ +//! Shared HTTP helpers for AI provider adapters. +//! +//! Mirrors the Open WebUI idea of keeping transport concerns separate from +//! provider routing and response normalization. + +use reqwest::{Client, StatusCode}; + +pub(super) fn build_provider_client() -> Client { + Client::builder() + .connect_timeout(std::time::Duration::from_secs(8)) + .pool_idle_timeout(std::time::Duration::from_secs(90)) + .pool_max_idle_per_host(8) + .build() + .unwrap_or_else(|_| Client::new()) +} + +pub(super) const fn should_retry_status(status: StatusCode) -> bool { + matches!( + status, + StatusCode::TOO_MANY_REQUESTS + | StatusCode::BAD_GATEWAY + | StatusCode::SERVICE_UNAVAILABLE + | StatusCode::GATEWAY_TIMEOUT + ) +} + +pub(super) fn should_retry_error(error: &reqwest::Error) -> bool { + error.is_connect() || error.is_timeout() +} + +pub(super) fn retry_delay(attempt: u32, status: StatusCode) -> std::time::Duration { + let capped_attempt = attempt.max(1); + let base_ms = if status == StatusCode::TOO_MANY_REQUESTS { + 700u64 + } else { + 350u64 + }; + let backoff_multiplier = 2u64.saturating_pow(capped_attempt.saturating_sub(1)); + let jitter_ms = rand::random_range(0..150u64); + let delay_ms = base_ms + .saturating_mul(backoff_multiplier) + .saturating_add(jitter_ms); + + std::time::Duration::from_millis(delay_ms) +} diff --git a/src-tauri/src/domain/ai/provider_payload.rs b/src-tauri/src/domain/ai/provider_payload.rs new file mode 100644 index 00000000..6424ac50 --- /dev/null +++ b/src-tauri/src/domain/ai/provider_payload.rs @@ -0,0 +1,257 @@ +//! Provider payload normalization. +//! +//! Axelate speaks an OpenAI-compatible chat shape internally. This module owns +//! the provider-specific request differences, similar to Open WebUI's payload +//! conversion layer. + +use super::types::{ChatRequest, WebSearchOptions}; + +pub(super) fn is_local_base_url(base_url: &str) -> bool { + let Ok(url) = reqwest::Url::parse(base_url.trim()) else { + return false; + }; + + let Some(host) = url.host_str() else { + return false; + }; + + if host.eq_ignore_ascii_case("localhost") { + return true; + } + + host.trim_start_matches('[') + .trim_end_matches(']') + .parse::() + .is_ok_and(|address| address.is_loopback()) +} + +pub(super) fn build_chat_completion_payload( + req: &ChatRequest, + stream: bool, + is_local: bool, +) -> serde_json::Map { + let mut payload = serde_json::Map::new(); + payload.insert( + "model".to_string(), + serde_json::Value::String(req.model.clone()), + ); + + let mapped_messages: Vec = req + .messages + .iter() + .map(|message| { + serde_json::json!({ + "role": message.role, + "content": message.content, + }) + }) + .collect(); + payload.insert( + "messages".to_string(), + serde_json::Value::Array(mapped_messages), + ); + payload.insert("stream".to_string(), serde_json::Value::Bool(stream)); + + if stream && !is_local { + payload.insert( + "stream_options".to_string(), + serde_json::json!({ "include_usage": true }), + ); + } + + if let Some(level) = normalized_reasoning_effort(req.thinking_level.as_deref()) + && !is_local + { + payload.insert( + "reasoning".to_string(), + serde_json::json!({ "effort": level }), + ); + } + + let max_tokens = serde_json::json!(req.max_tokens.unwrap_or(8192)); + if is_local { + payload.insert("max_tokens".to_string(), max_tokens); + } else { + payload.insert("max_completion_tokens".to_string(), max_tokens); + } + + if !is_local + && let Some(session_id) = req.session_id.as_ref().map(|value| value.trim()) + && !session_id.is_empty() + { + payload.insert( + "session_id".to_string(), + serde_json::Value::String(session_id.to_string()), + ); + } + + if !is_local + && should_attach_web_search(req) + && let Some(web_search) = req.web_search.as_ref() + { + payload.insert( + "tools".to_string(), + serde_json::Value::Array(vec![build_web_search_tool(web_search)]), + ); + payload.insert( + "tool_choice".to_string(), + serde_json::Value::String("auto".to_string()), + ); + } + + payload +} + +fn normalized_reasoning_effort(level: Option<&str>) -> Option { + let level = level?.trim().to_ascii_lowercase(); + if level.is_empty() { + return None; + } + + Some(if level == "off" { + "none".to_string() + } else { + level + }) +} + +pub(super) fn should_attach_web_search(req: &ChatRequest) -> bool { + if !req + .web_search + .as_ref() + .is_some_and(|web_search| web_search.enabled) + { + return false; + } + + let Some(last_user_message) = req + .messages + .iter() + .rev() + .find(|message| message.role == "user") + else { + return false; + }; + let text = extract_message_text(&last_user_message.content).to_lowercase(); + let text = text.trim(); + if text.is_empty() { + return false; + } + + const WEB_SEARCH_TRIGGERS: &[&str] = &[ + "актуаль", + "интернет", + "найди", + "новост", + "погугли", + "поиск", + "посмотри в сети", + "свеж", + "сейчас", + "сегодня", + "ссылка", + "site:", + "today", + "latest", + "current", + "recent", + "news", + "search", + "browse", + "web", + "internet", + "look up", + "price", + "weather", + ]; + + text.starts_with("http://") + || text.starts_with("https://") + || text.contains(" http://") + || text.contains(" https://") + || WEB_SEARCH_TRIGGERS + .iter() + .any(|trigger| text.contains(trigger)) +} + +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"), + _ => String::new(), + } +} + +pub(super) fn build_web_search_tool(options: &WebSearchOptions) -> serde_json::Value { + let mut parameters = serde_json::Map::new(); + parameters.insert( + "engine".to_string(), + serde_json::Value::String( + options + .engine + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "auto".to_string()), + ), + ); + parameters.insert( + "max_results".to_string(), + serde_json::json!(options.max_results.unwrap_or(5).clamp(1, 25)), + ); + parameters.insert( + "max_total_results".to_string(), + serde_json::json!(options.max_total_results.unwrap_or(10).max(1)), + ); + parameters.insert( + "search_context_size".to_string(), + serde_json::Value::String( + options + .search_context_size + .clone() + .filter(|value| matches!(value.as_str(), "low" | "medium" | "high")) + .unwrap_or_else(|| "medium".to_string()), + ), + ); + + if !options.allowed_domains.is_empty() { + parameters.insert( + "allowed_domains".to_string(), + serde_json::Value::Array( + options + .allowed_domains + .iter() + .filter(|value| !value.trim().is_empty()) + .map(|value| serde_json::Value::String(value.trim().to_string())) + .collect(), + ), + ); + } + + if !options.excluded_domains.is_empty() { + parameters.insert( + "excluded_domains".to_string(), + serde_json::Value::Array( + options + .excluded_domains + .iter() + .filter(|value| !value.trim().is_empty()) + .map(|value| serde_json::Value::String(value.trim().to_string())) + .collect(), + ), + ); + } + + serde_json::json!({ + "type": "openrouter:web_search", + "parameters": parameters, + }) +} diff --git a/src-tauri/src/domain/ai/provider_response.rs b/src-tauri/src/domain/ai/provider_response.rs new file mode 100644 index 00000000..2fe3c6a7 --- /dev/null +++ b/src-tauri/src/domain/ai/provider_response.rs @@ -0,0 +1,187 @@ +//! Provider response normalization. +//! +//! Converts OpenAI-compatible, Ollama-like, and llama.cpp-like response shapes +//! into Axelate's chat DTOs. + +use reqwest::StatusCode; + +use super::provider_payload::extract_message_text; +use super::types::{ChatReply, ChatResponse, TokenUsage}; + +pub(super) fn parse_non_stream_response( + body: &serde_json::Value, + message_id: String, + model: String, +) -> ChatResponse { + let usage = extract_token_usage(body); + + let reply_text = body + .get("choices") + .and_then(|choices| choices.as_array()) + .and_then(|choices| choices.first()) + .and_then(|choice| choice.get("message")) + .and_then(|message| message.get("content")) + .map(extract_message_text) + .unwrap_or_default(); + + if reply_text.trim().is_empty() { + return ChatResponse { + id: message_id, + ok: false, + reply: None, + error: Some("Empty response body from AI provider".to_string()), + model: Some(model), + thought_signature: None, + usage, + }; + } + + ChatResponse { + id: message_id, + ok: true, + reply: Some(ChatReply { + text: reply_text, + role: "assistant".to_string(), + }), + error: None, + model: Some(model), + thought_signature: None, + usage, + } +} + +pub(super) fn build_api_error_response( + message_id: String, + model: String, + status: StatusCode, + error_text: &str, +) -> ChatResponse { + ChatResponse { + id: message_id, + ok: false, + reply: None, + error: Some(format!("API Error {status}: {error_text}")), + model: Some(model), + thought_signature: None, + usage: None, + } +} + +pub(super) fn extract_stream_error_message(json: &serde_json::Value) -> Option { + json.get("error") + .and_then(extract_error_message) + .or_else(|| json.get("errors").and_then(extract_error_message)) +} + +pub(super) fn extract_error_message(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(message) => { + let trimmed = message.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + serde_json::Value::Array(items) => items.iter().find_map(extract_error_message), + serde_json::Value::Object(object) => ["message", "detail", "error"] + .iter() + .find_map(|key| object.get(*key).and_then(extract_error_message)), + _ => None, + } +} + +pub(super) fn extract_stream_text(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(text) => Some(text.clone()), + serde_json::Value::Array(parts) => { + let text = parts + .iter() + .filter_map(|part| { + part.get("text") + .and_then(|candidate| candidate.as_str()) + .or_else(|| part.get("content").and_then(|candidate| candidate.as_str())) + }) + .collect::>() + .join(""); + + (!text.is_empty()).then_some(text) + } + serde_json::Value::Object(object) => object + .get("text") + .and_then(|candidate| candidate.as_str()) + .map(ToOwned::to_owned), + _ => None, + } +} + +pub(super) fn extract_token_usage(json: &serde_json::Value) -> Option { + let usage = json.get("usage").unwrap_or(json); + let timings = json.get("timings"); + + let prompt_tokens = read_usage_count( + usage, + timings, + &[ + "prompt_tokens", + "input_tokens", + "prompt_eval_count", + "prompt_n", + ], + ); + let completion_tokens = read_usage_count( + usage, + timings, + &[ + "completion_tokens", + "output_tokens", + "eval_count", + "predicted_n", + ], + ); + let total_tokens = read_usage_count(usage, timings, &["total_tokens"]) + .or_else(|| (prompt_tokens.is_some() || completion_tokens.is_some()).then_some(0)) + .map(|total| { + if total > 0 { + total + } else { + prompt_tokens.unwrap_or(0) + completion_tokens.unwrap_or(0) + } + }); + + match (prompt_tokens, completion_tokens, total_tokens) { + (None, None, None) => None, + (prompt_tokens, completion_tokens, total_tokens) => Some(TokenUsage { + prompt_tokens: prompt_tokens.unwrap_or(0), + completion_tokens: completion_tokens.unwrap_or(0), + total_tokens: total_tokens.unwrap_or(0), + }), + } +} + +fn read_usage_count( + usage: &serde_json::Value, + timings: Option<&serde_json::Value>, + keys: &[&str], +) -> Option { + keys.iter() + .find_map(|key| usage.get(*key).and_then(json_number_to_u32)) + .or_else(|| { + timings.and_then(|timings| { + keys.iter() + .find_map(|key| timings.get(*key).and_then(json_number_to_u32)) + }) + }) +} + +fn json_number_to_u32(value: &serde_json::Value) -> Option { + value + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .or_else(|| { + value.as_f64().and_then(|value| { + let rounded = value.round(); + if rounded.is_finite() && rounded >= 0.0 && rounded <= f64::from(u32::MAX) { + rounded.to_string().parse::().ok() + } else { + None + } + }) + }) +} diff --git a/src-tauri/src/domain/ai/session.rs b/src-tauri/src/domain/ai/session.rs index 8c261f13..23c51a12 100644 --- a/src-tauri/src/domain/ai/session.rs +++ b/src-tauri/src/domain/ai/session.rs @@ -6,7 +6,7 @@ use dashmap::DashMap; use std::collections::HashMap; use std::sync::{ - Arc, + Arc, Mutex, atomic::{AtomicBool, Ordering}, }; use tokio::sync::Notify; @@ -44,6 +44,8 @@ struct LocalContextState { pub struct ChatSessionManager { sessions: Arc>, dirty: Arc, + persistence_available: Arc, + save_lock: Arc>, save_notify: Arc, } @@ -51,9 +53,19 @@ impl ChatSessionManager { /// Creates a new manager and loads existing sessions from disk. /// Call [`start_saver`] after the Tokio runtime is ready. pub fn new() -> Self { + let (sessions, persistence_available) = match Self::load_from_disk() { + Ok(sessions) => (sessions, true), + Err(error) => { + tracing::error!("Failed to load chat history; persistence disabled: {error}"); + (DashMap::new(), false) + } + }; + Self { - sessions: Arc::new(Self::load_from_disk().unwrap_or_default()), + sessions: Arc::new(sessions), dirty: Arc::new(AtomicBool::new(false)), + persistence_available: Arc::new(AtomicBool::new(persistence_available)), + save_lock: Arc::new(Mutex::new(())), save_notify: Arc::new(Notify::new()), } } @@ -70,14 +82,12 @@ impl ChatSessionManager { SessionPersistence::flush_snapshot(snapshot) } - fn take_snapshot(&self) -> HashMap { - self.sessions - .iter() - .map(|e| (e.key().clone(), e.value().clone())) - .collect() - } - fn mark_dirty(&self) { + if !self.persistence_available.load(Ordering::Relaxed) { + tracing::warn!("Chat history changed while persistence is disabled; skipping save"); + return; + } + self.dirty.store(true, Ordering::Relaxed); self.save_notify.notify_one(); } @@ -89,20 +99,28 @@ impl ChatSessionManager { pub fn start_saver(&self) { let sessions = Arc::clone(&self.sessions); let dirty = Arc::clone(&self.dirty); + let persistence_available = Arc::clone(&self.persistence_available); + let save_lock = Arc::clone(&self.save_lock); let save_notify = Arc::clone(&self.save_notify); tauri::async_runtime::spawn(async move { - tracing::info!("Background chat session saver started."); + tracing::debug!("Background chat session saver started."); loop { save_notify.notified().await; + if !persistence_available.load(Ordering::Relaxed) { + tracing::warn!("Chat session saver skipped because persistence is disabled"); + continue; + } + tokio::time::sleep(std::time::Duration::from_secs(5)).await; if dirty.swap(false, Ordering::AcqRel) { - let snapshot: HashMap = sessions - .iter() - .map(|e| (e.key().clone(), e.value().clone())) - .collect(); + let save_lock = Arc::clone(&save_lock); + let sessions = Arc::clone(&sessions); - match tokio::task::spawn_blocking(move || Self::flush_snapshot(&snapshot)).await + match tokio::task::spawn_blocking(move || { + Self::flush_sessions_locked(&save_lock, &sessions) + }) + .await { Ok(Ok(())) => { tracing::debug!("Chat history saved to disk."); @@ -127,20 +145,46 @@ impl ChatSessionManager { /// Immediately saves all sessions to disk, bypassing the debounce timer. pub async fn force_save(&self) -> Result<(), crate::errors::AppError> { - let snapshot = self.take_snapshot(); - tokio::task::spawn_blocking(move || Self::flush_snapshot(&snapshot)) - .await - .map_err(|e| crate::errors::AppError::Internal { - request_id: None, - message: format!("Blocking task failed: {e}"), - })??; - self.dirty.store(false, Ordering::Relaxed); - Ok(()) + self.ensure_persistence_available()?; + let save_lock = Arc::clone(&self.save_lock); + let sessions = Arc::clone(&self.sessions); + self.dirty.store(false, Ordering::Release); + + match tokio::task::spawn_blocking(move || { + Self::flush_sessions_locked(&save_lock, &sessions) + }) + .await + { + Ok(Ok(())) => Ok(()), + Ok(Err(error)) => { + self.dirty.store(true, Ordering::Release); + Err(error) + } + Err(error) => { + self.dirty.store(true, Ordering::Release); + Err(crate::errors::AppError::Internal { + request_id: None, + message: format!("Blocking task failed: {error}"), + }) + } + } } /// Synchronous save — intended for use in Tauri shutdown hooks (called from a blocking context). pub fn save_to_disk(&self) -> Result<(), crate::errors::AppError> { - Self::flush_snapshot(&self.take_snapshot()) + self.ensure_persistence_available()?; + Self::flush_sessions_locked(&self.save_lock, &self.sessions) + } + + fn ensure_persistence_available(&self) -> Result<(), crate::errors::AppError> { + if self.persistence_available.load(Ordering::Relaxed) { + return Ok(()); + } + + Err(crate::errors::AppError::Internal { + request_id: None, + message: "Chat history persistence is disabled after a load failure".to_string(), + }) } /// Merges the latest frontend-provided request messages into persistent history @@ -166,12 +210,18 @@ impl ChatSessionManager { }); let overlap = find_history_overlap(&entry.history, incoming_messages); - if let Some(new_messages) = incoming_messages.get(overlap..) { + let mut appended_messages = false; + if let Some(new_messages) = incoming_messages.get(overlap..) + && !new_messages.is_empty() + { entry.history.extend_from_slice(new_messages); + entry.last_updated = Self::current_timestamp(); + appended_messages = true; } - entry.last_updated = Self::current_timestamp(); drop(entry); - self.mark_dirty(); + if appended_messages { + self.mark_dirty(); + } incoming_messages.to_vec() } @@ -294,28 +344,94 @@ impl SessionPersistence { Self::history_path().with_extension("tmp") } + fn backup_history_path() -> std::path::PathBuf { + Self::history_path().with_extension("bak") + } + fn load_sessions() -> Result, crate::errors::AppError> { - Self::recover_from_interrupted_write(); + Self::recover_from_interrupted_write()?; if !Self::history_path().exists() { return Ok(DashMap::new()); } let content = std::fs::read_to_string(Self::history_path())?; - let persisted = Self::parse_sessions(&content)?; + let persisted = match Self::parse_sessions(&content) { + Ok(persisted) => persisted, + Err(error) => { + let backup_path = Self::backup_corrupt_history()?; + tracing::error!( + backup = %backup_path.display(), + "Failed to parse chat history. Moved corrupt file aside: {error}" + ); + return Ok(DashMap::new()); + } + }; Ok(Self::normalize_sessions(persisted)) } - fn recover_from_interrupted_write() { + fn backup_corrupt_history() -> Result { + let timestamp_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let backup_path = + Self::history_path().with_extension(format!("corrupt-{timestamp_ms}.json")); + std::fs::rename(Self::history_path(), &backup_path)?; + Ok(backup_path) + } + + fn recover_from_interrupted_write() -> Result<(), crate::errors::AppError> { let tmp_path = Self::temp_history_path(); - if tmp_path.exists() && !Self::history_path().exists() { - tracing::warn!("Detected crash during last save. Recovering from .tmp..."); - if let Err(error) = std::fs::rename(&tmp_path, Self::history_path()) { - tracing::error!("Crash recovery failed: {error}"); - } + if !tmp_path.exists() { + return Ok(()); + } + + if !Self::is_valid_history_file(&tmp_path) { + tracing::warn!( + tmp = %tmp_path.display(), + "Discarding invalid interrupted chat history write" + ); + Self::remove_recovered_tmp(&tmp_path)?; + return Ok(()); + } + + if !Self::history_path().exists() || Self::tmp_history_is_newer(&tmp_path) { + tracing::warn!("Detected interrupted chat history save. Recovering from .tmp..."); + std::fs::copy(&tmp_path, Self::history_path())?; + } + + Self::remove_recovered_tmp(&tmp_path)?; + Ok(()) + } + + fn remove_recovered_tmp(tmp_path: &std::path::Path) -> Result<(), crate::errors::AppError> { + match std::fs::remove_file(tmp_path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(crate::errors::AppError::Io(format!( + "Failed to remove recovered chat history tmp file {}: {error}", + tmp_path.display() + ))), } } + fn is_valid_history_file(path: &std::path::Path) -> bool { + std::fs::read_to_string(path) + .ok() + .and_then(|content| Self::parse_sessions(&content).ok()) + .is_some() + } + + fn tmp_history_is_newer(tmp_path: &std::path::Path) -> bool { + let tmp_modified = tmp_path.metadata().and_then(|metadata| metadata.modified()); + let history_modified = Self::history_path() + .metadata() + .and_then(|metadata| metadata.modified()); + + matches!((tmp_modified, history_modified), (Ok(tmp), Ok(history)) if tmp > history) + } + fn parse_sessions( content: &str, ) -> Result, crate::errors::AppError> { @@ -366,15 +482,78 @@ impl SessionPersistence { drop(file); if let Err(error) = std::fs::rename(&tmp_path, path) { - tracing::warn!("Rename failed ({error}), using fallback for Windows locks..."); - let _ = std::fs::remove_file(path); - std::fs::rename(&tmp_path, path)?; + tracing::warn!("Rename failed ({error}), using backup replace fallback..."); + let backup_path = Self::backup_history_path(); + match std::fs::remove_file(&backup_path) { + Ok(()) => {} + Err(remove_error) if remove_error.kind() == std::io::ErrorKind::NotFound => {} + Err(remove_error) => { + let _ = std::fs::remove_file(&tmp_path); + return Err(crate::errors::AppError::Io(format!( + "Failed to prepare chat history backup '{}': first rename failed: {error}; removing stale backup failed: {remove_error}", + backup_path.display() + ))); + } + } + + let had_original = path.exists(); + if had_original { + std::fs::rename(path, &backup_path).map_err(|backup_error| { + let _ = std::fs::remove_file(&tmp_path); + crate::errors::AppError::Io(format!( + "Failed to back up chat history '{}' to '{}': first rename failed: {error}; backup rename failed: {backup_error}", + path.display(), + backup_path.display() + )) + })?; + } + + if let Err(second_error) = std::fs::rename(&tmp_path, path) { + let restore_message = if had_original { + match std::fs::rename(&backup_path, path) { + Ok(()) => "backup restore succeeded".to_string(), + Err(restore_error) => { + format!("backup restore failed: {restore_error}") + } + } + } else { + "no original file to restore".to_string() + }; + let _ = std::fs::remove_file(&tmp_path); + return Err(crate::errors::AppError::Io(format!( + "Failed to publish chat history '{}': first rename failed: {error}; second rename failed: {second_error}; {restore_message}", + path.display() + ))); + } + + if had_original { + let _ = std::fs::remove_file(&backup_path); + } } Ok(()) } } +impl ChatSessionManager { + fn flush_sessions_locked( + save_lock: &Mutex<()>, + sessions: &DashMap, + ) -> Result<(), crate::errors::AppError> { + let _guard = save_lock + .lock() + .map_err(|_| crate::errors::AppError::Internal { + request_id: None, + message: "Chat history save lock is poisoned".to_string(), + })?; + let snapshot: HashMap = sessions + .iter() + .map(|e| (e.key().clone(), e.value().clone())) + .collect(); + Self::flush_snapshot(&snapshot) + } +} + impl LocalContextBudget { fn new(context_size: usize) -> Self { let normalized_context_size = context_size.max(4096); @@ -533,10 +712,15 @@ mod tests { #![allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] use super::*; + static TEST_CHAT_HISTORY_LOCK: std::sync::LazyLock> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(())); + fn test_manager() -> ChatSessionManager { ChatSessionManager { sessions: Arc::new(DashMap::new()), dirty: Arc::new(AtomicBool::new(false)), + persistence_available: Arc::new(AtomicBool::new(true)), + save_lock: Arc::new(Mutex::new(())), save_notify: Arc::new(Notify::new()), } } @@ -582,6 +766,30 @@ mod tests { assert!(manager.dirty.load(Ordering::Relaxed)); } + #[test] + fn test_disabled_persistence_does_not_mark_dirty() { + let manager = ChatSessionManager { + sessions: Arc::new(DashMap::new()), + dirty: Arc::new(AtomicBool::new(false)), + persistence_available: Arc::new(AtomicBool::new(false)), + save_lock: Arc::new(Mutex::new(())), + save_notify: Arc::new(Notify::new()), + }; + + manager.merge_request_messages( + "session-1", + &[ChatMessage { + id: "msg-1".to_string(), + role: "user".to_string(), + content: serde_json::Value::String("hello".to_string()), + thought_signature: None, + }], + ); + + assert!(!manager.dirty.load(Ordering::Relaxed)); + assert!(manager.save_to_disk().is_err()); + } + #[test] fn test_rewind_last_turn_in_memory() { let manager = test_manager(); @@ -711,6 +919,39 @@ mod tests { ); } + #[test] + fn test_merge_request_messages_does_not_mark_dirty_for_full_overlap() { + let manager = test_manager(); + + let existing = vec![ + ChatMessage { + id: "msg-1".to_string(), + role: "user".to_string(), + content: serde_json::Value::String("hello".to_string()), + thought_signature: None, + }, + ChatMessage { + id: "msg-2".to_string(), + role: "assistant".to_string(), + content: serde_json::Value::String("hi".to_string()), + thought_signature: None, + }, + ]; + + manager.merge_request_messages("session-1", &existing); + manager.dirty.store(false, Ordering::Relaxed); + + let runtime_context = manager.merge_request_messages("session-1", &existing); + let persisted = manager.get_chat_history("session-1"); + + assert_eq!(runtime_context.len(), 2); + assert_eq!(persisted.len(), 2); + assert!( + !manager.dirty.load(Ordering::Relaxed), + "fully overlapped request should not schedule a redundant save" + ); + } + #[test] fn test_chat_session_serialization() { use super::super::types::ChatSession; @@ -759,6 +1000,141 @@ mod tests { assert!((session.last_updated - 1_234_567_890.0).abs() < f64::EPSILON); } + #[test] + fn test_corrupt_history_is_backed_up_before_starting_empty() { + let _guard = TEST_CHAT_HISTORY_LOCK + .lock() + .expect("chat history test lock"); + let history_path = SessionPersistence::history_path(); + let chat_dir = history_path + .parent() + .expect("history path should have a parent"); + let _ = std::fs::remove_dir_all(chat_dir); + std::fs::create_dir_all(chat_dir).expect("chat dir should be created"); + std::fs::write(history_path, "{not valid json").expect("history fixture should be written"); + + let sessions = SessionPersistence::load_sessions().expect("corrupt history should recover"); + + assert!(sessions.is_empty()); + assert!(!history_path.exists()); + let backups = std::fs::read_dir(chat_dir) + .expect("chat dir should be readable") + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("history.corrupt-") + }) + .count(); + assert_eq!(backups, 1); + } + + #[test] + fn test_force_save_survives_manager_restart() { + let _guard = TEST_CHAT_HISTORY_LOCK + .lock() + .expect("chat history test lock"); + let history_path = SessionPersistence::history_path(); + let chat_dir = history_path + .parent() + .expect("history path should have a parent"); + let _ = std::fs::remove_dir_all(chat_dir); + std::fs::create_dir_all(chat_dir).expect("chat dir should be created"); + + let manager = test_manager(); + manager.merge_request_messages( + "session-restart", + &[ChatMessage { + id: "msg-1".to_string(), + role: "user".to_string(), + content: serde_json::Value::String("persist me".to_string()), + thought_signature: None, + }], + ); + tokio::runtime::Runtime::new() + .expect("tokio runtime") + .block_on(manager.force_save()) + .expect("force save should persist history"); + + let restarted = ChatSessionManager { + sessions: Arc::new( + SessionPersistence::load_sessions().expect("saved history should reload"), + ), + dirty: Arc::new(AtomicBool::new(false)), + persistence_available: Arc::new(AtomicBool::new(true)), + save_lock: Arc::new(Mutex::new(())), + save_notify: Arc::new(Notify::new()), + }; + let history = restarted.get_chat_history("session-restart"); + + assert_eq!(history.len(), 1); + assert_eq!( + history[0].content, + serde_json::Value::String("persist me".to_string()) + ); + } + + #[test] + fn test_interrupted_newer_tmp_history_is_recovered_on_restart() { + let _guard = TEST_CHAT_HISTORY_LOCK + .lock() + .expect("chat history test lock"); + let history_path = SessionPersistence::history_path(); + let tmp_path = SessionPersistence::temp_history_path(); + let chat_dir = history_path + .parent() + .expect("history path should have a parent"); + let _ = std::fs::remove_dir_all(chat_dir); + std::fs::create_dir_all(chat_dir).expect("chat dir should be created"); + + std::fs::write( + history_path, + serde_json::json!({ + "session-restart": { + "history": [{ + "id": "old", + "role": "user", + "content": "old", + "thought_signature": null + }], + "summary": null, + "summary_message_count": 0, + "last_updated": 1.0 + } + }) + .to_string(), + ) + .expect("old history should be written"); + std::thread::sleep(std::time::Duration::from_millis(20)); + std::fs::write( + &tmp_path, + serde_json::json!({ + "session-restart": { + "history": [{ + "id": "new", + "role": "user", + "content": "new", + "thought_signature": null + }], + "summary": null, + "summary_message_count": 0, + "last_updated": 2.0 + } + }) + .to_string(), + ) + .expect("tmp history should be written"); + + let sessions = SessionPersistence::load_sessions().expect("tmp history should recover"); + let restored = sessions + .get("session-restart") + .expect("session should be restored"); + + assert_eq!(restored.history[0].id, "new"); + assert!(!tmp_path.exists()); + } + #[test] fn test_build_local_context_uses_persisted_summary_and_recent_turns() { let manager = test_manager(); diff --git a/src-tauri/src/domain/ai/streaming.rs b/src-tauri/src/domain/ai/streaming.rs index 397d6c4b..bb427c86 100644 --- a/src-tauri/src/domain/ai/streaming.rs +++ b/src-tauri/src/domain/ai/streaming.rs @@ -10,7 +10,10 @@ use reqwest::{Client, StatusCode}; use std::sync::Arc; use tokio::sync::mpsc; -use super::types::{ChatReply, ChatRequest, ChatResponse, TokenUsage, WebSearchOptions}; +use super::provider_http; +use super::provider_payload; +use super::provider_response; +use super::types::{ChatReply, ChatRequest, ChatResponse, TokenUsage}; // ================================================================================== // Stream Protocol @@ -106,9 +109,6 @@ pub struct OpenAiCompatibleProvider { client: Client, } -/// Backward-compatible alias for the legacy provider name. -pub type OpenRouterProvider = OpenAiCompatibleProvider; - struct RequestExecution { endpoint: String, api_key: String, @@ -162,10 +162,7 @@ impl OpenAiCompatibleProvider { pub fn new(base_url: &str) -> Self { Self { base_url: base_url.to_string(), - client: Client::builder() - .connect_timeout(std::time::Duration::from_secs(8)) - .build() - .unwrap_or_else(|_| Client::new()), + client: provider_http::build_provider_client(), } } @@ -189,7 +186,7 @@ impl OpenAiCompatibleProvider { if !res.status().is_success() { let status = res.status(); let error_text = res.text().await.unwrap_or_default(); - return Ok(build_api_error_response( + return Ok(provider_response::build_api_error_response( message_id, req.model, status, @@ -204,7 +201,9 @@ impl OpenAiCompatibleProvider { } })?; - Ok(parse_non_stream_response(&body, message_id, req.model)) + Ok(provider_response::parse_non_stream_response( + &body, message_id, req.model, + )) } fn prepare_request_execution( @@ -216,7 +215,7 @@ impl OpenAiCompatibleProvider { Ok(RequestExecution { endpoint: format!("{}/chat/completions", self.base_url.trim_end_matches('/')), api_key: resolve_api_key(req, &self.base_url)?, - payload: build_request_payload(req, stream, is_local), + payload: provider_payload::build_chat_completion_payload(req, stream, is_local), }) } @@ -250,16 +249,19 @@ impl OpenAiCompatibleProvider { return Ok(resp); } let status = resp.status(); - if should_retry_status(status) && attempts <= MAX_RETRIES { - tokio::time::sleep(retry_delay(attempts, status)).await; + if provider_http::should_retry_status(status) && attempts <= MAX_RETRIES { + tokio::time::sleep(provider_http::retry_delay(attempts, status)).await; continue; } return Ok(resp); } Err(error) => { - if should_retry_error(&error) && attempts <= MAX_RETRIES { - tokio::time::sleep(retry_delay(attempts, StatusCode::REQUEST_TIMEOUT)) - .await; + if provider_http::should_retry_error(&error) && attempts <= MAX_RETRIES { + tokio::time::sleep(provider_http::retry_delay( + attempts, + StatusCode::REQUEST_TIMEOUT, + )) + .await; continue; } return Err(crate::errors::AppError::External { @@ -272,35 +274,8 @@ impl OpenAiCompatibleProvider { } } -const fn should_retry_status(status: StatusCode) -> bool { - matches!( - status, - StatusCode::TOO_MANY_REQUESTS - | StatusCode::BAD_GATEWAY - | StatusCode::SERVICE_UNAVAILABLE - | StatusCode::GATEWAY_TIMEOUT - ) -} - -fn should_retry_error(error: &reqwest::Error) -> bool { - error.is_connect() || error.is_timeout() -} - -fn retry_delay(attempt: u32, status: StatusCode) -> std::time::Duration { - let capped_attempt = attempt.max(1); - let base_ms = if status == StatusCode::TOO_MANY_REQUESTS { - 700u64 - } else { - 350u64 - }; - let backoff_multiplier = 2u64.saturating_pow(capped_attempt.saturating_sub(1)); - let jitter_ms = rand::random_range(0..150u64); - - std::time::Duration::from_millis(base_ms * backoff_multiplier + jitter_ms) -} - -fn is_local_base_url(base_url: &str) -> bool { - base_url.contains("localhost") || base_url.contains("127.0.0.1") +pub(super) fn is_local_base_url(base_url: &str) -> bool { + provider_payload::is_local_base_url(base_url) } fn resolve_accept_header(payload: &serde_json::Map) -> &'static str { @@ -330,284 +305,6 @@ fn resolve_api_key(req: &ChatRequest, base_url: &str) -> Result serde_json::Map { - let mut payload = serde_json::Map::new(); - payload.insert( - "model".to_string(), - serde_json::Value::String(req.model.clone()), - ); - - let mapped_messages: Vec = req - .messages - .iter() - .map(|message| { - serde_json::json!({ - "role": message.role, - "content": message.content, - }) - }) - .collect(); - payload.insert( - "messages".to_string(), - serde_json::Value::Array(mapped_messages), - ); - payload.insert("stream".to_string(), serde_json::Value::Bool(stream)); - - if stream && !is_local { - payload.insert( - "stream_options".to_string(), - serde_json::json!({ "include_usage": true }), - ); - } - - if let Some(level) = &req.thinking_level - && level != "off" - && !is_local - { - payload.insert( - "reasoning".to_string(), - serde_json::json!({ "effort": level }), - ); - } - - let max_tokens = serde_json::json!(req.max_tokens.unwrap_or(8192)); - if is_local { - payload.insert("max_tokens".to_string(), max_tokens); - } else { - payload.insert("max_completion_tokens".to_string(), max_tokens); - } - - if !is_local - && let Some(session_id) = req.session_id.as_ref().map(|value| value.trim()) - && !session_id.is_empty() - { - payload.insert( - "session_id".to_string(), - serde_json::Value::String(session_id.to_string()), - ); - } - - if !is_local - && should_attach_web_search(req) - && let Some(web_search) = req.web_search.as_ref() - { - payload.insert( - "tools".to_string(), - serde_json::Value::Array(vec![build_web_search_tool(web_search)]), - ); - payload.insert( - "tool_choice".to_string(), - serde_json::Value::String("auto".to_string()), - ); - } - - payload -} - -fn should_attach_web_search(req: &ChatRequest) -> bool { - if !req - .web_search - .as_ref() - .is_some_and(|web_search| web_search.enabled) - { - return false; - } - - let Some(last_user_message) = req - .messages - .iter() - .rev() - .find(|message| message.role == "user") - else { - return false; - }; - let text = extract_message_text(&last_user_message.content).to_lowercase(); - let text = text.trim(); - if text.is_empty() { - return false; - } - - const WEB_SEARCH_TRIGGERS: &[&str] = &[ - "актуаль", - "интернет", - "найди", - "новост", - "погугли", - "поиск", - "посмотри в сети", - "свеж", - "сейчас", - "сегодня", - "ссылка", - "site:", - "today", - "latest", - "current", - "recent", - "news", - "search", - "browse", - "web", - "internet", - "look up", - "price", - "weather", - ]; - - text.starts_with("http://") - || text.starts_with("https://") - || text.contains(" http://") - || text.contains(" https://") - || WEB_SEARCH_TRIGGERS - .iter() - .any(|trigger| text.contains(trigger)) -} - -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"), - _ => String::new(), - } -} - -fn parse_non_stream_response( - body: &serde_json::Value, - message_id: String, - model: String, -) -> ChatResponse { - let usage = extract_token_usage(body); - - let reply_text = body - .get("choices") - .and_then(|choices| choices.as_array()) - .and_then(|choices| choices.first()) - .and_then(|choice| choice.get("message")) - .and_then(|message| message.get("content")) - .map(extract_message_text) - .unwrap_or_default(); - - if reply_text.trim().is_empty() { - return ChatResponse { - id: message_id, - ok: false, - reply: None, - error: Some("Empty response body from AI provider".to_string()), - model: Some(model), - thought_signature: None, - usage, - }; - } - - ChatResponse { - id: message_id, - ok: true, - reply: Some(ChatReply { - text: reply_text, - role: "assistant".to_string(), - }), - error: None, - model: Some(model), - thought_signature: None, - usage, - } -} - -fn build_api_error_response( - message_id: String, - model: String, - status: StatusCode, - error_text: &str, -) -> ChatResponse { - ChatResponse { - id: message_id, - ok: false, - reply: None, - error: Some(format!("API Error {status}: {error_text}")), - model: Some(model), - thought_signature: None, - usage: None, - } -} - -fn build_web_search_tool(options: &WebSearchOptions) -> serde_json::Value { - let mut parameters = serde_json::Map::new(); - parameters.insert( - "engine".to_string(), - serde_json::Value::String( - options - .engine - .clone() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "auto".to_string()), - ), - ); - parameters.insert( - "max_results".to_string(), - serde_json::json!(options.max_results.unwrap_or(5).clamp(1, 25)), - ); - parameters.insert( - "max_total_results".to_string(), - serde_json::json!(options.max_total_results.unwrap_or(10).max(1)), - ); - parameters.insert( - "search_context_size".to_string(), - serde_json::Value::String( - options - .search_context_size - .clone() - .filter(|value| matches!(value.as_str(), "low" | "medium" | "high")) - .unwrap_or_else(|| "medium".to_string()), - ), - ); - - if !options.allowed_domains.is_empty() { - parameters.insert( - "allowed_domains".to_string(), - serde_json::Value::Array( - options - .allowed_domains - .iter() - .filter(|value| !value.trim().is_empty()) - .map(|value| serde_json::Value::String(value.trim().to_string())) - .collect(), - ), - ); - } - - if !options.excluded_domains.is_empty() { - parameters.insert( - "excluded_domains".to_string(), - serde_json::Value::Array( - options - .excluded_domains - .iter() - .filter(|value| !value.trim().is_empty()) - .map(|value| serde_json::Value::String(value.trim().to_string())) - .collect(), - ), - ); - } - - serde_json::json!({ - "type": "openrouter:web_search", - "parameters": parameters, - }) -} - #[async_trait] impl AiProvider for OpenAiCompatibleProvider { async fn generate_stream( @@ -633,7 +330,7 @@ impl AiProvider for OpenAiCompatibleProvider { if !res.status().is_success() { let status = res.status(); let error_text = res.text().await.unwrap_or_default(); - return Ok(build_api_error_response( + return Ok(provider_response::build_api_error_response( message_id, req.model, status, @@ -688,7 +385,13 @@ impl AiProvider for OpenAiCompatibleProvider { } } - if !saw_done && !state.saw_terminal_chunk && state.full_content.trim().is_empty() { + if !saw_done && !state.saw_terminal_chunk { + tracing::warn!( + request_id = %request_id, + message_id = %message_id, + chunks = state.chunks_emitted, + "AI stream ended before a completion marker was received" + ); return Ok(ChatResponse { id: message_id, ok: false, @@ -700,15 +403,6 @@ impl AiProvider for OpenAiCompatibleProvider { }); } - if !saw_done && !state.saw_terminal_chunk { - tracing::warn!( - request_id = %request_id, - message_id = %message_id, - chunks = state.chunks_emitted, - "AI stream ended without completion marker after emitting content" - ); - } - // Final event sink.emit(StreamEvent::Done { message_id: message_id.clone(), @@ -820,11 +514,11 @@ fn handle_stream_json_line( return StreamChunkResult::Error("AI stream returned malformed JSON chunk".to_string()); }; - if let Some(message) = extract_stream_error_message(&json) { + if let Some(message) = provider_response::extract_stream_error_message(&json) { return StreamChunkResult::Error(message); } - if let Some(usage) = extract_token_usage(&json) { + if let Some(usage) = provider_response::extract_token_usage(&json) { state.final_usage = Some(usage); } @@ -834,14 +528,17 @@ fn handle_stream_json_line( .and_then(|choices| choices.first()); if let Some(choice) = choice { - if let Some(message) = choice.get("error").and_then(extract_error_message) { + 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( - extract_error_message(choice) + provider_response::extract_error_message(choice) .unwrap_or_else(|| "AI provider reported a streaming error".to_string()), ); } @@ -868,11 +565,11 @@ fn handle_stream_json_line( if let Some(reasoning) = delta .and_then(|d| d.get("reasoning_content")) - .and_then(extract_stream_text) + .and_then(provider_response::extract_stream_text) .or_else(|| { delta .and_then(|d| d.get("reasoning")) - .and_then(extract_stream_text) + .and_then(provider_response::extract_stream_text) }) { sink.emit(StreamEvent::ThoughtChunk { @@ -883,28 +580,39 @@ fn handle_stream_json_line( let content = delta .and_then(|d| d.get("content")) - .and_then(extract_stream_text) + .and_then(provider_response::extract_stream_text) .or_else(|| { choice .and_then(|value| value.get("text")) - .and_then(extract_stream_text) + .and_then(provider_response::extract_stream_text) .or_else(|| { choice .and_then(|value| value.get("content")) - .and_then(extract_stream_text) + .and_then(provider_response::extract_stream_text) }) }) - .or_else(|| json.get("content").and_then(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(extract_stream_text) + .and_then(provider_response::extract_stream_text) }) - .or_else(|| json.get("response").and_then(extract_stream_text)) .or_else(|| { json.get("token") .and_then(|token| token.get("text")) - .and_then(extract_stream_text) + .and_then(provider_response::extract_stream_text) }); if let Some(content) = content { @@ -918,135 +626,14 @@ fn handle_stream_json_line( StreamChunkResult::Continue } -fn extract_stream_text(value: &serde_json::Value) -> Option { - match value { - serde_json::Value::String(text) => Some(text.clone()), - serde_json::Value::Array(parts) => { - let text = parts - .iter() - .filter_map(|part| { - part.get("text") - .and_then(|candidate| candidate.as_str()) - .or_else(|| part.get("content").and_then(|candidate| candidate.as_str())) - }) - .collect::>() - .join(""); - - (!text.is_empty()).then_some(text) - } - serde_json::Value::Object(object) => object - .get("text") - .and_then(|candidate| candidate.as_str()) - .map(ToOwned::to_owned), - _ => None, - } -} - -fn extract_token_usage(json: &serde_json::Value) -> Option { - let usage = json.get("usage").unwrap_or(json); - let timings = json.get("timings"); - - let prompt_tokens = read_usage_count( - usage, - timings, - &[ - "prompt_tokens", - "input_tokens", - "prompt_eval_count", - "prompt_n", - ], - ); - let completion_tokens = read_usage_count( - usage, - timings, - &[ - "completion_tokens", - "output_tokens", - "eval_count", - "predicted_n", - ], - ); - let total_tokens = read_usage_count(usage, timings, &["total_tokens"]) - .or_else(|| (prompt_tokens.is_some() || completion_tokens.is_some()).then_some(0)) - .map(|total| { - if total > 0 { - total - } else { - prompt_tokens.unwrap_or(0) + completion_tokens.unwrap_or(0) - } - }); - - match (prompt_tokens, completion_tokens, total_tokens) { - (None, None, None) => None, - (prompt_tokens, completion_tokens, total_tokens) => Some(TokenUsage { - prompt_tokens: prompt_tokens.unwrap_or(0), - completion_tokens: completion_tokens.unwrap_or(0), - total_tokens: total_tokens.unwrap_or(0), - }), - } -} - -fn read_usage_count( - usage: &serde_json::Value, - timings: Option<&serde_json::Value>, - keys: &[&str], -) -> Option { - keys.iter() - .find_map(|key| usage.get(*key).and_then(json_number_to_u32)) - .or_else(|| { - timings.and_then(|timings| { - keys.iter() - .find_map(|key| timings.get(*key).and_then(json_number_to_u32)) - }) - }) -} - -fn json_number_to_u32(value: &serde_json::Value) -> Option { - value - .as_u64() - .and_then(|value| u32::try_from(value).ok()) - .or_else(|| { - value.as_f64().and_then(|value| { - let rounded = value.round(); - if rounded.is_finite() && rounded >= 0.0 && rounded <= f64::from(u32::MAX) { - rounded.to_string().parse::().ok() - } else { - None - } - }) - }) -} - -fn extract_stream_error_message(json: &serde_json::Value) -> Option { - json.get("error") - .and_then(extract_error_message) - .or_else(|| json.get("errors").and_then(extract_error_message)) -} - -fn extract_error_message(value: &serde_json::Value) -> Option { - match value { - serde_json::Value::String(message) => { - let trimmed = message.trim(); - (!trimmed.is_empty()).then(|| trimmed.to_string()) - } - serde_json::Value::Array(items) => items.iter().find_map(extract_error_message), - serde_json::Value::Object(object) => ["message", "detail", "error"] - .iter() - .find_map(|key| object.get(*key).and_then(extract_error_message)), - _ => None, - } -} - #[cfg(test)] mod tests { #![allow(clippy::expect_used, clippy::indexing_slicing)] - use super::{ - StreamChunkResult, StreamingAccumulator, build_request_payload, build_web_search_tool, - is_local_base_url, process_stream_chunk, retry_delay, should_retry_status, - }; + use super::{StreamChunkResult, StreamingAccumulator, is_local_base_url, process_stream_chunk}; 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; @@ -1082,7 +669,7 @@ mod tests { #[test] fn build_web_search_tool_applies_defaults() { - let tool = build_web_search_tool(&WebSearchOptions { + let tool = provider_payload::build_web_search_tool(&WebSearchOptions { enabled: true, ..Default::default() }); @@ -1096,7 +683,7 @@ mod tests { #[test] fn build_web_search_tool_keeps_domain_filters() { - let tool = build_web_search_tool(&WebSearchOptions { + let tool = provider_payload::build_web_search_tool(&WebSearchOptions { enabled: true, allowed_domains: vec!["openai.com".to_string()], excluded_domains: vec!["reddit.com".to_string()], @@ -1111,28 +698,39 @@ mod tests { fn local_base_url_detection_matches_local_endpoints() { assert!(is_local_base_url("http://localhost:8081/v1")); assert!(is_local_base_url("http://127.0.0.1:8081/v1")); + assert!(is_local_base_url("http://[::1]:8081/v1")); assert!(!is_local_base_url("https://openrouter.ai/api/v1")); + assert!(!is_local_base_url("https://localhost.example.com/v1")); } #[test] fn retry_policy_is_limited_to_interactive_safe_cases() { - assert!(should_retry_status(StatusCode::TOO_MANY_REQUESTS)); - assert!(should_retry_status(StatusCode::SERVICE_UNAVAILABLE)); - assert!(should_retry_status(StatusCode::BAD_GATEWAY)); - assert!(should_retry_status(StatusCode::GATEWAY_TIMEOUT)); - assert!(!should_retry_status(StatusCode::INTERNAL_SERVER_ERROR)); - assert!(!should_retry_status(StatusCode::FORBIDDEN)); + assert!(provider_http::should_retry_status( + StatusCode::TOO_MANY_REQUESTS + )); + assert!(provider_http::should_retry_status( + StatusCode::SERVICE_UNAVAILABLE + )); + assert!(provider_http::should_retry_status(StatusCode::BAD_GATEWAY)); + assert!(provider_http::should_retry_status( + StatusCode::GATEWAY_TIMEOUT + )); + assert!(!provider_http::should_retry_status( + StatusCode::INTERNAL_SERVER_ERROR + )); + assert!(!provider_http::should_retry_status(StatusCode::FORBIDDEN)); } #[test] fn retry_delay_stays_short_for_chat_requests() { - assert!(retry_delay(1, StatusCode::SERVICE_UNAVAILABLE).as_millis() < 500); - assert!(retry_delay(1, StatusCode::TOO_MANY_REQUESTS).as_millis() < 900); + assert!(provider_http::retry_delay(1, StatusCode::SERVICE_UNAVAILABLE).as_millis() < 500); + assert!(provider_http::retry_delay(1, StatusCode::TOO_MANY_REQUESTS).as_millis() < 900); } #[test] fn build_request_payload_uses_cloud_token_field_and_session_id() { - let payload = build_request_payload(&sample_request(), true, false); + let payload = + provider_payload::build_chat_completion_payload(&sample_request(), true, false); assert_eq!(payload.get("max_completion_tokens"), Some(&json!(2048))); assert_eq!(payload.get("session_id"), Some(&json!("session-1"))); @@ -1140,6 +738,26 @@ mod tests { assert_eq!(payload.get("reasoning"), Some(&json!({ "effort": "high" }))); } + #[test] + fn build_request_payload_maps_off_reasoning_to_openrouter_none() { + let mut request = sample_request(); + request.thinking_level = Some("off".to_string()); + + let payload = provider_payload::build_chat_completion_payload(&request, true, false); + + assert_eq!(payload.get("reasoning"), Some(&json!({ "effort": "none" }))); + } + + #[test] + fn build_request_payload_keeps_explicit_none_reasoning() { + let mut request = sample_request(); + request.thinking_level = Some("none".to_string()); + + let payload = provider_payload::build_chat_completion_payload(&request, true, false); + + assert_eq!(payload.get("reasoning"), Some(&json!({ "effort": "none" }))); + } + #[test] fn build_request_payload_skips_web_search_for_generic_prompts() { let mut request = sample_request(); @@ -1148,7 +766,7 @@ mod tests { ..Default::default() }); - let payload = build_request_payload(&request, true, false); + let payload = provider_payload::build_chat_completion_payload(&request, true, false); assert!(payload.get("tool_choice").is_none()); assert!(payload.get("tools").is_none()); @@ -1163,7 +781,7 @@ mod tests { ..Default::default() }); - let payload = build_request_payload(&request, true, false); + let payload = provider_payload::build_chat_completion_payload(&request, true, false); assert_eq!(payload.get("tool_choice"), Some(&json!("auto"))); assert_eq!( @@ -1177,7 +795,8 @@ mod tests { #[test] fn build_request_payload_keeps_local_compatibility_fields() { - let payload = build_request_payload(&sample_request(), true, true); + let payload = + provider_payload::build_chat_completion_payload(&sample_request(), true, true); assert_eq!(payload.get("max_tokens"), Some(&json!(2048))); assert!(payload.get("max_completion_tokens").is_none()); @@ -1242,6 +861,25 @@ mod tests { )); } + #[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(); diff --git a/src-tauri/src/domain/engine/config.rs b/src-tauri/src/domain/engine/config.rs index df5ad77a..1c27c83b 100644 --- a/src-tauri/src/domain/engine/config.rs +++ b/src-tauri/src/domain/engine/config.rs @@ -5,7 +5,7 @@ use crate::domain::engine::types::{EngineComputeMode, EngineConfig, EngineDefinition}; -const MIN_LLAMACPP_CONTEXT_SIZE: u32 = 4096; +use super::engine_profile::minimum_context_size; /// Builds a runtime engine config from engine definition defaults. #[must_use] @@ -15,8 +15,6 @@ pub fn build_default_engine_config(def: &EngineDefinition) -> EngineConfig { compute_mode: EngineComputeMode::Gpu, context_size: def.default_context_size, model_path: None, - vae_path: None, - llm_path: None, extra_args: vec![], }) } @@ -34,8 +32,6 @@ pub fn merge_user_engine_config(def: &EngineDefinition, saved: &EngineConfig) -> compute_mode: saved.compute_mode, context_size: saved.context_size, model_path: saved.model_path.clone(), - vae_path: saved.vae_path.clone(), - llm_path: saved.llm_path.clone(), extra_args: saved.extra_args.clone(), }) } @@ -43,13 +39,8 @@ pub fn merge_user_engine_config(def: &EngineDefinition, saved: &EngineConfig) -> /// Normalizes launcher-managed engine settings. #[must_use] pub fn normalize_engine_config(mut config: EngineConfig) -> EngineConfig { - if config.engine_id == "llamacpp" && config.context_size < MIN_LLAMACPP_CONTEXT_SIZE { - config.context_size = MIN_LLAMACPP_CONTEXT_SIZE; - } - - if config.engine_id == "sdcpp" || config.engine_id == "stable-diffusion" { - config.vae_path = None; - config.llm_path = None; + if let Some(min_context_size) = minimum_context_size(&config.engine_id) { + config.context_size = config.context_size.max(min_context_size); } config @@ -73,20 +64,19 @@ mod tests { default_context_size: 4096, config_schema: None, installed: false, + installed_compute_modes: Vec::new(), managed_externally: false, } } #[test] - fn merge_user_engine_config_clears_sdcpp_companion_paths() { + fn merge_user_engine_config_keeps_sdcpp_runtime_settings() { let def = sample_definition(); let saved = EngineConfig { engine_id: "sdcpp".to_string(), compute_mode: EngineComputeMode::Cpu, context_size: 8192, model_path: Some("C:/models/test.gguf".to_string()), - vae_path: Some("C:/models/test.vae.safetensors".to_string()), - llm_path: Some("C:/models/test-mm.gguf".to_string()), extra_args: vec!["--flash-attn".to_string()], }; @@ -95,7 +85,6 @@ mod tests { assert_eq!(merged.compute_mode, EngineComputeMode::Cpu); assert_eq!(merged.context_size, 8192); assert_eq!(merged.model_path.as_deref(), Some("C:/models/test.gguf")); - assert_eq!(merged.vae_path, None); - assert_eq!(merged.llm_path, None); + assert_eq!(merged.extra_args, vec!["--flash-attn"]); } } diff --git a/src-tauri/src/domain/engine/detector.rs b/src-tauri/src/domain/engine/detector.rs index caa9bbf9..0c9b1208 100644 --- a/src-tauri/src/domain/engine/detector.rs +++ b/src-tauri/src/domain/engine/detector.rs @@ -6,12 +6,41 @@ use std::path::PathBuf; +use crate::domain::engine::types::EngineComputeMode; +use crate::errors::AppError; use crate::utils::paths::ENGINES_DIR; fn installed_engine_dir(engine_id: &str) -> PathBuf { ENGINES_DIR.join(engine_id) } +/// Deletes an Axelate-managed engine directory from `ENGINES_DIR/{id}`. +/// +/// System `PATH` installs are intentionally ignored because they are owned by the user. +pub async fn delete_installed_engine(engine_id: &str) -> Result<(), AppError> { + if !is_safe_id(engine_id) { + return Err(AppError::Validation(format!( + "Invalid engine id: {engine_id}" + ))); + } + + let engine_path = installed_engine_dir(engine_id); + if !tokio::fs::try_exists(&engine_path).await? { + return Ok(()); + } + + let engines_root = ENGINES_DIR.canonicalize()?; + let engine_path = engine_path.canonicalize()?; + if !engine_path.starts_with(&engines_root) { + return Err(AppError::Validation(format!( + "Engine path escapes engines directory: {engine_id}" + ))); + } + + tokio::fs::remove_dir_all(engine_path).await?; + Ok(()) +} + /// Checks if an engine is installed either in `ENGINES_DIR/{id}` or on system PATH. /// /// Returns `false` for invalid engine IDs (prevents directory traversal). @@ -36,6 +65,33 @@ pub fn is_engine_installed(engine_id: &str, binary_name: Option<&str>) -> bool { false } +/// Reads Axelate install metadata and returns the compute modes present on disk. +/// +/// Empty means the install source is unknown or predates metadata tracking. +pub fn installed_compute_modes(engine_id: &str) -> Vec { + if !is_safe_id(engine_id) { + return Vec::new(); + } + + let metadata_path = installed_engine_dir(engine_id).join("metadata.json"); + let Ok(source) = std::fs::read_to_string(metadata_path) else { + return Vec::new(); + }; + let Ok(value) = serde_json::from_str::(&source) else { + return Vec::new(); + }; + + match value + .get("compute_target") + .and_then(serde_json::Value::as_str) + { + Some("gpu") => vec![EngineComputeMode::Gpu], + Some("cpu") => vec![EngineComputeMode::Cpu], + Some("both") => vec![EngineComputeMode::Gpu, EngineComputeMode::Cpu], + _ => Vec::new(), + } +} + /// Returns the absolute path to an engine binary if found. /// /// Search order: diff --git a/src-tauri/src/domain/engine/engine_args.rs b/src-tauri/src/domain/engine/engine_args.rs index 7a001d15..5961a05a 100644 --- a/src-tauri/src/domain/engine/engine_args.rs +++ b/src-tauri/src/domain/engine/engine_args.rs @@ -1,69 +1,17 @@ -use std::path::{Path, PathBuf}; - -use crate::errors::AppError; +use std::path::PathBuf; +use super::engine_profile::minimum_context_size; use super::types::{EngineComputeMode, EngineConfig}; -fn is_qwen_model(model_path: Option<&str>) -> bool { - model_path.is_some_and(|path| path.to_ascii_lowercase().contains("qwen")) -} - -fn is_qwen_image_model(model_path: Option<&str>) -> bool { - model_path.is_some_and(|path| { - let normalized = path.replace('\\', "/").to_ascii_lowercase(); - normalized.contains("qwen-image") || normalized.contains("qwen_image") - }) -} - -fn has_arg(args: &[String], candidates: &[&str]) -> bool { - args.iter().any(|arg| { - candidates.iter().any(|candidate| { - arg == candidate - || arg - .strip_prefix(candidate) - .is_some_and(|suffix| suffix.starts_with('=')) - }) - }) -} - -fn push_arg_if_missing( - args: &mut Vec, - existing_args: &[String], - candidates: &[&str], - value: Option<&str>, -) { - if has_arg(args, candidates) || has_arg(existing_args, candidates) { - return; - } - - let Some(candidate) = candidates.first() else { - return; - }; - - args.push((*candidate).to_string()); - if let Some(value) = value { - args.push(value.to_string()); - } -} - -fn extract_arg_value(args: &[String], candidates: &[&str]) -> Option { - for (index, arg) in args.iter().enumerate() { - for candidate in candidates { - if arg == candidate { - if let Some(value) = args.get(index + 1) { - return Some(value.clone()); - } - } - - let prefix = format!("{candidate}="); - if let Some(value) = arg.strip_prefix(&prefix) { - return Some(value.to_string()); - } - } - } - - None -} +const SDCPP_UNSUPPORTED_FLAGS: [&str; 4] = [ + "--diffusion-on-cpu", + "--vae-on-gpu", + "--clip-on-gpu", + "--control-net-on-gpu", +]; +const SDCPP_SERVER_UNSUPPORTED_FLAGS: [&str; 3] = + ["--preview", "--preview-path", "--preview-interval"]; +const SDCPP_LAUNCHER_ONLY_PREVIEW_FLAG: &str = "--sdcpp-preview"; fn push_llamacpp_compute_args(args: &mut Vec, config: &EngineConfig) { match config.compute_mode { @@ -80,117 +28,127 @@ fn push_llamacpp_compute_args(args: &mut Vec, config: &EngineConfig) { } } -/// Resolves the explicit stable-diffusion.cpp preview output path from extra arguments. -pub fn resolve_sdcpp_preview_path(extra_args: &[String]) -> Option { - extract_arg_value(extra_args, &["--preview-path"]).map(PathBuf::from) -} - -pub(super) fn sdcpp_preview_enabled(extra_args: &[String]) -> bool { - extract_arg_value(extra_args, &["--preview"]) - .is_none_or(|value| !value.trim().eq_ignore_ascii_case("none")) +fn push_sdcpp_compute_args(args: &mut Vec, config: &EngineConfig) { + match config.compute_mode { + EngineComputeMode::Gpu => {} + EngineComputeMode::Cpu => { + args.push("--clip-on-cpu".to_string()); + args.push("--vae-on-cpu".to_string()); + } + } } -fn find_companion_model_file( - model_path: &Path, - stems: &[&str], - extensions: &[&str], -) -> Option { - let model_dir = model_path.parent()?; - let mut entries = std::fs::read_dir(model_dir) - .ok()? - .flatten() +fn sdcpp_extra_args(config: &EngineConfig) -> Vec { + let unsupported_flags = SDCPP_UNSUPPORTED_FLAGS + .iter() + .chain(SDCPP_SERVER_UNSUPPORTED_FLAGS.iter()) + .copied() + .chain(std::iter::once(SDCPP_LAUNCHER_ONLY_PREVIEW_FLAG)) .collect::>(); - entries.sort_by_key(std::fs::DirEntry::file_name); + let mut filtered = Vec::new(); + let mut index = 0; - for entry in entries { - let path = entry.path(); - if !path.is_file() { + while let Some(arg) = config.extra_args.get(index) { + let should_skip = unsupported_flags.iter().any(|flag| { + arg.as_str() == *flag + || arg + .strip_prefix(flag) + .is_some_and(|suffix| suffix.starts_with('=')) + }); + + if should_skip { + let skip_value = unsupported_flags.contains(&arg.as_str()) + && config + .extra_args + .get(index + 1) + .is_some_and(|next| !next.starts_with('-')); + index += if skip_value { 2 } else { 1 }; continue; } - let file_name = path.file_name()?.to_string_lossy().to_ascii_lowercase(); - let extension = path.extension()?.to_string_lossy().to_ascii_lowercase(); + filtered.push(arg.clone()); + index += 1; + } - if extensions.iter().all(|candidate| extension != *candidate) { - continue; + filtered +} + +/// Resolves the explicit stable-diffusion.cpp preview output path from extra arguments. +pub fn resolve_sdcpp_preview_path(extra_args: &[String]) -> Option { + let mut index = 0; + while let Some(arg) = extra_args.get(index) { + if let Some(path) = arg + .strip_prefix(SDCPP_LAUNCHER_ONLY_PREVIEW_FLAG) + .and_then(|suffix| suffix.strip_prefix('=')) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(PathBuf::from(path)); } - if stems.iter().any(|stem| file_name.contains(stem)) { - return Some(path.to_string_lossy().to_string()); + if arg == SDCPP_LAUNCHER_ONLY_PREVIEW_FLAG + && let Some(path) = extra_args + .get(index + 1) + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty() && !value.starts_with('-')) + { + return Some(PathBuf::from(path)); } + + index += 1; } None } -fn resolve_qwen_image_support_file( - model_path: &Path, - extra_args: &[String], - arg_names: &[&str], - stems: &[&str], - extensions: &[&str], -) -> Option { - extract_arg_value(extra_args, arg_names) - .or_else(|| find_companion_model_file(model_path, stems, extensions)) +pub(super) fn sdcpp_preview_enabled(extra_args: &[String]) -> bool { + resolve_sdcpp_preview_path(extra_args).is_some() + || extra_args.iter().any(|arg| { + arg == SDCPP_LAUNCHER_ONLY_PREVIEW_FLAG + || arg + .strip_prefix(SDCPP_LAUNCHER_ONLY_PREVIEW_FLAG) + .is_some_and(|suffix| suffix.starts_with('=')) + }) +} + +fn build_generic_engine_args(config: &EngineConfig, port: u16) -> Vec { + let mut args = vec!["--port".to_string(), port.to_string()]; + if let Some(model_path) = config.model_path.as_deref() { + args.push("--model".to_string()); + args.push(model_path.to_string()); + } + args.extend(config.extra_args.clone()); + args } -fn qwen_image_requirements_error(model_path: &str) -> AppError { - AppError::Validation(format!( - "Qwen Image model '{model_path}' needs companion files for stable-diffusion.cpp. Place 'qwen_image_vae.safetensors' and 'Qwen2.5-VL-7B-Instruct*.gguf' next to the selected model, or pass '--vae' and '--llm' in Extra Arguments." - )) +pub(super) fn build_engine_args(config: &EngineConfig, port: u16) -> Vec { + match config.engine_id.as_str() { + "llamacpp" => build_llamacpp_args(config, port), + "sdcpp" => build_sdcpp_args(config, port), + _ => build_generic_engine_args(config, port), + } } -pub(super) fn build_sdcpp_args(config: &EngineConfig, port: u16) -> Result, AppError> { +fn build_sdcpp_args(config: &EngineConfig, port: u16) -> Vec { let mut args = vec!["--listen-port".to_string(), port.to_string()]; + let extra_args = sdcpp_extra_args(config); + push_sdcpp_compute_args(&mut args, config); if let Some(model_path) = config.model_path.as_deref() { - if is_qwen_image_model(Some(model_path)) { - let model_path_buf = Path::new(model_path); - let vae_path = resolve_qwen_image_support_file( - model_path_buf, - &config.extra_args, - &["--vae"], - &["qwen_image_vae", "qwen-image-vae"], - &["safetensors"], - ); - let llm_path = resolve_qwen_image_support_file( - model_path_buf, - &config.extra_args, - &["--llm"], - &["qwen2.5-vl", "qwen2_5_vl", "qwen25-vl", "qwen25_vl"], - &["gguf"], - ); - - if vae_path.is_none() || llm_path.is_none() { - return Err(qwen_image_requirements_error(model_path)); - } - - args.push("--diffusion-model".to_string()); - args.push(model_path.to_string()); - push_arg_if_missing( - &mut args, - &config.extra_args, - &["--vae"], - vae_path.as_deref(), - ); - push_arg_if_missing( - &mut args, - &config.extra_args, - &["--llm"], - llm_path.as_deref(), - ); - } else { - args.push("--model".to_string()); - args.push(model_path.to_string()); - } + args.push("--model".to_string()); + args.push(model_path.to_string()); } - args.extend(config.extra_args.clone()); - Ok(args) + args.extend(extra_args); + args } -pub(super) fn build_llamacpp_args(config: &EngineConfig, port: u16) -> Vec { - let effective_context_size = config.context_size.max(4096); +fn build_llamacpp_args(config: &EngineConfig, port: u16) -> Vec { + let effective_context_size = minimum_context_size(&config.engine_id) + .map_or(config.context_size, |min_context_size| { + config.context_size.max(min_context_size) + }); let mut args = vec![ "--port".to_string(), port.to_string(), @@ -198,31 +156,10 @@ pub(super) fn build_llamacpp_args(config: &EngineConfig, port: u16) -> Vec Option { + match engine_id { + "llamacpp" => Some(LLAMACPP_MIN_CONTEXT_SIZE), + _ => None, + } +} diff --git a/src-tauri/src/domain/engine/engine_runtime.rs b/src-tauri/src/domain/engine/engine_runtime.rs index 4601fa7c..0bf21b47 100644 --- a/src-tauri/src/domain/engine/engine_runtime.rs +++ b/src-tauri/src/domain/engine/engine_runtime.rs @@ -36,7 +36,7 @@ pub(super) fn classify_engine_start_failure(log: &str) -> Option { || normalized.contains("failed to allocate compute") { return Some( - "Not enough memory to start the local model. Reduce context size or GPU layers, or use a smaller model." + "Not enough memory to start the local model. Reduce context size, switch compute mode, or use a smaller model." .to_string(), ); } @@ -105,6 +105,9 @@ pub(super) fn spawn_log_reader( } if !current_line.is_empty() { + if let Some(ref mut f) = file { + write_engine_log_line(f, ¤t_line); + } let trimmed = current_line.trim(); if is_progress_log_line(trimmed) { emitter.emit_log(&engine_id, trimmed); @@ -149,3 +152,65 @@ pub(super) async fn wait_for_health(endpoint: &str) -> Result<(), AppError> { ), }) } + +pub(super) async fn is_endpoint_healthy(endpoint: &str) -> bool { + let Ok(client) = reqwest::Client::builder() + .timeout(Duration::from_millis(900)) + .build() + else { + return false; + }; + + for health_url in [ + format!("{endpoint}/health"), + format!("{endpoint}/v1/models"), + format!("{endpoint}/"), + ] { + if matches!(client.get(&health_url).send().await, Ok(resp) if resp.status().is_success()) { + return true; + } + } + + false +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::spawn_log_reader; + use crate::domain::engine::events::NoopEmitter; + use std::io::Write; + use std::sync::Arc; + use tokio::io::AsyncWriteExt; + + #[tokio::test] + async fn log_reader_flushes_trailing_line_without_newline() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let log_path = temp_dir.path().join("stderr.log"); + let file = std::fs::File::create(&log_path).expect("log file"); + let (mut writer, reader) = tokio::io::duplex(64); + + spawn_log_reader( + reader, + Some(file), + Arc::new(NoopEmitter), + "llamacpp".to_string(), + ); + + writer + .write_all(b"fatal out of memory") + .await + .expect("write log chunk"); + drop(writer); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&log_path) + .expect("reopen log"); + file.flush().expect("flush log"); + let content = std::fs::read_to_string(log_path).expect("read log"); + assert!(content.contains("fatal out of memory")); + } +} diff --git a/src-tauri/src/domain/engine/manager.rs b/src-tauri/src/domain/engine/manager.rs index ed644192..e528ef6c 100644 --- a/src-tauri/src/domain/engine/manager.rs +++ b/src-tauri/src/domain/engine/manager.rs @@ -17,9 +17,10 @@ use tracing::{error, info, warn}; use crate::errors::AppError; -use super::engine_args::{build_llamacpp_args, build_sdcpp_args, sdcpp_preview_enabled}; +use super::engine_args::{build_engine_args, sdcpp_preview_enabled}; use super::engine_runtime::{ - diagnose_engine_start_failure, find_available_local_port, spawn_log_reader, wait_for_health, + diagnose_engine_start_failure, find_available_local_port, is_endpoint_healthy, + spawn_log_reader, wait_for_health, }; use super::events::EngineEventEmitter; use super::types::{ @@ -91,11 +92,13 @@ impl EngineManager { /// Checks if a definition exists for the given engine ID pub async fn has_definition(&self, id: &str) -> bool { + let id = canonical_engine_id(id); self.definitions.lock().await.iter().any(|d| d.id == id) } /// Gets the definition for an engine ID pub async fn get_definition(&self, id: &str) -> Option { + let id = canonical_engine_id(id); self.definitions .lock() .await @@ -106,6 +109,8 @@ impl EngineManager { /// Gets the current engine state (all active slots) pub async fn state(&self) -> EngineState { + self.prune_dead_slots().await; + let slots = self.slots.lock().await; if slots.is_empty() { return EngineState::Idle; @@ -130,6 +135,8 @@ impl EngineManager { /// Gets the endpoint for a given capability (if an active engine supports it) pub async fn endpoint_for(&self, capability: Capability) -> Option { + self.prune_dead_slots().await; + let slots = self.slots.lock().await; slots.get(&capability).and_then(|engine| { if engine.healthy { @@ -142,16 +149,19 @@ impl EngineManager { /// Checks if any active engine supports a capability pub async fn supports(&self, capability: Capability) -> bool { + self.prune_dead_slots().await; self.slots.lock().await.contains_key(&capability) } /// Checks if any engine is currently active pub async fn is_active(&self) -> bool { + self.prune_dead_slots().await; !self.slots.lock().await.is_empty() } /// Gets all active engine IDs pub async fn active_ids(&self) -> Vec { + self.prune_dead_slots().await; self.slots .lock() .await @@ -160,6 +170,17 @@ impl EngineManager { .collect() } + /// Checks whether the given engine is currently running in any capability slot. + pub async fn is_engine_running(&self, id: &str) -> bool { + let id = canonical_engine_id(id); + self.prune_dead_slots().await; + self.slots + .lock() + .await + .values() + .any(|engine| engine.definition.id == id) + } + /// Acquires exclusive access to local inference work. /// /// Hold this guard for the full request, not just engine startup. Without it, @@ -173,7 +194,7 @@ impl EngineManager { pub async fn active_image_preview_path(&self) -> Option { let slots = self.slots.lock().await; let engine = slots.get(&Capability::Image)?; - if engine.definition.id != "sdcpp" && engine.definition.id != "stable-diffusion" { + if engine.definition.id != "sdcpp" { return None; } @@ -189,6 +210,8 @@ impl EngineManager { /// launcher on a single active local engine by default. pub async fn start(&self, config: EngineConfig) -> Result { let _lifecycle_guard = self.lifecycle_lock.lock().await; + let mut config = config; + config.engine_id = canonical_engine_id(&config.engine_id); let definition = self.find_definition(&config.engine_id).await?; let primary_cap = definition .capabilities @@ -196,6 +219,78 @@ impl EngineManager { .copied() .unwrap_or(Capability::Text); // Check if this exact engine AND model is already running in this slot + { + let mut slots = self.slots.lock().await; + if let Some(existing) = slots.get_mut(&primary_cap) { + if existing.definition.id == config.engine_id + && existing.config.model_path == config.model_path + { + match existing.process.try_wait() { + Ok(Some(status)) => { + warn!( + engine = %config.engine_id, + slot = ?primary_cap, + exit_status = %status, + "Dropping stale engine slot because process already exited" + ); + slots.remove(&primary_cap); + } + Err(error) => { + warn!( + engine = %config.engine_id, + slot = ?primary_cap, + error = %error, + "Engine process status check failed; attempting to stop it before dropping slot" + ); + let stale = slots.remove(&primary_cap); + drop(slots); + if let Some(stale) = stale { + match Self::kill_engine_retaining_on_failure(stale).await { + Ok(()) => {} + Err((error, stale)) => { + self.slots.lock().await.insert(primary_cap, stale); + return Err(error); + } + } + } + } + Ok(None) => { + let status = EngineStatus { + id: existing.definition.id.clone(), + name: existing.definition.name.clone(), + capabilities: existing.definition.capabilities.clone(), + endpoint: existing.endpoint.clone(), + healthy: existing.healthy, + }; + let endpoint = existing.endpoint.clone(); + drop(slots); + + if is_endpoint_healthy(&endpoint).await { + info!(engine = %config.engine_id, slot = ?primary_cap, "Engine and model already running in slot"); + return Ok(status); + } + + warn!( + engine = %config.engine_id, + slot = ?primary_cap, + endpoint = %endpoint, + "Dropping stale engine slot because health check failed" + ); + let mut slots = self.slots.lock().await; + let stale = slots.remove(&primary_cap); + drop(slots); + if let Some(stale) = stale { + Self::kill_engine(stale).await?; + } + } + } + } else { + // Different engine or model: hot-swap below. + } + } + } + + // Re-check after stale cleanup; a different caller may have started it while we probed. { let slots = self.slots.lock().await; if let Some(existing) = slots.get(&primary_cap) { @@ -215,9 +310,12 @@ impl EngineManager { } // Hot-swap: stop every active engine before starting the next one. - let old_engines: Vec<(Capability, RunningEngine)> = - self.slots.lock().await.drain().collect(); - for (old_cap, old) in old_engines { + let old_caps = self.slots.lock().await.keys().copied().collect::>(); + for old_cap in old_caps { + let old = self.slots.lock().await.remove(&old_cap); + let Some(old) = old else { + continue; + }; info!( from = %old.definition.id, to = %config.engine_id, @@ -226,7 +324,10 @@ impl EngineManager { ); self.emitter .emit_swapping(&old.definition.id, &config.engine_id); - Self::kill_engine(old).await; + if let Err((error, old)) = Self::kill_engine_retaining_on_failure(old).await { + self.slots.lock().await.insert(old_cap, old); + return Err(error); + } } let binary_name = definition.binary.as_deref().ok_or_else(|| { @@ -269,39 +370,33 @@ impl EngineManager { cmd.creation_flags(CREATE_NO_WINDOW); } - if config.engine_id == "llamacpp" { - cmd.args(build_llamacpp_args(&config, selected_port)); - } else if config.engine_id == "sdcpp" { - let sdcpp_args = build_sdcpp_args(&config, selected_port).inspect_err(|error| { - self.emitter - .emit_error(&config.engine_id, &error.to_string()); - })?; - cmd.args(sdcpp_args); - } else { - // Default fallback for other engines - cmd.arg("--port").arg(selected_port.to_string()); - } - - if config.engine_id != "sdcpp" { - if let Some(ref model) = config.model_path { - cmd.arg("--model").arg(model); - } - - for arg in &config.extra_args { - cmd.arg(arg); - } - } + cmd.args(build_engine_args(&config, selected_port)); // Pipe engine stdout/stderr to files in logs directory let log_dir = crate::utils::paths::ENGINE_LOGS_DIR.join(canonical_engine_log_id(&config.engine_id)); - let _ = std::fs::create_dir_all(&log_dir); + std::fs::create_dir_all(&log_dir).map_err(|error| { + AppError::Io(format!( + "Failed to create engine log directory '{}': {error}", + log_dir.display() + )) + })?; let stdout_path = log_dir.join("stdout.log"); let stderr_path = log_dir.join("stderr.log"); - let stdout_file = File::create(&stdout_path).ok(); - let stderr_file = File::create(&stderr_path).ok(); + let stdout_file = File::create(&stdout_path).map_err(|error| { + AppError::Io(format!( + "Failed to create engine stdout log '{}': {error}", + stdout_path.display() + )) + })?; + let stderr_file = File::create(&stderr_path).map_err(|error| { + AppError::Io(format!( + "Failed to create engine stderr log '{}': {error}", + stderr_path.display() + )) + })?; cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); @@ -326,7 +421,7 @@ impl EngineManager { if let Some(stdout) = process.stdout.take() { spawn_log_reader( stdout, - stdout_file, + Some(stdout_file), Arc::clone(&self.emitter), config.engine_id.clone(), ); @@ -335,7 +430,7 @@ impl EngineManager { if let Some(stderr) = process.stderr.take() { spawn_log_reader( stderr, - stderr_file, + Some(stderr_file), Arc::clone(&self.emitter), config.engine_id.clone(), ); @@ -352,6 +447,18 @@ impl EngineManager { // Wait for health check match wait_for_health(&endpoint).await { Ok(()) => { + if let Ok(Some(status)) = running.process.try_wait() { + let message = format!( + "Engine '{}' exited during startup: {status}", + running.definition.id + ); + warn!(engine = %running.definition.id, %status, "Engine exited during startup"); + self.emitter.emit_error(&running.definition.id, &message); + return Err(AppError::External { + request_id: None, + message, + }); + } running.healthy = true; info!(engine = %running.definition.id, "Engine is healthy"); self.emitter.emit_ready(&running.definition.id, &endpoint); @@ -364,7 +471,13 @@ impl EngineManager { self.emitter .emit_error(&running.definition.id, &diagnosed_message); // Kill the process if health check fails - let _ = running.process.kill().await; + if let Err(error) = running.process.kill().await { + warn!( + engine = %running.definition.id, + error = %error, + "Failed to kill unhealthy engine process after startup failure" + ); + } return Err(AppError::External { request_id: None, message: diagnosed_message, @@ -385,13 +498,66 @@ impl EngineManager { Ok(status) } + /// Emits an error for the engine in a slot, then stops and removes that slot. + pub async fn stop_slot_after_error(&self, capability: Capability, message: &str) { + let engine_id = { + let slots = self.slots.lock().await; + slots + .get(&capability) + .map(|engine| engine.definition.id.clone()) + }; + + if let Some(engine_id) = engine_id { + self.emitter.emit_error(&engine_id, message); + let _lifecycle_guard = self.lifecycle_lock.lock().await; + let engine = { + let mut slots = self.slots.lock().await; + match slots.get(&capability) { + Some(current) if current.definition.id == engine_id => { + slots.remove(&capability) + } + _ => None, + } + }; + + if let Some(engine) = engine { + match Self::kill_engine_retaining_on_failure(engine).await { + Ok(()) => {} + Err((error, engine)) => { + self.slots.lock().await.insert(capability, engine); + warn!( + slot = ?capability, + error = %error, + "Failed to stop engine slot after runtime error" + ); + } + } + } + } + } + /// Stop all running engines pub async fn stop(&self) -> Result<(), AppError> { let _lifecycle_guard = self.lifecycle_lock.lock().await; - let engines: Vec<(Capability, RunningEngine)> = self.slots.lock().await.drain().collect(); - for (cap, engine) in engines { + let engine_caps = self.slots.lock().await.keys().copied().collect::>(); + let mut errors = Vec::new(); + for cap in engine_caps { + let engine = self.slots.lock().await.remove(&cap); + let Some(engine) = engine else { + continue; + }; info!(engine = %engine.definition.id, slot = ?cap, "Stopping engine"); - Self::kill_engine(engine).await; + if let Err((error, engine)) = Self::kill_engine_retaining_on_failure(engine).await { + warn!(slot = ?cap, error = %error, "Failed to stop engine in slot"); + self.slots.lock().await.insert(cap, engine); + errors.push(error.to_string()); + } + } + if !errors.is_empty() { + return Err(AppError::Internal { + request_id: None, + message: format!("Failed to stop one or more engines: {}", errors.join("; ")), + }); } Ok(()) } @@ -402,22 +568,120 @@ impl EngineManager { let engine = self.slots.lock().await.remove(&capability); if let Some(engine) = engine { info!(engine = %engine.definition.id, slot = ?capability, "Stopping engine in slot"); - Self::kill_engine(engine).await; + if let Err((error, engine)) = Self::kill_engine_retaining_on_failure(engine).await { + self.slots.lock().await.insert(capability, engine); + return Err(error); + } } Ok(()) } /// Kill an engine process and wait for exit - async fn kill_engine(mut engine: RunningEngine) { + async fn kill_engine(engine: RunningEngine) -> Result<(), AppError> { + Self::kill_engine_retaining_on_failure(engine) + .await + .map_err(|(error, _engine)| error) + } + + async fn kill_engine_retaining_on_failure( + mut engine: RunningEngine, + ) -> Result<(), (AppError, RunningEngine)> { if let Err(e) = engine.process.kill().await { error!(engine = %engine.definition.id, error = %e, "Failed to kill engine process"); + return Err(( + AppError::Internal { + request_id: None, + message: format!("Failed to kill engine '{}': {e}", engine.definition.id), + }, + engine, + )); + } + if let Err(error) = engine.process.wait().await { + return Err(( + AppError::Internal { + request_id: None, + message: format!( + "Failed to wait for engine '{}' after kill: {error}", + engine.definition.id + ), + }, + engine, + )); } - let _ = engine.process.wait().await; info!(engine = %engine.definition.id, "Engine stopped"); + Ok(()) + } + + async fn prune_dead_slots(&self) { + let mut exited = Vec::new(); + let mut errored = Vec::new(); + { + let mut slots = self.slots.lock().await; + for (capability, engine) in slots.iter_mut() { + match engine.process.try_wait() { + Ok(Some(status)) => { + warn!( + engine = %engine.definition.id, + slot = ?capability, + exit_status = %status, + "Pruning dead engine slot" + ); + exited.push((*capability, engine.definition.id.clone())); + } + Err(error) => { + warn!( + engine = %engine.definition.id, + slot = ?capability, + error = %error, + "Engine process status check failed; attempting to stop before pruning" + ); + errored.push((*capability, engine.definition.id.clone())); + } + Ok(None) => {} + } + } + + for (capability, _) in &exited { + slots.remove(capability); + } + } + + for (capability, failed_engine_id) in errored { + let engine = { + let mut slots = self.slots.lock().await; + match slots.get(&capability) { + Some(current) if current.definition.id == failed_engine_id => { + slots.remove(&capability) + } + _ => None, + } + }; + let Some(engine) = engine else { + continue; + }; + let engine_id = engine.definition.id.clone(); + match Self::kill_engine_retaining_on_failure(engine).await { + Ok(()) => exited.push((capability, engine_id)), + Err((error, engine)) => { + warn!( + slot = ?capability, + error = %error, + "Keeping engine slot because forced stop after status error failed" + ); + self.slots.lock().await.insert(capability, engine); + } + } + } + + for (_, engine_id) in exited { + self.emitter + .emit_error(&engine_id, "Local engine process exited."); + } } /// Find an engine definition by ID async fn find_definition(&self, id: &str) -> Result { + let id = canonical_engine_id(id); let definitions = self.definitions.lock().await; definitions .iter() @@ -427,11 +691,23 @@ impl EngineManager { } } -fn canonical_engine_log_id(engine_id: &str) -> &str { - match engine_id { - "stable-diffusion" => "sdcpp", - value => value, +/// 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)] @@ -442,9 +718,8 @@ mod tests { use crate::domain::engine::engine_runtime::classify_engine_start_failure; use crate::domain::engine::types::EngineComputeMode; use crate::domain::system::ports::ENGINE_LOCAL_PORT_RANGE; - use std::fs; use std::net::TcpListener; - use std::time::{SystemTime, UNIX_EPOCH}; + use std::path::PathBuf; fn sample_config(model_path: Option<&str>) -> EngineConfig { EngineConfig { @@ -452,8 +727,6 @@ mod tests { compute_mode: EngineComputeMode::Gpu, context_size: 4096, model_path: model_path.map(str::to_string), - vae_path: None, - llm_path: None, extra_args: vec![], } } @@ -464,18 +737,26 @@ mod tests { compute_mode: EngineComputeMode::Gpu, context_size: 4096, model_path: model_path.map(str::to_string), - vae_path: None, - llm_path: None, extra_args: vec![], } } #[test] fn builds_single_slot_llamacpp_args_by_default() { - let args = build_llamacpp_args(&sample_config(None), 8081); + let args = build_engine_args(&sample_config(None), 8081); assert!(args.windows(2).any(|w| w == ["-ngl", "all"])); - assert!(args.windows(2).any(|w| w == ["--parallel", "1"])); - assert!(args.windows(2).any(|w| w == ["--reasoning", "off"])); + assert!(!args.contains(&"--parallel".to_string())); + assert!(!args.contains(&"--reasoning".to_string())); + } + + #[test] + fn builds_llamacpp_model_args_from_config() { + let args = build_engine_args(&sample_config(Some("C:/models/chat.gguf")), 8081); + + assert!( + args.windows(2) + .any(|w| w == ["--model", "C:/models/chat.gguf"]) + ); } #[test] @@ -483,7 +764,7 @@ mod tests { let mut config = sample_config(None); config.compute_mode = EngineComputeMode::Cpu; - let args = build_llamacpp_args(&config, 8081); + let args = build_engine_args(&config, 8081); assert!(args.windows(2).any(|w| w == ["--device", "none"])); assert!(args.windows(2).any(|w| w == ["-ngl", "0"])); @@ -495,22 +776,10 @@ mod tests { let mut config = sample_config(None); config.context_size = 1024; - let args = build_llamacpp_args(&config, 8081); + let args = build_engine_args(&config, 8081); assert!(args.windows(2).any(|w| w == ["--ctx-size", "4096"])); } - #[test] - fn adds_qwen_specific_llamacpp_args() { - let args = build_llamacpp_args(&sample_config(Some("Qwen3.5-9B-Q4_K_M.gguf")), 8081); - assert!(args.contains(&"--jinja".to_string())); - assert!( - args.windows(2) - .any(|w| w == ["--reasoning-format", "deepseek"]) - ); - assert!(args.contains(&"--no-context-shift".to_string())); - assert!(args.windows(2).any(|w| w == ["--flash-attn", "on"])); - } - #[test] fn picks_preferred_port_when_it_is_free() { let port = 8085; @@ -544,7 +813,7 @@ mod tests { assert_eq!( message.as_deref(), Some( - "Not enough memory to start the local model. Reduce context size or GPU layers, or use a smaller model." + "Not enough memory to start the local model. Reduce context size, switch compute mode, or use a smaller model." ) ); } @@ -563,11 +832,10 @@ mod tests { #[test] fn builds_plain_sdcpp_model_args() { - let args = build_sdcpp_args( + let args = build_engine_args( &sample_sdcpp_config(Some("C:/models/sd15.safetensors")), 8082, - ) - .unwrap(); + ); assert!(args.windows(2).any(|w| w == ["--listen-port", "8082"])); assert!( @@ -577,59 +845,109 @@ mod tests { } #[test] - fn rejects_qwen_image_without_companion_files() { - let error = build_sdcpp_args( - &sample_sdcpp_config(Some("C:/models/qwen-image-Q2_K.gguf")), - 8082, - ) - .unwrap_err(); - - match error { - AppError::Validation(message) => { - assert!(message.contains("Qwen Image model")); - assert!(message.contains("--vae")); - assert!(message.contains("--llm")); - } - other => panic!("expected validation error, got {other:?}"), - } + fn builds_cpu_sdcpp_args_when_requested() { + let mut config = sample_sdcpp_config(Some("C:/models/sd15.safetensors")); + config.compute_mode = EngineComputeMode::Cpu; + + let args = build_engine_args(&config, 8082); + + assert!(args.contains(&"--clip-on-cpu".to_string())); + assert!(args.contains(&"--vae-on-cpu".to_string())); } #[test] - fn auto_detects_qwen_image_companion_files_in_model_directory() { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_dir = std::env::temp_dir().join(format!("axelate-sdcpp-qwen-{unique}")); - fs::create_dir_all(&temp_dir).unwrap(); - - let diffusion = temp_dir.join("qwen-image-Q2_K.gguf"); - let vae = temp_dir.join("qwen_image_vae.safetensors"); - let llm = temp_dir.join("Qwen2.5-VL-7B-Instruct.Q4_K_M.gguf"); - - fs::write(&diffusion, []).unwrap(); - fs::write(&vae, []).unwrap(); - fs::write(&llm, []).unwrap(); - - let args = build_sdcpp_args( - &sample_sdcpp_config(Some(diffusion.to_string_lossy().as_ref())), - 8082, - ) - .unwrap(); + fn sdcpp_keeps_user_supplied_cpu_extra_args() { + let mut config = sample_sdcpp_config(Some("C:/models/sd15.safetensors")); + config.extra_args = vec![ + "--offload-to-cpu".to_string(), + "--clip-on-cpu".to_string(), + "--vae-on-cpu".to_string(), + "--mmap".to_string(), + ]; - assert!( - args.windows(2) - .any(|w| w == ["--diffusion-model", diffusion.to_string_lossy().as_ref()]) - ); - assert!( - args.windows(2) - .any(|w| w == ["--vae", vae.to_string_lossy().as_ref()]) - ); - assert!( - args.windows(2) - .any(|w| w == ["--llm", llm.to_string_lossy().as_ref()]) + let args = build_engine_args(&config, 8082); + + assert!(args.contains(&"--offload-to-cpu".to_string())); + assert!(args.contains(&"--clip-on-cpu".to_string())); + assert!(args.contains(&"--vae-on-cpu".to_string())); + assert!(args.contains(&"--mmap".to_string())); + } + + #[test] + fn sdcpp_filters_cli_only_preview_flags_from_server_args() { + let mut config = sample_sdcpp_config(Some("C:/models/sd15.safetensors")); + config.extra_args = vec![ + "--preview".to_string(), + "vae".to_string(), + "--preview-path".to_string(), + "C:/tmp/preview.png".to_string(), + "--preview-interval=1".to_string(), + ]; + + let args = build_engine_args(&config, 8082); + + assert!(!args.contains(&"--preview".to_string())); + assert!(!args.contains(&"--preview-path".to_string())); + assert!(!args.contains(&"--preview-interval=1".to_string())); + assert!(!sdcpp_preview_enabled(&config.extra_args)); + assert!(resolve_sdcpp_preview_path(&config.extra_args).is_none()); + } + + #[test] + fn sdcpp_filters_unsupported_flags_with_separate_values() { + let mut config = sample_sdcpp_config(Some("C:/models/sd15.safetensors")); + config.extra_args = vec![ + "--vae-on-gpu".to_string(), + "1".to_string(), + "--mmap".to_string(), + "--preview-path".to_string(), + "C:/tmp/preview.png".to_string(), + ]; + + let args = build_engine_args(&config, 8082); + + assert!(!args.contains(&"--vae-on-gpu".to_string())); + assert!(!args.contains(&"1".to_string())); + assert!(!args.contains(&"--preview-path".to_string())); + assert!(!args.contains(&"C:/tmp/preview.png".to_string())); + assert!(args.contains(&"--mmap".to_string())); + } + + #[test] + fn sdcpp_resolves_launcher_preview_path_without_passing_it_to_server() { + let mut config = sample_sdcpp_config(Some("C:/models/sd15.safetensors")); + config.extra_args = vec![ + "--sdcpp-preview".to_string(), + "C:/tmp/sdcpp-preview.png".to_string(), + "--mmap".to_string(), + ]; + + let args = build_engine_args(&config, 8082); + + assert_eq!( + resolve_sdcpp_preview_path(&config.extra_args), + Some(PathBuf::from("C:/tmp/sdcpp-preview.png")) ); + assert!(sdcpp_preview_enabled(&config.extra_args)); + assert!(!args.contains(&"--sdcpp-preview".to_string())); + assert!(!args.contains(&"C:/tmp/sdcpp-preview.png".to_string())); + assert!(args.contains(&"--mmap".to_string())); + } + + #[test] + fn sdcpp_preview_flag_enables_preview_without_explicit_path() { + let extra_args = vec!["--sdcpp-preview".to_string()]; - let _ = fs::remove_dir_all(&temp_dir); + 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 27e9c309..d50a6763 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_profile; mod engine_runtime; /// Engine event emission trait pub mod events; diff --git a/src-tauri/src/domain/engine/registry.rs b/src-tauri/src/domain/engine/registry.rs index 798d42d3..3135c7b1 100644 --- a/src-tauri/src/domain/engine/registry.rs +++ b/src-tauri/src/domain/engine/registry.rs @@ -17,7 +17,7 @@ pub fn load_engine_definitions(modules: &[ModuleItem]) -> Vec .map(convert_module_to_definition) .collect(); - tracing::info!( + tracing::debug!( count = defs.len(), "Loaded engine definitions from local_modules" ); @@ -67,6 +67,7 @@ fn convert_module_to_definition(item: &ModuleItem) -> EngineDefinition { default_context_size, config_schema: item.raw_config_schema.clone(), installed: false, // populated at request time by get_engine_definitions + installed_compute_modes: Vec::new(), managed_externally: item.managed_externally, } } diff --git a/src-tauri/src/domain/engine/types.rs b/src-tauri/src/domain/engine/types.rs index f5973c62..2b9016fd 100644 --- a/src-tauri/src/domain/engine/types.rs +++ b/src-tauri/src/domain/engine/types.rs @@ -85,6 +85,11 @@ pub struct EngineDefinition { /// Whether the engine binary is currently installed (populated at runtime, not from JSON) #[serde(default)] pub installed: bool, + /// Compute modes present in the Axelate-managed install metadata. + /// + /// Empty means unknown, usually a system PATH install or an older install without metadata. + #[serde(default)] + pub installed_compute_modes: Vec, /// True when the launcher connects to a user-managed external engine instead of installing it #[serde(default)] pub managed_externally: bool, @@ -107,12 +112,6 @@ pub struct EngineConfig { pub context_size: u32, /// Path to model file pub model_path: Option, - /// Optional companion VAE path for image engines - #[serde(default)] - pub vae_path: Option, - /// Optional companion LLM path for multimodal image engines - #[serde(default)] - pub llm_path: Option, /// Extra CLI arguments #[serde(default)] pub extra_args: Vec, diff --git a/src-tauri/src/domain/integration_api.rs b/src-tauri/src/domain/integration_api.rs index 0d7aebc5..42dbba24 100644 --- a/src-tauri/src/domain/integration_api.rs +++ b/src-tauri/src/domain/integration_api.rs @@ -11,24 +11,36 @@ use crate::domain::ai::types::{ use crate::domain::ai::{ChatSessionManager, ImageGenerationState}; use crate::domain::engine::manager::EngineManager; use crate::domain::modules::controller::{self as module_controller, ModuleAction}; +use crate::domain::modules::paths as module_paths; use crate::domain::system::config_service::ConfigService; use crate::domain::system::ports::{LAUNCHER_LOCAL_PORT_RANGE, LocalPortPurpose}; use crate::errors::AppError; use crate::infrastructure::config::settings::SettingsService; use crate::infrastructure::config::ui_state::UiStateService; -use crate::models::{AiModel, ApiProvider, ModelTier, ModuleItem, ProviderType, SelectedModule}; +use crate::models::{ + AiModel, ApiProvider, ModelTier, Module, ModuleItem, ProviderType, SelectedModule, +}; use once_cell::sync::{Lazy, OnceCell}; use serde::{Deserialize, Serialize}; use serde_json::json; use std::collections::HashMap; use std::io::{Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; -use std::sync::Arc; +use std::panic::AssertUnwindSafe; +use std::sync::{Arc, Mutex, mpsc}; use std::time::Duration; use tauri::{AppHandle, Emitter}; const DEFAULT_API_BASE_URL: &str = "http://127.0.0.1:3000"; +/// Public launcher integration SDK contract version exposed to module runtimes. +pub const SDK_API_VERSION: &str = "1"; const MAX_REQUEST_BYTES: usize = 1024 * 1024; +const MAX_HTTP_API_WORKERS: usize = 8; +const MAX_HTTP_API_QUEUE: usize = 32; +const CUSTOM_TEXT_PROVIDER_ID: &str = "openrouter-custom-text"; +const CUSTOM_IMAGE_PROVIDER_ID: &str = "openrouter-custom-image"; +const CUSTOM_TEXT_BACKEND_PROVIDER_ID: &str = "gpt"; +const CUSTOM_IMAGE_BACKEND_PROVIDER_ID: &str = "gpt-image"; static API_BASE_URL: OnceCell = OnceCell::new(); static API_TOKEN: Lazy = Lazy::new(|| { @@ -38,6 +50,8 @@ static API_TOKEN: Lazy = Lazy::new(|| { uuid::Uuid::new_v4().simple() ) }); +static MODULE_API_TOKENS: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); /// Handle for the running launcher HTTP API server. #[derive(Debug, Clone)] @@ -65,10 +79,60 @@ pub fn api_token() -> &'static str { } /// Adds local launcher API environment variables to a module process. -pub fn apply_process_env(command: &mut tokio::process::Command) { +pub fn apply_process_env( + command: &mut tokio::process::Command, + module_id: &str, +) -> Result<(), AppError> { + let module_dir = crate::domain::modules::downloader::get_module_path(module_id); + let module_runtime_dir = crate::domain::modules::paths::runtime_root(module_id); + let module_log_dir = crate::domain::modules::paths::log_dir(module_id); + let token = issue_module_api_token(module_id)?; + command .env("AXELATE_HTTP_API_BASE", api_base_url()) - .env("AXELATE_HTTP_API_TOKEN", api_token()); + .env("AXELATE_HTTP_API_TOKEN", token) + .env("AXELATE_INTEGRATION_API_VERSION", SDK_API_VERSION) + .env("AXELATE_MODULE_ID", module_id) + .env("AXELATE_MODULE_DIR", module_dir) + .env("AXELATE_RUNTIME_DIR", &*crate::utils::paths::RUNTIME_DIR) + .env("AXELATE_MODULE_RUNTIME_DIR", module_runtime_dir) + .env("AXELATE_MODULE_LOG_DIR", module_log_dir); + + Ok(()) +} + +fn issue_module_api_token(module_id: &str) -> Result { + let token = format!("{module_id}.{}", uuid::Uuid::new_v4().simple()); + let mut tokens = MODULE_API_TOKENS.lock().map_err(|_| AppError::Internal { + request_id: None, + message: format!("Failed to register module API token for {module_id}"), + })?; + tokens.insert(module_id.to_string(), token.clone()); + Ok(token) +} + +/// Revokes the local API token for a module process. +pub fn revoke_module_api_token(module_id: &str) { + match MODULE_API_TOKENS.lock() { + Ok(mut tokens) => { + tokens.remove(module_id); + } + Err(error) => { + tracing::warn!("Failed to revoke module API token for {module_id}: {error}"); + } + } +} + +/// Revokes all module-scoped local API tokens. +pub fn revoke_all_module_api_tokens() { + match MODULE_API_TOKENS.lock() { + Ok(mut tokens) => { + tokens.clear(); + } + Err(error) => { + tracing::warn!("Failed to revoke module API tokens: {error}"); + } + } } /// Starts the local launcher HTTP API server. @@ -90,7 +154,7 @@ pub fn start_launcher_http_api( message: format!("Failed to start launcher HTTP API thread: {error}"), })?; - tracing::info!("Launcher integration API listening at {base_url}"); + tracing::debug!("Launcher integration API listening at {base_url}"); Ok(LauncherHttpApiHandle { base_url }) } @@ -175,10 +239,25 @@ struct HttpResponse { body: serde_json::Value, } +struct ValidatedHttpRequest { + stream: TcpStream, + request: HttpRequest, + context: LauncherHttpApiContext, + peer_addr: Option, +} + +type HttpWorkerReceiver = Arc>>; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum AuthorizedClient { + Launcher, + Module(String), +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct IntegrationTextRequest { - prompt: String, + prompt: Option, provider: Option, model: Option, session_id: Option, @@ -237,12 +316,17 @@ struct ImageApiResponse { response: ImageGenerationResponse, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] -struct SelectedModuleChangedEvent { - category: String, - module: SelectedModule, - source: &'static str, +struct ModuleContextApiResponse { + ok: bool, + api_version: &'static str, + module_id: String, + module_dir: String, + runtime_dir: String, + module_runtime_dir: String, + module_log_dir: String, + http_api_base: String, } #[derive(Debug, Clone, Serialize)] @@ -257,13 +341,66 @@ struct ModuleStageChangedEvent { } fn serve_launcher_http_api(listener: &TcpListener, context: &LauncherHttpApiContext) { + let (sender, receiver) = mpsc::sync_channel(MAX_HTTP_API_QUEUE); + let receiver = Arc::new(Mutex::new(receiver)); + let mut started_workers = 0_usize; + for worker_index in 0..MAX_HTTP_API_WORKERS { + let worker_receiver = Arc::clone(&receiver); + match std::thread::Builder::new() + .name(format!("axelate-local-http-worker-{worker_index}")) + .spawn(move || run_http_api_worker(&worker_receiver)) + { + Ok(_) => { + started_workers += 1; + } + Err(error) => { + tracing::warn!("Failed to spawn launcher HTTP API worker: {error}"); + } + } + } + + if started_workers == 0 { + tracing::error!("Launcher HTTP API started with zero worker threads"); + return; + } + for incoming in listener.incoming() { match incoming { - Ok(stream) => { + Ok(mut stream) => { let request_context = context.clone(); - let _ = std::thread::Builder::new() - .name("axelate-local-http-request".to_string()) - .spawn(move || handle_stream(stream, request_context)); + let peer_addr = stream.peer_addr().ok(); + let request = match read_http_request_head(&mut stream) { + Ok(request) => request, + Err(error) => { + let response = HttpResponse { + status: 400, + body: json!({ "ok": false, "error": error }), + }; + write_response_or_log(&mut stream, &response); + continue; + } + }; + if let Some(response) = preflight_http_request(&request, peer_addr) { + write_response_or_log(&mut stream, &response); + continue; + } + let job = ValidatedHttpRequest { + stream, + request, + context: request_context, + peer_addr, + }; + match sender.try_send(job) { + Ok(()) => {} + Err(mpsc::TrySendError::Full(mut job)) => { + let response = json_error(503, "Launcher API request queue is full"); + write_response_or_log(&mut job.stream, &response); + } + Err(mpsc::TrySendError::Disconnected(mut job)) => { + let response = json_error(500, "Launcher API workers are unavailable"); + write_response_or_log(&mut job.stream, &response); + } + } } Err(error) => { tracing::warn!("Launcher HTTP API accept failed: {error}"); @@ -272,9 +409,62 @@ fn serve_launcher_http_api(listener: &TcpListener, context: &LauncherHttpApiCont } } -fn handle_stream(mut stream: TcpStream, context: LauncherHttpApiContext) { - let peer_addr = stream.peer_addr().ok(); - let response = match read_http_request(&mut stream) { +fn run_http_api_worker(receiver: &HttpWorkerReceiver) { + loop { + let job = { + let Ok(receiver) = receiver.lock() else { + tracing::warn!("Launcher HTTP API worker receiver lock is poisoned"); + std::thread::sleep(Duration::from_millis(100)); + continue; + }; + receiver.recv() + }; + + match job { + Ok(job) => { + if std::panic::catch_unwind(AssertUnwindSafe(|| { + handle_validated_request(job.stream, job.request, job.context, job.peer_addr); + })) + .is_err() + { + tracing::error!("Launcher HTTP API worker recovered from request panic"); + } + } + Err(_) => return, + } + } +} + +fn preflight_http_request( + request: &HttpRequest, + peer_addr: Option, +) -> Option { + if !is_loopback_peer(peer_addr) { + return Some(json_error( + 403, + "Launcher API only accepts loopback clients", + )); + } + + let path = request_path(request); + if request.method == "GET" && path == "/v1/health" { + return None; + } + + if !is_authorized(&request.headers) { + return Some(json_error(401, "Missing or invalid launcher API token")); + } + + None +} + +fn handle_validated_request( + mut stream: TcpStream, + request: HttpRequest, + context: LauncherHttpApiContext, + peer_addr: Option, +) { + let response = match complete_http_request_body(&mut stream, request) { Ok(request) => { tauri::async_runtime::block_on(dispatch_http_request(request, context, peer_addr)) } @@ -283,13 +473,22 @@ fn handle_stream(mut stream: TcpStream, context: LauncherHttpApiContext) { body: json!({ "ok": false, "error": error }), }, }; + write_response_or_log(&mut stream, &response); +} - if let Err(error) = write_http_response(&mut stream, &response) { +fn write_response_or_log(stream: &mut TcpStream, response: &HttpResponse) { + if let Err(error) = write_http_response(stream, response) { tracing::warn!("Failed to write launcher HTTP API response: {error}"); } } +#[cfg(test)] fn read_http_request(stream: &mut TcpStream) -> Result { + let request = read_http_request_head(stream)?; + complete_http_request_body(stream, request) +} + +fn read_http_request_head(stream: &mut TcpStream) -> Result { stream .set_read_timeout(Some(Duration::from_secs(5))) .map_err(|error| format!("Failed to configure read timeout: {error}"))?; @@ -333,43 +532,77 @@ fn read_http_request(stream: &mut TcpStream) -> Result { .next() .ok_or_else(|| "HTTP path is missing".to_string())? .to_string(); + let version = request_parts + .next() + .ok_or_else(|| "HTTP version is missing".to_string())?; + if request_parts.next().is_some() { + return Err("HTTP request line has too many parts".to_string()); + } + if !version.starts_with("HTTP/") { + return Err(format!("Unsupported HTTP version: {version}")); + } - let headers = lines - .filter_map(parse_header_line) - .collect::>(); + let headers = parse_header_lines(lines)?; let content_length = headers.get("content-length").map_or(Ok(0_usize), |value| { value .parse::() .map_err(|error| format!("Invalid content-length: {error}")) })?; + if content_length > MAX_REQUEST_BYTES { + return Err("HTTP request body is too large".to_string()); + } let body_start = header_end .checked_add(4) .ok_or_else(|| "Internal HTTP body offset overflowed".to_string())?; let mut body = buffer.get(body_start..).unwrap_or_default().to_vec(); - while body.len() < content_length { + body.truncate(content_length); + + Ok(HttpRequest { + method, + path, + headers, + body, + }) +} + +fn complete_http_request_body( + stream: &mut TcpStream, + mut request: HttpRequest, +) -> Result { + let content_length = request + .headers + .get("content-length") + .map_or(Ok(0_usize), |value| { + value + .parse::() + .map_err(|error| format!("Invalid content-length: {error}")) + })?; + if content_length > MAX_REQUEST_BYTES { + return Err("HTTP request body is too large".to_string()); + } + let mut chunk = [0_u8; 4096]; + while request.body.len() < content_length { let read = stream .read(&mut chunk) .map_err(|error| format!("Failed to read request body: {error}"))?; if read == 0 { - break; + return Err(format!( + "HTTP request body ended before content-length was reached: expected {content_length} bytes, got {}", + request.body.len() + )); } let read_chunk = chunk .get(..read) .ok_or_else(|| "Internal HTTP body buffer range is invalid".to_string())?; - body.extend_from_slice(read_chunk); - if body.len() > MAX_REQUEST_BYTES { + request.body.extend_from_slice(read_chunk); + if request.body.len() > MAX_REQUEST_BYTES { return Err("HTTP request body is too large".to_string()); } } - body.truncate(content_length); + request.body.truncate(content_length); - Ok(HttpRequest { - method, - path, - headers, - body, - }) + Ok(request) } fn find_header_end(buffer: &[u8]) -> Option { @@ -381,6 +614,34 @@ fn parse_header_line(line: &str) -> Option<(String, String)> { Some((name.trim().to_ascii_lowercase(), value.trim().to_string())) } +fn parse_header_lines<'a>( + lines: impl Iterator, +) -> Result, String> { + let mut headers = HashMap::new(); + + for line in lines { + if line.trim().is_empty() { + continue; + } + + let Some((name, value)) = parse_header_line(line) else { + return Err(format!("Malformed HTTP header line: {line}")); + }; + + if name.is_empty() { + return Err("HTTP header name is empty".to_string()); + } + + if name == "content-length" && headers.contains_key("content-length") { + return Err("Duplicate content-length header".to_string()); + } + + headers.insert(name, value); + } + + Ok(headers) +} + async fn dispatch_http_request( request: HttpRequest, context: LauncherHttpApiContext, @@ -390,18 +651,34 @@ async fn dispatch_http_request( return json_error(403, "Launcher API only accepts loopback clients"); } - let path = request.path.split('?').next().unwrap_or(&request.path); + let path = request_path(&request); if request.method == "GET" && path == "/v1/health" { return json_response(200, json!({ "ok": true, "service": "axelate-launcher" })); } - if !is_authorized(&request.headers) { + let Some(client) = authorize_request(&request.headers) else { return json_error(401, "Missing or invalid launcher API token"); - } + }; - match route_authorized_request(path, &request, context).await { + match route_authorized_request(path, &request, context, &client).await { Ok(response) => response, - Err(error) => json_error(500, &error.to_string()), + Err(error) => json_error(status_for_app_error(&error), &error.to_string()), + } +} + +fn request_path(request: &HttpRequest) -> &str { + request.path.split('?').next().unwrap_or(&request.path) +} + +const fn status_for_app_error(error: &AppError) -> u16 { + match error { + AppError::Validation(_) | AppError::Config(_) => 400, + AppError::NotFound(_) => 404, + AppError::PermissionDenied(_) | AppError::FrontendSecretForbidden(_) => 403, + AppError::Io(_) + | AppError::Serialization(_) + | AppError::External { .. } + | AppError::Internal { .. } => 500, } } @@ -409,6 +686,7 @@ async fn route_authorized_request( path: &str, request: &HttpRequest, context: LauncherHttpApiContext, + client: &AuthorizedClient, ) -> Result { let segments = path .trim_matches('/') @@ -418,23 +696,45 @@ async fn route_authorized_request( match (request.method.as_str(), segments.as_slice()) { ("GET", ["v1", "modules"]) => { - let modules = module_controller::get_all_modules().await; + let modules = + modules_visible_to_client(module_controller::get_all_modules().await, client); Ok(json_response( 200, json!({ "ok": true, "modules": modules }), )) } ("GET", ["v1", "modules", module_id, "status"]) => { + ensure_module_route_owner(client, module_id)?; + crate::domain::modules::downloader::validate_module_id(module_id)?; let status = module_controller::get_module_status(module_id).await; Ok(json_response( 200, json!({ "ok": true, "moduleId": module_id, "status": status }), )) } + ("GET", ["v1", "modules", module_id, "context"]) => { + ensure_module_route_owner(client, module_id)?; + handle_module_context_request(module_id) + } + ("GET", ["v1", "modules", module_id, "settings"]) => { + ensure_module_route_owner(client, module_id)?; + 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 + } + ("PATCH", ["v1", "modules", module_id, "settings"]) => { + ensure_module_route_owner(client, module_id)?; + handle_patch_module_settings_request(request, &context, module_id).await + } ("POST", ["v1", "modules", module_id, "stage"]) => { + ensure_module_route_owner(client, module_id)?; handle_module_stage_request(request, &context, module_id) } ("POST", ["v1", "modules", module_id, action]) => { + 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?; Ok(json_response( @@ -442,17 +742,156 @@ async fn route_authorized_request( json!({ "ok": response.success, "response": response }), )) } - ("POST", ["v1", "ai", "text"]) => handle_text_request(request, context).await, - ("POST", ["v1", "ai", "image"]) => handle_image_request(request, context).await, + ("POST", ["v1", "ai", "text"]) => handle_text_request(request, context, client).await, + ("POST", ["v1", "ai", "image"]) => handle_image_request(request, context, client).await, _ => Ok(json_error(404, "Unknown launcher API route")), } } +fn modules_visible_to_client(mut modules: Vec, client: &AuthorizedClient) -> Vec { + if let AuthorizedClient::Module(module_id) = client { + modules.retain(|module| module.id == *module_id); + } + modules +} + +fn handle_module_context_request(module_id: &str) -> Result { + ensure_installed_module_id(module_id)?; + let module_dir = crate::domain::modules::downloader::get_module_path(module_id); + let module_runtime_dir = module_paths::runtime_root(module_id); + let module_log_dir = module_paths::log_dir(module_id); + + Ok(json_response( + 200, + json!(ModuleContextApiResponse { + ok: true, + api_version: SDK_API_VERSION, + module_id: module_id.to_string(), + module_dir: module_dir.display().to_string(), + runtime_dir: crate::utils::paths::RUNTIME_DIR.display().to_string(), + module_runtime_dir: module_runtime_dir.display().to_string(), + module_log_dir: module_log_dir.display().to_string(), + http_api_base: api_base_url().to_string(), + }), + )) +} + +async fn handle_get_module_settings_request( + context: &LauncherHttpApiContext, + module_id: &str, +) -> Result { + ensure_installed_module_id(module_id)?; + let settings = context + .settings_service + .get_module_settings(module_id) + .await?; + + Ok(json_response( + 200, + json!({ "ok": true, "moduleId": module_id, "settings": settings }), + )) +} + +async fn handle_put_module_settings_request( + request: &HttpRequest, + context: &LauncherHttpApiContext, + module_id: &str, +) -> Result { + ensure_installed_module_id(module_id)?; + let settings: HashMap = parse_json_body(request)?; + context + .settings_service + .save_module_settings(module_id, &settings) + .await?; + + Ok(json_response( + 200, + json!({ "ok": true, "moduleId": module_id, "settings": settings }), + )) +} + +async fn handle_patch_module_settings_request( + request: &HttpRequest, + context: &LauncherHttpApiContext, + module_id: &str, +) -> Result { + ensure_installed_module_id(module_id)?; + let updates: HashMap = parse_json_body(request)?; + let mut settings = context + .settings_service + .get_module_settings(module_id) + .await?; + merge_json_settings(&mut settings, updates); + context + .settings_service + .save_module_settings(module_id, &settings) + .await?; + + Ok(json_response( + 200, + json!({ "ok": true, "moduleId": module_id, "settings": settings }), + )) +} + +fn merge_json_settings( + settings: &mut HashMap, + updates: HashMap, +) { + for (key, update) in updates { + match settings.get_mut(&key) { + Some(existing) => merge_json_value(existing, update), + None => { + settings.insert(key, update); + } + } + } +} + +fn merge_json_value(target: &mut serde_json::Value, update: serde_json::Value) { + match (target, update) { + (serde_json::Value::Object(target), serde_json::Value::Object(update)) => { + for (key, value) in update { + match target.get_mut(&key) { + Some(existing) => merge_json_value(existing, value), + None => { + target.insert(key, value); + } + } + } + } + (target, update) => { + *target = update; + } + } +} + +fn ensure_installed_module_id(module_id: &str) -> Result<(), AppError> { + crate::domain::modules::downloader::validate_module_id(module_id)?; + if crate::domain::modules::downloader::is_module_installed(module_id) { + Ok(()) + } else { + Err(AppError::NotFound(format!( + "Module {module_id} is not installed" + ))) + } +} + +fn ensure_module_route_owner(client: &AuthorizedClient, module_id: &str) -> Result<(), AppError> { + match client { + AuthorizedClient::Launcher => Ok(()), + AuthorizedClient::Module(owner_id) if owner_id == module_id => Ok(()), + AuthorizedClient::Module(_) => Err(AppError::PermissionDenied( + "Integration token cannot access another integration".to_string(), + )), + } +} + fn handle_module_stage_request( request: &HttpRequest, context: &LauncherHttpApiContext, module_id: &str, ) -> Result { + crate::domain::modules::downloader::validate_module_id(module_id)?; let payload: IntegrationModuleStageRequest = parse_json_body(request)?; let stage = payload.stage.trim(); let label = payload.label.trim(); @@ -501,42 +940,53 @@ fn parse_module_action(action: &str) -> Result { async fn handle_text_request( request: &HttpRequest, context: LauncherHttpApiContext, + client: &AuthorizedClient, ) -> Result { let payload: IntegrationTextRequest = parse_json_body(request)?; - let provider = match payload.provider.filter(|value| !value.trim().is_empty()) { - Some(provider) => provider, + let prompt = payload.prompt.as_deref().unwrap_or(""); + let requested_provider = payload.provider.filter(|value| !value.trim().is_empty()); + let ui_provider = match requested_provider.as_ref() { + Some(provider) => provider.clone(), None => selected_module_id(&context.ui_state_service, "ai_text") - .await + .await? .ok_or_else(|| AppError::Validation("No selected text AI provider".to_string()))?, }; - select_provider_for_category(&context, "ai_text", &provider).await?; + if requested_provider.is_some() { + resolve_selected_provider_module(&context.config_service, &ui_provider)?; + } + let provider = backend_provider_id(&ui_provider).to_string(); let model = resolve_model_id( &context.config_service, &context.ui_state_service, &provider, + Some(&ui_provider), payload.model.as_deref(), "text", ) .await?; - let session_id = - resolve_session_id(&context.ui_state_service, payload.session_id.as_deref()).await; + let session_id = resolve_session_id(payload.session_id.as_deref(), client); let mut messages = payload.messages.unwrap_or_default(); - if messages.is_empty() || !payload.prompt.trim().is_empty() { + if messages.is_empty() && prompt.trim().is_empty() { + return Err(AppError::Validation( + "Text request requires a prompt or messages".to_string(), + )); + } + if messages.is_empty() && !prompt.trim().is_empty() { messages.push(ChatMessage { id: uuid::Uuid::new_v4().to_string(), role: "user".to_string(), - content: serde_json::Value::String(payload.prompt), + content: serde_json::Value::String(prompt.to_string()), thought_signature: None, }); } let thinking_level = match payload.thinking_level { Some(value) => Some(value), - None => selected_thinking_level(&context.ui_state_service, &provider).await, + None => selected_thinking_level(&context.ui_state_service, &ui_provider).await?, }; let web_search = match payload.web_search { Some(value) => Some(value), - None => selected_web_search(&context.ui_state_service, &provider).await, + None => selected_web_search(&context.ui_state_service, &ui_provider).await?, }; let mut chat_request = ChatRequest { @@ -575,31 +1025,41 @@ async fn handle_text_request( async fn handle_image_request( request: &HttpRequest, context: LauncherHttpApiContext, + client: &AuthorizedClient, ) -> Result { let payload: IntegrationImageRequest = parse_json_body(request)?; - let provider = match payload.provider.filter(|value| !value.trim().is_empty()) { - Some(provider) => provider, + if payload.prompt.trim().is_empty() { + return Err(AppError::Validation( + "Image request requires a prompt".to_string(), + )); + } + let requested_provider = payload.provider.filter(|value| !value.trim().is_empty()); + let ui_provider = match requested_provider.as_ref() { + Some(provider) => provider.clone(), None => selected_module_id(&context.ui_state_service, "ai_image") - .await + .await? .ok_or_else(|| AppError::Validation("No selected image AI provider".to_string()))?, }; - select_provider_for_category(&context, "ai_image", &provider).await?; + if requested_provider.is_some() { + resolve_selected_provider_module(&context.config_service, &ui_provider)?; + } + let provider = backend_provider_id(&ui_provider).to_string(); let model = resolve_model_id( &context.config_service, &context.ui_state_service, &provider, + Some(&ui_provider), payload.model.as_deref(), "image", ) .await?; - let session_id = - resolve_session_id(&context.ui_state_service, payload.session_id.as_deref()).await; + let session_id = resolve_session_id(payload.session_id.as_deref(), client); let image_request = ImageGenerationRequest { provider: provider.clone(), prompt: payload.prompt.clone(), original_prompt: Some(payload.prompt), model: model.clone(), - settings_key: payload.settings_key.or_else(|| Some(provider.clone())), + settings_key: payload.settings_key.or_else(|| Some(ui_provider.clone())), session_id, steps: payload.steps, cfg_scale: payload.cfg_scale, @@ -640,46 +1100,6 @@ fn parse_json_body Deserialize<'de>>(request: &HttpRequest) -> Resul .map_err(|error| AppError::Validation(format!("Invalid JSON request body: {error}"))) } -async fn select_provider_for_category( - context: &LauncherHttpApiContext, - category: &str, - provider_id: &str, -) -> Result<(), AppError> { - let selected_module = resolve_selected_provider_module(&context.config_service, provider_id)?; - let mut state = context - .ui_state_service - .get_ui_state() - .await - .unwrap_or_default(); - let previous_id = state - .selected_modules - .get(category) - .map(|module| module.id.as_str()); - - if previous_id == Some(selected_module.id.as_str()) { - return Ok(()); - } - - state - .selected_modules - .insert(category.to_string(), selected_module.clone()); - context.ui_state_service.save_ui_state(&state).await?; - - let payload = SelectedModuleChangedEvent { - category: category.to_string(), - module: selected_module, - source: "integration-api", - }; - if let Err(error) = context - .app - .emit("ui-state:selected-module-changed", payload) - { - tracing::warn!("Failed to emit selected module change: {error}"); - } - - Ok(()) -} - fn resolve_selected_provider_module( config_service: &ConfigService, provider_id: &str, @@ -732,64 +1152,78 @@ fn selected_module_from_api_provider(provider: &ApiProvider) -> SelectedModule { } } -async fn selected_module_id(ui_state_service: &UiStateService, category: &str) -> Option { - let state = ui_state_service.get_ui_state().await.ok()?; - state +fn backend_provider_id(provider_id: &str) -> &str { + match provider_id { + CUSTOM_TEXT_PROVIDER_ID => CUSTOM_TEXT_BACKEND_PROVIDER_ID, + CUSTOM_IMAGE_PROVIDER_ID => CUSTOM_IMAGE_BACKEND_PROVIDER_ID, + _ => provider_id, + } +} + +fn is_custom_provider_id(provider_id: &str) -> bool { + matches!( + provider_id, + CUSTOM_TEXT_PROVIDER_ID | CUSTOM_IMAGE_PROVIDER_ID + ) +} + +async fn selected_module_id( + ui_state_service: &UiStateService, + category: &str, +) -> Result, AppError> { + let state = ui_state_service.get_ui_state().await?; + Ok(state .selected_modules .get(category) .map(|module| module.id.trim().to_string()) - .filter(|value| !value.is_empty()) + .filter(|value| !value.is_empty())) } -async fn resolve_session_id( - ui_state_service: &UiStateService, - requested: Option<&str>, -) -> Option { +fn resolve_session_id(requested: Option<&str>, client: &AuthorizedClient) -> Option { if let Some(session_id) = requested.map(str::trim).filter(|value| !value.is_empty()) { return Some(session_id.to_string()); } - ui_state_service - .get_ui_state() - .await - .ok() - .and_then(|state| state.ai_session_id) - .filter(|value| !value.trim().is_empty()) + match client { + AuthorizedClient::Module(module_id) => Some(format!("integration:{module_id}")), + AuthorizedClient::Launcher => None, + } } async fn selected_thinking_level( ui_state_service: &UiStateService, - provider: &str, -) -> Option { - ui_state_service - .get_ui_state() - .await - .ok() - .and_then(|state| state.ai_thinking_level.get(provider).cloned()) - .filter(|value| !value.trim().is_empty()) + provider_id: &str, +) -> Result, AppError> { + let state = ui_state_service.get_ui_state().await?; + Ok(state + .ai_thinking_level + .get(provider_id) + .cloned() + .filter(|value| !value.trim().is_empty())) } async fn selected_web_search( ui_state_service: &UiStateService, - provider: &str, -) -> Option { - let enabled = ui_state_service - .get_ui_state() - .await - .ok() - .and_then(|state| state.ai_web_search_enabled.get(provider).copied()) + provider_id: &str, +) -> Result, AppError> { + let state = ui_state_service.get_ui_state().await?; + let enabled = state + .ai_web_search_enabled + .get(provider_id) + .copied() .unwrap_or(false); - enabled.then_some(WebSearchOptions { + Ok(enabled.then_some(WebSearchOptions { enabled, ..WebSearchOptions::default() - }) + })) } async fn resolve_model_id( config_service: &ConfigService, ui_state_service: &UiStateService, provider_id: &str, + ui_provider_id: Option<&str>, requested_model: Option<&str>, capability: &str, ) -> Result { @@ -800,11 +1234,12 @@ async fn resolve_model_id( return Ok(model.to_string()); } - let selected_model = ui_state_service - .get_ui_state() - .await - .ok() - .and_then(|state| state.selected_ai_models.get(provider_id).cloned()); + let state = ui_state_service.get_ui_state().await?; + let selected_model = match ui_provider_id { + Some(id) => state.selected_ai_models.get(id), + None => state.selected_ai_models.get(provider_id), + } + .cloned(); let config = config_service.load_full_config()?; let provider = config .api_providers @@ -818,6 +1253,15 @@ async fn resolve_model_id( return Ok(model); } + if ui_provider_id.is_some_and(is_custom_provider_id) + && let Some(model) = selected_model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(model.to_string()); + } + if let Some(model) = provider.and_then(|provider| strongest_provider_model(provider, capability)) { @@ -841,7 +1285,6 @@ fn strongest_provider_model(provider: &ApiProvider, capability: &str) -> Option< let models = provider.models.as_ref()?; models .iter() - .filter(|model| model.deprecated != Some(true)) .max_by_key(|model| { ( tier_rank(&model.tier), @@ -874,13 +1317,42 @@ fn is_loopback_peer(peer_addr: Option) -> bool { } fn is_authorized(headers: &HashMap) -> bool { - let bearer = format!("Bearer {}", api_token()); + authorize_request(headers).is_some() +} + +fn authorize_request(headers: &HashMap) -> Option { headers .get("authorization") - .is_some_and(|value| value.trim() == bearer) - || headers - .get("x-axelate-token") - .is_some_and(|value| value.trim() == api_token()) + .and_then(|value| authorized_bearer_client(value)) +} + +fn authorized_bearer_client(value: &str) -> Option { + let mut parts = value.split_whitespace(); + let scheme = parts.next()?; + let token = parts.next()?; + if parts.next().is_some() || !scheme.eq_ignore_ascii_case("bearer") { + return None; + } + + authorized_token_client(token) +} + +fn authorized_token_client(token: &str) -> Option { + if token == api_token() { + return Some(AuthorizedClient::Launcher); + } + + let (module_id, digest) = token.split_once('.')?; + crate::domain::modules::downloader::validate_module_id(module_id).ok()?; + if digest.is_empty() { + return None; + } + MODULE_API_TOKENS + .lock() + .ok() + .and_then(|tokens| tokens.get(module_id).cloned()) + .filter(|expected| expected == token) + .map(|_| AuthorizedClient::Module(module_id.to_string())) } const fn json_response(status: u16, body: serde_json::Value) -> HttpResponse { @@ -919,6 +1391,7 @@ const fn status_text(status: u16) -> &'static str { 403 => "Forbidden", 404 => "Not Found", 500 => "Internal Server Error", + 503 => "Service Unavailable", _ => "Unknown", } } @@ -928,10 +1401,22 @@ mod tests { #![allow(clippy::expect_used)] use super::{ - find_header_end, is_authorized, model_api_id, parse_header_line, status_text, tier_rank, + IntegrationTextRequest, ModuleContextApiResponse, backend_provider_id, find_header_end, + is_authorized, is_loopback_peer, json_error, json_response, model_api_id, + modules_visible_to_client, parse_header_line, parse_header_lines, parse_json_body, + parse_module_action, read_http_request, selected_module_from_api_provider, + selected_module_from_catalog_item, status_for_app_error, status_text, tier_rank, + }; + use crate::domain::modules::controller::ModuleAction; + use crate::errors::AppError; + use crate::models::{ + AiModel, ApiModelConfig, ModelStats, ModelTier, Module, ModuleItem, ProviderType, + SelectedModule, }; - use crate::models::{AiModel, ApiModelConfig, ModelStats, ModelTier}; use std::collections::HashMap; + use std::io::Write; + use std::net::{Shutdown, TcpListener, TcpStream}; + use std::time::Duration; fn model_with_api_ids() -> AiModel { AiModel { @@ -944,7 +1429,6 @@ mod tests { release_date: None, context_window: None, max_output_tokens: None, - deprecated: None, pricing: None, stats: ModelStats { speed: 1, @@ -966,13 +1450,29 @@ mod tests { assert_eq!(value, "Bearer abc"); } + #[test] + fn rejects_malformed_http_header_lines() { + let error = parse_header_lines(["Host: localhost", "broken header"].into_iter()) + .expect_err("malformed header must fail"); + + assert!(error.contains("Malformed HTTP header line")); + } + + #[test] + fn rejects_duplicate_content_length_headers() { + let error = parse_header_lines(["Content-Length: 1", "content-length: 2"].into_iter()) + .expect_err("duplicate content-length must fail"); + + assert_eq!(error, "Duplicate content-length header"); + } + #[test] fn finds_standard_http_header_separator() { assert_eq!(find_header_end(b"GET / HTTP/1.1\r\n\r\n"), Some(14)); } #[test] - fn authorization_accepts_bearer_or_header_token() { + fn authorization_accepts_bearer_token() { let mut headers = HashMap::new(); headers.insert( "authorization".to_string(), @@ -980,12 +1480,108 @@ mod tests { ); assert!(is_authorized(&headers)); - headers.clear(); + headers.insert( + "authorization".to_string(), + format!("bearer {}", super::api_token()), + ); + assert!(is_authorized(&headers)); + } + + #[test] + fn authorization_rejects_old_header_token() { + let mut headers = HashMap::new(); headers.insert( "x-axelate-token".to_string(), super::api_token().to_string(), ); - assert!(is_authorized(&headers)); + + assert!(!is_authorized(&headers)); + } + + #[test] + fn authorization_maps_module_tokens_to_module_owner() { + let token = super::issue_module_api_token("sample-module").expect("module token"); + let mut headers = HashMap::new(); + headers.insert("authorization".to_string(), format!("Bearer {token}")); + + assert_eq!( + super::authorize_request(&headers), + Some(super::AuthorizedClient::Module("sample-module".to_string())) + ); + assert!( + super::ensure_module_route_owner( + &super::AuthorizedClient::Module("sample-module".to_string()), + "sample-module" + ) + .is_ok() + ); + assert!(matches!( + super::ensure_module_route_owner( + &super::AuthorizedClient::Module("sample-module".to_string()), + "other-module" + ), + Err(AppError::PermissionDenied(_)) + )); + } + + #[test] + fn issuing_new_module_token_invalidates_previous_token() { + let old_token = super::issue_module_api_token("rotating-module").expect("old token"); + let new_token = super::issue_module_api_token("rotating-module").expect("new token"); + let mut headers = HashMap::new(); + + headers.insert("authorization".to_string(), format!("Bearer {old_token}")); + assert_eq!(super::authorize_request(&headers), None); + + headers.insert("authorization".to_string(), format!("Bearer {new_token}")); + assert_eq!( + super::authorize_request(&headers), + Some(super::AuthorizedClient::Module( + "rotating-module".to_string() + )) + ); + } + + #[test] + fn module_requests_default_to_module_scoped_session() { + assert_eq!( + super::resolve_session_id( + None, + &super::AuthorizedClient::Module("sample-module".to_string()) + ), + Some("integration:sample-module".to_string()) + ); + assert_eq!( + super::resolve_session_id( + Some(" explicit-session "), + &super::AuthorizedClient::Module("sample-module".to_string()) + ), + Some("explicit-session".to_string()) + ); + } + + #[test] + fn launcher_requests_without_session_do_not_use_ui_state() { + assert_eq!( + super::resolve_session_id(None, &super::AuthorizedClient::Launcher), + None + ); + } + + #[test] + fn authorization_rejects_malformed_bearer_values() { + let mut headers = HashMap::new(); + headers.insert( + "authorization".to_string(), + format!("Bearer {} extra", super::api_token()), + ); + assert!(!is_authorized(&headers)); + + headers.insert( + "authorization".to_string(), + format!("Token {}", super::api_token()), + ); + assert!(!is_authorized(&headers)); } #[test] @@ -995,9 +1591,431 @@ mod tests { assert_eq!(model_api_id(&model, "image").as_deref(), Some("api-image")); } + #[test] + fn maps_custom_ui_provider_ids_to_backend_providers() { + assert_eq!(backend_provider_id("openrouter-custom-text"), "gpt"); + assert_eq!(backend_provider_id("openrouter-custom-image"), "gpt-image"); + assert_eq!(backend_provider_id("llamacpp"), "llamacpp"); + } + + #[test] + fn parses_text_request_without_prompt_when_messages_are_present() { + let request = super::HttpRequest { + method: "POST".to_string(), + path: "/v1/ai/text".to_string(), + headers: HashMap::new(), + body: br#"{"messages":[{"id":"m1","role":"user","content":"hello"}]}"#.to_vec(), + }; + + let payload: IntegrationTextRequest = parse_json_body(&request).expect("payload"); + + assert!(payload.prompt.is_none()); + assert_eq!(payload.messages.expect("messages").len(), 1); + } + + #[test] + fn parses_module_settings_request_as_json_object() { + let request = super::HttpRequest { + method: "PUT".to_string(), + path: "/v1/modules/sample/settings".to_string(), + headers: HashMap::new(), + body: br#"{"enabled":true,"threshold":3}"#.to_vec(), + }; + + let settings: HashMap = + parse_json_body(&request).expect("settings object"); + + assert_eq!( + settings.get("enabled").and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + settings + .get("threshold") + .and_then(serde_json::Value::as_i64), + Some(3) + ); + } + + #[test] + fn rejects_module_settings_request_when_body_is_not_object() { + let request = super::HttpRequest { + method: "PUT".to_string(), + path: "/v1/modules/sample/settings".to_string(), + headers: HashMap::new(), + body: br#"["not","an","object"]"#.to_vec(), + }; + + let error = + parse_json_body::>(&request).expect_err("array"); + + assert!(matches!(error, AppError::Validation(_))); + } + + #[test] + fn patch_settings_merge_nested_objects_without_dropping_existing_keys() { + let mut settings = HashMap::from([ + ( + "notifications".to_string(), + serde_json::json!({ + "enabled": true, + "channels": { "chat": true, "logs": true } + }), + ), + ("theme".to_string(), serde_json::json!("dark")), + ]); + let updates = HashMap::from([ + ( + "notifications".to_string(), + serde_json::json!({ "channels": { "logs": false } }), + ), + ("theme".to_string(), serde_json::json!("light")), + ]); + + super::merge_json_settings(&mut settings, updates); + + assert_eq!( + settings.get("notifications"), + Some(&serde_json::json!({ + "enabled": true, + "channels": { "chat": true, "logs": false } + })) + ); + assert_eq!(settings.get("theme"), Some(&serde_json::json!("light"))); + } + + #[test] + fn module_context_response_uses_public_camel_case_contract() { + let response = serde_json::to_value(ModuleContextApiResponse { + ok: true, + api_version: "1", + module_id: "sample".to_string(), + module_dir: "module".to_string(), + runtime_dir: "runtime".to_string(), + module_runtime_dir: "module-runtime".to_string(), + module_log_dir: "logs".to_string(), + http_api_base: "http://127.0.0.1:3000".to_string(), + }) + .expect("context response"); + + assert_eq!( + response + .get("apiVersion") + .and_then(serde_json::Value::as_str), + Some("1") + ); + assert_eq!( + response.get("moduleId").and_then(serde_json::Value::as_str), + Some("sample") + ); + assert_eq!( + response + .get("moduleRuntimeDir") + .and_then(serde_json::Value::as_str), + Some("module-runtime") + ); + assert_eq!( + response + .get("httpApiBase") + .and_then(serde_json::Value::as_str), + Some("http://127.0.0.1:3000") + ); + } + + #[test] + fn apply_process_env_sets_documented_integration_contract() { + let module_id = "sample"; + let mut command = tokio::process::Command::new("sample-command"); + super::apply_process_env(&mut command, module_id).expect("process env"); + + let envs = command + .as_std() + .get_envs() + .filter_map(|(key, value)| { + Some(( + key.to_string_lossy().to_string(), + value?.to_string_lossy().to_string(), + )) + }) + .collect::>(); + + assert_eq!( + envs.get("AXELATE_INTEGRATION_API_VERSION") + .map(String::as_str), + Some("1") + ); + assert_eq!( + envs.get("AXELATE_MODULE_ID").map(String::as_str), + Some(module_id) + ); + assert!(envs.contains_key("AXELATE_HTTP_API_BASE")); + let token = envs + .get("AXELATE_HTTP_API_TOKEN") + .expect("module API token"); + let headers = HashMap::from([("authorization".to_string(), format!("Bearer {token}"))]); + assert_eq!( + super::authorize_request(&headers), + Some(super::AuthorizedClient::Module(module_id.to_string())) + ); + assert!(envs.contains_key("AXELATE_MODULE_DIR")); + assert!(envs.contains_key("AXELATE_RUNTIME_DIR")); + assert!(envs.contains_key("AXELATE_MODULE_RUNTIME_DIR")); + assert!(envs.contains_key("AXELATE_MODULE_LOG_DIR")); + } + #[test] fn ranks_model_tiers_for_default_selection() { assert!(tier_rank(&ModelTier::Strong) > tier_rank(&ModelTier::Medium)); assert_eq!(status_text(404), "Not Found"); } + + #[test] + fn module_action_parser_accepts_integration_routes_only() { + assert_eq!( + parse_module_action("start").expect("start"), + ModuleAction::Start + ); + assert_eq!( + parse_module_action("stop").expect("stop"), + ModuleAction::Stop + ); + assert_eq!( + parse_module_action("restart").expect("restart"), + ModuleAction::Restart + ); + assert!(matches!( + parse_module_action("install"), + Err(AppError::Validation(_)) + )); + } + + #[test] + fn loopback_guard_rejects_missing_or_remote_peers() { + assert!(is_loopback_peer(Some( + "127.0.0.1:3000".parse().expect("loopback socket") + ))); + assert!(is_loopback_peer(Some( + "[::1]:3000".parse().expect("ipv6 loopback socket") + ))); + assert!(!is_loopback_peer(None)); + assert!(!is_loopback_peer(Some( + "192.168.1.10:3000".parse().expect("remote socket") + ))); + } + + #[test] + fn json_response_helpers_preserve_status_and_error_shape() { + let ok = json_response(200, serde_json::json!({ "ok": true })); + let error = json_error(401, "denied"); + + assert_eq!(ok.status, 200); + assert_eq!( + ok.body.get("ok").and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!(error.status, 401); + assert_eq!( + error.body.get("error").and_then(serde_json::Value::as_str), + Some("denied") + ); + } + + #[test] + fn selected_module_from_catalog_preserves_localized_metadata() { + let module = ModuleItem { + id: "llamacpp".to_string(), + name_key: "ui.module.llamacpp".to_string(), + desc_key: "ui.module.llamacpp.desc".to_string(), + name: "llama.cpp".to_string(), + desc: "Local text engine".to_string(), + icon: "cpu".to_string(), + preview: None, + type_name: "local".to_string(), + dl_type: None, + capabilities: vec!["text".to_string()], + binary: Some("llama-server".to_string()), + repo_url: None, + expected_hash: None, + coming_soon: false, + managed_externally: false, + version: "1.0.0".to_string(), + installed: true, + raw_config_schema: None, + config_schema: None, + }; + + let selected = selected_module_from_catalog_item(&module); + + assert_eq!(selected.id, "llamacpp"); + assert_eq!(selected.name_key.as_deref(), Some("ui.module.llamacpp")); + assert_eq!( + selected.desc_key.as_deref(), + Some("ui.module.llamacpp.desc") + ); + assert_eq!(selected.type_, "local"); + } + + #[test] + fn module_tokens_only_see_their_own_module_in_list_route() { + fn module(id: &str) -> Module { + Module { + id: id.to_string(), + name: id.to_string(), + description: String::new(), + version: String::new(), + author: String::new(), + category: "service".to_string(), + icon: String::new(), + preview: None, + path: String::new(), + installed: true, + local: true, + enabled: false, + status: None, + is_deletable: true, + config: HashMap::new(), + config_schema: None, + settings_ui: None, + } + } + + let visible = modules_visible_to_client( + vec![module("owned-module"), module("other-module")], + &super::AuthorizedClient::Module("owned-module".to_string()), + ); + + assert_eq!(visible.len(), 1); + assert_eq!( + visible.first().map(|module| module.id.as_str()), + Some("owned-module") + ); + } + + #[test] + fn selected_module_from_api_provider_maps_provider_type() { + let provider = crate::models::ApiProvider { + id: "cloud".to_string(), + name: "Cloud".to_string(), + desc_key: Some("ui.module.cloud.desc".to_string()), + description: Some("Cloud provider".to_string()), + icon: Some("cloud".to_string()), + provider_type: Some(ProviderType::Openai), + base_url: Some("https://api.example.test/v1".to_string()), + api_key_env: None, + models: None, + capabilities: None, + model_aliases: None, + }; + let local = crate::models::ApiProvider { + provider_type: Some(ProviderType::Local), + ..provider.clone() + }; + + let selected_cloud: SelectedModule = selected_module_from_api_provider(&provider); + let selected_local = selected_module_from_api_provider(&local); + + assert_eq!(selected_cloud.type_, "api"); + assert_eq!(selected_cloud.desc, "Cloud provider"); + assert_eq!(selected_local.type_, "local"); + } + + #[test] + fn maps_app_errors_to_http_status_codes() { + assert_eq!( + status_for_app_error(&AppError::Validation("bad input".to_string())), + 400 + ); + assert_eq!( + status_for_app_error(&AppError::NotFound("missing".to_string())), + 404 + ); + assert_eq!( + status_for_app_error(&AppError::PermissionDenied("denied".to_string())), + 403 + ); + assert_eq!(status_for_app_error(&AppError::Io("disk".to_string())), 500); + } + + #[test] + fn rejects_http_body_larger_than_limit_before_waiting_for_body() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); + let addr = listener.local_addr().expect("local addr"); + let client = std::thread::spawn(move || { + let mut stream = TcpStream::connect(addr).expect("connect test listener"); + write!( + stream, + "POST /v1/ai/text HTTP/1.1\r\nContent-Length: {}\r\n\r\n", + super::MAX_REQUEST_BYTES + 1 + ) + .expect("write request"); + }); + + let (mut stream, _) = listener.accept().expect("accept test client"); + let error = read_http_request(&mut stream).expect_err("oversized body must fail"); + client.join().expect("client thread"); + + assert_eq!(error, "HTTP request body is too large"); + } + + #[test] + fn reads_headers_without_waiting_for_full_body() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); + let addr = listener.local_addr().expect("local addr"); + let client = std::thread::spawn(move || { + let mut stream = TcpStream::connect(addr).expect("connect test listener"); + stream + .write_all(b"POST /v1/ai/text HTTP/1.1\r\nContent-Length: 8\r\n\r\nabc") + .expect("write partial request"); + std::thread::sleep(Duration::from_millis(200)); + stream.shutdown(Shutdown::Write).expect("shutdown write"); + }); + + let (mut stream, _) = listener.accept().expect("accept test client"); + let request = super::read_http_request_head(&mut stream).expect("request head"); + + assert_eq!(request.path, "/v1/ai/text"); + assert_eq!(request.body, b"abc"); + let error = super::complete_http_request_body(&mut stream, request) + .expect_err("remaining body should still be required"); + client.join().expect("client thread"); + assert!(error.contains("expected 8 bytes, got 3")); + } + + #[test] + fn rejects_http_body_shorter_than_content_length() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); + let addr = listener.local_addr().expect("local addr"); + let client = std::thread::spawn(move || { + let mut stream = TcpStream::connect(addr).expect("connect test listener"); + stream + .write_all(b"POST /v1/ai/text HTTP/1.1\r\nContent-Length: 8\r\n\r\nabc") + .expect("write request"); + stream.shutdown(Shutdown::Write).expect("shutdown write"); + }); + + let (mut stream, _) = listener.accept().expect("accept test client"); + let error = read_http_request(&mut stream).expect_err("truncated body must fail"); + client.join().expect("client thread"); + + assert!(error.contains("expected 8 bytes, got 3")); + } + + #[test] + fn rejects_malformed_http_request_line() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); + let addr = listener.local_addr().expect("local addr"); + let client = std::thread::spawn(move || { + let mut stream = TcpStream::connect(addr).expect("connect test listener"); + stream + .write_all(b"GET /v1/health HTTP/1.1 extra\r\n\r\n") + .expect("write request"); + stream.shutdown(Shutdown::Write).expect("shutdown write"); + }); + + let (mut stream, _) = listener.accept().expect("accept test client"); + let error = read_http_request(&mut stream).expect_err("bad request line must fail"); + client.join().expect("client thread"); + + assert_eq!(error, "HTTP request line has too many parts"); + } } diff --git a/src-tauri/src/domain/license/mod.rs b/src-tauri/src/domain/license/mod.rs deleted file mode 100644 index c7d50e40..00000000 --- a/src-tauri/src/domain/license/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! License management module -//! -//! Handles license verification, activation, and feature gating - -/// License storage operations -pub mod storage; -/// License types and status -pub mod types; -/// License verification logic -pub mod verifier; - -pub use types::{LicenseInfo, LicenseStatus}; -pub use verifier::{activate, deactivate, has_feature, verify}; diff --git a/src-tauri/src/domain/license/storage.rs b/src-tauri/src/domain/license/storage.rs deleted file mode 100644 index c3f90b0f..00000000 --- a/src-tauri/src/domain/license/storage.rs +++ /dev/null @@ -1,24 +0,0 @@ -use super::types::LicenseInfo; -use crate::errors::AppError; -use crate::infrastructure::crypto::secure_storage::SecureStorage; - -const LICENSE_KEY: &str = "license_data"; - -/// Loads license from storage -pub fn load_license() -> Option { - match SecureStorage::get_key(LICENSE_KEY) { - Ok(Some(json)) => serde_json::from_str(&json).ok(), - _ => None, - } -} - -/// Saves license to encrypted storage -pub fn save_license(info: &LicenseInfo) -> Result<(), AppError> { - let json = serde_json::to_string(info).map_err(|e| AppError::Serialization(e.to_string()))?; - SecureStorage::save_key(LICENSE_KEY.to_string(), json) -} - -/// Clears license from storage -pub fn clear_license() -> Result<(), AppError> { - SecureStorage::remove_key(LICENSE_KEY) -} diff --git a/src-tauri/src/domain/license/types.rs b/src-tauri/src/domain/license/types.rs deleted file mode 100644 index f0204ff2..00000000 --- a/src-tauri/src/domain/license/types.rs +++ /dev/null @@ -1,30 +0,0 @@ -use serde::{Deserialize, Serialize}; -use specta::Type; - -/// License tier status -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Type)] -pub enum LicenseStatus { - /// Free tier - Free, - /// Pro tier - Pro, - /// Enterprise tier - Enterprise, - /// Expired license - Expired, - /// Invalid license key - Invalid, -} - -/// License information -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct LicenseInfo { - /// License key - pub key: String, - /// User email - pub email: Option, - /// License tier - pub tier: LicenseStatus, - /// Expiration timestamp - pub expires_at: Option, -} diff --git a/src-tauri/src/domain/license/verifier.rs b/src-tauri/src/domain/license/verifier.rs deleted file mode 100644 index e8c626ba..00000000 --- a/src-tauri/src/domain/license/verifier.rs +++ /dev/null @@ -1,65 +0,0 @@ -use super::storage; -use super::types::{LicenseInfo, LicenseStatus}; -use crate::errors::AppError; - -/// Verifies current license status -pub fn verify() -> LicenseStatus { - match storage::load_license() { - Some(info) => verify_license_info(&info), - None => LicenseStatus::Free, - } -} - -/// Verifies a license info object -pub fn verify_license_info(info: &LicenseInfo) -> LicenseStatus { - // Basic verification logic - if info.key.starts_with("PRO-") { - LicenseStatus::Pro - } else if info.key.starts_with("ENT-") { - LicenseStatus::Enterprise - } else { - LicenseStatus::Invalid - } -} - -/// Activates a license key -pub fn activate(key: &str, email: Option) -> Result { - let status = if key.starts_with("PRO-") { - LicenseStatus::Pro - } else if key.starts_with("ENT-") { - LicenseStatus::Enterprise - } else { - return Err(AppError::Validation( - "Invalid license key format".to_string(), - )); - }; - - let info = LicenseInfo { - key: key.to_string(), - email, - tier: status.clone(), - expires_at: None, - }; - - storage::save_license(&info)?; - Ok(status) -} - -/// Deactivates the current license -#[allow(clippy::missing_const_for_fn)] // Calls non-const storage function -pub fn deactivate() -> Result<(), AppError> { - storage::clear_license() -} - -/// Checks if a feature is available in the current license -pub fn has_feature(feature: &str) -> bool { - let status = verify(); - match status { - LicenseStatus::Enterprise => true, - LicenseStatus::Pro => { - // Pro features list - matches!(feature, "advanced_stats" | "custom_themes") - } - _ => false, - } -} diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs index 5b4297e8..93b95709 100644 --- a/src-tauri/src/domain/mod.rs +++ b/src-tauri/src/domain/mod.rs @@ -6,8 +6,6 @@ pub mod engine; pub mod filesystem; /// Local HTTP API for launcher integrations pub mod integration_api; -/// License domain logic -pub mod license; /// Module domain logic pub mod modules; /// Monitoring domain logic diff --git a/src-tauri/src/domain/modules/controller/lifecycle.rs b/src-tauri/src/domain/modules/controller/lifecycle.rs index 20287c49..130458ff 100644 --- a/src-tauri/src/domain/modules/controller/lifecycle.rs +++ b/src-tauri/src/domain/modules/controller/lifecycle.rs @@ -4,14 +4,30 @@ use crate::domain::modules::lifecycle::{CommandDefinition, ModuleManifest}; use crate::domain::modules::paths as module_paths; use crate::errors::AppError; use crate::models::ControlResponse; +use std::collections::HashMap; use std::fs::OpenOptions; use std::path::{Path, PathBuf}; use std::process::Stdio; +use std::sync::{Arc, LazyLock}; use std::time::Duration; +use tokio::fs; use tokio::process::{Child, Command}; +use tokio::sync::Mutex; use tokio::time::timeout; const MODULE_CHILD_EXIT_POLL_INTERVAL: Duration = Duration::from_secs(1); +type ModuleLifecycleLocks = HashMap>>; +static MODULE_LIFECYCLE_LOCKS: LazyLock> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +async fn module_lifecycle_lock(module_id: &str) -> Arc> { + let mut locks = MODULE_LIFECYCLE_LOCKS.lock().await; + Arc::clone( + locks + .entry(module_id.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))), + ) +} fn build_command(cmd: CommandDefinition) -> Command { match cmd { @@ -57,6 +73,9 @@ impl<'a> LifecycleExecutor<'a> { /// Safely starts a module with the given manifest pub async fn start(&self, manifest: &ModuleManifest) -> Result { + let lifecycle_lock = module_lifecycle_lock(&self.module_id).await; + let _lifecycle_guard = lifecycle_lock.lock().await; + // 1. Guard against double-start // Check registry first (atomic-ish) if self.controller.registry.contains_key(&self.module_id) { @@ -79,7 +98,7 @@ impl<'a> LifecycleExecutor<'a> { }); } - if let Some(entry_path) = self.resolve_script_entry_path(manifest) { + if let Some(entry_path) = self.resolve_script_entry_path(manifest)? { if let Some(existing_pid) = self .reconcile_existing_script_processes(&entry_path) .await? @@ -132,13 +151,14 @@ impl<'a> LifecycleExecutor<'a> { let mut builder = build_command(start_cmd); builder .current_dir(self.module_path) + .env("AXELATE_MODULE_ID", &self.module_id) .stdout(Stdio::from( log_file .try_clone() .map_err(|e| AppError::Io(e.to_string()))?, )) .stderr(Stdio::from(log_file)); - crate::domain::integration_api::apply_process_env(&mut builder); + crate::domain::integration_api::apply_process_env(&mut builder, &self.module_id)?; builder.spawn().map_err(|e| AppError::Internal { request_id: None, @@ -150,15 +170,24 @@ impl<'a> LifecycleExecutor<'a> { } async fn register_spawned_child(&self, mut child: Child) -> Result { - let pid = child.id().ok_or_else(|| AppError::Internal { - request_id: None, - message: format!("Spawned process for {} has no PID", self.module_id), - })?; - if let Err(error) = self.persist_pid(pid as usize) { - let _ = child.kill().await; - return Err(error); - } + let Some(pid) = child.id().map(|pid| pid as usize) else { + self.kill_unregistered_child(&mut child, "missing PID after spawn") + .await; + return Err(AppError::Internal { + request_id: None, + message: format!("Module {} exited before PID capture", self.module_id), + }); + }; + self.persist_or_kill_spawned_child(&mut child, pid) + .await + .inspect_err(|error| { + tracing::error!( + module_id = %self.module_id, + pid, + "Failed to publish module PID file after spawn: {error}" + ); + })?; let module_id = self.module_id.clone(); let controller_registry = self.controller.registry; // Pass registry reference to the task @@ -178,6 +207,7 @@ impl<'a> LifecycleExecutor<'a> { match outcome { Ok(Some(_status)) => { controller_registry.remove(&module_id); + crate::domain::integration_api::revoke_module_api_token(&module_id); tracing::info!( "Module {module_id} exited naturally and was cleaned up from registry" ); @@ -190,7 +220,16 @@ impl<'a> LifecycleExecutor<'a> { tracing::warn!( "Failed to poll child status for module {module_id}: {error}" ); - controller_registry.remove(&module_id); + if let Some((_, mut child)) = controller_registry.remove(&module_id) { + crate::domain::integration_api::revoke_module_api_token(&module_id); + if let Err(kill_error) = child.kill().await { + tracing::warn!( + module_id = %module_id, + "Failed to kill module after status polling failed: {kill_error}" + ); + } + Self::reap_child_after_kill_attempt(&module_id, &mut child).await; + } return; } } @@ -204,39 +243,110 @@ impl<'a> LifecycleExecutor<'a> { }) } - fn module_log_path(&self) -> PathBuf { - module_paths::runtime_log_path(&self.module_id) + async fn kill_unregistered_child(&self, child: &mut Child, reason: &str) { + if let Err(error) = child.kill().await { + tracing::warn!( + module_id = %self.module_id, + "Failed to kill spawned module after {reason}: {error}" + ); + } + + Self::reap_child_after_kill_attempt(&self.module_id, child).await; + } + + async fn reap_child_after_kill_attempt(module_id: &str, child: &mut Child) { + match child.try_wait() { + Ok(Some(_)) => return, + Ok(None) => {} + Err(error) => { + tracing::warn!( + module_id, + "Failed to poll module child after kill attempt: {error}" + ); + } + } + + match timeout(Duration::from_secs(5), child.wait()).await { + Ok(Ok(_)) => {} + Ok(Err(error)) => { + tracing::warn!( + module_id, + "Failed to wait module child after kill attempt: {error}" + ); + } + Err(_) => { + tracing::warn!( + module_id, + "Timed out waiting for module child after kill attempt" + ); + } + } } - fn persist_pid(&self, pid: usize) -> Result<(), AppError> { + async fn persist_pid(&self, pid: usize) -> Result<(), AppError> { let pid_file = self.module_path.join("module.pid"); let temp_pid_file = self.module_path.join("module.pid.tmp"); - std::fs::write(&temp_pid_file, pid.to_string()).map_err(|error| { - AppError::Io(format!( - "Failed to write temp PID file {}: {error}", - temp_pid_file.display() - )) - })?; - - std::fs::rename(&temp_pid_file, &pid_file).map_err(|error| { - AppError::Io(format!( - "Failed to move temp PID file {} to {}: {error}", + fs::write(&temp_pid_file, pid.to_string()) + .await + .map_err(|error| { + AppError::Io(format!( + "Failed to write module PID temp file '{}': {error}", + temp_pid_file.display() + )) + })?; + + if let Err(error) = fs::rename(&temp_pid_file, &pid_file).await { + let _ = fs::remove_file(&temp_pid_file).await; + return Err(AppError::Io(format!( + "Failed to publish module PID file '{}' -> '{}': {error}", temp_pid_file.display(), pid_file.display() - )) - })?; + ))); + } Ok(()) } + async fn persist_or_kill_spawned_child( + &self, + child: &mut Child, + pid: usize, + ) -> Result<(), AppError> { + match self.persist_pid(pid).await { + Ok(()) => Ok(()), + Err(error) => { + self.kill_unregistered_child(child, "PID publish failure") + .await; + Err(error) + } + } + } + + fn log_reconciled_pid_publish_failure(&self, error: &AppError) { + tracing::error!( + module_id = %self.module_id, + "Failed to publish reconciled module PID file: {error}" + ); + } + + fn module_log_path(&self) -> PathBuf { + module_paths::runtime_log_path(&self.module_id) + } /// Gracefully stops a module with escalation - pub async fn stop(&self, manifest: &ModuleManifest) -> ControlResponse { + pub async fn stop(&self, manifest: &ModuleManifest) -> Result { + let lifecycle_lock = module_lifecycle_lock(&self.module_id).await; + let _lifecycle_guard = lifecycle_lock.lock().await; tracing::info!("Stopping module: {}", self.module_id); - let script_entry_path = self.resolve_script_entry_path(manifest); + let script_entry_path = self.resolve_script_entry_path(manifest)?; // 1. Run stop script if exists if let Some(stop_cmd) = manifest.lifecycle.as_ref().and_then(|l| l.stop.clone()) { - let _ = self.run_command(stop_cmd, Duration::from_secs(5)).await; + if let Err(error) = self.run_command(stop_cmd, Duration::from_secs(5)).await { + tracing::warn!( + module_id = %self.module_id, + "Module stop script failed, continuing with process termination: {error}" + ); + } } // 2. Attempt soft termination and wait @@ -255,10 +365,31 @@ impl<'a> LifecycleExecutor<'a> { } } - // Wait with timeout - if timeout(Duration::from_secs(5), child.wait()).await.is_err() { - tracing::warn!("Module {} stop timed out, forcing kill", self.module_id); - let _ = child.kill().await; + match timeout(Duration::from_secs(5), child.wait()).await { + Ok(Ok(_status)) => {} + Ok(Err(error)) => { + tracing::warn!( + module_id = %self.module_id, + "Failed to wait registered module process during stop: {error}" + ); + if let Err(kill_error) = child.kill().await { + tracing::warn!( + module_id = %self.module_id, + "Failed to force-kill registered module process after wait error: {kill_error}" + ); + } + Self::reap_child_after_kill_attempt(&self.module_id, &mut child).await; + } + Err(_) => { + tracing::warn!("Module {} stop timed out, forcing kill", self.module_id); + if let Err(error) = child.kill().await { + tracing::warn!( + module_id = %self.module_id, + "Failed to force-kill registered module process: {error}" + ); + } + Self::reap_child_after_kill_attempt(&self.module_id, &mut child).await; + } } } @@ -297,16 +428,44 @@ impl<'a> LifecycleExecutor<'a> { && let Ok(pid) = pid_str.trim().parse::() { // Safe kill_orphan now includes existence check - let _ = crate::domain::modules::controller::process::kill_orphan(pid); + if let Err(error) = + crate::domain::modules::controller::process::kill_orphan(pid) + { + tracing::warn!( + module_id = %self.module_id, + pid, + "Failed to force-kill orphan module process: {error}" + ); + } } } tokio::time::sleep(Duration::from_millis(500)).await; } + if self + .controller + .is_running(&self.module_id, self.module_path) + .await + { + return Err(AppError::Internal { + request_id: None, + message: format!("Module {} failed to stop", self.module_id), + }); + } + // 4. Cleanup PID file (Wait loop already verified termination) - let _ = std::fs::remove_file(self.module_path.join("module.pid")); + match std::fs::remove_file(self.module_path.join("module.pid")) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + tracing::warn!( + module_id = %self.module_id, + "Failed to remove module PID file after stop: {error}" + ); + } + } - ControlResponse { + Ok(ControlResponse { success: process_scan_error.is_none(), message: process_scan_error.map_or_else( || format!("Module {} stopped", self.module_id), @@ -318,11 +477,18 @@ impl<'a> LifecycleExecutor<'a> { }, ), status: Some("stopped".to_string()), - } + }) } - fn resolve_script_entry_path(&self, manifest: &ModuleManifest) -> Option { - script_runtime::resolve_entry_path(self.module_path, manifest).ok() + fn resolve_script_entry_path( + &self, + manifest: &ModuleManifest, + ) -> Result, AppError> { + if !script_runtime::supports_manifest(manifest) { + return Ok(None); + } + + script_runtime::resolve_entry_path(self.module_path, manifest).map(Some) } async fn reconcile_existing_script_processes( @@ -337,7 +503,10 @@ impl<'a> LifecycleExecutor<'a> { if let Some(&existing_pid) = matching_pids.first() && matching_pids.len() == 1 { - self.persist_pid(existing_pid)?; + if let Err(error) = self.persist_pid(existing_pid).await { + self.log_reconciled_pid_publish_failure(&error); + return Err(error); + } return Ok(Some(existing_pid)); } @@ -348,21 +517,47 @@ impl<'a> LifecycleExecutor<'a> { ); if let Some(mut child) = self.controller.unregister(&self.module_id) { - let _ = child.kill().await; - let _ = child.wait().await; + if let Err(error) = child.kill().await { + tracing::warn!( + module_id = %self.module_id, + "Failed to kill registered duplicate module child: {error}" + ); + } + Self::reap_child_after_kill_attempt(&self.module_id, &mut child).await; } for pid in matching_pids { - let _ = process::kill_orphan(pid); + process::kill_orphan(pid).map_err(|error| AppError::Internal { + request_id: None, + message: format!( + "Failed to clean duplicate module process {pid} for '{}': {error}", + self.module_id + ), + })?; } - let _ = std::fs::remove_file(self.module_path.join("module.pid")); + match std::fs::remove_file(self.module_path.join("module.pid")) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + tracing::warn!( + module_id = %self.module_id, + "Failed to remove stale module PID file after duplicate cleanup: {error}" + ); + } + } Ok(None) } async fn kill_matching_script_processes(&self, entry_path: &Path) -> Result<(), AppError> { for pid in self.find_matching_script_processes(entry_path).await? { - let _ = process::kill_orphan(pid); + process::kill_orphan(pid).map_err(|error| AppError::Internal { + request_id: None, + message: format!( + "Failed to kill module process {pid} for '{}': {error}", + self.module_id + ), + })?; } Ok(()) } @@ -380,13 +575,19 @@ impl<'a> LifecycleExecutor<'a> { .await { Ok(pids) => Ok(pids), - Err(error) => Err(AppError::Internal { - request_id: None, - message: format!( + Err(error) => { + tracing::error!( "Failed to scan matching script module processes for {}: {error}", self.module_id - ), - }), + ); + Err(AppError::Internal { + request_id: None, + message: format!( + "Failed to scan module processes for '{}': {error}", + self.module_id + ), + }) + } } } diff --git a/src-tauri/src/domain/modules/controller/mod.rs b/src-tauri/src/domain/modules/controller/mod.rs index 614b683f..e259b8ca 100644 --- a/src-tauri/src/domain/modules/controller/mod.rs +++ b/src-tauri/src/domain/modules/controller/mod.rs @@ -366,16 +366,22 @@ pub async fn control( if action == ModuleAction::Uninstall { let executor = LifecycleExecutor::new(&controller, module_id.to_string(), &module_path); if let Ok(manifest) = module_lifecycle::ManifestLoader::load(&module_path) { - let _ = executor.stop(&manifest).await; + executor.stop(&manifest).await?; } else { let pid_file = module_path.join("module.pid"); if let Ok(pid_str) = std::fs::read_to_string(&pid_file) && let Ok(pid) = pid_str.trim().parse::() { - let _ = process::kill_orphan(pid); + process::kill_orphan(pid).map_err(|error| AppError::Internal { + request_id: None, + message: format!( + "Failed to stop module {module_id} from PID file before uninstall: {error}" + ), + })?; } } downloader::delete_module(module_id).await?; + crate::domain::integration_api::revoke_module_api_token(module_id); return Ok(ControlResponse { success: true, message: format!("Module {module_id} uninstalled successfully"), @@ -392,10 +398,10 @@ pub async fn control( match action { ModuleAction::Start => executor.start(&manifest).await, - ModuleAction::Stop => Ok(executor.stop(&manifest).await), + ModuleAction::Stop => executor.stop(&manifest).await, ModuleAction::Restart => { tracing::info!("Restarting module: {module_id}"); - let _ = executor.stop(&manifest).await; + executor.stop(&manifest).await?; // Wait for it to actually die (up to 5s) with survival check let mut terminated = false; @@ -426,3 +432,218 @@ pub async fn control( }), } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::{ + ModuleAction, apply_localized_module_preview, load_preview_translations, + preview_image_mime, read_module_preview_image, resolve_module_preview, + }; + use crate::models::modules::ModulePreview; + use std::str::FromStr; + + #[test] + fn module_action_parses_supported_actions_case_insensitively() { + assert_eq!( + ModuleAction::from_str("start").unwrap(), + ModuleAction::Start + ); + assert_eq!(ModuleAction::from_str("STOP").unwrap(), ModuleAction::Stop); + assert_eq!( + ModuleAction::from_str("Restart").unwrap(), + ModuleAction::Restart + ); + assert_eq!( + ModuleAction::from_str("install").unwrap(), + ModuleAction::Install + ); + assert_eq!( + ModuleAction::from_str("uninstall").unwrap(), + ModuleAction::Uninstall + ); + assert_eq!( + ModuleAction::from_str("update").unwrap(), + ModuleAction::Update + ); + assert!(ModuleAction::from_str("delete").is_err()); + } + + #[test] + fn preview_image_mime_accepts_supported_extensions() { + assert_eq!( + preview_image_mime(std::path::Path::new("card.PNG")).unwrap(), + "image/png" + ); + assert_eq!( + preview_image_mime(std::path::Path::new("card.jpg")).unwrap(), + "image/jpeg" + ); + assert_eq!( + preview_image_mime(std::path::Path::new("card.jpeg")).unwrap(), + "image/jpeg" + ); + assert_eq!( + preview_image_mime(std::path::Path::new("card.webp")).unwrap(), + "image/webp" + ); + assert_eq!( + preview_image_mime(std::path::Path::new("card.gif")).unwrap(), + "image/gif" + ); + assert_eq!( + preview_image_mime(std::path::Path::new("card.svg")).unwrap(), + "image/svg+xml" + ); + assert!(preview_image_mime(std::path::Path::new("card.txt")).is_err()); + } + + #[tokio::test] + async fn read_module_preview_image_returns_data_url_and_rejects_unsafe_paths() { + let temp = tempfile::tempdir().unwrap(); + let image_path = temp.path().join("preview.png"); + tokio::fs::write(&image_path, b"png-bytes").await.unwrap(); + + let data_url = read_module_preview_image(temp.path(), "preview.png") + .await + .unwrap(); + + assert_eq!(data_url, "data:image/png;base64,cG5nLWJ5dGVz"); + assert!( + read_module_preview_image(temp.path(), "../preview.png") + .await + .is_err() + ); + assert!( + read_module_preview_image(temp.path(), "missing.png") + .await + .is_err() + ); + assert!( + read_module_preview_image(temp.path(), "preview.txt") + .await + .is_err() + ); + } + + #[tokio::test] + async fn read_module_preview_image_rejects_large_files_and_directories() { + let temp = tempfile::tempdir().unwrap(); + let large_path = temp.path().join("large.png"); + let dir_path = temp.path().join("dir.png"); + tokio::fs::write(&large_path, vec![0_u8; 2 * 1024 * 1024 + 1]) + .await + .unwrap(); + tokio::fs::create_dir(&dir_path).await.unwrap(); + + assert!( + read_module_preview_image(temp.path(), "large.png") + .await + .is_err() + ); + assert!( + read_module_preview_image(temp.path(), "dir.png") + .await + .is_err() + ); + } + + #[test] + fn load_preview_translations_accepts_supported_language_prefixes() { + let temp = tempfile::tempdir().unwrap(); + let i18n = temp.path().join("i18n"); + std::fs::create_dir(&i18n).unwrap(); + std::fs::write( + i18n.join("ru.json"), + r#"{"preview.title":"Ru title","preview.description":"Ru description"}"#, + ) + .unwrap(); + + let translations = + load_preview_translations(temp.path(), std::path::Path::new("i18n"), "ru-BY").unwrap(); + + assert_eq!( + translations + .get("preview.title") + .and_then(serde_json::Value::as_str), + Some("Ru title") + ); + assert!( + load_preview_translations(temp.path(), std::path::Path::new("i18n"), "fr").is_none() + ); + } + + #[test] + fn apply_localized_module_preview_ignores_unsafe_i18n_paths() { + let temp = tempfile::tempdir().unwrap(); + let mut traversal = ModulePreview { + title: Some("Original".to_string()), + i18n: Some("../i18n".to_string()), + ..ModulePreview::default() + }; + let mut absolute = ModulePreview { + title: Some("Original".to_string()), + i18n: Some(temp.path().to_string_lossy().to_string()), + ..ModulePreview::default() + }; + + apply_localized_module_preview(temp.path(), &mut traversal); + apply_localized_module_preview(temp.path(), &mut absolute); + + assert_eq!(traversal.title.as_deref(), Some("Original")); + assert_eq!(absolute.title.as_deref(), Some("Original")); + } + + #[tokio::test] + async fn resolve_module_preview_keeps_external_images_and_embeds_local_images() { + let temp = tempfile::tempdir().unwrap(); + let i18n = temp.path().join("i18n"); + std::fs::create_dir(&i18n).unwrap(); + std::fs::write( + i18n.join("en.json"), + r#"{"preview.title":"Localized","preview.description":"Localized description"}"#, + ) + .unwrap(); + tokio::fs::write(temp.path().join("preview.svg"), b"") + .await + .unwrap(); + + let embedded = resolve_module_preview( + temp.path(), + Some(ModulePreview { + title: Some("Original".to_string()), + description: None, + image: Some("preview.svg".to_string()), + i18n: Some("i18n".to_string()), + ..ModulePreview::default() + }), + ) + .await + .unwrap(); + let external = resolve_module_preview( + temp.path(), + Some(ModulePreview { + image: Some("https://example.test/card.png".to_string()), + ..ModulePreview::default() + }), + ) + .await + .unwrap(); + + assert_eq!(embedded.title.as_deref(), Some("Localized")); + assert_eq!( + embedded.description.as_deref(), + Some("Localized description") + ); + assert_eq!( + embedded.image.as_deref(), + Some("data:image/svg+xml;base64,PHN2Zy8+") + ); + assert_eq!( + external.image.as_deref(), + Some("https://example.test/card.png") + ); + assert!(resolve_module_preview(temp.path(), None).await.is_none()); + } +} diff --git a/src-tauri/src/domain/modules/controller/script_runtime.rs b/src-tauri/src/domain/modules/controller/script_runtime.rs index ff139f17..7bea3e5d 100644 --- a/src-tauri/src/domain/modules/controller/script_runtime.rs +++ b/src-tauri/src/domain/modules/controller/script_runtime.rs @@ -1,3 +1,4 @@ +use crate::domain::integration_api::SDK_API_VERSION; use crate::domain::modules::lifecycle::{ModuleManifest, ModuleRuntimeKind}; use crate::domain::modules::paths as module_paths; use crate::errors::AppError; @@ -134,11 +135,10 @@ async fn spawn_python_process( let mut command = Command::new(&python_path); command .arg(&entry_path) - .current_dir(module_path) .env("PYTHONUNBUFFERED", "1") .env("PYTHONUTF8", "1"); - spawn_runtime_command(module_id, &runtime_root, command, "Python").await + spawn_runtime_command(module_id, module_path, &runtime_root, command, "Python").await } async fn spawn_node_process( @@ -171,11 +171,10 @@ async fn spawn_node_process( let mut command = Command::new(node_executable); command .arg(entry_path) - .current_dir(module_path) .env("NODE_PATH", env_dir.join("node_modules")) .env("AXELATE_NODE_ENV_DIR", env_dir); - spawn_runtime_command(module_id, &runtime_root, command, "Node").await + spawn_runtime_command(module_id, module_path, &runtime_root, command, "Node").await } async fn spawn_bun_process( @@ -207,15 +206,15 @@ async fn spawn_bun_process( let mut command = Command::new(bun_executable); command .arg(entry_path) - .current_dir(module_path) .env("NODE_PATH", env_dir.join("node_modules")) .env("AXELATE_BUN_ENV_DIR", env_dir); - spawn_runtime_command(module_id, &runtime_root, command, "Bun").await + spawn_runtime_command(module_id, module_path, &runtime_root, command, "Bun").await } async fn spawn_runtime_command( module_id: &str, + module_path: &Path, language_runtime_root: &Path, mut command: Command, runtime_name: &str, @@ -238,7 +237,8 @@ async fn spawn_runtime_command( .map_err(|e| AppError::Io(format!("Failed to open runtime log: {e}")))?; command - .env("BOT_CONFIG_DIR", CONFIG_DIR.as_os_str()) + .current_dir(module_path) + .env("AXELATE_INTEGRATION_API_VERSION", SDK_API_VERSION) .env("AXELATE_CONFIG_DIR", CONFIG_DIR.as_os_str()) .env("AXELATE_RUNTIME_DIR", RUNTIME_DIR.as_os_str()) .env( @@ -249,12 +249,14 @@ async fn spawn_runtime_command( "AXELATE_MODULE_RUNTIME_DIR", module_runtime_root.as_os_str(), ) + .env("AXELATE_MODULE_DIR", module_path.as_os_str()) + .env("AXELATE_MODULE_LOG_DIR", module_log_dir.as_os_str()) .env("AXELATE_MODULE_ID", module_id) .stdout(Stdio::from(log_file.try_clone().map_err(|e| { AppError::Io(format!("Failed to clone runtime log file: {e}")) })?)) .stderr(Stdio::from(log_file)); - crate::domain::integration_api::apply_process_env(&mut command); + crate::domain::integration_api::apply_process_env(&mut command, module_id)?; command.spawn().map_err(|e| AppError::Internal { request_id: None, diff --git a/src-tauri/src/domain/modules/downloader.rs b/src-tauri/src/domain/modules/downloader.rs index a410e700..3265fc02 100644 --- a/src-tauri/src/domain/modules/downloader.rs +++ b/src-tauri/src/domain/modules/downloader.rs @@ -4,17 +4,23 @@ use super::downloader_progress::{ AggregateDownloadContext, DownloadInterruption, ProgressEvent, ProgressSnapshot, compute_progress, emit_progress, }; -use super::downloader_service::{DownloadRequest, resolve_existing_module_path}; +use super::downloader_service::resolve_existing_module_path; use super::downloader_support::{package_install_dir, remove_partial_metadata}; use super::downloader_transfer::{ - DownloadTask, ReleaseDownloadAsset, build_client, clone_repository_into, download_file, - resolve_download_url, + DownloadTask, ReleaseDownloadAsset, build_client, build_public_client, clone_repository_into, + download_file, resolve_download_url, }; +use super::github_releases::ReleaseDownloadSelection; +use super::lifecycle::{ManifestLoader, ModuleManifest}; use crate::errors::AppError; +use crate::utils::paths::{INTEGRATIONS_DIR, TEMP_DIR}; use std::path::{Path, PathBuf}; use tauri::AppHandle; -pub use super::downloader_service::DownloaderService; +const IMPORT_FILE_COUNT_LIMIT: usize = 20_000; +const IMPORT_TOTAL_SIZE_LIMIT: u64 = 3 * 1024 * 1024 * 1024; + +pub use super::downloader_service::{DownloadRequest, DownloaderService}; /// Validates module ID to prevent directory traversal and injection attacks pub fn validate_module_id(module_id: &str) -> Result<(), AppError> { @@ -53,6 +59,16 @@ pub fn is_module_installed(module_id: &str) -> bool { resolve_existing_module_path(module_id).is_some() } +/// Lists compatible release versions and CPU/GPU package choices for a module. +pub async fn get_release_download_options( + module_id: &str, + repo_url: &str, +) -> Result { + validate_module_id(module_id)?; + let client = build_public_client()?; + super::github_releases::fetch_release_download_options(&client, repo_url, module_id).await +} + /// Deletes a module from disk pub async fn delete_module(module_id: &str) -> Result<(), AppError> { validate_module_id(module_id)?; @@ -67,12 +83,133 @@ pub async fn delete_module(module_id: &str) -> Result<(), AppError> { "Downloader", "info", ); + crate::domain::integration_api::revoke_module_api_token(module_id); Ok(()) } else { Err(AppError::NotFound("Module not found".to_string())) } } +/// Imports an integration from an existing local folder. +pub async fn import_integration_folder(path: &Path) -> Result { + ensure_source_directory(path)?; + let manifest = ManifestLoader::load(path)?; + let module_id = validate_integration_manifest(&manifest)?; + let staging_path = ArchiveExtractor::prepare_staging(&module_id)?; + let source_path = path.to_path_buf(); + let blocking_staging_path = staging_path.clone(); + + let result = tokio::task::spawn_blocking(move || { + copy_directory_contents_secure(&source_path, &blocking_staging_path)?; + finalize_imported_integration(&blocking_staging_path, Some("local-folder")) + }) + .await + .map_err(|error| AppError::Internal { + request_id: None, + message: format!("Integration folder import worker failed: {error}"), + })?; + + cleanup_staging_on_error(&result, &staging_path); + result +} + +/// Imports an integration from a local path, auto-detecting folder or archive sources. +pub async fn import_integration_path(app: AppHandle, path: PathBuf) -> Result { + let metadata = std::fs::metadata(&path).map_err(|error| { + AppError::Io(format!( + "Failed to read integration source '{}': {error}", + path.display() + )) + })?; + + if metadata.is_dir() { + return import_integration_folder(&path).await; + } + + if metadata.is_file() { + return import_integration_archive(app, path).await; + } + + Err(AppError::Validation( + "Selected integration source must be a folder or archive file".to_string(), + )) +} + +/// Imports an integration from a local archive file. +pub async fn import_integration_archive(app: AppHandle, path: PathBuf) -> Result { + ensure_source_file(&path)?; + let import_id = build_import_id(); + let staging_path = ArchiveExtractor::prepare_staging(&import_id)?; + + let result = async { + ArchiveExtractor::extract_into(&app, &path, &import_id, &staging_path, None).await?; + finalize_imported_integration(&staging_path, Some("local-archive")) + } + .await; + + cleanup_staging_on_error(&result, &staging_path); + result +} + +/// Downloads and imports an integration from a repository or archive URL. +pub async fn import_integration_url( + app: AppHandle, + downloader: &DownloaderService, + source_url: String, +) -> Result { + let source_url = validate_import_url(&source_url)?; + let import_id = build_import_id(); + let staging_path = ArchiveExtractor::prepare_staging(&import_id)?; + let control = downloader.request_control(&import_id); + let mut archive_path = None; + + let result = async { + let client = build_public_client()?; + let final_url = resolve_download_url(&client, &source_url).await?; + let resolved_archive_path = build_import_archive_path(&import_id, &final_url); + archive_path = Some(resolved_archive_path.clone()); + let download_result = download_file( + DownloadTask { + app: &app, + downloader, + client: &client, + url: &final_url, + dest_path: &resolved_archive_path, + module_id: &import_id, + control: &control, + }, + None, + ) + .await?; + + if let Some(interruption) = download_result.interruption { + return Err(AppError::External { + request_id: None, + message: interruption.as_error_message().to_string(), + }); + } + + ArchiveExtractor::extract_into( + &app, + &resolved_archive_path, + &import_id, + &staging_path, + Some(download_result.snapshot), + ) + .await?; + + finalize_imported_integration(&staging_path, Some(&source_url)) + } + .await; + + downloader.remove_control(&import_id); + cleanup_staging_on_error(&result, &staging_path); + if let Some(archive_path) = archive_path { + cleanup_import_archive(&archive_path).await; + } + result +} + /// Downloads and extracts a module from a remote repository pub async fn download_module( app: AppHandle, @@ -81,6 +218,7 @@ pub async fn download_module( repo_url: String, expected_hash: Option, dl_type: Option, + release_selection: Option, ) -> Result { validate_module_id(&module_id)?; downloader.remember_request( @@ -89,6 +227,7 @@ pub async fn download_module( repo_url: repo_url.clone(), expected_hash: expected_hash.clone(), dl_type: dl_type.clone(), + release_selection: release_selection.clone(), }, ); @@ -110,7 +249,11 @@ pub async fn download_module( speed: 0, }); - let client = build_client(&module_id)?; + let client = if dl_type.as_deref() == Some("release") && is_github_repo_url(&repo_url) { + build_public_client()? + } else { + build_client(&module_id)? + }; let extraction_path = ArchiveExtractor::prepare_staging(&module_id)?; staging_path = Some(extraction_path.clone()); let mut completed_downloaded_bytes: u64 = 0; @@ -122,7 +265,10 @@ pub async fn download_module( (None, Vec::new()) } else if dl_type.as_deref() == Some("release") { let bundle = crate::domain::modules::github_releases::fetch_release_bundle( - &client, &repo_url, &module_id, + &client, + &repo_url, + &module_id, + release_selection.as_ref(), ) .await?; @@ -199,7 +345,7 @@ pub async fn download_module( }); } - ensure_not_cancelled(&control)?; + ensure_not_interrupted(&control)?; FileVerifier::verify( &app, &archive_path, @@ -209,7 +355,7 @@ pub async fn download_module( ) .await?; - ensure_not_cancelled(&control)?; + ensure_not_interrupted(&control)?; ArchiveExtractor::extract_into( &app, &archive_path, @@ -219,18 +365,23 @@ pub async fn download_module( ) .await?; - ensure_not_cancelled(&control)?; + ensure_not_interrupted(&control)?; } } final_progress_snapshot = latest_progress_snapshot; - ensure_not_cancelled(&control)?; + ensure_not_interrupted(&control)?; + let release_compute_target = release_selection + .as_ref() + .map(|selection| selection.compute_target.as_metadata_value()); + ArchiveExtractor::finalize( &module_id, &extraction_path, expected_hash.as_ref(), release_tag.as_deref(), + release_compute_target, )?; Ok::<(), AppError>(()) @@ -250,9 +401,23 @@ pub async fn download_module( if cleanup_archives { for archive_path in &temp_archives { if archive_path.exists() { - let _ = tokio::fs::remove_file(archive_path).await; + if let Err(error) = tokio::fs::remove_file(archive_path).await + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + module_id = module_id, + path = %archive_path.display(), + "Failed to remove temporary archive after interrupted download: {error}" + ); + } + } + if let Err(error) = remove_partial_metadata(archive_path).await { + tracing::warn!( + module_id = module_id, + path = %archive_path.display(), + "Failed to remove partial download metadata after interrupted download: {error}" + ); } - remove_partial_metadata(archive_path).await; } } @@ -260,7 +425,13 @@ pub async fn download_module( && let Some(path) = &staging_path && path.exists() { - let _ = tokio::fs::remove_dir_all(path).await; + if let Err(error) = tokio::fs::remove_dir_all(path).await { + tracing::warn!( + module_id = module_id, + path = %path.display(), + "Failed to remove staging directory after failed install: {error}" + ); + } } if let Err(e) = result { @@ -304,7 +475,13 @@ pub async fn download_module( }); for archive_path in &temp_archives { - remove_partial_metadata(archive_path).await; + if let Err(error) = remove_partial_metadata(archive_path).await { + tracing::warn!( + module_id = module_id, + path = %archive_path.display(), + "Failed to remove partial download metadata after successful install: {error}" + ); + } } crate::infrastructure::logging::logger::add_log( @@ -317,7 +494,308 @@ pub async fn download_module( Ok("completed".to_string()) } -fn ensure_not_cancelled( +fn validate_integration_manifest(manifest: &ModuleManifest) -> Result { + validate_module_id(&manifest.id)?; + + if let Some(category) = manifest.category.as_deref() { + let normalized = category.trim().to_ascii_lowercase(); + if !matches!( + normalized.as_str(), + "service" | "services" | "integration" | "integrations" + ) { + return Err(AppError::Validation( + "Custom integration manifest must use category = \"service\"".to_string(), + )); + } + } + + Ok(manifest.id.clone()) +} + +fn finalize_imported_integration( + extraction_path: &Path, + source: Option<&str>, +) -> Result { + let manifest = ManifestLoader::load(extraction_path)?; + let module_id = validate_integration_manifest(&manifest)?; + let final_path = INTEGRATIONS_DIR.join(&module_id); + + std::fs::create_dir_all(&*INTEGRATIONS_DIR).map_err(|error| { + AppError::Io(format!( + "Failed to create integrations directory '{}': {error}", + INTEGRATIONS_DIR.display() + )) + })?; + + let metadata = serde_json::json!({ + "module_id": module_id, + "installed_at": chrono::Local::now().to_rfc3339(), + "archive_hash": null, + "status": "complete", + "version": manifest.version, + "source": source, + }); + let metadata_path = extraction_path.join("metadata.json"); + let metadata_file = std::fs::File::create(&metadata_path).map_err(|error| { + AppError::Io(format!( + "Failed to create install metadata {}: {error}", + metadata_path.display() + )) + })?; + serde_json::to_writer_pretty(metadata_file, &metadata).map_err(|error| { + AppError::Serialization(format!( + "Failed to write install metadata {}: {error}", + metadata_path.display() + )) + })?; + + let backup_path = TEMP_DIR.join(format!( + "{}_integration_backup_{}", + module_id, + uuid::Uuid::new_v4().simple() + )); + + if final_path.exists() { + std::fs::rename(&final_path, &backup_path).map_err(|error| { + AppError::Io(format!( + "Failed to move old integration version to backup: {error}" + )) + })?; + } + + if let Err(error) = std::fs::rename(extraction_path, &final_path) { + if backup_path.exists() + && let Err(restore_error) = std::fs::rename(&backup_path, &final_path) + { + tracing::error!( + module_id, + backup = %backup_path.display(), + target = %final_path.display(), + "Failed to restore previous integration after import failure: {restore_error}" + ); + } + + return Err(AppError::Io(format!( + "Atomic integration import failed during move: {error}" + ))); + } + + if backup_path.exists() { + std::fs::remove_dir_all(&backup_path).map_err(|error| { + AppError::Io(format!("Failed to remove old integration backup: {error}")) + })?; + } + + crate::infrastructure::logging::logger::add_log( + &format!("Integration {module_id} imported successfully"), + "Downloader", + "info", + ); + + Ok(module_id) +} + +fn copy_directory_contents_secure(source: &Path, destination: &Path) -> Result<(), AppError> { + let mut state = ImportCopyState::default(); + copy_directory_contents_secure_inner(source, destination, &mut state) +} + +#[derive(Default)] +struct ImportCopyState { + file_count: usize, + total_size: u64, +} + +fn copy_directory_contents_secure_inner( + source: &Path, + destination: &Path, + state: &mut ImportCopyState, +) -> Result<(), AppError> { + for entry in std::fs::read_dir(source)? { + let entry = entry?; + let source_path = entry.path(); + let metadata = std::fs::symlink_metadata(&source_path)?; + if metadata.file_type().is_symlink() { + return Err(AppError::Validation(format!( + "Symlinks are not supported in integration imports: {}", + source_path.display() + ))); + } + + let destination_path = destination.join(entry.file_name()); + if metadata.is_dir() { + std::fs::create_dir_all(&destination_path)?; + copy_directory_contents_secure_inner(&source_path, &destination_path, state)?; + continue; + } + + if !metadata.is_file() { + return Err(AppError::Validation(format!( + "Unsupported filesystem entry in integration import: {}", + source_path.display() + ))); + } + + state.file_count += 1; + if state.file_count > IMPORT_FILE_COUNT_LIMIT { + return Err(AppError::Validation(format!( + "Integration folder contains too many files. Limit is {IMPORT_FILE_COUNT_LIMIT}." + ))); + } + + state.total_size = state + .total_size + .checked_add(metadata.len()) + .ok_or_else(|| AppError::Validation("Integration folder size overflow".to_string()))?; + if state.total_size > IMPORT_TOTAL_SIZE_LIMIT { + return Err(AppError::Validation( + "Integration folder is too large to import".to_string(), + )); + } + + if let Some(parent) = destination_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(&source_path, &destination_path).map_err(|error| { + AppError::Io(format!( + "Failed to copy '{}' to '{}': {error}", + source_path.display(), + destination_path.display() + )) + })?; + } + + Ok(()) +} + +fn ensure_source_directory(path: &Path) -> Result<(), AppError> { + let metadata = std::fs::metadata(path).map_err(|error| { + AppError::Io(format!( + "Failed to read integration folder '{}': {error}", + path.display() + )) + })?; + if !metadata.is_dir() { + return Err(AppError::Validation( + "Selected integration source is not a folder".to_string(), + )); + } + + Ok(()) +} + +fn ensure_source_file(path: &Path) -> Result<(), AppError> { + let metadata = std::fs::metadata(path).map_err(|error| { + AppError::Io(format!( + "Failed to read integration archive '{}': {error}", + path.display() + )) + })?; + if !metadata.is_file() { + return Err(AppError::Validation( + "Selected integration source is not an archive file".to_string(), + )); + } + + Ok(()) +} + +fn validate_import_url(source_url: &str) -> Result { + let source_url = source_url.trim(); + if source_url.is_empty() { + return Err(AppError::Validation( + "Integration URL cannot be empty".to_string(), + )); + } + + let url = reqwest::Url::parse(source_url) + .map_err(|error| AppError::Validation(format!("Integration URL is invalid: {error}")))?; + match url.scheme() { + "https" => {} + "http" if is_local_import_host(url.host_str()) => {} + "http" => { + return Err(AppError::Validation( + "Integration URL must use https:// unless it targets localhost development" + .to_string(), + )); + } + _ => { + return Err(AppError::Validation( + "Integration URL must start with https://".to_string(), + )); + } + } + + Ok(source_url.to_string()) +} + +fn is_local_import_host(host: Option<&str>) -> bool { + matches!(host, Some("localhost" | "127.0.0.1" | "::1")) +} + +fn build_import_id() -> String { + format!("integration-import-{}", uuid::Uuid::new_v4().simple()) +} + +fn build_import_archive_path(import_id: &str, source_url: &str) -> PathBuf { + let normalized = source_url + .split(['?', '#']) + .next() + .unwrap_or(source_url) + .to_ascii_lowercase(); + let extension = if normalized.ends_with(".tar.gz") { + "tar.gz" + } else { + match Path::new(&normalized) + .extension() + .and_then(|extension| extension.to_str()) + { + Some(extension) if extension.eq_ignore_ascii_case("tgz") => "tgz", + Some(extension) if extension.eq_ignore_ascii_case("7z") => "7z", + _ => "zip", + } + }; + + TEMP_DIR.join(format!("{import_id}.{extension}")) +} + +fn cleanup_staging_on_error(result: &Result, staging_path: &Path) { + if result.is_ok() || !staging_path.exists() { + return; + } + + if let Err(error) = std::fs::remove_dir_all(staging_path) { + tracing::warn!( + path = %staging_path.display(), + "Failed to clean integration import staging directory: {error}" + ); + } +} + +async fn cleanup_import_archive(archive_path: &Path) { + if let Err(error) = remove_partial_metadata(archive_path).await { + tracing::warn!( + path = %archive_path.display(), + "Failed to remove integration import partial metadata: {error}" + ); + } + match tokio::fs::remove_file(archive_path).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + tracing::warn!( + path = %archive_path.display(), + "Failed to remove temporary integration archive: {error}" + ); + } + } +} + +fn is_github_repo_url(repo_url: &str) -> bool { + repo_url.trim().to_ascii_lowercase().contains("github.com/") +} + +fn ensure_not_interrupted( control: &super::downloader_service::DownloadControl, ) -> Result<(), AppError> { if control.is_cancel_requested() { @@ -329,6 +807,13 @@ fn ensure_not_cancelled( }); } + if control.is_pause_requested() { + return Err(AppError::External { + request_id: None, + message: DownloadInterruption::Paused.as_error_message().to_string(), + }); + } + Ok(()) } @@ -340,13 +825,53 @@ pub fn check_module_installed(module_id: &str) -> bool { #[cfg(test)] #[allow(clippy::expect_used)] mod tests { + use super::ensure_not_interrupted; + use crate::domain::modules::downloader_service::DownloaderService; use crate::domain::modules::downloader_support::{ PartialDownloadMetadata, TarEntryAction, classify_tar_entry_type, if_range_validator, - normalize_archive_relative_path, parse_content_range_total, + load_partial_metadata, normalize_archive_relative_path, parse_content_range_total, + store_partial_metadata, }; + use crate::errors::AppError; use sevenz_rust2::{ArchiveReader, Password}; use std::path::{Path, PathBuf}; + #[test] + fn ensure_not_interrupted_reports_pause_requests() { + let service = DownloaderService::new(); + let control = service.request_control("demo"); + assert!(service.pause("demo")); + + let error = ensure_not_interrupted(&control).expect_err("pause should interrupt"); + + assert!(error.to_string().contains("Download paused")); + } + + #[test] + fn ensure_not_interrupted_reports_cancel_requests() { + let service = DownloaderService::new(); + let control = service.request_control("demo"); + assert!(service.cancel("demo")); + + let error = ensure_not_interrupted(&control).expect_err("cancel should interrupt"); + + assert!(error.to_string().contains("Download cancelled")); + } + + #[test] + fn validate_import_url_rejects_plain_http_except_localhost() { + assert!( + super::validate_import_url("https://github.com/F0RLE/demo/archive/main.zip").is_ok() + ); + assert!(super::validate_import_url("http://localhost:4000/integration.zip").is_ok()); + assert!(super::validate_import_url("http://127.0.0.1:4000/integration.zip").is_ok()); + + let error = super::validate_import_url("http://example.com/integration.zip") + .expect_err("plain remote http should be rejected"); + + assert!(matches!(error, AppError::Validation(_))); + } + #[test] fn normalize_archive_relative_path_rejects_traversal() { let error = normalize_archive_relative_path(Path::new("../escape/file.txt")) @@ -421,6 +946,48 @@ mod tests { assert_eq!(if_range_validator(&metadata), Some("\"etag-value\"")); } + #[tokio::test] + async fn partial_metadata_load_reports_corrupt_json() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let archive_path = temp_dir.path().join("module.zip"); + tokio::fs::write( + format!("{}.resume.json", archive_path.to_string_lossy()), + "{not-json", + ) + .await + .expect("write corrupt metadata"); + + let error = load_partial_metadata(&archive_path) + .await + .expect_err("corrupt metadata must be reported"); + + assert!(error.to_string().contains("partial download metadata")); + } + + #[tokio::test] + async fn partial_metadata_round_trips_valid_json() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let archive_path = temp_dir.path().join("module.zip"); + let metadata = PartialDownloadMetadata { + url: "https://example.com/module.zip".to_string(), + etag: Some("\"etag-value\"".to_string()), + last_modified: None, + total_bytes: Some(42), + }; + + store_partial_metadata(&archive_path, &metadata) + .await + .expect("store metadata"); + let loaded = load_partial_metadata(&archive_path) + .await + .expect("load metadata") + .expect("metadata exists"); + + assert_eq!(loaded.url, metadata.url); + assert_eq!(loaded.etag, metadata.etag); + assert_eq!(loaded.total_bytes, metadata.total_bytes); + } + #[test] #[ignore = "manual local archive debug helper; requires AXELATE_DEBUG_7Z"] fn debug_extract_local_comfyui_archive() { diff --git a/src-tauri/src/domain/modules/downloader_install.rs b/src-tauri/src/domain/modules/downloader_install.rs index cd9dc412..3ebb2729 100644 --- a/src-tauri/src/domain/modules/downloader_install.rs +++ b/src-tauri/src/domain/modules/downloader_install.rs @@ -223,9 +223,19 @@ impl ArchiveExtractor { let extraction_path = TEMP_DIR.join(extraction_id); if extraction_path.exists() { - fs::remove_dir_all(&extraction_path).ok(); + fs::remove_dir_all(&extraction_path).map_err(|error| { + AppError::Io(format!( + "Failed to remove stale extraction directory '{}': {error}", + extraction_path.display() + )) + })?; } - fs::create_dir_all(&extraction_path).map_err(|e| AppError::Io(e.to_string()))?; + fs::create_dir_all(&extraction_path).map_err(|error| { + AppError::Io(format!( + "Failed to create extraction directory '{}': {error}", + extraction_path.display() + )) + })?; Ok(extraction_path) } @@ -577,7 +587,12 @@ impl ArchiveExtractor { } if file.name().ends_with('/') { - fs::create_dir_all(&outpath).ok(); + fs::create_dir_all(&outpath).map_err(|error| { + format!( + "Failed to create extraction directory {}: {error}", + outpath.display() + ) + })?; } else { Self::extract_zip_file_entry( &mut file, @@ -642,7 +657,12 @@ impl ArchiveExtractor { if let Some(parent) = outpath.parent() && !parent.exists() { - fs::create_dir_all(parent).ok(); + fs::create_dir_all(parent).map_err(|error| { + format!( + "Failed to create extraction directory {}: {error}", + parent.display() + ) + })?; } let u_size = file.size(); @@ -682,6 +702,7 @@ impl ArchiveExtractor { extraction_path: &Path, expected_hash: Option<&String>, release_tag: Option<&str>, + release_compute_target: Option<&str>, ) -> Result<(), AppError> { let final_path = package_install_dir(module_id); @@ -694,11 +715,21 @@ impl ArchiveExtractor { "archive_hash": expected_hash.cloned(), "status": "complete", "version": release_tag.unwrap_or("unknown"), + "compute_target": release_compute_target, }); let manifest_path = extraction_path.join("metadata.json"); - if let Ok(manifest_file) = fs::File::create(manifest_path) { - let _ = serde_json::to_writer_pretty(manifest_file, &manifest); - } + let manifest_file = fs::File::create(&manifest_path).map_err(|error| { + AppError::Io(format!( + "Failed to create install metadata {}: {error}", + manifest_path.display() + )) + })?; + serde_json::to_writer_pretty(manifest_file, &manifest).map_err(|error| { + AppError::Serialization(format!( + "Failed to write install metadata {}: {error}", + manifest_path.display() + )) + })?; let backup_path = TEMP_DIR.join(format!("{module_id}_backup_{}", uuid::Uuid::new_v4())); @@ -710,7 +741,14 @@ impl ArchiveExtractor { if let Err(e) = fs::rename(extraction_path, &final_path) { if backup_path.exists() { - let _ = fs::rename(&backup_path, &final_path); + if let Err(restore_error) = fs::rename(&backup_path, &final_path) { + tracing::error!( + module_id, + backup = %backup_path.display(), + target = %final_path.display(), + "Failed to restore previous module version after install failure: {restore_error}" + ); + } } return Err(AppError::Io(format!( diff --git a/src-tauri/src/domain/modules/downloader_progress.rs b/src-tauri/src/domain/modules/downloader_progress.rs index 15a03aff..fc09b9bc 100644 --- a/src-tauri/src/domain/modules/downloader_progress.rs +++ b/src-tauri/src/domain/modules/downloader_progress.rs @@ -184,7 +184,7 @@ pub fn emit_extraction_progress( } pub fn emit_progress(event: ProgressEvent<'_>) { - let _ = event.app.emit( + if let Err(error) = event.app.emit( "download_progress", DownloadProgress { module_id: event.module_id.to_string(), @@ -195,5 +195,11 @@ pub fn emit_progress(event: ProgressEvent<'_>) { total: event.total, speed: event.speed, }, - ); + ) { + tracing::warn!( + module_id = event.module_id, + status = event.status, + "Failed to emit download progress: {error}" + ); + } } diff --git a/src-tauri/src/domain/modules/downloader_service.rs b/src-tauri/src/domain/modules/downloader_service.rs index c1e2e2c9..72a5893a 100644 --- a/src-tauri/src/domain/modules/downloader_service.rs +++ b/src-tauri/src/domain/modules/downloader_service.rs @@ -36,11 +36,18 @@ pub struct DownloaderService { requests: Arc>>, } +/// Backend-owned request metadata retained so interrupted downloads can resume without frontend +/// resending stale package details. #[derive(Clone, Debug)] pub struct DownloadRequest { + /// Source repository or package URL. pub repo_url: String, + /// Optional expected content hash for verification. pub expected_hash: Option, + /// Optional download type hint, for example a release bundle. pub dl_type: Option, + /// Optional explicit GitHub release and compute-target selection. + pub release_selection: Option, } #[derive(Clone, Copy, Debug)] @@ -240,6 +247,7 @@ mod tests { repo_url: "https://example.com/file.zip".to_string(), expected_hash: Some("hash".to_string()), dl_type: Some("release".to_string()), + release_selection: None, }, ); diff --git a/src-tauri/src/domain/modules/downloader_support.rs b/src-tauri/src/domain/modules/downloader_support.rs index d701be28..a33961b9 100644 --- a/src-tauri/src/domain/modules/downloader_support.rs +++ b/src-tauri/src/domain/modules/downloader_support.rs @@ -6,6 +6,7 @@ const MAX_ARCHIVE_TOTAL_UNCOMPRESSED_SIZE: u64 = 3 * 1024 * 1024 * 1024; const MAX_ARCHIVE_TOTAL_UNCOMPRESSED_SIZE_LARGE_MODULE: u64 = 12 * 1024 * 1024 * 1024; const MAX_ARCHIVE_FILE_COUNT: usize = 10000; const MAX_ARCHIVE_FILE_COUNT_LARGE_MODULE: usize = 100_000; +const LARGE_ARCHIVE_MODULE_IDS: [&str; 1] = ["comfyui"]; pub(super) fn is_engine_package(package_id: &str) -> bool { serde_json::from_str::>(include_str!( @@ -101,10 +102,23 @@ fn partial_metadata_path(dest_path: &Path) -> PathBuf { PathBuf::from(format!("{}.resume.json", dest_path.to_string_lossy())) } -pub(super) async fn load_partial_metadata(dest_path: &Path) -> Option { +pub(super) async fn load_partial_metadata( + dest_path: &Path, +) -> Result, AppError> { let metadata_path = partial_metadata_path(dest_path); - let raw = tokio::fs::read_to_string(metadata_path).await.ok()?; - serde_json::from_str(&raw).ok() + match tokio::fs::read_to_string(&metadata_path).await { + Ok(raw) => serde_json::from_str(&raw).map(Some).map_err(|error| { + AppError::Serialization(format!( + "Failed to parse partial download metadata '{}': {error}", + metadata_path.display() + )) + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(AppError::Io(format!( + "Failed to read partial download metadata '{}': {error}", + metadata_path.display() + ))), + } } pub(super) async fn store_partial_metadata( @@ -121,10 +135,15 @@ pub(super) async fn store_partial_metadata( .map_err(|e| AppError::Io(e.to_string())) } -pub(super) async fn remove_partial_metadata(dest_path: &Path) { +pub(super) async fn remove_partial_metadata(dest_path: &Path) -> Result<(), AppError> { let metadata_path = partial_metadata_path(dest_path); - if tokio::fs::try_exists(&metadata_path).await.unwrap_or(false) { - let _ = tokio::fs::remove_file(metadata_path).await; + match tokio::fs::remove_file(&metadata_path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(AppError::Io(format!( + "Failed to remove partial download metadata '{}': {error}", + metadata_path.display() + ))), } } @@ -210,7 +229,7 @@ pub(super) fn strip_archive_root(path: &Path, root_to_skip: Option<&str>) -> Pat } pub(super) fn archive_file_count_limit(module_id: &str) -> usize { - if module_id == "comfyui" { + if uses_large_archive_limits(module_id) { return MAX_ARCHIVE_FILE_COUNT_LARGE_MODULE; } @@ -218,9 +237,13 @@ pub(super) fn archive_file_count_limit(module_id: &str) -> usize { } pub(super) fn archive_total_uncompressed_size_limit(module_id: &str) -> u64 { - if module_id == "comfyui" { + if uses_large_archive_limits(module_id) { return MAX_ARCHIVE_TOTAL_UNCOMPRESSED_SIZE_LARGE_MODULE; } MAX_ARCHIVE_TOTAL_UNCOMPRESSED_SIZE } + +fn uses_large_archive_limits(module_id: &str) -> bool { + LARGE_ARCHIVE_MODULE_IDS.contains(&module_id) +} diff --git a/src-tauri/src/domain/modules/downloader_transfer.rs b/src-tauri/src/domain/modules/downloader_transfer.rs index 20be0aee..be3e6c48 100644 --- a/src-tauri/src/domain/modules/downloader_transfer.rs +++ b/src-tauri/src/domain/modules/downloader_transfer.rs @@ -23,11 +23,7 @@ pub(super) async fn resolve_download_url( client: &reqwest::Client, download_url: &str, ) -> Result { - if download_url.contains("github.com") - && !Path::new(download_url) - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("zip")) - { + if is_github_repository_reference(download_url) { let base_url = download_url.trim_end_matches(".git").trim_end_matches('/'); let main_url = format!("{base_url}/archive/refs/heads/main.zip"); let master_url = format!("{base_url}/archive/refs/heads/master.zip"); @@ -46,31 +42,56 @@ pub(super) async fn resolve_download_url( return Ok(main_url); } - if response.status() == reqwest::StatusCode::NOT_FOUND { - tracing::info!("main branch not found, trying master: {master_url}"); - let response_master = - client - .get(&master_url) - .send() - .await - .map_err(|error| AppError::External { - request_id: None, - message: format!("Failed to connect: {error}"), - })?; - - if response_master.status().is_success() { - return Ok(master_url); - } + let main_status = response.status(); + tracing::info!("main branch probe returned {main_status}, trying master: {master_url}"); + let response_master = + client + .get(&master_url) + .send() + .await + .map_err(|error| AppError::External { + request_id: None, + message: format!("Failed to connect: {error}"), + })?; + + if response_master.status().is_success() { + return Ok(master_url); } - return Err(AppError::NotFound(format!( - "Module source not found. Tried both 'main' and 'master' branches at {base_url}" - ))); + let master_status = response_master.status(); + return Err(AppError::External { + request_id: None, + message: format!( + "Module source probes failed at {base_url}: main.zip returned {main_status}, master.zip returned {master_status}" + ), + }); } Ok(download_url.to_string()) } +fn is_github_repository_reference(download_url: &str) -> bool { + let Ok(url) = reqwest::Url::parse(download_url.trim()) else { + return false; + }; + if !matches!(url.host_str(), Some("github.com" | "www.github.com")) { + return false; + } + + let Some(segments) = url.path_segments() else { + return false; + }; + let segments = segments + .filter(|segment| !segment.is_empty()) + .collect::>(); + let [owner, repo] = segments.as_slice() else { + return false; + }; + + let repo = repo.trim_end_matches(".git"); + !owner.is_empty() && !repo.is_empty() +} + pub(super) async fn clone_repository_into( app: &AppHandle, module_id: &str, @@ -130,38 +151,43 @@ pub(super) async fn clone_repository_into( }); let git_dir = extraction_path.join(".git"); - if git_dir.exists() { - let _ = tokio::fs::remove_dir_all(git_dir).await; + match tokio::fs::remove_dir_all(&git_dir).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(AppError::Io(format!( + "Failed to remove git metadata directory '{}': {error}", + git_dir.display() + ))); + } } Ok(()) } pub(super) fn build_client(module_id: &str) -> Result { - let mut client_builder = reqwest::Client::builder() - .user_agent("Axelate/1.0.0 (Tauri; Windows)") - .timeout(std::time::Duration::from_secs(600)); - - if let Some(license) = crate::domain::license::storage::load_license() - && !license.key.is_empty() - { - tracing::info!("Injecting license key for module download: {module_id}"); - let mut headers = reqwest::header::HeaderMap::new(); - if let Ok(auth_val) = - reqwest::header::HeaderValue::from_str(&format!("Bearer {}", license.key)) - { - headers.insert(reqwest::header::AUTHORIZATION, auth_val); - } - if let Ok(lic_val) = reqwest::header::HeaderValue::from_str(&license.key) { - headers.insert("X-Axelate-License", lic_val); - } - client_builder = client_builder.default_headers(headers); - } + tracing::debug!(module_id = module_id, "Building module download client"); + construct_client_builder() + .build() + .map_err(|error| AppError::External { + request_id: None, + message: format!("Client error: {error}"), + }) +} - client_builder.build().map_err(|error| AppError::External { - request_id: None, - message: format!("Client error: {error}"), - }) +fn construct_client_builder() -> reqwest::ClientBuilder { + reqwest::Client::builder() + .user_agent(format!("Axelate/1.0.0 (Tauri; {})", std::env::consts::OS)) + .timeout(std::time::Duration::from_mins(10)) +} + +pub(super) fn build_public_client() -> Result { + construct_client_builder() + .build() + .map_err(|error| AppError::External { + request_id: None, + message: format!("Client error: {error}"), + }) } pub(super) struct DownloadTask<'a> { @@ -185,14 +211,42 @@ pub(super) async fn download_file( }; fs::create_dir_all(&*TEMP_DIR).map_err(|error| AppError::Io(error.to_string()))?; - let resume_metadata = load_partial_metadata(task.dest_path) - .await - .filter(|metadata| metadata.url == task.url); - let existing_bytes = tokio::fs::metadata(task.dest_path) + let loaded_resume_metadata = match load_partial_metadata(task.dest_path).await { + Ok(metadata) => metadata, + Err(AppError::Serialization(error)) => { + tracing::warn!( + module_id = task.module_id, + path = %task.dest_path.display(), + "Ignoring corrupt partial download metadata: {error}" + ); + remove_partial_metadata(task.dest_path).await?; + None + } + Err(error) => return Err(error), + }; + let resume_metadata = loaded_resume_metadata.filter(|metadata| metadata.url == task.url); + let mut existing_bytes = tokio::fs::metadata(task.dest_path) .await .ok() .filter(std::fs::Metadata::is_file) .map_or(0, |metadata| metadata.len()); + if existing_bytes > 0 && resume_metadata.is_none() { + tracing::warn!( + module_id = task.module_id, + path = %task.dest_path.display(), + "Discarding partial download without matching resume metadata" + ); + remove_partial_metadata(task.dest_path).await?; + if let Err(error) = tokio::fs::remove_file(task.dest_path).await + && error.kind() != std::io::ErrorKind::NotFound + { + return Err(AppError::Io(format!( + "Failed to remove stale partial download '{}': {error}", + task.dest_path.display() + ))); + } + existing_bytes = 0; + } let mut request = task.client.get(task.url); if existing_bytes > 0 { @@ -220,7 +274,7 @@ pub(super) async fn download_file( }); } - remove_partial_metadata(task.dest_path).await; + remove_partial_metadata(task.dest_path).await?; response = task .client .get(task.url) @@ -343,6 +397,9 @@ pub(super) async fn download_file( file.flush() .await .map_err(|error| AppError::Io(error.to_string()))?; + file.sync_all() + .await + .map_err(|error| AppError::Io(error.to_string()))?; Ok(DownloadResult { asset_downloaded: bytes_downloaded, @@ -350,3 +407,31 @@ pub(super) async fn download_file( interruption: None, }) } + +#[cfg(test)] +mod tests { + use super::is_github_repository_reference; + + #[test] + fn github_repository_reference_accepts_repo_roots_only() { + assert!(is_github_repository_reference( + "https://github.com/F0RLE/Axelate-telegram-parser" + )); + assert!(is_github_repository_reference( + "https://github.com/F0RLE/Axelate-telegram-parser.git" + )); + } + + #[test] + fn github_repository_reference_rejects_direct_assets_and_archive_urls() { + for url in [ + "https://github.com/F0RLE/Axelate-telegram-parser/archive/refs/heads/main.zip", + "https://github.com/F0RLE/Axelate-telegram-parser/releases/download/v1/parser.7z", + "https://github.com/F0RLE/Axelate-telegram-parser/releases/download/v1/parser.tar.gz", + "https://github.com/F0RLE/Axelate-telegram-parser/raw/main/parser.zip", + "https://example.com/F0RLE/Axelate-telegram-parser", + ] { + assert!(!is_github_repository_reference(url), "{url}"); + } + } +} diff --git a/src-tauri/src/domain/modules/github_release_selection.rs b/src-tauri/src/domain/modules/github_release_selection.rs index 1011ed6f..c9f122f9 100644 --- a/src-tauri/src/domain/modules/github_release_selection.rs +++ b/src-tauri/src/domain/modules/github_release_selection.rs @@ -6,6 +6,66 @@ use super::github_releases::{ Asset, HardwareProfile, Platform, PlatformArch, PlatformOs, ReleaseAsset, }; +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +enum CudaTrack { + Cuda12, + Cuda13, +} + +#[derive(Clone, Copy)] +struct ModuleReleaseNaming { + runtime_prefix: &'static str, + main_prefix: &'static str, + main_requires_bin_marker: bool, +} + +const DEFAULT_RELEASE_NAMING: ModuleReleaseNaming = ModuleReleaseNaming { + runtime_prefix: "cudart-", + main_prefix: "", + main_requires_bin_marker: false, +}; + +const SDCPP_RELEASE_NAMING: ModuleReleaseNaming = ModuleReleaseNaming { + runtime_prefix: "cudart-sd-", + main_prefix: "sd-", + main_requires_bin_marker: true, +}; + +const LLAMACPP_RELEASE_NAMING: ModuleReleaseNaming = ModuleReleaseNaming { + runtime_prefix: "cudart-llama-", + main_prefix: "llama-", + main_requires_bin_marker: true, +}; + +const COMFYUI_RELEASE_NAMING: ModuleReleaseNaming = ModuleReleaseNaming { + runtime_prefix: "cudart-", + main_prefix: "comfyui_windows_portable_", + main_requires_bin_marker: false, +}; + +impl CudaTrack { + const fn min_driver_major(self) -> u32 { + match self { + Self::Cuda12 => 525, + Self::Cuda13 => 580, + } + } + + const fn base_score(self) -> i32 { + match self { + Self::Cuda12 => 450, + Self::Cuda13 => 500, + } + } + + const fn runtime_score(self) -> i32 { + match self { + Self::Cuda12 => 180, + Self::Cuda13 => 200, + } + } +} + pub(super) async fn detect_hardware_profile() -> HardwareProfile { let probe = probe_gpu_info().await; HardwareProfile::from_probe(&probe) @@ -32,44 +92,53 @@ pub(super) fn select_release_assets( && platform.os == PlatformOs::Windows && hardware.accelerator == AcceleratorClass::NvidiaCuda { - let has_cuda_main = main_candidates.iter().copied().any(|idx| { - assets - .get(idx) - .and_then(|asset| detect_cuda_track(&asset.name)) - .is_some() - }); - - for main_idx in &main_candidates { - let main = assets.get(*main_idx)?; + let supported_cuda_candidates = main_candidates + .iter() + .copied() + .filter(|idx| { + assets + .get(*idx) + .and_then(|asset| detect_cuda_track(&asset.name)) + .is_some_and(|track| hardware.supports_cuda_track(track)) + }) + .collect::>(); + for main_idx in &supported_cuda_candidates { + let Some(main) = assets.get(*main_idx) else { + continue; + }; if let Some(cuda_track) = detect_cuda_track(&main.name) && let Some(runtime_idx) = runtime_candidates.iter().copied().find(|idx| { assets .get(*idx) .is_some_and(|asset| detect_cuda_track(&asset.name) == Some(cuda_track)) }) + && let (Some(runtime_asset), Some(main_asset)) = ( + assets.get(runtime_idx).and_then(asset_to_release_asset), + asset_to_release_asset(main), + ) { - return Some(vec![ - asset_to_release_asset(assets.get(runtime_idx)?)?, - asset_to_release_asset(main)?, - ]); + return Some(vec![runtime_asset, main_asset]); } + } - if detect_cuda_track(&main.name).is_some() { + for main_idx in &main_candidates { + let Some(main) = assets.get(*main_idx) else { continue; - } - - if !has_cuda_main { - return Some(vec![asset_to_release_asset(main)?]); + }; + if detect_cuda_track(&main.name).is_none() + && let Some(asset) = asset_to_release_asset(main) + { + return Some(vec![asset]); } } - if has_cuda_main { - return None; - } + return None; } - let selected_main = main_candidates.first().copied()?; - Some(vec![asset_to_release_asset(assets.get(selected_main)?)?]) + main_candidates + .iter() + .filter_map(|idx| assets.get(*idx)) + .find_map(|asset| asset_to_release_asset(asset).map(|asset| vec![asset])) } fn runtime_assets(module_id: &str, platform: Platform, assets: &[Asset]) -> Vec { @@ -119,7 +188,10 @@ fn main_assets( fn parse_sha256_digest(digest: Option<&str>) -> Option { let value = digest?.trim(); - let hash = value.strip_prefix("sha256:")?; + let (algorithm, hash) = value.split_once(':')?; + if !algorithm.eq_ignore_ascii_case("sha256") { + return None; + } if hash.len() != 64 || !hash.chars().all(|ch| ch.is_ascii_hexdigit()) { return None; } @@ -142,11 +214,7 @@ fn is_runtime_asset(module_id: &str, name: &str) -> bool { return false; } - match module_id { - "sdcpp" => lower.starts_with("cudart-sd-"), - "llamacpp" => lower.starts_with("cudart-llama-"), - _ => lower.starts_with("cudart-"), - } + lower.starts_with(release_naming(module_id).runtime_prefix) } fn is_main_asset(module_id: &str, name: &str) -> bool { @@ -155,11 +223,21 @@ fn is_main_asset(module_id: &str, name: &str) -> bool { return false; } + let naming = release_naming(module_id); + if naming.main_prefix.is_empty() { + return !lower.starts_with(naming.runtime_prefix); + } + + lower.starts_with(naming.main_prefix) + && (!naming.main_requires_bin_marker || lower.contains("-bin-")) +} + +fn release_naming(module_id: &str) -> ModuleReleaseNaming { match module_id { - "sdcpp" => lower.starts_with("sd-") && lower.contains("-bin-"), - "llamacpp" => lower.starts_with("llama-") && lower.contains("-bin-"), - "comfyui" => lower.starts_with("comfyui_windows_portable_"), - _ => !lower.starts_with("cudart-"), + "sdcpp" => SDCPP_RELEASE_NAMING, + "llamacpp" => LLAMACPP_RELEASE_NAMING, + "comfyui" => COMFYUI_RELEASE_NAMING, + _ => DEFAULT_RELEASE_NAMING, } } @@ -182,13 +260,21 @@ fn platform_matches(module_id: &str, platform: Platform, name: &str) -> bool { fn os_matches(os: PlatformOs, lower_name: &str) -> bool { match os { - PlatformOs::Windows => lower_name.contains("win"), + PlatformOs::Windows => is_windows_asset_name(lower_name), PlatformOs::Linux => lower_name.contains("linux") || lower_name.contains("ubuntu"), PlatformOs::Macos => lower_name.contains("darwin") || lower_name.contains("macos"), PlatformOs::Other => true, } } +fn is_windows_asset_name(lower_name: &str) -> bool { + lower_name.contains("windows") + || lower_name.contains("-win-") + || lower_name.contains("_win_") + || lower_name.contains("-win_") + || lower_name.contains("_win-") +} + fn arch_matches(module_id: &str, arch: PlatformArch, lower_name: &str) -> bool { match arch { PlatformArch::X64 => { @@ -202,11 +288,20 @@ fn arch_matches(module_id: &str, arch: PlatformArch, lower_name: &str) -> bool { && !lower_name.contains("_x86")) } PlatformArch::Arm64 => lower_name.contains("arm64") || lower_name.contains("aarch64"), - PlatformArch::X86 => lower_name.contains("-x86") || lower_name.contains("_x86"), + PlatformArch::X86 => is_x86_asset_name(lower_name), PlatformArch::Other => true, } } +fn is_x86_asset_name(lower_name: &str) -> bool { + (lower_name.contains("-x86") || lower_name.contains("_x86")) + && !lower_name.contains("x86_64") + && !lower_name.contains("x64") + && !lower_name.contains("amd64") + && !lower_name.contains("arm64") + && !lower_name.contains("aarch64") +} + fn main_score(module_id: &str, name: &str, hardware: HardwareProfile) -> i32 { let lower = name.to_ascii_lowercase(); if module_id == "comfyui" { @@ -217,8 +312,12 @@ fn main_score(module_id: &str, name: &str, hardware: HardwareProfile) -> i32 { match hardware.accelerator { AcceleratorClass::NvidiaCuda => { - if detect_cuda_track(&lower).is_some() { - score += 1_000; + if let Some(cuda_track) = detect_cuda_track(&lower) { + score += if hardware.supports_cuda_track(cuda_track) { + 1_000 + cuda_track.base_score() + } else { + -1_500 + }; } if lower.contains("vulkan") || lower.contains("rocm") @@ -269,7 +368,7 @@ fn main_score(module_id: &str, name: &str, hardware: HardwareProfile) -> i32 { score -= 500; } } - AcceleratorClass::CpuOnly => { + AcceleratorClass::CpuOnly | AcceleratorClass::Unknown => { if detect_cuda_track(&lower).is_some() || lower.contains("vulkan") || lower.contains("rocm") @@ -282,7 +381,6 @@ fn main_score(module_id: &str, name: &str, hardware: HardwareProfile) -> i32 { score += cpu_feature_score(&lower, hardware.cpu_tier); } - AcceleratorClass::Unknown => {} } score @@ -329,11 +427,7 @@ fn comfyui_main_score(lower: &str, hardware: HardwareProfile) -> i32 { pub(super) fn base_main_score(lower: &str) -> i32 { let mut score = 0; if let Some(cuda_track) = detect_cuda_track(lower) { - score += match cuda_track { - "cuda13" => 500, - "cuda12" => 450, - _ => 400, - }; + score += cuda_track.base_score(); } if lower.contains("vulkan") { @@ -389,23 +483,18 @@ fn has_avx_marker(lower: &str) -> bool { } fn runtime_score(name: &str) -> i32 { - match detect_cuda_track(name) { - Some("cuda13") => 200, - Some("cuda12") => 180, - Some(_) => 160, - None => 0, - } + detect_cuda_track(name).map_or(0, CudaTrack::runtime_score) } -fn detect_cuda_track(name: &str) -> Option<&'static str> { +fn detect_cuda_track(name: &str) -> Option { let lower = name.to_ascii_lowercase(); if lower.contains("cuda-12") || lower.contains("cuda12") || lower.contains("cu12") { - return Some("cuda12"); + return Some(CudaTrack::Cuda12); } if lower.contains("cuda-13") || lower.contains("cuda13") || lower.contains("cu13") { - return Some("cuda13"); + return Some(CudaTrack::Cuda13); } None @@ -452,4 +541,17 @@ impl HardwareProfile { _ => false, } } + + fn supports_cuda_track(&self, track: CudaTrack) -> bool { + match self.cuda_driver_major { + // Values below 100 are CUDA majors such as 12/13; values at or above 100 + // are NVIDIA driver majors such as 525/580. + Some(driver_major) if driver_major < 100 => match track { + CudaTrack::Cuda12 => driver_major >= 12, + CudaTrack::Cuda13 => driver_major >= 13, + }, + Some(driver_major) => driver_major >= track.min_driver_major(), + None => track == CudaTrack::Cuda12, + } + } } diff --git a/src-tauri/src/domain/modules/github_releases.rs b/src-tauri/src/domain/modules/github_releases.rs index 86261b06..3ca7616b 100644 --- a/src-tauri/src/domain/modules/github_releases.rs +++ b/src-tauri/src/domain/modules/github_releases.rs @@ -1,8 +1,9 @@ //! GitHub release asset selection for platform-specific module bundles. use crate::errors::AppError; -use reqwest::Client; -use serde::Deserialize; +use reqwest::{Client, header}; +use serde::{Deserialize, Serialize}; +use specta::Type; use super::github_release_selection::{ current_platform, detect_hardware_profile, select_release_assets, @@ -30,11 +31,88 @@ pub struct ReleaseBundle { pub assets: Vec, } +/// User-facing compute target for release package selection. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "snake_case")] +pub enum ReleaseComputeTarget { + /// Let Axelate choose the best compatible package for this machine. + #[default] + Auto, + /// Prefer a GPU package, for example CUDA, Vulkan, HIP, or SYCL. + Gpu, + /// Prefer a CPU package. + Cpu, + /// Download both CPU and GPU packages when both are compatible. + Both, +} + +impl ReleaseComputeTarget { + /// Stable string stored in install metadata. + #[must_use] + pub const fn as_metadata_value(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Gpu => "gpu", + Self::Cpu => "cpu", + Self::Both => "both", + } + } +} + +/// Explicit release package selection passed from the frontend. +#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] +pub struct ReleaseDownloadSelection { + /// GitHub release tag to download. `None` means the newest compatible release. + pub tag_name: Option, + /// Compute target selected by the user. + #[serde(default)] + pub compute_target: ReleaseComputeTarget, +} + +/// User-visible release download options for a single module. +#[derive(Clone, Debug, Serialize, Type)] +pub struct ReleaseDownloadOptions { + /// Module identifier these options belong to. + pub module_id: String, + /// GitHub release versions in newest-first order. + pub versions: Vec, +} + +/// User-visible package choices for a GitHub release version. +#[derive(Clone, Debug, Serialize, Type)] +pub struct ReleaseDownloadVersion { + /// GitHub release tag. + pub tag_name: String, + /// GitHub release publish timestamp when available. + pub published_at: Option, + /// CPU package choice for this release, when compatible. + pub cpu: Option, + /// GPU package choice for this release, when compatible. + pub gpu: Option, + /// Recommended package target for this machine. + pub recommended: ReleaseComputeTarget, +} + +/// User-visible package variant for one compute target. +#[derive(Clone, Debug, Serialize, Type)] +pub struct ReleaseDownloadVariant { + /// Compute target represented by this variant. + pub compute_target: ReleaseComputeTarget, + /// Asset filenames that will be downloaded. + pub assets: Vec, + /// Combined download size in bytes. + pub total_size: u64, +} + const RELEASES_PER_PAGE: u8 = 100; +const MAX_RELEASE_DOWNLOAD_OPTIONS: usize = 50; +const MAX_RELEASE_PAGES: u32 = 10; +const GITHUB_API_USER_AGENT: &str = concat!("Axelate/", env!("CARGO_PKG_VERSION")); #[derive(Clone, Debug, Deserialize)] struct Release { tag_name: String, + published_at: Option, #[serde(default)] draft: bool, #[serde(default)] @@ -86,21 +164,37 @@ pub async fn fetch_release_bundle( client: &Client, repo_url: &str, module_id: &str, + selection: Option<&ReleaseDownloadSelection>, ) -> Result { let repo_ref = parse_repo(repo_url)?; let platform = current_platform(); let hardware = detect_hardware_profile().await; + tracing::info!( + "Selecting release bundle for {module_id}: platform={:?}, hardware={:?}", + platform, + hardware + ); let mut page = 1_u32; - loop { + while page <= MAX_RELEASE_PAGES { let releases = fetch_release_page(client, &repo_ref, module_id, page).await?; if releases.is_empty() { break; } if let Some(bundle) = - find_compatible_release_bundle(module_id, platform, hardware, releases) + find_compatible_release_bundle(module_id, platform, hardware, releases, selection) { + tracing::info!( + "Selected release bundle for {module_id}: tag={} assets={}", + bundle.tag_name, + bundle + .assets + .iter() + .map(|asset| asset.name.as_str()) + .collect::>() + .join(", ") + ); return Ok(bundle); } @@ -112,6 +206,40 @@ pub async fn fetch_release_bundle( ))) } +/// Returns user-visible release choices for the current machine. +pub async fn fetch_release_download_options( + client: &Client, + repo_url: &str, + module_id: &str, +) -> Result { + let repo_ref = parse_repo(repo_url)?; + let platform = current_platform(); + let hardware = detect_hardware_profile().await; + let mut page = 1_u32; + let mut versions = Vec::new(); + + while page <= MAX_RELEASE_PAGES { + let releases = fetch_release_page(client, &repo_ref, module_id, page).await?; + if releases.is_empty() { + break; + } + + versions.extend(release_download_versions( + module_id, platform, hardware, releases, + )); + if versions.len() >= MAX_RELEASE_DOWNLOAD_OPTIONS { + versions.truncate(MAX_RELEASE_DOWNLOAD_OPTIONS); + break; + } + page += 1; + } + + Ok(ReleaseDownloadOptions { + module_id: module_id.to_string(), + versions, + }) +} + fn map_release_fetch_error( status: reqwest::StatusCode, response: &reqwest::Response, @@ -181,11 +309,19 @@ async fn fetch_release_page( ) -> Result, AppError> { let response = client .get(repo_ref.releases_api_url(page)) - .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .header(header::ACCEPT, "application/vnd.github+json") + .header(header::USER_AGENT, GITHUB_API_USER_AGENT) .send() .await?; if !response.status().is_success() { + if response.status() == reqwest::StatusCode::UNPROCESSABLE_ENTITY && page > 1 { + tracing::warn!( + "GitHub release pagination stopped for {module_id} at page {page}: {}", + response.status() + ); + return Ok(Vec::new()); + } return Err(map_release_fetch_error( response.status(), &response, @@ -201,12 +337,31 @@ fn find_compatible_release_bundle( platform: Platform, hardware: HardwareProfile, releases: Vec, + selection: Option<&ReleaseDownloadSelection>, ) -> Option { + let selected_tag = selection + .and_then(|selection| selection.tag_name.as_deref()) + .filter(|tag| !tag.trim().is_empty()); + let selected_target = selection.map_or(ReleaseComputeTarget::Auto, |selection| { + selection.compute_target + }); releases .into_iter() .filter(|release| !release.draft && !release.prerelease) + .filter(|release| selected_tag.is_none_or(|tag| release.tag_name == tag)) .find_map(|release| { - let assets = select_release_assets(module_id, platform, hardware, &release.assets)?; + let assets = select_assets_for_target( + module_id, + platform, + hardware, + selected_target, + &release.assets, + )?; + if selected_target != ReleaseComputeTarget::Auto + && !release_assets_match_target(&assets, selected_target) + { + return None; + } Some(ReleaseBundle { tag_name: release.tag_name, assets, @@ -214,21 +369,316 @@ fn find_compatible_release_bundle( }) } -fn parse_repo(repo_url: &str) -> Result { - let trimmed = repo_url.trim_end_matches(".git").trim_end_matches('/'); - let parts: Vec<&str> = trimmed.split('/').collect(); +fn release_download_version( + module_id: &str, + platform: Platform, + hardware: HardwareProfile, + release: Release, +) -> Option { + let cpu = select_release_assets( + module_id, + platform, + hardware_for_target(hardware, ReleaseComputeTarget::Cpu), + &release.assets, + ) + .and_then(|assets| release_download_variant(ReleaseComputeTarget::Cpu, assets)); + let gpu = select_release_assets( + module_id, + platform, + hardware_for_target(hardware, ReleaseComputeTarget::Gpu), + &release.assets, + ) + .and_then(|assets| release_download_variant(ReleaseComputeTarget::Gpu, assets)); + + if cpu.is_none() && gpu.is_none() { + return None; + } + + let recommended = recommended_release_target(cpu.as_ref(), gpu.as_ref(), hardware); + + Some(ReleaseDownloadVersion { + tag_name: release.tag_name, + published_at: release.published_at, + cpu, + gpu, + recommended, + }) +} + +fn release_download_versions( + module_id: &str, + platform: Platform, + hardware: HardwareProfile, + releases: Vec, +) -> Vec { + releases + .into_iter() + .filter(|release| !release.draft && !release.prerelease) + .filter_map(|release| release_download_version(module_id, platform, hardware, release)) + .collect() +} + +fn release_download_variant( + compute_target: ReleaseComputeTarget, + assets: Vec, +) -> Option { + if assets.is_empty() { + return None; + } + if !release_assets_match_target(&assets, compute_target) { + return None; + } + + let total_size = assets + .iter() + .fold(0_u64, |acc, asset| acc.saturating_add(asset.size)); + + Some(ReleaseDownloadVariant { + compute_target, + assets: assets.into_iter().map(|asset| asset.name).collect(), + total_size, + }) +} + +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" + }) +} - if parts.len() < 2 { +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, + hardware: HardwareProfile, + target: ReleaseComputeTarget, + assets: &[Asset], +) -> Option> { + if target != ReleaseComputeTarget::Both { + return select_release_assets( + module_id, + platform, + hardware_for_target(hardware, target), + assets, + ); + } + + let mut selected = select_release_assets( + module_id, + platform, + hardware_for_target(hardware, ReleaseComputeTarget::Gpu), + assets, + )?; + let cpu_assets = select_release_assets( + module_id, + platform, + hardware_for_target(hardware, ReleaseComputeTarget::Cpu), + assets, + )?; + + for asset in cpu_assets { + if selected.iter().any(|existing| { + existing.download_url == asset.download_url || existing.name == asset.name + }) { + continue; + } + selected.push(asset); + } + + Some(selected) +} + +fn parse_repo(repo_url: &str) -> Result { + let trimmed = repo_url + .trim() + .split(['?', '#']) + .next() + .unwrap_or_default() + .trim_end_matches('/'); + if trimmed.contains("://") + && !trimmed.starts_with("https://github.com/") + && !trimmed.starts_with("http://github.com/") + && !trimmed.starts_with("https://www.github.com/") + && !trimmed.starts_with("http://www.github.com/") + { + return Err(invalid_repo_url(repo_url)); + } + let matched_github_prefix = trimmed.starts_with("https://github.com/") + || trimmed.starts_with("http://github.com/") + || trimmed.starts_with("https://www.github.com/") + || trimmed.starts_with("http://www.github.com/") + || trimmed.starts_with("github.com/") + || trimmed.starts_with("www.github.com/") + || trimmed.starts_with("git@github.com:"); + let path = trimmed + .strip_prefix("https://github.com/") + .or_else(|| trimmed.strip_prefix("http://github.com/")) + .or_else(|| trimmed.strip_prefix("https://www.github.com/")) + .or_else(|| trimmed.strip_prefix("http://www.github.com/")) + .or_else(|| trimmed.strip_prefix("github.com/")) + .or_else(|| trimmed.strip_prefix("www.github.com/")) + .or_else(|| trimmed.strip_prefix("git@github.com:")) + .unwrap_or(trimmed); + let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect(); + if !matched_github_prefix + && (parts.len() != 2 + || parts.first().is_some_and(|owner| { + owner.contains('.') || owner.contains('@') || owner.contains(':') + })) + { return Err(invalid_repo_url(repo_url)); } - let repo = parts - .last() + let owner = parts + .first() .ok_or_else(|| invalid_repo_url(repo_url))? .to_string(); - let owner = parts - .get(parts.len().saturating_sub(2)) + let repo = parts + .get(1) .ok_or_else(|| invalid_repo_url(repo_url))? + .trim_end_matches(".git") .to_string(); if owner.is_empty() || repo.is_empty() { @@ -250,17 +700,141 @@ mod tests { use crate::domain::system::hardware_probe::{AcceleratorClass, CpuInstructionTier}; #[test] - fn skips_incomplete_sdcpp_release_and_accepts_complete_previous_bundle() { + fn parse_repo_uses_owner_and_repo_from_github_urls_with_extra_path() { + let parsed = parse_repo("https://github.com/ggml-org/llama.cpp/releases/latest") + .expect("valid GitHub URL should parse"); + + assert_eq!(parsed.owner, "ggml-org"); + assert_eq!(parsed.repo, "llama.cpp"); + } + + #[test] + fn parse_repo_supports_git_suffix_and_shorthand() { + let parsed = parse_repo("ggml-org/llama.cpp.git").expect("valid shorthand should parse"); + + assert_eq!(parsed.owner, "ggml-org"); + assert_eq!(parsed.repo, "llama.cpp"); + } + + #[test] + fn parse_repo_supports_github_host_without_scheme() { + let parsed = + parse_repo("github.com/ggml-org/llama.cpp").expect("valid host shorthand should parse"); + + assert_eq!(parsed.owner, "ggml-org"); + assert_eq!(parsed.repo, "llama.cpp"); + } + + #[test] + fn parse_repo_rejects_non_github_urls() { + let parsed = parse_repo("https://example.com/ggml-org/llama.cpp"); + + assert!(parsed.is_err()); + } + + #[test] + fn parse_repo_rejects_non_github_host_like_shorthands() { + assert!(parse_repo("gitlab.com/org/repo").is_err()); + assert!(parse_repo("git@gitlab.com:org/repo.git").is_err()); + assert!(parse_repo("www.gitlab.com/org/repo").is_err()); + } + + #[test] + fn asset_classification_does_not_treat_amd64_as_amd_gpu() { + assert!(!is_gpu_asset_name("llama-b8981-bin-win-amd64.zip")); + assert!(is_cpu_asset_name("llama-b8981-bin-win-amd64.zip")); + assert!(is_gpu_asset_name("llama-b8981-bin-win-hip-radeon-x64.zip")); + } + + #[test] + fn unknown_accelerator_tokens_are_not_classified_as_cpu() { + assert!(!is_cpu_asset_name("llama-b8981-bin-win-metal-x64.zip")); + assert!(!is_cpu_asset_name("llama-b8981-bin-win-npu-x64.zip")); + assert!(!is_cpu_asset_name("llama-b8981-bin-win-xpu-x64.zip")); + assert!(!is_cpu_asset_name("llama-b8981-bin-win-rtx5090-x64.zip")); + } + + #[test] + fn metal_assets_are_classified_as_gpu() { + assert!(is_gpu_asset_name_lower( + "llama-b9028-bin-darwin-metal-arm64.zip" + )); + } + + #[test] + fn windows_selection_does_not_treat_darwin_assets_as_windows() { let platform = Platform { os: PlatformOs::Windows, arch: PlatformArch::X64, }; let hardware = HardwareProfile { - accelerator: AcceleratorClass::NvidiaCuda, + accelerator: AcceleratorClass::CpuOnly, cpu_tier: CpuInstructionTier::Avx2, cuda_driver_major: None, cuda_driver_minor: None, }; + let assets = vec![asset("llama-b9028-bin-darwin-x64.tar.gz")]; + + assert!(select_release_assets("llamacpp", platform, hardware, &assets).is_none()); + } + + #[test] + fn x86_selection_does_not_treat_x86_64_assets_as_32_bit() { + let platform = Platform { + os: PlatformOs::Linux, + arch: PlatformArch::X86, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::CpuOnly, + cpu_tier: CpuInstructionTier::Baseline, + cuda_driver_major: None, + cuda_driver_minor: None, + }; + let assets = vec![asset("llama-b9028-bin-linux-x86_64.tar.gz")]; + + assert!(select_release_assets("llamacpp", platform, hardware, &assets).is_none()); + } + + #[test] + fn uppercase_sha256_digest_is_accepted() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::CpuOnly, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: None, + cuda_driver_minor: None, + }; + let assets = vec![Asset { + name: "llama-b9028-bin-win-cpu-x64.zip".to_string(), + browser_download_url: "https://example.com/llama.zip".to_string(), + size: 1024, + digest: Some(format!("SHA256:{}", "A".repeat(64))), + }]; + + let selected = select_release_assets("llamacpp", platform, hardware, &assets) + .expect("uppercase SHA256 prefix should be accepted"); + + assert_eq!( + selected.first().map(|asset| asset.sha256.as_str()), + Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ); + } + + #[test] + fn skips_incomplete_sdcpp_release_and_accepts_complete_previous_bundle() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::NvidiaCuda, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: Some(580), + cuda_driver_minor: Some(0), + }; let incomplete_latest = vec![ asset("cudart-sd-bin-win-cu12-x64.zip"), @@ -298,8 +872,8 @@ mod tests { let hardware = HardwareProfile { accelerator: AcceleratorClass::NvidiaCuda, cpu_tier: CpuInstructionTier::Avx2, - cuda_driver_major: None, - cuda_driver_minor: None, + cuda_driver_major: Some(580), + cuda_driver_minor: Some(0), }; let assets = vec![ asset("cudart-llama-bin-win-cuda-12.4-x64.zip"), @@ -331,8 +905,8 @@ mod tests { let hardware = HardwareProfile { accelerator: AcceleratorClass::NvidiaCuda, cpu_tier: CpuInstructionTier::Avx2, - cuda_driver_major: None, - cuda_driver_minor: None, + cuda_driver_major: Some(580), + cuda_driver_minor: Some(0), }; let assets = vec![ asset("cudart-llama-bin-win-cuda-12.4-x64.zip"), @@ -358,6 +932,324 @@ mod tests { ); } + #[test] + fn treats_cuda_runtime_version_as_cuda_track_support() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::NvidiaCuda, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: Some(13), + cuda_driver_minor: Some(2), + }; + let assets = vec![ + asset("cudart-llama-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8971-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8971-bin-win-vulkan-x64.zip"), + ]; + + let selected = select_release_assets("llamacpp", platform, hardware, &assets) + .expect("expected cuda runtime version to support cuda asset selection"); + + assert_eq!(selected.len(), 2); + assert_eq!( + selected.first().map(|asset| asset.name.as_str()), + Some("cudart-llama-bin-win-cuda-13.1-x64.zip") + ); + assert_eq!( + selected.get(1).map(|asset| asset.name.as_str()), + Some("llama-b8971-bin-win-cuda-13.1-x64.zip") + ); + } + + #[test] + fn explicit_release_selection_respects_cpu_target() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::NvidiaCuda, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: Some(580), + cuda_driver_minor: Some(0), + }; + let releases = vec![Release { + tag_name: "b8971".to_string(), + published_at: Some("2026-04-29T10:00:00Z".to_string()), + draft: false, + prerelease: false, + assets: vec![ + asset("cudart-llama-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8971-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8971-bin-win-cpu-x64.zip"), + ], + }]; + let selection = ReleaseDownloadSelection { + tag_name: Some("b8971".to_string()), + compute_target: ReleaseComputeTarget::Cpu, + }; + + let bundle = find_compatible_release_bundle( + "llamacpp", + platform, + hardware, + releases, + Some(&selection), + ) + .expect("expected explicit CPU release bundle"); + + assert_eq!(bundle.assets.len(), 1); + assert_eq!( + bundle.assets.first().map(|asset| asset.name.as_str()), + Some("llama-b8971-bin-win-cpu-x64.zip") + ); + } + + #[test] + fn explicit_release_selection_respects_gpu_target() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::NvidiaCuda, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: Some(580), + cuda_driver_minor: Some(0), + }; + let releases = vec![Release { + tag_name: "b8971".to_string(), + published_at: Some("2026-04-29T10:00:00Z".to_string()), + draft: false, + prerelease: false, + assets: vec![ + asset("cudart-llama-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8971-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8971-bin-win-cpu-x64.zip"), + ], + }]; + let selection = ReleaseDownloadSelection { + tag_name: Some("b8971".to_string()), + compute_target: ReleaseComputeTarget::Gpu, + }; + + let bundle = find_compatible_release_bundle( + "llamacpp", + platform, + hardware, + releases, + Some(&selection), + ) + .expect("expected explicit GPU release bundle"); + + assert_eq!(bundle.assets.len(), 2); + assert_eq!( + bundle.assets.first().map(|asset| asset.name.as_str()), + Some("cudart-llama-bin-win-cuda-13.1-x64.zip") + ); + assert_eq!( + bundle.assets.get(1).map(|asset| asset.name.as_str()), + Some("llama-b8971-bin-win-cuda-13.1-x64.zip") + ); + } + + #[test] + fn explicit_release_selection_can_download_both_targets() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::NvidiaCuda, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: Some(580), + cuda_driver_minor: Some(0), + }; + let releases = vec![Release { + tag_name: "b8971".to_string(), + published_at: Some("2026-04-29T10:00:00Z".to_string()), + draft: false, + prerelease: false, + assets: vec![ + asset("cudart-llama-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8971-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8971-bin-win-cpu-x64.zip"), + ], + }]; + let selection = ReleaseDownloadSelection { + tag_name: Some("b8971".to_string()), + compute_target: ReleaseComputeTarget::Both, + }; + + let bundle = find_compatible_release_bundle( + "llamacpp", + platform, + hardware, + releases, + Some(&selection), + ) + .expect("expected combined CPU and GPU release bundle"); + + let asset_names = bundle + .assets + .iter() + .map(|asset| asset.name.as_str()) + .collect::>(); + assert_eq!( + asset_names, + vec![ + "cudart-llama-bin-win-cuda-13.1-x64.zip", + "llama-b8971-bin-win-cuda-13.1-x64.zip", + "llama-b8971-bin-win-cpu-x64.zip", + ] + ); + } + + #[test] + fn release_download_versions_keeps_compatible_older_release_options() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::CpuOnly, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: None, + cuda_driver_minor: None, + }; + let releases = vec![ + Release { + tag_name: "draft".to_string(), + published_at: None, + draft: true, + prerelease: false, + assets: vec![asset("llama-draft-bin-win-cpu-x64.zip")], + }, + Release { + tag_name: "linux-only".to_string(), + published_at: None, + draft: false, + prerelease: false, + assets: vec![asset("llama-linux-bin-linux-cpu-x64.zip")], + }, + Release { + tag_name: "older-compatible".to_string(), + published_at: Some("2026-04-29T10:00:00Z".to_string()), + draft: false, + prerelease: false, + assets: vec![asset("llama-older-bin-win-cpu-x64.zip")], + }, + ]; + + let versions = release_download_versions("llamacpp", platform, hardware, releases); + + assert_eq!(versions.len(), 1); + assert_eq!( + versions.first().map(|version| version.tag_name.as_str()), + Some("older-compatible") + ); + assert!( + versions + .first() + .and_then(|version| version.cpu.as_ref()) + .is_some() + ); + } + + #[test] + fn release_download_version_recommends_available_gpu_when_cpu_variant_is_missing() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::Unknown, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: None, + cuda_driver_minor: None, + }; + let releases = vec![Release { + tag_name: "gpu-only".to_string(), + published_at: Some("2026-04-29T10:00:00Z".to_string()), + draft: false, + prerelease: false, + assets: vec![asset("llama-gpu-only-bin-win-vulkan-x64.zip")], + }]; + + let versions = release_download_versions("llamacpp", platform, hardware, releases); + + assert_eq!(versions.len(), 1); + let version = versions.first().expect("expected gpu-only option"); + assert!(version.cpu.is_none()); + assert!(version.gpu.is_some()); + assert_eq!(version.recommended, ReleaseComputeTarget::Gpu); + } + + #[test] + fn prefers_cuda12_when_cuda_driver_version_is_unknown() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::NvidiaCuda, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: None, + cuda_driver_minor: None, + }; + let assets = vec![ + asset("cudart-llama-bin-win-cuda-12.4-x64.zip"), + asset("cudart-llama-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8461-bin-win-cuda-12.4-x64.zip"), + asset("llama-b8461-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8461-bin-win-cpu-x64.zip"), + ]; + + let selected = select_release_assets("llamacpp", platform, hardware, &assets) + .expect("expected a compatible llama.cpp bundle"); + + assert_eq!(selected.len(), 2); + assert_eq!( + selected.first().map(|asset| asset.name.as_str()), + Some("cudart-llama-bin-win-cuda-12.4-x64.zip") + ); + assert_eq!( + selected.get(1).map(|asset| asset.name.as_str()), + Some("llama-b8461-bin-win-cuda-12.4-x64.zip") + ); + } + + #[test] + fn falls_back_to_cpu_when_only_unsupported_cuda_track_is_available() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::NvidiaCuda, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: Some(550), + cuda_driver_minor: Some(0), + }; + let assets = vec![ + asset("cudart-llama-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8461-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8461-bin-win-cpu-x64.zip"), + ]; + + let selected = select_release_assets("llamacpp", platform, hardware, &assets) + .expect("expected CPU fallback when CUDA 13 is unsupported"); + + assert_eq!(selected.len(), 1); + assert_eq!( + selected.first().map(|asset| asset.name.as_str()), + Some("llama-b8461-bin-win-cpu-x64.zip") + ); + } + #[test] fn never_selects_runtime_only_asset_as_install_bundle() { let platform = Platform { @@ -396,7 +1288,7 @@ mod tests { } #[test] - fn skips_incomplete_windows_cuda_release_when_runtime_pair_is_missing() { + fn falls_back_when_windows_cuda_runtime_pair_is_missing() { let platform = Platform { os: PlatformOs::Windows, arch: PlatformArch::X64, @@ -404,15 +1296,22 @@ mod tests { let hardware = HardwareProfile { accelerator: AcceleratorClass::NvidiaCuda, cpu_tier: CpuInstructionTier::Avx2, - cuda_driver_major: None, - cuda_driver_minor: None, + cuda_driver_major: Some(580), + cuda_driver_minor: Some(0), }; let assets = vec![ asset("llama-b8726-bin-win-cuda-13.1-x64.zip"), asset("llama-b8726-bin-win-cpu-x64.zip"), ]; - assert!(select_release_assets("llamacpp", platform, hardware, &assets).is_none()); + let selected = select_release_assets("llamacpp", platform, hardware, &assets) + .expect("expected CPU fallback when CUDA runtime pair is missing"); + + assert_eq!(selected.len(), 1); + assert_eq!( + selected.first().map(|asset| asset.name.as_str()), + Some("llama-b8726-bin-win-cpu-x64.zip") + ); } #[test] @@ -562,6 +1461,94 @@ mod tests { ); } + #[test] + fn current_sdcpp_release_names_build_cpu_and_gpu_options() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::Unknown, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: None, + cuda_driver_minor: None, + }; + let releases = vec![Release { + tag_name: "master-593-3d6064b".to_string(), + published_at: Some("2026-04-29T10:00:00Z".to_string()), + draft: false, + prerelease: false, + assets: vec![ + asset("cudart-sd-bin-win-cu12-x64.zip"), + asset("sd-master-3d6064b-bin-win-avx-x64.zip"), + asset("sd-master-3d6064b-bin-win-avx2-x64.zip"), + asset("sd-master-3d6064b-bin-win-avx512-x64.zip"), + asset("sd-master-3d6064b-bin-win-cuda12-x64.zip"), + asset("sd-master-3d6064b-bin-win-noavx-x64.zip"), + asset("sd-master-3d6064b-bin-win-rocm-x64.zip"), + asset("sd-master-3d6064b-bin-win-vulkan-x64.zip"), + ], + }]; + + let versions = release_download_versions("sdcpp", platform, hardware, releases); + + assert_eq!(versions.len(), 1); + let version = versions.first().expect("expected sdcpp options"); + assert_eq!(version.recommended, ReleaseComputeTarget::Cpu); + assert_eq!( + version.cpu.as_ref().and_then(|cpu| cpu.assets.first()), + Some(&"sd-master-3d6064b-bin-win-avx2-x64.zip".to_string()) + ); + assert_eq!( + version.gpu.as_ref().and_then(|gpu| gpu.assets.first()), + Some(&"sd-master-3d6064b-bin-win-vulkan-x64.zip".to_string()) + ); + } + + #[test] + fn current_llamacpp_release_names_build_cpu_and_gpu_options() { + let platform = Platform { + os: PlatformOs::Windows, + arch: PlatformArch::X64, + }; + let hardware = HardwareProfile { + accelerator: AcceleratorClass::Unknown, + cpu_tier: CpuInstructionTier::Avx2, + cuda_driver_major: None, + cuda_driver_minor: None, + }; + let releases = vec![Release { + tag_name: "b8981".to_string(), + published_at: Some("2026-04-29T10:00:00Z".to_string()), + draft: false, + prerelease: false, + assets: vec![ + asset("cudart-llama-bin-win-cuda-12.4-x64.zip"), + asset("cudart-llama-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8981-bin-win-cpu-x64.zip"), + asset("llama-b8981-bin-win-cuda-12.4-x64.zip"), + asset("llama-b8981-bin-win-cuda-13.1-x64.zip"), + asset("llama-b8981-bin-win-hip-radeon-x64.zip"), + asset("llama-b8981-bin-win-sycl-x64.zip"), + asset("llama-b8981-bin-win-vulkan-x64.zip"), + ], + }]; + + let versions = release_download_versions("llamacpp", platform, hardware, releases); + + assert_eq!(versions.len(), 1); + let version = versions.first().expect("expected llama.cpp options"); + assert_eq!(version.recommended, ReleaseComputeTarget::Cpu); + assert_eq!( + version.cpu.as_ref().and_then(|cpu| cpu.assets.first()), + Some(&"llama-b8981-bin-win-cpu-x64.zip".to_string()) + ); + assert_eq!( + version.gpu.as_ref().and_then(|gpu| gpu.assets.first()), + Some(&"llama-b8981-bin-win-vulkan-x64.zip".to_string()) + ); + } + #[test] fn selects_comfyui_nvidia_portable_release_without_runtime_pairing() { let platform = Platform { diff --git a/src-tauri/src/domain/modules/integration_watcher.rs b/src-tauri/src/domain/modules/integration_watcher.rs new file mode 100644 index 00000000..900263d3 --- /dev/null +++ b/src-tauri/src/domain/modules/integration_watcher.rs @@ -0,0 +1,105 @@ +//! Filesystem watcher for externally changed integration folders. + +use crate::utils::paths::INTEGRATIONS_DIR; +use notify::{EventKind, RecursiveMode, Watcher}; +use serde::Serialize; +use std::sync::mpsc; +use std::time::{Duration, Instant}; +use tauri::Emitter; + +const INTEGRATIONS_CHANGED_EVENT: &str = "integrations_changed"; +const EVENT_DEBOUNCE: Duration = Duration::from_millis(350); + +/// Payload emitted when the integrations folder changes on disk. +#[derive(Debug, Clone, Serialize)] +pub struct IntegrationsChangedPayload { + /// Absolute path of the watched integrations directory. + pub path: String, +} + +/// Starts a background watcher for integration folder changes. +pub fn start(app: tauri::AppHandle) { + let path = INTEGRATIONS_DIR.clone(); + + if let Err(error) = std::fs::create_dir_all(&path) { + tracing::warn!( + path = %path.display(), + "Failed to create integrations directory for watcher: {error}" + ); + return; + } + + std::thread::Builder::new() + .name("integration-folder-watcher".to_string()) + .spawn(move || { + let (tx, rx) = mpsc::channel(); + let mut watcher = match notify::recommended_watcher(tx) { + Ok(watcher) => watcher, + Err(error) => { + tracing::warn!("Failed to start integrations watcher: {error}"); + return; + } + }; + + if let Err(error) = watcher.watch(&path, RecursiveMode::Recursive) { + tracing::warn!( + path = %path.display(), + "Failed to watch integrations directory: {error}" + ); + return; + } + + let mut debounce_deadline: Option = None; + loop { + let event = match debounce_deadline { + Some(deadline) => { + let now = Instant::now(); + if now >= deadline { + emit_integrations_changed(&app, &path); + debounce_deadline = None; + continue; + } + rx.recv_timeout(deadline.saturating_duration_since(now)) + } + None => rx.recv().map_err(|_| mpsc::RecvTimeoutError::Disconnected), + }; + + match event { + Ok(Ok(event)) if is_integration_change(event.kind) => { + debounce_deadline = Some(Instant::now() + EVENT_DEBOUNCE); + } + Ok(Ok(_)) => {} + Ok(Err(error)) => { + tracing::warn!("Integrations watcher error: {error}"); + } + Err(mpsc::RecvTimeoutError::Timeout) => { + emit_integrations_changed(&app, &path); + debounce_deadline = None; + } + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + }) + .map_err(|error| { + tracing::warn!("Failed to spawn integrations watcher thread: {error}"); + }) + .ok(); +} + +fn emit_integrations_changed(app: &tauri::AppHandle, path: &std::path::Path) { + if let Err(error) = app.emit( + INTEGRATIONS_CHANGED_EVENT, + IntegrationsChangedPayload { + path: path.to_string_lossy().to_string(), + }, + ) { + tracing::warn!("Failed to emit integrations change event: {error}"); + } +} + +const fn is_integration_change(kind: EventKind) -> bool { + matches!( + kind, + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) + ) +} diff --git a/src-tauri/src/domain/modules/lifecycle.rs b/src-tauri/src/domain/modules/lifecycle.rs index c750f36c..5c25ac2c 100644 --- a/src-tauri/src/domain/modules/lifecycle.rs +++ b/src-tauri/src/domain/modules/lifecycle.rs @@ -64,7 +64,7 @@ pub struct ModuleManifest { #[serde(default)] pub author: Option, /// Module category used by the launcher UI. - #[serde(default, alias = "type")] + #[serde(default)] pub category: Option, /// Module icon shown in the launcher UI. #[serde(default)] @@ -75,18 +75,18 @@ pub struct ModuleManifest { /// Human-readable module documentation file. #[serde(default)] pub readme: Option, - /// Legacy launcher-owned schema file path for richer forms. - #[serde(default, alias = "settingsSchema")] + /// Launcher-owned schema file path for richer forms. + #[serde(default)] pub settings_schema: Option, /// Module-owned custom settings UI entry point. - #[serde(default, alias = "settingsUi")] + #[serde(default)] pub settings_ui: Option, /// Launcher-managed module runtime. pub runtime: ModuleRuntime, /// Lifecycle scripts pub lifecycle: Option, /// Configuration schema - #[serde(default, alias = "configSchema")] + #[serde(default)] pub config_schema: Option>, } @@ -262,7 +262,7 @@ name = "Demo Module" version = "1.2.3" description = "Example manifest" author = "Axelate" -type = "service" +category = "service" icon = "🤖" settings_ui = "settings-ui/index.html" @@ -302,7 +302,7 @@ start = { program = "uv", args = ["run", "src/main.py"] } } #[test] - fn rejects_non_toml_manifest_files() { + fn rejects_json_manifest_files() { let temp_dir = tempfile::tempdir().expect("temp dir"); let manifest_path = temp_dir.path().join("module.json"); @@ -310,10 +310,10 @@ start = { program = "uv", args = ["run", "src/main.py"] } &manifest_path, r#"{ "api_version": "1", - "id": "legacy-demo", - "name": "Legacy Demo", + "id": "json-demo", + "name": "JSON Demo", "version": "0.1.0", - "description": "Legacy manifest", + "description": "JSON manifest", "dependencies": [] }"#, ) @@ -358,8 +358,8 @@ entry = "src/main.py" temp_dir.path().join(PRIMARY_MANIFEST_FILE), r#" api_version = "1" -id = "legacy" -name = "Legacy" +id = "missing-runtime" +name = "Invalid Runtime" version = "1.0.0" entry = "src/main.py" dependencies = ["python"] diff --git a/src-tauri/src/domain/modules/mod.rs b/src-tauri/src/domain/modules/mod.rs index dbbe782c..387575d5 100644 --- a/src-tauri/src/domain/modules/mod.rs +++ b/src-tauri/src/domain/modules/mod.rs @@ -11,6 +11,8 @@ mod downloader_transfer; mod github_release_selection; /// Open-Source engine GitHub releases parsing pub mod github_releases; +/// Filesystem watcher for externally changed integrations. +pub mod integration_watcher; /// Module lifecycle management pub mod lifecycle; /// Module-scoped filesystem paths diff --git a/src-tauri/src/domain/modules/settings_ui_protocol.rs b/src-tauri/src/domain/modules/settings_ui_protocol.rs index 9835520e..12dc77e9 100644 --- a/src-tauri/src/domain/modules/settings_ui_protocol.rs +++ b/src-tauri/src/domain/modules/settings_ui_protocol.rs @@ -20,7 +20,7 @@ type ModuleSettingsPayload = HashMap; const MODULE_SETTINGS_SCHEME: &str = "module-settings"; const MODULE_SETTINGS_LABEL_PREFIX: &str = "module-settings"; -const MODULE_SETTINGS_SESSION_TTL: Duration = Duration::from_secs(60 * 60); +const MODULE_SETTINGS_SESSION_TTL: Duration = Duration::from_hours(1); const MODULE_SETTINGS_MAX_SESSIONS: usize = 128; const HOST_INDEX_HTML: &str = include_str!("../../../resources/module_settings_host/index.html"); const HOST_SCRIPT: &str = include_str!("../../../resources/module_settings_host/host.js"); @@ -344,13 +344,16 @@ fn parse_module_id_from_label(label: &str) -> Result { let module_id = parts.next(); let nonce = parts.next(); - if prefix != Some(MODULE_SETTINGS_LABEL_PREFIX) || module_id.is_none() || nonce.is_none() { + let Some(module_id) = module_id.filter(|value| !value.trim().is_empty()) else { + return Err(AppError::PermissionDenied( + "Module settings route is only available to owned settings webviews".to_string(), + )); + }; + if prefix != Some(MODULE_SETTINGS_LABEL_PREFIX) || nonce.is_none() { return Err(AppError::PermissionDenied( "Module settings route is only available to owned settings webviews".to_string(), )); } - - let module_id = module_id.unwrap_or_default(); crate::domain::modules::downloader::validate_module_id(module_id)?; Ok(module_id.to_string()) } @@ -481,7 +484,9 @@ const fn status_for_error(error: &AppError) -> StatusCode { match error { AppError::Validation(_) => StatusCode::BAD_REQUEST, AppError::NotFound(_) => StatusCode::NOT_FOUND, - AppError::PermissionDenied(_) => StatusCode::FORBIDDEN, + AppError::PermissionDenied(_) | AppError::FrontendSecretForbidden(_) => { + StatusCode::FORBIDDEN + } AppError::Io(_) | AppError::Serialization(_) | AppError::Config(_) diff --git a/src-tauri/src/domain/system/config_service.rs b/src-tauri/src/domain/system/config_service.rs index cfd1ca0a..cbb59c28 100644 --- a/src-tauri/src/domain/system/config_service.rs +++ b/src-tauri/src/domain/system/config_service.rs @@ -127,3 +127,207 @@ impl ConfigService { self.repo.load_custom_models() } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::ConfigService; + use crate::domain::system::config_repository::ConfigRepository; + use crate::errors::AppError; + use crate::models::config::{ApiProvider, AppMeta, ConfigCatalog, ModuleItem, ProviderType}; + use crate::models::custom_models::{CustomModel, CustomModelConfig}; + use serde_json::json; + + #[derive(Debug)] + struct FakeConfigRepository { + providers: Vec, + local_modules: Vec, + custom_models: CustomModelConfig, + } + + impl ConfigRepository for FakeConfigRepository { + fn load_app_meta(&self) -> Result { + Ok(AppMeta { + version: "9.9.9".to_string(), + }) + } + + fn load_api_providers(&self) -> Result, AppError> { + Ok(self.providers.clone()) + } + + fn load_local_modules(&self) -> Result, AppError> { + Ok(self.local_modules.clone()) + } + + fn load_custom_models(&self) -> Result { + Ok(self.custom_models.clone()) + } + } + + fn provider( + id: &str, + provider_type: Option, + base_url: Option<&str>, + capabilities: Option>, + ) -> ApiProvider { + ApiProvider { + id: id.to_string(), + name: format!("{id} Provider"), + desc_key: None, + description: None, + icon: None, + provider_type, + base_url: base_url.map(str::to_string), + api_key_env: None, + models: None, + capabilities: capabilities.map(|items| items.into_iter().map(str::to_string).collect()), + model_aliases: None, + } + } + + fn module(value: serde_json::Value) -> ModuleItem { + serde_json::from_value(value).unwrap() + } + + fn service(repo: FakeConfigRepository) -> ConfigService { + ConfigService::new(Box::new(repo)) + } + + #[test] + fn load_full_config_hydrates_api_provider_modules() { + let service = service(FakeConfigRepository { + providers: vec![ + provider( + "openai-compatible", + Some(ProviderType::OpenaiCompatible), + Some("https://example.test/v1"), + None, + ), + provider( + "image-api", + Some(ProviderType::Api), + None, + Some(vec!["image"]), + ), + ], + local_modules: Vec::new(), + custom_models: CustomModelConfig::default(), + }); + + let config = service.load_full_config().unwrap(); + let compatible = config + .catalog + .ai + .iter() + .find(|item| item.id == "openai-compatible") + .unwrap(); + let image_api = config + .catalog + .ai + .iter() + .find(|item| item.id == "image-api") + .unwrap(); + let schema = compatible.config_schema.as_ref().unwrap(); + + assert_eq!(config.version, "9.9.9"); + assert!(compatible.installed); + assert_eq!(compatible.capabilities, vec!["text"]); + assert!(schema.contains_key("apiKey")); + assert_eq!( + schema + .get("endpoint") + .and_then(|field| field.default.as_ref()), + Some(&json!("https://example.test/v1")) + ); + assert_eq!(image_api.capabilities, vec!["image"]); + assert!( + image_api + .config_schema + .as_ref() + .unwrap() + .contains_key("apiKey") + ); + assert!( + !image_api + .config_schema + .as_ref() + .unwrap() + .contains_key("endpoint") + ); + } + + #[test] + fn load_full_config_routes_local_modules_by_type() { + let service = service(FakeConfigRepository { + providers: Vec::new(), + local_modules: vec![ + module(json!({ + "id": "local-ai", + "nameKey": "ui.module.local_ai", + "descKey": "ui.module.local_ai.desc", + "name": "Local AI", + "desc": "Local model", + "icon": "cpu", + "type": "local", + "repoUrl": null, + "expectedHash": null + })), + module(json!({ + "id": "service-module", + "nameKey": "ui.module.service", + "descKey": "ui.module.service.desc", + "name": "Service", + "desc": "Service module", + "icon": "plug", + "type": "service", + "repoUrl": null, + "expectedHash": null + })), + ], + custom_models: CustomModelConfig::default(), + }); + + let ConfigCatalog { + ai, + services, + stars, + } = service.load_full_config().unwrap().catalog; + + assert_eq!(ai.len(), 1); + assert_eq!(ai.first().map(|item| item.id.as_str()), Some("local-ai")); + assert_eq!(services.len(), 1); + assert_eq!( + services.first().map(|item| item.id.as_str()), + Some("service-module") + ); + assert!(stars.is_empty()); + } + + #[test] + fn load_custom_models_delegates_to_repository() { + let custom_models = CustomModelConfig { + models: vec![CustomModel { + id: "custom".to_string(), + name: "Custom".to_string(), + provider_id: "gpt".to_string(), + base_model_id: "base".to_string(), + created_at: 123.0, + }], + }; + let service = service(FakeConfigRepository { + providers: Vec::new(), + local_modules: Vec::new(), + custom_models, + }); + + let loaded = service.load_custom_models().unwrap(); + + assert_eq!(loaded.models.len(), 1); + assert_eq!( + loaded.models.first().map(|model| model.id.as_str()), + Some("custom") + ); + } +} diff --git a/src-tauri/src/domain/system/hardware_probe.rs b/src-tauri/src/domain/system/hardware_probe.rs index a32b606f..7a340c41 100644 --- a/src-tauri/src/domain/system/hardware_probe.rs +++ b/src-tauri/src/domain/system/hardware_probe.rs @@ -86,17 +86,12 @@ impl GpuInfo { "cuda" => AcceleratorClass::NvidiaCuda, "hip" => AcceleratorClass::AmdGpu, "sycl" => AcceleratorClass::IntelGpu, - "vulkan" => { - if gpu_name_brand(&self.name) == GpuBrand::Amd { - AcceleratorClass::AmdGpu - } else if gpu_name_brand(&self.name) == GpuBrand::Intel { - AcceleratorClass::IntelGpu - } else if self.detected { - AcceleratorClass::GenericGpu - } else { - AcceleratorClass::CpuOnly - } - } + "vulkan" => match gpu_name_brand(&self.name) { + GpuBrand::Amd => AcceleratorClass::AmdGpu, + GpuBrand::Intel => AcceleratorClass::IntelGpu, + _ if self.detected => AcceleratorClass::GenericGpu, + _ => AcceleratorClass::CpuOnly, + }, "cpu" => AcceleratorClass::CpuOnly, _ => { if self.detected { @@ -176,7 +171,7 @@ async fn probe_gpu_info_uncached() -> GpuInfo { fn probe_gpu_from_names(names: Option>) -> GpuInfo { match names { Some(names) if !names.is_empty() => gpu_probe_from_names(&names), - _ => default_probe(), + _ => nvidia_probe_from_nvml().unwrap_or_else(default_probe), } } @@ -207,6 +202,7 @@ async fn probe_windows_gpu_names() -> Option> { } #[cfg(target_os = "windows")] +#[allow(clippy::manual_let_else)] fn query_windows_gpu_names_wmi() -> Option> { #[derive(serde::Deserialize)] #[serde(rename_all = "PascalCase")] @@ -214,10 +210,15 @@ fn query_windows_gpu_names_wmi() -> Option> { name: Option, } - let connection = wmi::WMIConnection::new().ok()?; - let controllers: Vec = connection - .raw_query("SELECT Name FROM Win32_VideoController") - .ok()?; + let connection = match wmi::WMIConnection::new() { + Ok(connection) => connection, + Err(_) => return None, + }; + let controllers: Vec = + match connection.raw_query("SELECT Name FROM Win32_VideoController") { + Ok(controllers) => controllers, + Err(_) => return None, + }; let names = controllers .into_iter() .filter_map(|controller| controller.name) @@ -225,7 +226,7 @@ fn query_windows_gpu_names_wmi() -> Option> { .filter(|name| !name.is_empty()) .collect::>(); - Some(names) + normalize_names(names) } #[cfg(target_os = "macos")] @@ -293,8 +294,15 @@ fn gpu_probe_from_names(names: &[String]) -> GpuInfo { .or_else(|| names.first().cloned()) .unwrap_or_else(|| "Integrated / No GPU".to_string()); - let backend = preferred_backend_for_gpu_name(&primary_name); - let detected = gpu_name_brand(&primary_name) != GpuBrand::Software; + let brand = gpu_name_brand(&primary_name); + if brand == GpuBrand::Software { + if let Some(probe) = nvidia_probe_from_nvml() { + return probe; + } + } + + let backend = preferred_backend_for_gpu_brand(brand); + let detected = brand != GpuBrand::Software; let (cuda_driver_major, cuda_driver_minor) = if backend == "cuda" { detect_cuda_driver_version() } else { @@ -315,8 +323,36 @@ fn gpu_probe_from_names(names: &[String]) -> GpuInfo { } } -fn preferred_backend_for_gpu_name(name: &str) -> &'static str { - match gpu_name_brand(name) { +fn nvidia_probe_from_nvml() -> Option { + let Ok(nvml) = Nvml::init() else { + return None; + }; + let Ok(device_count) = nvml.device_count() else { + return None; + }; + if device_count == 0 { + return None; + } + let Ok(version) = nvml.sys_cuda_driver_version() else { + return None; + }; + + let major = u32::try_from(cuda_driver_version_major(version)).ok()?; + let minor = u32::try_from(cuda_driver_version_minor(version)).ok(); + + Some(GpuInfo { + detected: true, + name: "NVIDIA CUDA GPU".to_string(), + cuda: true, + backend: "cuda".to_string(), + memory: 0, + cuda_driver_major: Some(major), + cuda_driver_minor: minor, + }) +} + +const fn preferred_backend_for_gpu_brand(brand: GpuBrand) -> &'static str { + match brand { GpuBrand::Nvidia => "cuda", GpuBrand::Amd => "hip", GpuBrand::Intel => "sycl", @@ -511,8 +547,13 @@ mod tests { assert_eq!(intel.backend, "sycl"); let fallback = gpu_probe_from_names(&[String::from("Microsoft Basic Display Adapter")]); - assert_eq!(fallback.backend, "cpu"); - assert!(!fallback.detected); + if fallback.detected { + assert_eq!(fallback.backend, "cuda"); + assert!(fallback.cuda); + } else { + assert_eq!(fallback.backend, "cpu"); + assert!(!fallback.detected); + } } #[test] @@ -555,6 +596,17 @@ mod tests { assert_eq!(probe.backend, "cuda"); } + #[test] + fn nvml_fallback_is_used_when_windows_gpu_names_are_unavailable() { + let probe = probe_gpu_from_names(None); + if probe.detected { + assert_eq!(probe.backend, "cuda"); + assert!(probe.cuda); + } else { + assert_eq!(probe.backend, "cpu"); + } + } + #[test] fn merge_probe_with_runtime_stats_updates_name_and_memory() { let probe = GpuInfo { diff --git a/src-tauri/src/errors.rs b/src-tauri/src/errors.rs index 66568e37..ddfbf7bb 100644 --- a/src-tauri/src/errors.rs +++ b/src-tauri/src/errors.rs @@ -24,6 +24,10 @@ pub enum AppError { #[error("Permission denied: {0}")] PermissionDenied(String), + /// Frontend tried to access a secret outside the managed allowlist + #[error("Frontend secret access forbidden: {0}")] + FrontendSecretForbidden(String), + /// File system I/O error #[error("IO error: {0}")] Io(String), @@ -82,6 +86,7 @@ impl From for IpcError { AppError::Validation(msg) => ("VALIDATION", msg.clone()), AppError::NotFound(msg) => ("NOT_FOUND", msg.clone()), AppError::PermissionDenied(msg) => ("PERMISSION_DENIED", msg.clone()), + AppError::FrontendSecretForbidden(msg) => ("FRONTEND_SECRET_FORBIDDEN", msg.clone()), AppError::Io(msg) => ("IO_ERROR", msg.clone()), AppError::Serialization(msg) => ("SERIALIZATION", msg.clone()), AppError::Config(msg) => ("CONFIG", msg.clone()), diff --git a/src-tauri/src/infrastructure/config/config_repository.rs b/src-tauri/src/infrastructure/config/config_repository.rs index 9ac7db3e..57423a9b 100644 --- a/src-tauri/src/infrastructure/config/config_repository.rs +++ b/src-tauri/src/infrastructure/config/config_repository.rs @@ -1,6 +1,7 @@ use crate::domain::system::config_repository::ConfigRepository; use crate::errors::AppError; use crate::models::config::{AiModel, ApiProvider, AppMeta, ModuleItem}; +use crate::models::custom_models::CustomModelConfig; use std::path::{Path, PathBuf}; use tauri::AppHandle; @@ -71,21 +72,26 @@ impl FileConfigRepository { .map_err(|e| AppError::Config(format!("Failed to parse {filename}: {e}"))) } - fn parse_api_providers(content: &str) -> Result, AppError> { - let raw: serde_json::Value = serde_json::from_str(content) - .map_err(|e| AppError::Config(format!("Failed to parse api_providers.json: {e}")))?; - let providers = raw.as_array().ok_or_else(|| { - AppError::Config("Failed to parse api_providers.json: expected array".to_string()) - })?; - - let mut parsed_providers = Vec::with_capacity(providers.len()); - for (index, provider) in providers.iter().cloned().enumerate() { - if let Some(parsed) = Self::parse_api_provider(provider, index) { - parsed_providers.push(parsed); + fn load_custom_models_from_path(path: &Path) -> Result { + let content = match std::fs::read_to_string(path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(CustomModelConfig::default()); } - } + Err(error) => { + return Err(AppError::Io(format!( + "Failed to read custom models config at {}: {error}", + path.display() + ))); + } + }; - Ok(parsed_providers) + serde_json::from_str(&content).map_err(|error| { + AppError::Serialization(format!( + "Failed to parse custom models config at {}: {error}", + path.display() + )) + }) } fn load_api_provider_directory(root: &Path) -> Result, AppError> { @@ -213,15 +219,15 @@ impl ConfigRepository for FileConfigRepository { return Ok(providers); } - tracing::warn!( - "No API providers loaded from {}, trying legacy api_providers.json", + return Err(AppError::Config(format!( + "No API providers loaded from {}", root.display() - ); + ))); } - let content = Self::load_file_content("api_providers.json", "[]"); - - Self::parse_api_providers(&content) + Err(AppError::Config( + "API provider directory not found".to_string(), + )) } fn load_local_modules(&self) -> Result, AppError> { @@ -231,18 +237,9 @@ impl ConfigRepository for FileConfigRepository { ) } - fn load_custom_models( - &self, - ) -> Result { + fn load_custom_models(&self) -> Result { let custom_path = crate::utils::paths::CONFIG_DIR.join("custom_models.json"); - if custom_path.exists() { - if let Ok(content) = std::fs::read_to_string(&custom_path) { - if let Ok(config) = serde_json::from_str(&content) { - return Ok(config); - } - } - } - Ok(crate::models::custom_models::CustomModelConfig::default()) + Self::load_custom_models_from_path(&custom_path) } } @@ -251,65 +248,95 @@ mod tests { #![allow(clippy::expect_used)] use super::FileConfigRepository; + use crate::errors::AppError; use std::path::PathBuf; #[test] fn api_provider_parser_skips_invalid_models_without_dropping_catalog() -> Result<(), String> { - let providers = FileConfigRepository::parse_api_providers( + let provider = FileConfigRepository::parse_api_provider_file( r#" - [ - { - "id": "broken-provider", - "name": "Broken Provider", - "type": "api", - "models": [ - { - "id": "good-model", - "name": "Good Model", - "desc": "Valid model", - "tier": "medium", - "stats": { "speed": 8, "logic": 8, "creative": 6 } - }, - { - "id": "bad-model", - "name": "Bad Model", - "desc": "Invalid tier should not break the catalog", - "tier": "invalid", - "stats": { "speed": 8, "logic": 8, "creative": 6 } - } - ] - }, - { - "id": "healthy-provider", - "name": "Healthy Provider", - "type": "api", - "models": [] - } - ] + { + "id": "broken-provider", + "name": "Broken Provider", + "type": "api", + "models": [ + { + "id": "good-model", + "name": "Good Model", + "desc": "Valid model", + "tier": "medium", + "stats": { "speed": 8, "logic": 8, "creative": 6 } + }, + { + "id": "bad-model", + "name": "Bad Model", + "desc": "Invalid tier should not break the catalog", + "tier": "invalid", + "stats": { "speed": 8, "logic": 8, "creative": 6 } + } + ] + } "#, + PathBuf::from("broken-provider.json").as_path(), ) - .expect("provider list should parse"); + .ok_or_else(|| "provider should parse".to_string())?; - assert_eq!(providers.len(), 2); - let broken_provider = providers - .first() - .ok_or_else(|| "broken provider".to_string())?; - assert_eq!(broken_provider.id, "broken-provider"); - let models = broken_provider + assert_eq!(provider.id, "broken-provider"); + let models = provider .models .as_ref() .ok_or_else(|| "models".to_string())?; assert_eq!(models.len(), 1); let model = models.first().ok_or_else(|| "model".to_string())?; assert_eq!(model.id, "good-model"); - - let healthy_provider = providers - .get(1) - .ok_or_else(|| "healthy provider".to_string())?; - assert_eq!(healthy_provider.id, "healthy-provider"); Ok(()) } + #[test] + fn api_provider_directory_loads_multiple_provider_files() { + let temp = tempfile::tempdir().expect("temp dir"); + let text_dir = temp.path().join("text"); + let image_dir = temp.path().join("image"); + std::fs::create_dir_all(&text_dir).expect("text dir"); + std::fs::create_dir_all(&image_dir).expect("image dir"); + + std::fs::write( + text_dir.join("first.json"), + r#"{ + "id": "first-provider", + "name": "First Provider", + "type": "api", + "models": [] + }"#, + ) + .expect("first provider"); + std::fs::write( + image_dir.join("second.json"), + r#"{ + "id": "second-provider", + "name": "Second Provider", + "type": "api", + "models": [] + }"#, + ) + .expect("second provider"); + + let providers = + FileConfigRepository::load_api_provider_directory(temp.path()).expect("providers"); + + assert_eq!(providers.len(), 2); + assert!( + providers + .iter() + .any(|provider| provider.id == "first-provider") + ); + assert!( + providers + .iter() + .any(|provider| provider.id == "second-provider") + ); + } + #[test] fn api_provider_directory_skips_invalid_files_without_dropping_others() { let temp = tempfile::tempdir().expect("temp dir"); @@ -386,4 +413,52 @@ mod tests { }) })); } + + #[test] + fn custom_models_loader_defaults_only_when_file_is_missing() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let missing_path = temp_dir.path().join("custom_models.json"); + + let config = FileConfigRepository::load_custom_models_from_path(&missing_path) + .expect("missing custom models should default"); + + assert!(config.models.is_empty()); + } + + #[test] + fn custom_models_loader_parses_valid_json() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let path = temp_dir.path().join("custom_models.json"); + std::fs::write( + &path, + r#"{"models":[{"id":"custom-1","name":"Custom One","provider_id":"gpt","base_model_id":"gpt-4.1","created_at":1712345678.0}]}"#, + ) + .expect("valid custom models fixture"); + + let config = FileConfigRepository::load_custom_models_from_path(&path) + .expect("valid custom models should parse"); + + let model = config.models.first().expect("written custom model"); + assert_eq!(config.models.len(), 1); + assert_eq!(model.id, "custom-1"); + assert_eq!(model.name, "Custom One"); + assert_eq!(model.provider_id, "gpt"); + assert_eq!(model.base_model_id, "gpt-4.1"); + assert!((model.created_at - 1_712_345_678.0).abs() < f64::EPSILON); + } + + #[test] + fn custom_models_loader_reports_invalid_json() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let path = temp_dir.path().join("custom_models.json"); + std::fs::write(&path, "{broken").expect("broken custom models fixture"); + + let error = FileConfigRepository::load_custom_models_from_path(&path) + .expect_err("invalid custom models should not default"); + + assert!(matches!( + error, + AppError::Serialization(message) if message.contains("custom_models.json") + )); + } } diff --git a/src-tauri/src/infrastructure/config/engine_settings.rs b/src-tauri/src/infrastructure/config/engine_settings.rs index 6eb15aa6..d808ae05 100644 --- a/src-tauri/src/infrastructure/config/engine_settings.rs +++ b/src-tauri/src/infrastructure/config/engine_settings.rs @@ -3,6 +3,7 @@ //! Handles reading and writing `engine_config.json` outside of the API layer. use std::collections::HashMap; +use std::io::ErrorKind; use crate::domain::engine::config::normalize_engine_config; use crate::domain::engine::types::EngineConfig; @@ -36,7 +37,11 @@ pub async fn load_engine_config_map() -> Result { /// Saves persisted engine configuration map atomically. pub async fn save_engine_config_map(map: &EngineConfigMap) -> Result<(), AppError> { let path = &*FILE_ENGINE_CONFIG; - let tmp = path.with_extension("tmp"); + let tmp = path.with_extension(format!( + "tmp-{}-{}", + std::process::id(), + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); if let Some(dir) = path.parent() { tokio::fs::create_dir_all(dir) @@ -46,16 +51,36 @@ pub async fn save_engine_config_map(map: &EngineConfigMap) -> Result<(), AppErro let json = serde_json::to_string_pretty(map).map_err(|e| AppError::Serialization(e.to_string()))?; - tokio::fs::write(&tmp, &json) - .await - .map_err(|e| AppError::Io(e.to_string()))?; - - if let Err(e) = tokio::fs::rename(&tmp, path).await { - let _ = tokio::fs::remove_file(path).await; - tokio::fs::rename(&tmp, path) + { + let mut file = tokio::fs::File::create(&tmp) + .await + .map_err(|e| AppError::Io(e.to_string()))?; + tokio::io::AsyncWriteExt::write_all(&mut file, json.as_bytes()) + .await + .map_err(|e| AppError::Io(e.to_string()))?; + file.sync_all() .await - .map_err(|_| AppError::Io(e.to_string()))?; + .map_err(|e| AppError::Io(e.to_string()))?; + } + + if let Err(rename_error) = tokio::fs::rename(&tmp, path).await { + cleanup_engine_config_tmp(&tmp).await; + return Err(AppError::Io(format!( + "Failed to atomically publish engine config '{}': {rename_error}", + path.display() + ))); } Ok(()) } + +async fn cleanup_engine_config_tmp(tmp: &std::path::Path) { + if let Err(error) = tokio::fs::remove_file(tmp).await { + if error.kind() != ErrorKind::NotFound { + tracing::warn!( + "Failed to remove temporary engine config {}: {error}", + tmp.display() + ); + } + } +} diff --git a/src-tauri/src/infrastructure/config/settings.rs b/src-tauri/src/infrastructure/config/settings.rs index 973383cc..09fd5111 100644 --- a/src-tauri/src/infrastructure/config/settings.rs +++ b/src-tauri/src/infrastructure/config/settings.rs @@ -120,8 +120,7 @@ impl SettingsService { let mut ui_state = self .json_store .load_async::(&FILE_UI_STATE) - .await - .unwrap_or_default(); + .await?; let normalized = language.trim().to_lowercase(); ui_state.preferred_language = if normalized.is_empty() { diff --git a/src-tauri/src/infrastructure/config/theme.rs b/src-tauri/src/infrastructure/config/theme.rs index 7c14b376..34de4181 100644 --- a/src-tauri/src/infrastructure/config/theme.rs +++ b/src-tauri/src/infrastructure/config/theme.rs @@ -34,12 +34,5 @@ pub fn get_theme_colors() -> HashMap { colors.insert("text_muted".to_string(), "#6c757d".to_string()); colors.insert("secondary".to_string(), "#a0a0a0".to_string()); - // Legacy aliases - colors.insert("bg".to_string(), "#111015".to_string()); - colors.insert("sidebar".to_string(), "#1a1920".to_string()); - colors.insert("input_bg".to_string(), "#26252d".to_string()); - colors.insert("hover".to_string(), "#31303a".to_string()); - colors.insert("card_bg".to_string(), "#1a1920".to_string()); - colors } diff --git a/src-tauri/src/infrastructure/config/translations.rs b/src-tauri/src/infrastructure/config/translations.rs index 54e9b875..2b96b4a6 100644 --- a/src-tauri/src/infrastructure/config/translations.rs +++ b/src-tauri/src/infrastructure/config/translations.rs @@ -20,12 +20,24 @@ pub fn get_translations(_app: &AppHandle, lang: &str) -> Result None, }; - if let Some(content) = target_content - && let Ok(target_json) = serde_json::from_str::(&content) - && let Some(target_map) = target_json.as_object() - { - for (k, v) in target_map { - translations.insert(k.clone(), v.clone()); + if let Some(content) = target_content { + match serde_json::from_str::(&content) { + Ok(target_json) => { + if let Some(target_map) = target_json.as_object() { + for (k, v) in target_map { + translations.insert(k.clone(), v.clone()); + } + } else { + tracing::warn!( + "Locale '{lang}' root is not a JSON object; using English fallbacks" + ); + } + } + Err(error) => { + tracing::warn!( + "Failed to parse locale '{lang}', using English fallbacks: {error}" + ); + } } } } diff --git a/src-tauri/src/infrastructure/config/ui_state.rs b/src-tauri/src/infrastructure/config/ui_state.rs index 04251583..927b2722 100644 --- a/src-tauri/src/infrastructure/config/ui_state.rs +++ b/src-tauri/src/infrastructure/config/ui_state.rs @@ -80,3 +80,52 @@ fn clamp_zoom(zoom: f64) -> f64 { zoom.clamp(SCALING_MIN_ZOOM, SCALING_MAX_ZOOM) } + +#[cfg(test)] +mod tests { + use super::normalize_ui_state; + use crate::models::UIState; + + #[test] + fn normalize_ui_state_preserves_local_max_output_tokens() { + let mut state = UIState::default(); + state + .local_max_output_tokens + .insert("llamacpp".to_string(), 8192); + + let normalized = normalize_ui_state(state); + + assert_eq!( + normalized.local_max_output_tokens.get("llamacpp"), + Some(&8192) + ); + } + + #[test] + fn ui_state_deserializes_without_local_max_output_tokens() { + let state_result = serde_json::from_str::( + r#"{ + "sidebar_collapsed": false, + "sidebar_width": 280, + "hidden_nav_items": [], + "hidden_monitors": [], + "card_widths": {}, + "download_limit_enabled": false, + "download_max_speed": 50, + "selected_modules": {}, + "zoom_level": 1.0, + "selected_ai_models": {}, + "last_page": null, + "resolution_zoom": {}, + "sound_enabled": true + }"#, + ); + assert!( + state_result.is_ok(), + "UI state with omitted optional maps should remain readable" + ); + if let Ok(state) = state_result { + assert!(state.local_max_output_tokens.is_empty()); + } + } +} diff --git a/src-tauri/src/infrastructure/config/window_settings.rs b/src-tauri/src/infrastructure/config/window_settings.rs index 87feb86d..42eeff96 100644 --- a/src-tauri/src/infrastructure/config/window_settings.rs +++ b/src-tauri/src/infrastructure/config/window_settings.rs @@ -261,7 +261,7 @@ mod tests { }; #[test] - fn normalize_window_settings_raises_legacy_small_sizes() { + fn normalize_window_settings_raises_too_small_sizes() { let normalized = normalize_window_settings(WindowSettings { width: 1000, height: 600, diff --git a/src-tauri/src/infrastructure/crypto/secure_storage.rs b/src-tauri/src/infrastructure/crypto/secure_storage.rs index f6a0202f..6cc82826 100644 --- a/src-tauri/src/infrastructure/crypto/secure_storage.rs +++ b/src-tauri/src/infrastructure/crypto/secure_storage.rs @@ -124,12 +124,31 @@ impl SecureStorage { fs::rename(&path, &backup_path).map_err(|e| AppError::Io(e.to_string()))?; } - if let Err(error) = fs::copy(&pending_path, &path) { - if backup_path.exists() { - let _ = fs::rename(&backup_path, &path); + if let Err(error) = fs::rename(&pending_path, &path) { + let rollback_error = if backup_path.exists() { + fs::rename(&backup_path, &path).err() + } else { + None + }; + if pending_path.exists() + && let Err(cleanup_error) = fs::remove_file(&pending_path) + { + tracing::warn!( + path = %pending_path.display(), + "Failed to remove pending secure storage file after publish failure: {cleanup_error}" + ); + } + if let Some(rollback_error) = rollback_error { + return Err(AppError::Io(format!( + "Failed to publish secure storage '{}': {error}; rollback from '{}' also failed: {rollback_error}", + path.display(), + backup_path.display() + ))); } - let _ = fs::remove_file(&pending_path); - return Err(AppError::Io(error.to_string())); + return Err(AppError::Io(format!( + "Failed to publish secure storage '{}': {error}", + path.display() + ))); } fs::OpenOptions::new() diff --git a/src-tauri/src/infrastructure/engine/tauri_emitter.rs b/src-tauri/src/infrastructure/engine/tauri_emitter.rs index 39500240..0ea62043 100644 --- a/src-tauri/src/infrastructure/engine/tauri_emitter.rs +++ b/src-tauri/src/infrastructure/engine/tauri_emitter.rs @@ -30,13 +30,6 @@ impl TauriEngineEmitter { } } -fn canonical_image_engine_id(engine_id: &str) -> &str { - match engine_id { - "stable-diffusion" => "sdcpp", - value => value, - } -} - fn parse_step_totals(line: &str) -> Option<(u32, u32)> { for token in line.split_whitespace() { let Some((step, total)) = token.split_once('/') else { @@ -129,46 +122,58 @@ fn current_time_ms_f64() -> f64 { impl EngineEventEmitter for TauriEngineEmitter { fn emit_swapping(&self, from: &str, to: &str) { - let _ = self + if let Err(error) = self .handle - .emit("ai:engine:swapping", json!({ "from": from, "to": to })); + .emit("ai:engine:swapping", json!({ "from": from, "to": to })) + { + tracing::warn!("Failed to emit engine swapping event: {error}"); + } } fn emit_starting(&self, engine_id: &str) { - let _ = self + if let Err(error) = self .handle - .emit("ai:engine:starting", json!({ "engine_id": engine_id })); + .emit("ai:engine:starting", json!({ "engine_id": engine_id })) + { + tracing::warn!("Failed to emit engine starting event for {engine_id}: {error}"); + } } fn emit_ready(&self, engine_id: &str, endpoint: &str) { - let _ = self.handle.emit( + if let Err(error) = self.handle.emit( "ai:engine:ready", json!({ "engine_id": engine_id, "endpoint": endpoint }), - ); + ) { + tracing::warn!("Failed to emit engine ready event for {engine_id}: {error}"); + } } fn emit_error(&self, engine_id: &str, message: &str) { - let _ = self.handle.emit( + if let Err(error) = self.handle.emit( "ai:engine:error", json!({ "engine_id": engine_id, "message": message }), - ); + ) { + tracing::warn!("Failed to emit engine error event for {engine_id}: {error}"); + } } fn emit_log(&self, engine_id: &str, line: &str) { - if engine_id == "sdcpp" || engine_id == "stable-diffusion" { + if engine_id == "sdcpp" { crate::app::tray::update_background_generation_progress(&self.handle, line); if let Some(progress) = parse_sdcpp_progress_line(line) { let state = Arc::clone(&self.image_generation_state); - let provider = canonical_image_engine_id(engine_id).to_string(); + let provider = engine_id.to_string(); tauri::async_runtime::spawn(async move { state.update_progress(&provider, progress).await; }); } } - let _ = self.handle.emit( + if let Err(error) = self.handle.emit( "ai:engine:log", json!({ "engine_id": engine_id, "line": line }), - ); + ) { + tracing::warn!("Failed to emit engine log event for {engine_id}: {error}"); + } } } diff --git a/src-tauri/src/infrastructure/filesystem/local_file_service.rs b/src-tauri/src/infrastructure/filesystem/local_file_service.rs index 6e0ed422..700a24a0 100644 --- a/src-tauri/src/infrastructure/filesystem/local_file_service.rs +++ b/src-tauri/src/infrastructure/filesystem/local_file_service.rs @@ -3,6 +3,7 @@ use crate::errors::AppError; use async_trait::async_trait; use std::path::{Path, PathBuf}; use tokio::fs; +use tokio::io::AsyncWriteExt; /// Local filesystem implementation of FileService #[derive(Debug, Default, Clone)] @@ -13,6 +14,106 @@ impl LocalFileService { pub const fn new() -> Self { Self } + + #[cfg(not(target_os = "windows"))] + async fn sync_parent_dir(path: &Path) -> Result<(), AppError> { + let Some(parent) = path.parent() else { + return Ok(()); + }; + + let dir = fs::File::open(parent) + .await + .map_err(|e| AppError::Io(e.to_string()))?; + dir.sync_all() + .await + .map_err(|e| AppError::Io(e.to_string())) + } + + #[cfg(target_os = "windows")] + #[allow(clippy::unused_async)] + async fn sync_parent_dir(_path: &Path) -> Result<(), AppError> { + Ok(()) + } + + async fn write_atomic(path: &Path, content: &[u8]) -> Result<(), AppError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .await + .map_err(|e| AppError::Io(e.to_string()))?; + } + + let tmp = path.with_extension(format!( + "tmp-{}-{}", + std::process::id(), + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + + let mut file = fs::File::create(&tmp) + .await + .map_err(|e| AppError::Io(e.to_string()))?; + file.write_all(content) + .await + .map_err(|e| AppError::Io(e.to_string()))?; + file.sync_all() + .await + .map_err(|e| AppError::Io(e.to_string()))?; + drop(file); + + if let Err(first_error) = fs::rename(&tmp, path).await { + let retryable_replace = matches!( + first_error.kind(), + std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::PermissionDenied + ); + if !retryable_replace { + let _ = fs::remove_file(&tmp).await; + return Err(AppError::Io(format!( + "Failed to publish atomic write to '{}': rename failed: {first_error}", + path.display() + ))); + } + + let backup = path.with_extension(format!( + "bak-{}-{}", + std::process::id(), + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + let had_original = fs::try_exists(path) + .await + .map_err(|e| AppError::Io(e.to_string()))?; + if had_original && let Err(backup_error) = fs::rename(path, &backup).await { + let _ = fs::remove_file(&tmp).await; + return Err(AppError::Io(format!( + "Failed to replace '{}': rename failed: {first_error}; backing up existing file failed: {backup_error}", + path.display() + ))); + } + + if let Err(second_error) = fs::rename(&tmp, path).await { + let restore_message = if had_original { + match fs::rename(&backup, path).await { + Ok(()) => "backup restore succeeded".to_string(), + Err(restore_error) => { + format!("backup restore failed: {restore_error}") + } + } + } else { + "no original file to restore".to_string() + }; + let _ = fs::remove_file(&tmp).await; + return Err(AppError::Io(format!( + "Failed to publish atomic write to '{}': first rename failed: {first_error}; second rename failed: {second_error}; {restore_message}", + path.display() + ))); + } + + if had_original { + let _ = fs::remove_file(&backup).await; + } + } + Self::sync_parent_dir(path).await?; + + Ok(()) + } } #[async_trait] @@ -24,12 +125,7 @@ impl FileService for LocalFileService { } async fn write_string(&self, path: &Path, content: &str) -> Result<(), AppError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).await.ok(); - } - fs::write(path, content) - .await - .map_err(|e| AppError::Io(e.to_string())) + Self::write_atomic(path, content.as_bytes()).await } async fn read_bytes(&self, path: &Path) -> Result, AppError> { @@ -39,26 +135,19 @@ impl FileService for LocalFileService { } async fn write_bytes(&self, path: &Path, content: &[u8]) -> Result<(), AppError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).await.ok(); - } - fs::write(path, content) - .await - .map_err(|e| AppError::Io(e.to_string())) + Self::write_atomic(path, content).await } async fn delete(&self, path: &Path) -> Result<(), AppError> { - if !path.exists() { - return Ok(()); - } - if path.is_dir() { - fs::remove_dir_all(path) + match fs::metadata(path).await { + Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(path) .await - .map_err(|e| AppError::Io(e.to_string())) - } else { - fs::remove_file(path) + .map_err(|e| AppError::Io(e.to_string())), + Ok(_) => fs::remove_file(path) .await - .map_err(|e| AppError::Io(e.to_string())) + .map_err(|e| AppError::Io(e.to_string())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(AppError::Io(error.to_string())), } } @@ -88,3 +177,57 @@ impl FileService for LocalFileService { Ok(combined) } } + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::LocalFileService; + use crate::domain::filesystem::service::FileService; + use crate::errors::AppError; + + #[tokio::test] + async fn write_string_creates_missing_parent_directories() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let target = temp_dir.path().join("nested").join("value.txt"); + let service = LocalFileService::new(); + + service + .write_string(&target, "ok") + .await + .expect("write should create parents"); + + assert_eq!(std::fs::read_to_string(target).expect("written file"), "ok"); + } + + #[tokio::test] + async fn write_string_reports_parent_creation_failures() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let file_parent = temp_dir.path().join("not-a-directory"); + std::fs::write(&file_parent, "occupied").expect("fixture file"); + let target = file_parent.join("value.txt"); + let service = LocalFileService::new(); + + let error = service + .write_string(&target, "ok") + .await + .expect_err("parent creation failure should surface"); + + assert!(matches!(error, AppError::Io(_))); + } + + #[tokio::test] + async fn write_bytes_replaces_existing_file_atomically() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let target = temp_dir.path().join("value.bin"); + std::fs::write(&target, b"old").expect("fixture file"); + let service = LocalFileService::new(); + + service + .write_bytes(&target, b"new") + .await + .expect("write should replace existing file"); + + assert_eq!(std::fs::read(target).expect("written file"), b"new"); + } +} diff --git a/src-tauri/src/infrastructure/logging/logger.rs b/src-tauri/src/infrastructure/logging/logger.rs index b38ad645..aa83989f 100644 --- a/src-tauri/src/infrastructure/logging/logger.rs +++ b/src-tauri/src/infrastructure/logging/logger.rs @@ -1,3 +1,5 @@ +use crate::domain::engine::manager::canonical_engine_id as normalize_engine_id; +use chrono::TimeZone; use serde::Serialize; use std::cmp::Ordering; use std::collections::VecDeque; @@ -155,39 +157,9 @@ pub fn get_logs_since(since: f64) -> Vec { } } -fn is_frontend_relevant_log(entry: &LogEntry) -> bool { - let message = entry.message.to_ascii_uppercase(); - let source = entry.source.to_ascii_uppercase(); - - let is_bot_source = source.contains("CHATSERVICE") - || source.contains("AIBRIDGE") - || source.contains("AI_SERVICE") - || source.contains("DOMAIN::AI") - || source.contains("DOMAIN::ENGINE") - || source.contains("INFRASTRUCTURE::ENGINE"); - - let is_ai_noise = message.contains("GEMINI_ERROR") - || message.contains("ERROR 429") - || message.contains("ERROR 400") - || message.contains("ERROR 403") - || message.contains("ERROR 500") - || message.contains("QUOTA") - || message.contains("PERMISSION_DENIED") - || message.contains("INVALID_ARGUMENT") - || message.contains("DEADLINE_EXCEEDED") - || message.contains("FAILED_PRECONDITION") - || message.contains("UNAVAILABLE") - || message.contains("INTERNAL_ERROR"); - - !(is_bot_source || is_ai_noise) -} - -/// Retrieves frontend-facing log entries since a timestamp with noisy AI chatter removed. +/// Retrieves frontend-facing log entries since a timestamp. pub fn get_frontend_logs_since(since: f64) -> Vec { - let mut logs: Vec = get_logs_since(since) - .into_iter() - .filter(is_frontend_relevant_log) - .collect(); + let mut logs: Vec = get_logs_since(since); logs.extend(RuntimeLogCollector::collect_since(since)); logs.sort_by(|left, right| { @@ -199,10 +171,19 @@ pub fn get_frontend_logs_since(since: f64) -> Vec { logs } +/// Retrieves frontend-facing log entries for one explicit console view. +pub fn get_frontend_logs_for_view(view_id: &str, since: f64) -> Vec { + get_frontend_logs_since(since) + .into_iter() + .filter(|entry| is_entry_in_console_view(entry, view_id)) + .collect() +} + fn parse_runtime_log_line( namespace: RuntimeLogNamespace, runtime_id: &str, line: &str, + line_index: usize, since: f64, ) -> Option { let line = line.trim(); @@ -210,7 +191,8 @@ fn parse_runtime_log_line( return None; } - let timestamp = parse_log_timestamp(line)?; + let timestamp_offset = f64::from(u32::try_from(line_index).ok()?) / 1_000_000.0; + let timestamp = parse_log_timestamp(line)? + timestamp_offset; if timestamp <= since { return None; } @@ -563,24 +545,20 @@ fn sanitize_module_id(raw: &str) -> Option { fn infer_runtime_log_source(namespace: RuntimeLogNamespace, runtime_id: &str) -> String { match namespace { - RuntimeLogNamespace::Engine => canonical_engine_id(runtime_id).to_string(), + RuntimeLogNamespace::Engine => normalize_engine_id(runtime_id), RuntimeLogNamespace::Module => format!("module:{runtime_id}"), } } -fn canonical_engine_id(engine_id: &str) -> &str { - match engine_id { - "stable-diffusion" => "sdcpp", - value => value, - } -} - fn parse_log_timestamp(line: &str) -> Option { let timestamp_text = line.get(..19)?; chrono::NaiveDateTime::parse_from_str(timestamp_text, "%Y-%m-%d %H:%M:%S") .ok() .and_then(|timestamp| { - let timestamp = timestamp.and_utc(); + 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() @@ -634,7 +612,7 @@ fn is_entry_in_console_view(entry: &LogEntry, view_id: &str) -> bool { } if let Some(engine_id) = view_id.strip_prefix("engine:") { - return canonical_engine_id(&entry.source) == canonical_engine_id(engine_id); + return normalize_engine_id(&entry.source) == normalize_engine_id(engine_id); } false @@ -648,6 +626,7 @@ fn clear_module_runtime_logs() { pub fn init_global_logger() -> Result { let log_dir = &*crate::utils::paths::LOG_DIR; std::fs::create_dir_all(log_dir).map_err(|e| e.to_string())?; + clear_startup_log_files(log_dir).map_err(|e| e.to_string())?; // Keep the launcher log easy to open from the UI and external editors. let file_appender = tracing_appender::rolling::never(log_dir, "axelate.log"); @@ -662,6 +641,9 @@ pub fn init_global_logger() -> Result Result std::io::Result<()> { + fs::write(log_dir.join("axelate.log"), "")?; + clear_module_runtime_logs(); + Ok(()) +} + impl RuntimeLogCollector { fn is_known_engine_source(source: &str) -> bool { - let source = canonical_engine_id(source); + let source = normalize_engine_id(source); Self::runtime_ids(&crate::utils::paths::ENGINE_LOGS_DIR) .into_iter() - .any(|runtime_id| canonical_engine_id(&runtime_id) == source) + .any(|runtime_id| normalize_engine_id(&runtime_id) == source) } fn runtime_ids(root: &Path) -> Vec { @@ -785,7 +773,10 @@ impl RuntimeLogCollector { entries.extend( content .lines() - .filter_map(|line| parse_runtime_log_line(namespace, runtime_id, line, since)), + .enumerate() + .filter_map(|(line_index, line)| { + parse_runtime_log_line(namespace, runtime_id, line, line_index, since) + }), ); } @@ -810,7 +801,12 @@ impl RuntimeLogCollector { for log_file in log_files.filter_map(Result::ok) { let path = log_file.path(); if Self::is_log_file(&path) { - let _ = fs::write(path, ""); + if let Err(error) = fs::write(&path, "") { + tracing::warn!( + path = %path.display(), + "Failed to clear runtime log file: {error}" + ); + } } } } @@ -863,6 +859,7 @@ mod tests { RuntimeLogNamespace::Module, "sample-integration", "2026-04-24 07:00:00 [INFO] Integration started", + 0, 0.0, ) .ok_or_else(|| "module runtime log entry".to_string())?; @@ -879,6 +876,7 @@ mod tests { RuntimeLogNamespace::Engine, "llamacpp", "2026-04-24 07:00:00 [INFO] model loaded", + 0, 0.0, ) .ok_or_else(|| "engine runtime log entry".to_string())?; @@ -888,4 +886,19 @@ mod tests { assert_eq!(entry.source_label.as_deref(), Some("Llamacpp")); Ok(()) } + + #[test] + fn engine_runtime_log_line_uses_shared_engine_id_normalization() -> Result<(), String> { + let entry = parse_runtime_log_line( + RuntimeLogNamespace::Engine, + "llama.cpp", + "2026-04-24 07:00:00 [INFO] model loaded", + 0, + 0.0, + ) + .ok_or_else(|| "engine runtime log entry".to_string())?; + + assert_eq!(entry.source, "llama-cpp"); + Ok(()) + } } diff --git a/src-tauri/src/infrastructure/monitoring/tauri_emitter.rs b/src-tauri/src/infrastructure/monitoring/tauri_emitter.rs index 78fefea6..0dc87117 100644 --- a/src-tauri/src/infrastructure/monitoring/tauri_emitter.rs +++ b/src-tauri/src/infrastructure/monitoring/tauri_emitter.rs @@ -19,6 +19,8 @@ impl TauriMonitoringEmitter { impl SystemStatsEmitter for TauriMonitoringEmitter { fn emit_stats(&self, stats: &SystemStats) { - let _ = self.app.emit("system_stats", stats.clone()); + if let Err(error) = self.app.emit("system_stats", stats.clone()) { + tracing::warn!("Failed to emit system stats: {error}"); + } } } diff --git a/src-tauri/src/infrastructure/persistence/json_store.rs b/src-tauri/src/infrastructure/persistence/json_store.rs index 658abb7a..c404828e 100644 --- a/src-tauri/src/infrastructure/persistence/json_store.rs +++ b/src-tauri/src/infrastructure/persistence/json_store.rs @@ -33,16 +33,12 @@ impl JsonStore { let content = self.file_service.read_to_string(path).await?; - match serde_json::from_str(&content) { - Ok(data) => Ok(data), - Err(e) => { - tracing::warn!( - "Failed to parse JSON at {}, resetting to defaults: {e}", - path.display() - ); - Ok(T::default()) - } - } + serde_json::from_str(&content).map_err(|error| { + AppError::Serialization(format!( + "Failed to parse JSON at {}: {error}", + path.display() + )) + }) } /// Saves a data structure to a JSON file asynchronously @@ -56,3 +52,43 @@ impl JsonStore { self.file_service.write_string(path, &content).await } } + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::JsonStore; + use crate::errors::AppError; + use crate::infrastructure::filesystem::local_file_service::LocalFileService; + use std::sync::Arc; + + #[tokio::test] + async fn load_async_defaults_only_when_file_is_missing() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let store = JsonStore::new(Arc::new(LocalFileService::new())); + + let value: serde_json::Value = store + .load_async(&temp_dir.path().join("missing.json")) + .await + .expect("missing file should default"); + + assert_eq!(value, serde_json::Value::Null); + } + + #[tokio::test] + async fn load_async_returns_error_for_invalid_json() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let path = temp_dir.path().join("broken.json"); + std::fs::write(&path, "{broken").expect("broken json fixture"); + let store = JsonStore::new(Arc::new(LocalFileService::new())); + + let error = store + .load_async::(&path) + .await + .expect_err("invalid json should not default"); + + assert!( + matches!(error, AppError::Serialization(message) if message.contains("broken.json")) + ); + } +} diff --git a/src-tauri/src/infrastructure/system/startup.rs b/src-tauri/src/infrastructure/system/startup.rs index 24d62f45..59418d15 100644 --- a/src-tauri/src/infrastructure/system/startup.rs +++ b/src-tauri/src/infrastructure/system/startup.rs @@ -236,17 +236,23 @@ fn escape_applescript(value: &str) -> String { fn open_external_url(url: &str) { #[cfg(target_os = "windows")] { - let _ = Command::new("cmd").args(["/C", "start", "", url]).spawn(); + if let Err(error) = Command::new("cmd").args(["/C", "start", "", url]).spawn() { + tracing::warn!("Failed to open startup install guide URL '{url}': {error}"); + } } #[cfg(target_os = "macos")] { - let _ = Command::new("open").arg(url).spawn(); + if let Err(error) = Command::new("open").arg(url).spawn() { + tracing::warn!("Failed to open startup install guide URL '{url}': {error}"); + } } #[cfg(target_os = "linux")] { - let _ = Command::new("xdg-open").arg(url).spawn(); + if let Err(error) = Command::new("xdg-open").arg(url).spawn() { + tracing::warn!("Failed to open startup install guide URL '{url}': {error}"); + } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 99d88fac..d03c305b 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, license, + ai, engine, modules::{self, downloader}, secure, settings::{self, theme, translations, ui_state, window_settings}, @@ -77,7 +77,7 @@ use infrastructure::{ persistence::json_store::JsonStore, }; -use specta_typescript::Typescript; +use specta_typescript::{Typescript, define, semantic::Configuration}; use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; use tauri::Manager; @@ -89,101 +89,111 @@ const TYPESCRIPT_BINDINGS_HEADER: &str = "// @ts-nocheck\n/* eslint-disable @typ /// Creates and configures the Specta builder with all application commands. pub fn create_specta_builder() -> Builder { - Builder::::new().commands(collect_commands![ - health::get_health, - config::get_config, - settings::get_settings, - settings::save_settings, - settings::save_setting, - settings::get_module_settings, - settings::save_module_settings, - settings::get_system_language, - logs::get_logs, - logs::get_console_overview, - logs::clear_logs, - logs::clear_console_logs, - logs::get_log_dir, - logs::open_log_dir, - logs::open_console_log_target, - logs::add_log, - logs::log_batch, - downloader::download_module, - downloader::resume_download, - downloader::check_module_installed, - downloader::get_module_path, - downloader::delete_module, - downloader::list_module_files, - downloader::set_download_settings, - downloader::cancel_download, - downloader::pause_download, - system::get_system_stats, - system::get_gpu_info, - system::set_monitoring_paused, - modules::get_modules, - modules::control_module, - modules::get_module_status, - modules::create_module_settings_session, - window::minimize_window, - window::maximize_window, - window::close_window, - window::show_window, - window::hide_window, - translations::get_translations, - license::get_license_status, - license::activate_license, - license::deactivate_license, - license::check_feature, - theme::get_theme_colors, - window_settings::get_window_settings, - window_settings::save_window_size, - window_settings::save_window_position, - window_settings::save_maximized_state, - window_settings::save_zoom_level, - window_settings::set_webview_zoom, - window_settings::save_current_resolution_zoom, - window_settings::get_webview_zoom, - window_settings::get_resolution_zoom, - window_settings::get_window_config, - window_settings::get_window_policy, - ui_state::get_ui_state, - ui_state::save_ui_state, - bootstrap::get_app_bootstrap_data, - secure::save_secure_key, - secure::get_secure_key, - secure::has_secure_key, - secure::get_secure_key_meta, - ai::send_chat_message, - ai::cancel_chat_generation, - ai::validate_api_key, - ai::validate_stored_api_key, - ai::clear_chat_history, - ai::get_chat_history, - ai::rewind_last_turn, - ai::count_tokens, - ai::generate_image, - ai::generate_image_background, - ai::cancel_image_generation, - ai::get_image_generation_preview, - ai::delete_chat_image, - ai::open_chat_image_location, - ai::save_chat_image_default, - voice::recognize_voice_once, - voice::cancel_voice_recognition, - voice::open_voice_privacy_settings, - custom_model_service::get_custom_models, - custom_model_service::add_custom_model, - custom_model_service::remove_custom_model, - file_service::process_file_content, - engine::start_engine, - engine::stop_engine, - engine::stop_engine_slot, - engine::get_engine_state, - engine::check_engine_installed, - engine::get_engine_definitions, - engine::get_engine_config, - engine::get_engine_settings_payload, - engine::set_engine_config, - ]) + let semantic_types = Configuration::default() + .enable_lossless_floats() + .define::(|_| define("unknown").into(), None, None); + + Builder::::new() + .dangerously_cast_bigints_to_number() + .semantic_types(semantic_types) + .commands(collect_commands![ + health::get_health, + config::get_config, + settings::get_settings, + settings::save_settings, + settings::save_setting, + settings::get_module_settings, + settings::save_module_settings, + settings::get_system_language, + logs::get_logs, + logs::get_console_logs, + logs::get_console_overview, + logs::clear_logs, + logs::clear_console_logs, + logs::get_log_dir, + logs::open_log_dir, + logs::open_console_log_target, + logs::add_log, + logs::log_batch, + downloader::download_module, + downloader::get_release_download_options, + downloader::import_integration_folder, + downloader::import_integration_archive, + downloader::import_integration_path, + downloader::import_integration_url, + downloader::resume_download, + downloader::check_module_installed, + downloader::get_module_path, + downloader::delete_module, + downloader::list_module_files, + downloader::set_download_settings, + downloader::cancel_download, + downloader::pause_download, + system::get_system_stats, + system::get_gpu_info, + system::set_monitoring_paused, + modules::get_modules, + modules::control_module, + modules::get_module_status, + modules::create_module_settings_session, + window::minimize_window, + window::maximize_window, + window::close_window, + window::show_window, + window::hide_window, + translations::get_translations, + theme::get_theme_colors, + window_settings::get_window_settings, + window_settings::save_window_size, + window_settings::save_window_position, + window_settings::save_maximized_state, + window_settings::save_zoom_level, + window_settings::set_webview_zoom, + window_settings::save_current_resolution_zoom, + window_settings::get_webview_zoom, + window_settings::get_resolution_zoom, + window_settings::get_window_config, + window_settings::get_window_policy, + ui_state::get_ui_state, + ui_state::save_ui_state, + bootstrap::get_app_bootstrap_data, + secure::save_secure_key, + secure::remove_secure_key, + secure::get_secure_key, + secure::has_secure_key, + secure::get_secure_key_meta, + ai::send_chat_message, + ai::cancel_chat_generation, + ai::validate_api_key, + ai::validate_stored_api_key, + ai::clear_chat_history, + ai::get_chat_history, + ai::rewind_last_turn, + ai::count_tokens, + ai::generate_image, + ai::cancel_image_generation, + ai::get_image_generation_preview, + ai::delete_chat_image, + ai::open_chat_image_location, + ai::save_chat_image_default, + voice::recognize_voice_once, + voice::cancel_voice_recognition, + voice::open_voice_privacy_settings, + custom_model_service::get_custom_models, + custom_model_service::add_custom_model, + custom_model_service::remove_custom_model, + file_service::process_file_content, + engine::start_engine, + engine::stop_engine, + engine::stop_engine_slot, + engine::get_engine_state, + engine::check_engine_installed, + engine::delete_engine, + engine::get_engine_definitions, + engine::get_engine_config, + engine::get_engine_settings_payload, + engine::set_engine_config, + ]) } /// Exports Specta TypeScript bindings and normalizes generated whitespace. @@ -229,6 +239,8 @@ fn strip_generated_trailing_whitespace(path: &Path) -> Result<(), std::io::Error /// Registers all managed services into Tauri's DI container. fn setup_dependencies(app: &tauri::App) -> Result<(), Box> { + crate::utils::paths::init_filesystem()?; + let file_service: std::sync::Arc = std::sync::Arc::new(LocalFileService::new()); let json_store = JsonStore::new(std::sync::Arc::clone(&file_service)); @@ -300,20 +312,19 @@ fn setup_dependencies(app: &tauri::App) -> Result<(), Box ui_state_service_for_api, ), )?; - tracing::info!( + tracing::debug!( "Launcher integration API ready at {}", integration_api.base_url() ); app.manage(integration_api); - crate::utils::paths::init_filesystem().ok(); - let monitor_emitter = std::sync::Arc::new( crate::infrastructure::monitoring::tauri_emitter::TauriMonitoringEmitter::new( app.handle().clone(), ), ); monitor_service.start_monitoring(monitor_emitter, DEFAULT_MONITORING_INTERVAL_MS); + crate::domain::modules::integration_watcher::start(app.handle().clone()); #[cfg(desktop)] setup_global_shortcut(app)?; @@ -414,6 +425,13 @@ pub fn run() { ); if IS_QUITTING.load(Ordering::Relaxed) { tracing::info!("App Exiting..."); + crate::domain::integration_api::revoke_all_module_api_tokens(); + if let Some(sessions) = + app_handle.try_state::>() + && let Err(error) = sessions.save_to_disk() + { + tracing::error!("Failed to save chat history during exit: {error:?}"); + } if let Some(am) = app_handle.try_state::>() { tauri::async_runtime::block_on(async move { let _ = am.stop().await; diff --git a/src-tauri/src/models/config.rs b/src-tauri/src/models/config.rs index 879c3988..d642ab4d 100644 --- a/src-tauri/src/models/config.rs +++ b/src-tauri/src/models/config.rs @@ -18,10 +18,10 @@ pub struct ApiModelConfig { #[derive(Debug, Serialize, Deserialize, Clone, Type)] #[serde(rename_all = "snake_case")] pub struct PricingConfig { - /// Cost per 1M input tokens - pub input_per_1m: Option, - /// Cost per 1M output tokens - pub output_per_1m: Option, + /// Input-side cost or score shown in the launcher UI + pub input: Option, + /// Output-side cost or score shown in the launcher UI + pub output: Option, /// Currency code pub currency: Option, /// Additional notes @@ -150,10 +150,7 @@ pub struct AiModel { pub context_window: Option, /// Maximum output tokens allowed pub max_output_tokens: Option, - /// Whether the model is deprecated - pub deprecated: Option, - - /// Pricing configuration (New Object Format) + /// Pricing configuration pub pricing: Option, /// Performance statistics diff --git a/src-tauri/src/models/license.rs b/src-tauri/src/models/license.rs deleted file mode 100644 index 0731b3c7..00000000 --- a/src-tauri/src/models/license.rs +++ /dev/null @@ -1,12 +0,0 @@ -use crate::domain::license::types::LicenseStatus; -use serde::{Deserialize, Serialize}; -use specta::Type; - -/// License activation status response -#[derive(Serialize, Deserialize, Type, Debug)] -pub struct LicenseStatusResponse { - /// Current license activation status - pub status: LicenseStatus, - /// Email address associated with the license - pub email: Option, -} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 244a4445..7c4b6535 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -2,8 +2,6 @@ pub mod config; /// Custom AI model definitions pub mod custom_models; -/// License activation and validation data types -pub mod license; /// Application module metadata and state pub mod modules; /// Application settings data models @@ -15,7 +13,6 @@ pub mod ui_state; pub use config::*; pub use custom_models::*; -pub use license::*; pub use modules::*; pub use settings::*; pub use system::*; diff --git a/src-tauri/src/models/ui_state.rs b/src-tauri/src/models/ui_state.rs index c0506d8d..b37d1bf1 100644 --- a/src-tauri/src/models/ui_state.rs +++ b/src-tauri/src/models/ui_state.rs @@ -62,9 +62,12 @@ pub struct UIState { /// Enables provider-side internet search by AI provider #[serde(default)] pub ai_web_search_enabled: std::collections::HashMap, - /// Current persistent AI session identifier + /// Per-provider local model output token limits. #[serde(default)] - pub ai_session_id: Option, + pub local_max_output_tokens: std::collections::HashMap, + /// Last directory used by the custom integration import dialog. + #[serde(default)] + pub integration_import_last_directory: Option, /// Preferred launcher interface language #[serde(default)] pub preferred_language: Option, @@ -92,7 +95,8 @@ impl Default for UIState { sound_enabled: true, ai_thinking_level: std::collections::HashMap::new(), ai_web_search_enabled: std::collections::HashMap::new(), - ai_session_id: None, + local_max_output_tokens: std::collections::HashMap::new(), + integration_import_last_directory: None, preferred_language: None, pending_chat_reveal: false, } diff --git a/src-tauri/src/utils/paths.rs b/src-tauri/src/utils/paths.rs index c96e79d6..e3cdf401 100644 --- a/src-tauri/src/utils/paths.rs +++ b/src-tauri/src/utils/paths.rs @@ -1,6 +1,5 @@ use crate::errors::AppError; -use std::fs::{self, OpenOptions}; -use std::io::Write; +use std::fs; use std::path::{Path, PathBuf}; use std::sync::LazyLock; @@ -17,26 +16,8 @@ fn append_appdata_dir(root: &Path) -> PathBuf { #[cfg(test)] const TEST_APPDATA_ROOT_DIR_NAME: &str = "axelate-tests"; -#[cfg(test)] -fn cleanup_legacy_test_roots() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - for legacy_root in [ - manifest_dir.join("test_appdata_roaming"), - manifest_dir.join("test_appdata_local"), - ] { - if !legacy_root.exists() { - continue; - } - - let _ = fs::remove_dir_all(legacy_root); - } -} - #[cfg(test)] fn resolve_test_root(kind: &str) -> PathBuf { - static CLEANUP_LEGACY_TEST_ROOTS: LazyLock<()> = LazyLock::new(cleanup_legacy_test_roots); - LazyLock::force(&CLEANUP_LEGACY_TEST_ROOTS); - std::env::temp_dir() .join(TEST_APPDATA_ROOT_DIR_NAME) .join(std::process::id().to_string()) @@ -71,23 +52,6 @@ fn resolve_config_root() -> PathBuf { } } -#[cfg(target_os = "windows")] -fn resolve_windows_local_data_root() -> PathBuf { - #[cfg(test)] - { - resolve_test_root("local") - } - - #[cfg(not(test))] - { - let root = dirs::data_local_dir() - .or_else(|| std::env::var("LOCALAPPDATA").ok().map(PathBuf::from)) - .unwrap_or_else(resolve_config_root); - - append_appdata_dir(&root) - } -} - fn is_valid_resource_dir(path: &Path) -> bool { path.join(LOCALES_DIR_NAME).exists() } @@ -253,239 +217,16 @@ fn managed_directories() -> [&'static PathBuf; 15] { /// # Errors /// Returns `AppError::Io` if directory creation fails. pub fn init_filesystem() -> Result<(), AppError> { - migrate_windows_system_root_to_roaming()?; - migrate_legacy_module_directories()?; - for dir in managed_directories() { fs::create_dir_all(dir)?; } - migrate_legacy_module_runtime_logs()?; - // Cleanup old log files (keep only last MAX_LOG_FILES) cleanup_old_logs()?; Ok(()) } -#[cfg(target_os = "windows")] -fn legacy_windows_system_root() -> PathBuf { - resolve_windows_local_data_root().join("System") -} - -fn ensure_parent_dir(path: &Path) -> Result<(), AppError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - Ok(()) -} - -fn move_file(source_path: &Path, target_path: &Path) -> Result<(), AppError> { - ensure_parent_dir(target_path)?; - - if fs::rename(source_path, target_path).is_err() { - fs::copy(source_path, target_path)?; - fs::remove_file(source_path)?; - } - - Ok(()) -} - -fn append_file(source_path: &Path, target_path: &Path) -> Result<(), AppError> { - ensure_parent_dir(target_path)?; - - let content = fs::read(source_path)?; - let mut target = OpenOptions::new() - .create(true) - .append(true) - .open(target_path)?; - if target_path - .metadata() - .is_ok_and(|metadata| metadata.len() > 0) - { - target.write_all(b"\n")?; - } - target.write_all(&content)?; - fs::remove_file(source_path)?; - Ok(()) -} - -fn migrate_legacy_module_runtime_logs() -> Result<(), AppError> { - let legacy_module_logs_dir = LOG_DIR.join("Modules"); - if legacy_module_logs_dir.exists() { - merge_directories(&legacy_module_logs_dir, &INTEGRATION_LOGS_DIR)?; - remove_empty_dirs(&legacy_module_logs_dir)?; - } - - let legacy_engine_logs_dir = LOG_DIR.join("Engines"); - if legacy_engine_logs_dir.exists() && legacy_engine_logs_dir != *ENGINE_LOGS_DIR { - merge_directories(&legacy_engine_logs_dir, &ENGINE_LOGS_DIR)?; - remove_empty_dirs(&legacy_engine_logs_dir)?; - } - - let legacy_runtime_engine_logs_dir = ENGINE_RUNTIME_DIR.join("Logs"); - if legacy_runtime_engine_logs_dir.exists() && legacy_runtime_engine_logs_dir != *ENGINE_LOGS_DIR - { - merge_directories(&legacy_runtime_engine_logs_dir, &ENGINE_LOGS_DIR)?; - remove_empty_dirs(&legacy_runtime_engine_logs_dir)?; - } - - if !ENGINE_LOGS_DIR.exists() { - return Ok(()); - } - - for entry in fs::read_dir(&*ENGINE_LOGS_DIR)? { - let entry = entry?; - if !entry.file_type()?.is_dir() { - continue; - } - - let legacy_runtime_log = entry.path().join("runtime.log"); - if !legacy_runtime_log.exists() { - continue; - } - - let target_runtime_log = INTEGRATION_LOGS_DIR - .join(entry.file_name()) - .join("runtime.log"); - - if target_runtime_log.exists() { - append_file(&legacy_runtime_log, &target_runtime_log)?; - } else { - move_file(&legacy_runtime_log, &target_runtime_log)?; - } - } - - Ok(()) -} - -fn legacy_engine_ids() -> std::collections::HashSet { - serde_json::from_str::>(include_str!( - "../../resources/config/local_modules.json" - )) - .unwrap_or_default() - .into_iter() - .filter(|item| item.type_name == "local") - .map(|item| item.id) - .collect() -} - -fn migrate_legacy_module_directories() -> Result<(), AppError> { - let legacy_modules_dir = SYSTEM_ROOT.join("Modules"); - if !legacy_modules_dir.exists() { - return Ok(()); - } - - let engine_ids = legacy_engine_ids(); - fs::create_dir_all(&*INTEGRATIONS_DIR)?; - fs::create_dir_all(&*ENGINES_DIR)?; - - for entry in fs::read_dir(&legacy_modules_dir)? { - let entry = entry?; - if !entry.file_type()?.is_dir() { - continue; - } - - let id = entry.file_name().to_string_lossy().to_string(); - let target_root = if engine_ids.contains(&id) { - &*ENGINES_DIR - } else { - &*INTEGRATIONS_DIR - }; - let target_path = target_root.join(entry.file_name()); - - if target_path.exists() { - merge_directories(&entry.path(), &target_path)?; - remove_empty_dirs(&entry.path())?; - } else { - fs::rename(entry.path(), target_path)?; - } - } - - remove_empty_dirs(&legacy_modules_dir)?; - Ok(()) -} - -fn migrate_windows_system_root_to_roaming() -> Result<(), AppError> { - #[cfg(target_os = "windows")] - { - let legacy_system_root = legacy_windows_system_root(); - if legacy_system_root == *SYSTEM_ROOT || !legacy_system_root.exists() { - return Ok(()); - } - - ensure_parent_dir(&SYSTEM_ROOT)?; - - if matches!(fs::rename(&legacy_system_root, &*SYSTEM_ROOT), Ok(())) { - tracing::info!( - from = %legacy_system_root.display(), - to = %SYSTEM_ROOT.display(), - "Migrated system data from Local AppData to Roaming AppData" - ); - } else { - merge_directories(&legacy_system_root, &SYSTEM_ROOT)?; - remove_empty_dirs(&legacy_system_root)?; - tracing::info!( - from = %legacy_system_root.display(), - to = %SYSTEM_ROOT.display(), - "Merged legacy system data from Local AppData into Roaming AppData" - ); - } - } - - Ok(()) -} - -fn merge_directories(source: &Path, target: &Path) -> Result<(), AppError> { - if !source.exists() { - return Ok(()); - } - - fs::create_dir_all(target)?; - - for entry in fs::read_dir(source)? { - let entry = entry?; - let source_path = entry.path(); - let target_path = target.join(entry.file_name()); - - if entry.file_type()?.is_dir() { - merge_directories(&source_path, &target_path)?; - remove_empty_dirs(&source_path)?; - continue; - } - - if target_path.exists() { - fs::remove_file(&source_path)?; - continue; - } - - move_file(&source_path, &target_path)?; - } - - Ok(()) -} - -fn remove_empty_dirs(path: &Path) -> Result<(), AppError> { - if !path.exists() || !path.is_dir() { - return Ok(()); - } - - for entry in fs::read_dir(path)? { - let entry = entry?; - let child = entry.path(); - if entry.file_type()?.is_dir() { - remove_empty_dirs(&child)?; - } - } - - if fs::read_dir(path)?.next().is_none() { - fs::remove_dir(path)?; - } - - Ok(()) -} - /// Remove old log files, keeping only the most recent MAX_LOG_FILES. /// /// # Errors diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c92dbc77..369fb0fa 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "Axelate", "mainBinaryName": "Axelate", - "version": "0.1.5", + "version": "0.2.0", "identifier": "com.axelate", "build": { "beforeDevCommand": "node .github/scripts/start-vite-dev.mjs", diff --git a/src/.prettierignore b/src/.prettierignore index 83b6de7f..95f701a3 100644 --- a/src/.prettierignore +++ b/src/.prettierignore @@ -1,4 +1,10 @@ +node_modules dist coverage +.axelate +.vite +.cache +*.log +*.tsbuildinfo shared/types/bindings.ts .axelate diff --git a/src/app/CoreAssembly.ts b/src/app/CoreAssembly.ts index 135cab67..b57a889f 100644 --- a/src/app/CoreAssembly.ts +++ b/src/app/CoreAssembly.ts @@ -13,6 +13,8 @@ import { configureTracerTransport, registerCoreContainer, } from './CoreComposition'; +import { createClipboardReader, createClipboardWriter } from './CoreUiBridgeHelpers'; +import { GlobalTextContextMenu } from '@/shared/shell/GlobalTextContextMenu'; type CoreAssemblyState = { isDestroyed: () => boolean; @@ -38,6 +40,7 @@ type AssemblyParts = { infra: CoreInfrastructure; bridge: GlobalBridge; eventHandler: EventHandler; + globalTextContextMenu: GlobalTextContextMenu; }; function createStateManager(args: { @@ -65,7 +68,7 @@ export function createCoreAssembly(args: CreateCoreAssemblyArgs): CoreAssembly { const serviceBundle = createCoreServiceBundle(args.tracer); configureTracerTransport(args.tracer, serviceBundle.tauriProvider); - args.tracer.info(`AXELATE v${__APP_VERSION__}`); + args.tracer.debug(`AXELATE v${__APP_VERSION__}`); configureCoreServices({ windowService: serviceBundle.windowService, @@ -112,6 +115,12 @@ export function createCoreAssembly(args: CreateCoreAssemblyArgs): CoreAssembly { windowService: serviceBundle.windowService, windowUI: ui.windowUI, }); + const globalTextContextMenu = new GlobalTextContextMenu({ + translate: (key, fallback) => serviceBundle.i18n.t(key, fallback), + copyText: createClipboardWriter(serviceBundle.tauriProvider), + readClipboardText: createClipboardReader(serviceBundle.tauriProvider), + tracer: args.tracer, + }); bindAIBridgeContext({ aiBridge: serviceBundle.aiBridge, @@ -169,7 +178,7 @@ export function createCoreAssembly(args: CreateCoreAssemblyArgs): CoreAssembly { const lifecycleController = new CoreLifecycleController( createLifecycleDeps( - { services, ui, infra, bridge, eventHandler }, + { services, ui, infra, bridge, eventHandler, globalTextContextMenu }, { state: args.state, globalShortcutKeydown: args.globalShortcutKeydown, @@ -256,6 +265,7 @@ function createLifecycleDeps( aiBridge: services.aiBridge, bridge, errorHandler: infra.errorHandler, + globalTextContextMenu: parts.globalTextContextMenu, }, state: runtime.state, globalShortcutKeydown: runtime.globalShortcutKeydown, diff --git a/src/app/CoreBootstrapRunner.test.ts b/src/app/CoreBootstrapRunner.test.ts index c80caf86..94d5f9a2 100644 --- a/src/app/CoreBootstrapRunner.test.ts +++ b/src/app/CoreBootstrapRunner.test.ts @@ -69,6 +69,7 @@ function createRunnerDeps() { navigationUI: { showPage: recordAsync('navigation-ui:show-page'), init: record('navigation-ui:init'), + syncActiveNavigationButton: record('navigation-ui:sync-active-button'), }, chatController: { init: record('chat:init'), diff --git a/src/app/CoreChatFactory.ts b/src/app/CoreChatFactory.ts index a588449d..4b1d705e 100644 --- a/src/app/CoreChatFactory.ts +++ b/src/app/CoreChatFactory.ts @@ -1,5 +1,5 @@ import type { AIBridge } from '@/features/ai/services/AIBridge'; -import { ChatController } from '@/features/chat/chat'; +import { ChatController } from '@/features/chat/ChatController'; import type { I18nService } from '@/infrastructure/i18n/I18nService'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { TauriProvider } from '@/infrastructure/tauri/TauriProvider'; @@ -7,7 +7,6 @@ import type { EventBus } from '@/shared/services/EventBus'; import type { UiStateStore } from '@/shared/services/state/UiStateStore'; import type { AppUI } from '@/shared/shell/AppUI'; import { - createClipboardReader, createClipboardWriter, createExternalUrlOpener, createToastBridge, @@ -29,7 +28,6 @@ export function createChatController(deps: CreateChatControllerDeps): ChatContro const isTauriRuntime = (): boolean => deps.tauriProvider.isTauri(); const showToast = createToastBridge(deps.appUI); const copyText = createClipboardWriter(deps.tauriProvider); - const readClipboardText = createClipboardReader(deps.tauriProvider); const openExternalUrl = createExternalUrlOpener(deps.tauriProvider); const estimateTokens = createTokenEstimator({ tauriProvider: deps.tauriProvider, @@ -42,7 +40,6 @@ export function createChatController(deps: CreateChatControllerDeps): ChatContro isTauriRuntime, openExternalUrl, copyText, - readClipboardText, getPendingChatRevealStore: () => ({ getState: () => deps.stateStore.getState(), updateState: (updates) => deps.stateStore.updateState(updates), diff --git a/src/app/CoreComposition.test.ts b/src/app/CoreComposition.test.ts new file mode 100644 index 00000000..c26f0f69 --- /dev/null +++ b/src/app/CoreComposition.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { destroyCoreResources } from './CoreComposition'; + +function destroyable(fn = vi.fn()) { + return { destroy: fn }; +} + +describe('destroyCoreResources', () => { + it('continues destroying remaining resources after one destroyer fails', async () => { + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => { + return; + }); + const stateManagerDestroy = vi.fn(() => { + throw new Error('state failed'); + }); + const eventHandlerDestroy = vi.fn(); + const errorHandlerDestroy = vi.fn(); + + const args = { + deferredChatInitTimer: 123 as unknown as ReturnType, + stateManager: destroyable(stateManagerDestroy), + eventHandler: destroyable(eventHandlerDestroy), + chatController: destroyable(), + appUI: destroyable(), + settingsUI: destroyable(), + moduleSettingsUI: destroyable(), + downloadUI: destroyable(), + navigationUI: destroyable(), + windowUI: destroyable(), + windowService: destroyable(), + moduleService: destroyable(), + i18nUI: destroyable(), + consoleUI: destroyable(), + monitoringUI: destroyable(), + monitoringService: destroyable(), + sidebarUI: destroyable(), + particles: destroyable(), + soundService: destroyable(), + stateStore: destroyable(), + aiBridge: destroyable(), + bridge: destroyable(), + errorHandler: destroyable(errorHandlerDestroy), + } as unknown as Parameters[0]; + + try { + await expect(destroyCoreResources(args)).rejects.toThrow(AggregateError); + + expect(clearTimeoutSpy).toHaveBeenCalledWith(123); + expect(eventHandlerDestroy).toHaveBeenCalledTimes(1); + expect(errorHandlerDestroy).toHaveBeenCalledTimes(1); + } finally { + clearTimeoutSpy.mockRestore(); + } + }); +}); diff --git a/src/app/CoreComposition.ts b/src/app/CoreComposition.ts index ff7333fe..b37a289a 100644 --- a/src/app/CoreComposition.ts +++ b/src/app/CoreComposition.ts @@ -65,31 +65,47 @@ export function registerCoreContainer(args: RegisterCoreContainerArgs): void { container.lock(); } -export function destroyCoreResources(args: DestroyCoreResourcesArgs): void { +export async function destroyCoreResources(args: DestroyCoreResourcesArgs): Promise { if (args.deferredChatInitTimer !== null) { globalThis.clearTimeout(args.deferredChatInitTimer); } - args.stateManager.destroy(); - args.eventHandler.destroy(); - args.chatController.destroy(); - args.appUI.destroy(); - args.settingsUI.destroy(); - args.moduleSettingsUI.destroy(); - args.downloadUI.destroy(); - args.navigationUI.destroy(); - args.windowUI.destroy(); - args.windowService.destroy(); - args.moduleService.destroy(); - args.i18nUI.destroy(); - args.consoleUI.destroy(); - args.monitoringUI.destroy(); - args.monitoringService.destroy(); - args.sidebarUI.destroy(); - args.particles.destroy(); - args.soundService.destroy(); - args.stateStore.destroy(); - args.aiBridge.destroy(); - args.bridge.destroy(); - args.errorHandler.destroy(); + const destroyers: Array<() => Promise | void> = [ + () => args.stateManager.destroy(), + () => args.eventHandler.destroy(), + () => args.chatController.destroy(), + () => args.appUI.destroy(), + () => args.settingsUI.destroy(), + () => args.moduleSettingsUI.destroy(), + () => args.downloadUI.destroy(), + () => args.navigationUI.destroy(), + () => args.windowUI.destroy(), + () => args.windowService.destroy(), + () => args.moduleService.destroy(), + () => args.i18nUI.destroy(), + () => args.consoleUI.destroy(), + () => args.monitoringUI.destroy(), + () => args.monitoringService.destroy(), + () => args.sidebarUI.destroy(), + () => args.particles.destroy(), + () => args.soundService.destroy(), + () => args.stateStore.destroy(), + () => args.aiBridge.destroy(), + () => args.bridge.destroy(), + () => args.errorHandler.destroy(), + () => args.globalTextContextMenu.destroy(), + ]; + const errors: unknown[] = []; + + for (const destroy of destroyers) { + try { + await destroy(); + } catch (error: unknown) { + errors.push(error); + } + } + + if (errors.length > 0) { + throw new AggregateError(errors, 'Core resource cleanup failed'); + } } diff --git a/src/app/CoreContainer.ts b/src/app/CoreContainer.ts index 39115ef8..e1e1e4fe 100644 --- a/src/app/CoreContainer.ts +++ b/src/app/CoreContainer.ts @@ -18,7 +18,7 @@ import type { ModuleSettingsService } from '@/shared/services/modules/ModuleSett import type { MonitoringService } from '@/features/monitoring/services/MonitoringService'; import type { ConsoleLogService } from '@/features/console/services/ConsoleLogService'; import type { SettingsService } from '@/features/settings/services/SettingsService'; -import type { ChatController } from '@/features/chat/chat'; +import type { ChatController } from '@/features/chat/ChatController'; import type { ModulePlatformService } from '@/shared/services/ModulePlatformService'; import type { AIBridge } from '@/features/ai/services/AIBridge'; import type { AppUI } from '@/shared/shell/AppUI'; diff --git a/src/app/CoreEntry.ts b/src/app/CoreEntry.ts index 1e3a113d..0da8c7bf 100644 --- a/src/app/CoreEntry.ts +++ b/src/app/CoreEntry.ts @@ -2,52 +2,90 @@ import type { LoggerService } from '@/infrastructure/logging/LoggerService'; type CoreRuntime = { init: () => Promise; - destroy: () => void; + destroy: () => Promise | void; }; type CoreFactory = () => CoreRuntime; -type EntryLogger = Pick; +type EntryLogger = Pick; -let activeCoreInstance: CoreRuntime | null = null; -let coreInitializationInFlight = false; -let coreBootBound = false; -let coreBeforeUnloadBound = false; -let bootHandler: (() => void) | null = null; -let beforeUnloadHandler: (() => void) | null = null; +type CoreEntryState = { + activeCoreInstance: CoreRuntime | null; + coreInitializationInFlight: boolean; + coreBootBound: boolean; + coreBeforeUnloadBound: boolean; + bootHandler: (() => void) | null; + beforeUnloadHandler: (() => void) | null; +}; + +const CORE_ENTRY_STATE_KEY = '__AXELATE_CORE_ENTRY_STATE__'; + +function getCoreEntryState(): CoreEntryState { + const runtime = globalThis as typeof globalThis & { + [CORE_ENTRY_STATE_KEY]?: CoreEntryState; + }; + + runtime[CORE_ENTRY_STATE_KEY] ??= { + activeCoreInstance: null, + coreInitializationInFlight: false, + coreBootBound: false, + coreBeforeUnloadBound: false, + bootHandler: null, + beforeUnloadHandler: null, + }; + + return runtime[CORE_ENTRY_STATE_KEY]; +} function clearBootState(): void { - activeCoreInstance = null; - coreInitializationInFlight = false; + const state = getCoreEntryState(); + state.activeCoreInstance = null; + state.coreInitializationInFlight = false; } -function destroyActiveCoreInstance(): void { - activeCoreInstance?.destroy(); - clearBootState(); +async function destroyActiveCoreInstance( + tracer?: EntryLogger, + context = 'Destroy failed', +): Promise { + const state = getCoreEntryState(); + const coreInstance = state.activeCoreInstance; + if (coreInstance === null) { + clearBootState(); + return; + } + + try { + await coreInstance.destroy(); + } catch (error: unknown) { + tracer?.error(`[Core] ${context}: ${String(error)}`); + } + + if (state.activeCoreInstance === coreInstance) { + clearBootState(); + } } function bootCoreOnce(createCore: CoreFactory, tracer: EntryLogger): void { - if (activeCoreInstance !== null) { - tracer.warn('[Core] Double init blocked (global singleton already active).'); + const state = getCoreEntryState(); + + if (state.activeCoreInstance !== null) { return; } - if (coreInitializationInFlight) { - tracer.warn('[Core] Double init blocked (initialization already in flight).'); + if (state.coreInitializationInFlight) { return; } - coreInitializationInFlight = true; + state.coreInitializationInFlight = true; try { const coreInstance = createCore(); - activeCoreInstance = coreInstance; - coreInitializationInFlight = false; + state.activeCoreInstance = coreInstance; + state.coreInitializationInFlight = false; - coreInstance.init().catch((error: unknown) => { - if (activeCoreInstance === coreInstance) { - clearBootState(); + coreInstance.init().catch(async (error: unknown) => { + if (state.activeCoreInstance === coreInstance) { + await destroyActiveCoreInstance(tracer, 'Destroy after boot failure failed'); } - coreInstance.destroy(); tracer.error(`[Core] Boot failed: ${String(error)}`); }); } catch (error: unknown) { @@ -58,40 +96,45 @@ function bootCoreOnce(createCore: CoreFactory, tracer: EntryLogger): void { } export function bindCoreEntry(createCore: CoreFactory, tracer: EntryLogger): void { + const state = getCoreEntryState(); + if (document.readyState === 'loading') { - if (!coreBootBound) { - coreBootBound = true; - bootHandler = () => { - bootHandler = null; + if (!state.coreBootBound) { + state.coreBootBound = true; + state.bootHandler = () => { + state.bootHandler = null; bootCoreOnce(createCore, tracer); }; - document.addEventListener('DOMContentLoaded', bootHandler, { once: true }); + document.addEventListener('DOMContentLoaded', state.bootHandler, { once: true }); } } else { bootCoreOnce(createCore, tracer); } - if (!coreBeforeUnloadBound) { - coreBeforeUnloadBound = true; - beforeUnloadHandler = () => { - destroyActiveCoreInstance(); + if (!state.coreBeforeUnloadBound) { + state.coreBeforeUnloadBound = true; + state.beforeUnloadHandler = () => { + void destroyActiveCoreInstance(tracer); }; - globalThis.addEventListener('beforeunload', beforeUnloadHandler); + globalThis.addEventListener('beforeunload', state.beforeUnloadHandler); } if (import.meta.hot) { import.meta.hot.dispose(() => { - destroyActiveCoreInstance(); - if (bootHandler !== null) { - document.removeEventListener('DOMContentLoaded', bootHandler); - bootHandler = null; - } - if (beforeUnloadHandler !== null) { - globalThis.removeEventListener('beforeunload', beforeUnloadHandler); - beforeUnloadHandler = null; - } - coreBootBound = false; - coreBeforeUnloadBound = false; + return destroyActiveCoreInstance(tracer, 'Destroy during HMR dispose failed').finally( + () => { + if (state.bootHandler !== null) { + document.removeEventListener('DOMContentLoaded', state.bootHandler); + state.bootHandler = null; + } + if (state.beforeUnloadHandler !== null) { + globalThis.removeEventListener('beforeunload', state.beforeUnloadHandler); + state.beforeUnloadHandler = null; + } + state.coreBootBound = false; + state.coreBeforeUnloadBound = false; + }, + ); }); } } diff --git a/src/app/CoreLifecycleController.test.ts b/src/app/CoreLifecycleController.test.ts new file mode 100644 index 00000000..5b0cd611 --- /dev/null +++ b/src/app/CoreLifecycleController.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + runCoreBootstrap: vi.fn(), + initializeDeferredUi: vi.fn(), + restoreSelectedModules: vi.fn(), + destroyCoreResources: vi.fn(), +})); + +vi.mock('./CoreBootstrapRunner', () => ({ + runCoreBootstrap: mocks.runCoreBootstrap, +})); + +vi.mock('./CoreRuntimeSupport', () => ({ + initializeDeferredUi: mocks.initializeDeferredUi, + restoreSelectedModules: mocks.restoreSelectedModules, +})); + +vi.mock('./CoreComposition', () => ({ + destroyCoreResources: mocks.destroyCoreResources, +})); + +import { CoreLifecycleController, type CoreLifecycleDeps } from './CoreLifecycleController'; + +function createDeps(isDestroyed: () => boolean): CoreLifecycleDeps { + const tauriProvider = { + isTauri: vi.fn(() => true), + listen: vi.fn().mockResolvedValue(vi.fn()), + }; + return { + bootstrap: { + aiBridge: {}, + tauriProvider, + tracer: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + templateLoader: {}, + stateStore: {}, + windowService: {}, + windowUI: {}, + i18n: {}, + i18nUI: {}, + catalog: {}, + navigation: {}, + navigationUI: {}, + chatController: { init: vi.fn(), destroy: vi.fn() }, + bridge: {}, + eventHandler: {}, + }, + immediateUi: { + navigation: {}, + navigationUI: {}, + downloadUI: {}, + moduleService: {}, + sidebarUI: {}, + }, + deferredUi: { + settingsService: {}, + monitoringUI: {}, + settingsUI: {}, + moduleSettingsUI: {}, + i18nUI: {}, + consoleUI: {}, + tracer: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + moduleSettings: {}, + catalog: {}, + appUI: {}, + aiBridge: {}, + }, + backendSelection: { + tauriProvider, + stateStore: { updateNestedState: vi.fn() }, + appUI: { updateModuleCard: vi.fn() }, + }, + disposables: { + stateManager: {}, + eventHandler: {}, + chatController: {}, + appUI: {}, + settingsUI: {}, + moduleSettingsUI: {}, + downloadUI: {}, + navigationUI: {}, + windowUI: {}, + windowService: {}, + moduleService: {}, + i18nUI: {}, + consoleUI: {}, + monitoringUI: {}, + monitoringService: {}, + sidebarUI: {}, + particles: {}, + soundService: {}, + stateStore: {}, + aiBridge: {}, + bridge: {}, + errorHandler: {}, + globalTextContextMenu: { init: vi.fn(), destroy: vi.fn() }, + }, + state: { isDestroyed }, + globalShortcutKeydown: vi.fn(), + } as unknown as CoreLifecycleDeps; +} + +describe('CoreLifecycleController', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.runCoreBootstrap.mockResolvedValue({ currentPage: 'home' }); + mocks.initializeDeferredUi.mockResolvedValue(undefined); + }); + + it('stops async initialization after bootstrap if core was destroyed', async () => { + const deps = createDeps(() => true); + const controller = new CoreLifecycleController(deps); + + await controller.runInit(); + + expect(mocks.initializeDeferredUi).not.toHaveBeenCalled(); + expect(deps.backendSelection.tauriProvider.listen).not.toHaveBeenCalled(); + expect(deps.bootstrap.tracer.info).not.toHaveBeenCalledWith('[Core] Ready.'); + }); + + it('unsubscribes backend selection listener if destroy happens while subscribing', async () => { + let destroyed = false; + const unlisten = vi.fn(); + const deps = createDeps(() => destroyed); + vi.mocked(deps.backendSelection.tauriProvider.listen).mockImplementationOnce(() => { + destroyed = true; + return Promise.resolve(unlisten); + }); + const controller = new CoreLifecycleController(deps); + + await controller.runInit(); + + expect(unlisten).toHaveBeenCalledTimes(1); + expect(deps.bootstrap.tracer.info).not.toHaveBeenCalledWith('[Core] Ready.'); + }); +}); diff --git a/src/app/CoreLifecycleController.ts b/src/app/CoreLifecycleController.ts index f40f6112..f997aaf0 100644 --- a/src/app/CoreLifecycleController.ts +++ b/src/app/CoreLifecycleController.ts @@ -1,5 +1,5 @@ import type { AIBridge } from '@/features/ai/services/AIBridge'; -import type { ChatController } from '@/features/chat/chat'; +import type { ChatController } from '@/features/chat/ChatController'; import type { DownloadUI } from '@/features/downloads/ui/DownloadUI'; import type { MonitoringService } from '@/features/monitoring/services/MonitoringService'; import type { SettingsService } from '@/features/settings/services/SettingsService'; @@ -19,6 +19,7 @@ import type { ErrorHandler } from '@/shared/services/ErrorHandler'; import type { ModuleSettingsService } from '@/shared/services/modules/ModuleSettingsService'; import type { UiStateStore } from '@/shared/services/state/UiStateStore'; import type { AppUI } from '@/shared/shell/AppUI'; +import type { GlobalTextContextMenu } from '@/shared/shell/GlobalTextContextMenu'; import type { Particles } from '@/shared/shell/Particles'; import type { SidebarUI } from '@/shared/shell/SidebarUI'; import type { WindowUI } from '@/shared/shell/WindowUI'; @@ -113,6 +114,7 @@ export type CoreDisposables = { aiBridge: AIBridge; bridge: GlobalBridge; errorHandler: ErrorHandler; + globalTextContextMenu: GlobalTextContextMenu; }; export type CoreLifecycleDeps = { @@ -133,6 +135,10 @@ export class CoreLifecycleController { constructor(private readonly _deps: CoreLifecycleDeps) {} public async runInit(): Promise { + if (this._deps.state.isDestroyed()) { + return; + } + const bootstrapResult = await runCoreBootstrap({ bootstrap: this._deps.bootstrap, immediateUi: this._deps.immediateUi, @@ -140,6 +146,10 @@ export class CoreLifecycleController { this.initGlobalShortcuts(); }, }); + if (this._deps.state.isDestroyed()) { + return; + } + this._deps.disposables.globalTextContextMenu.init(); if (bootstrapResult.currentPage !== 'chat') { this.scheduleDeferredChatInit(); @@ -161,23 +171,39 @@ export class CoreLifecycleController { }); }, }); + if (this._deps.state.isDestroyed()) { + return; + } await this._listenForBackendSelectedModuleChanges(); + if (this._deps.state.isDestroyed()) { + return; + } this._deps.bootstrap.tracer.info('[Core] Ready.'); } - public destroy(): void { + public async destroy(): Promise { if (this._activeGlobalShortcutKeydown !== null) { globalThis.removeEventListener('keydown', this._activeGlobalShortcutKeydown); this._activeGlobalShortcutKeydown = null; } - this._selectedModuleChangedUnlisten?.(); + try { + this._selectedModuleChangedUnlisten?.(); + } catch (error) { + this._deps.bootstrap.tracer.warn( + '[Core] Failed to remove selected module listener during destroy:', + error, + ); + } this._selectedModuleChangedUnlisten = null; - destroyCoreResources({ - deferredChatInitTimer: this._deferredChatInitTimer, - ...this._deps.disposables, - }); - this._deferredChatInitTimer = null; + try { + await destroyCoreResources({ + deferredChatInitTimer: this._deferredChatInitTimer, + ...this._deps.disposables, + }); + } finally { + this._deferredChatInitTimer = null; + } } public initGlobalShortcuts(globalShortcutKeydown?: (e: KeyboardEvent) => void): void { @@ -216,13 +242,17 @@ export class CoreLifecycleController { return; } - this._selectedModuleChangedUnlisten = - await backendSelection.tauriProvider.listen( - 'ui-state:selected-module-changed', - (payload) => { - this._applyBackendSelectedModuleChange(payload); - }, - ); + const unlisten = await backendSelection.tauriProvider.listen( + 'ui-state:selected-module-changed', + (payload) => { + this._applyBackendSelectedModuleChange(payload); + }, + ); + if (this._deps.state.isDestroyed()) { + unlisten(); + return; + } + this._selectedModuleChangedUnlisten = unlisten; } private _applyBackendSelectedModuleChange(payload: SelectedModuleChangedPayload): void { diff --git a/src/app/CoreRuntimeSupport.test.ts b/src/app/CoreRuntimeSupport.test.ts index 59c12992..6b30c427 100644 --- a/src/app/CoreRuntimeSupport.test.ts +++ b/src/app/CoreRuntimeSupport.test.ts @@ -17,8 +17,8 @@ describe('CoreRuntimeSupport', () => { init: vi.fn(() => { callOrder.push('navigation:init'); }), - showPage: vi.fn(() => { - callOrder.push('navigation:showPage'); + syncActiveNavigationButton: vi.fn(() => { + callOrder.push('navigation:syncActiveNavigationButton'); }), }; const moduleService = { @@ -42,11 +42,11 @@ describe('CoreRuntimeSupport', () => { expect(sidebarUI.init).toHaveBeenCalledTimes(1); expect(navigationUI.init).toHaveBeenCalledTimes(1); - expect(navigationUI.showPage).toHaveBeenCalledWith('settings', null, true, true); + expect(navigationUI.syncActiveNavigationButton).toHaveBeenCalledWith('settings'); expect(callOrder).toEqual([ 'sidebar:init', 'navigation:init', - 'navigation:showPage', + 'navigation:syncActiveNavigationButton', 'module:init', 'download:init', ]); @@ -61,7 +61,7 @@ describe('CoreRuntimeSupport', () => { }; const navigationUI = { init: vi.fn(() => {}), - showPage: vi.fn(() => {}), + syncActiveNavigationButton: vi.fn(() => {}), }; const moduleService = { init: vi.fn(() => {}), @@ -78,6 +78,6 @@ describe('CoreRuntimeSupport', () => { sidebarUI: sidebarUI as never, }); - expect(navigationUI.showPage).toHaveBeenCalledWith('home', null, true, true); + expect(navigationUI.syncActiveNavigationButton).toHaveBeenCalledWith('home'); }); }); diff --git a/src/app/CoreRuntimeSupport.ts b/src/app/CoreRuntimeSupport.ts index 6ea7d4e9..028c22e1 100644 --- a/src/app/CoreRuntimeSupport.ts +++ b/src/app/CoreRuntimeSupport.ts @@ -1,4 +1,4 @@ -import type { ChatController } from '@/features/chat/chat'; +import type { ChatController } from '@/features/chat/ChatController'; import type { DownloadUI } from '@/features/downloads/ui/DownloadUI'; import type { I18nService } from '@/infrastructure/i18n/I18nService'; import type { I18nUI } from '@/infrastructure/i18n/I18nUI'; @@ -71,7 +71,6 @@ const INITIAL_TEMPLATE_TARGETS = [ ['pages/home', 'page-home'], ['pages/chat', 'page-chat'], ['pages/modules', 'page-modules'], - ['pages/marketplace', 'page-marketplace'], ['pages/downloads', 'page-downloads'], ['pages/console', 'page-console'], ['pages/settings', 'page-settings'], @@ -154,7 +153,7 @@ export async function showInitialPage(args: ShowInitialPageArgs): Promise export async function initializeImmediateUi(args: InitializeImmediateUiArgs): Promise { await args.sidebarUI.init(); args.navigationUI.init(); - await args.navigationUI.showPage(args.navigation.getCurrentPage() ?? 'home', null, true, true); + args.navigationUI.syncActiveNavigationButton(args.navigation.getCurrentPage() ?? 'home'); void args.moduleService.init(); void args.downloadUI.init(); } diff --git a/src/app/CoreUiBridgeHelpers.ts b/src/app/CoreUiBridgeHelpers.ts index 0c64131f..8f742490 100644 --- a/src/app/CoreUiBridgeHelpers.ts +++ b/src/app/CoreUiBridgeHelpers.ts @@ -67,8 +67,15 @@ export function createExternalUrlOpener( export function createTokenEstimator( deps: TokenEstimatorDeps, ): (text: string, model?: string) => Promise { + let backendCountInFlight = false; + return async (text, model = 'gpt-4') => { if (deps.tauriProvider.isTauri()) { + if (backendCountInFlight) { + return estimateTokenCount(text); + } + + backendCountInFlight = true; try { return await withTimeout( deps.tauriProvider.invoke('count_tokens', { @@ -79,6 +86,8 @@ export function createTokenEstimator( ); } catch (error) { deps.tracer.warn(`[TokenCount] Backend failed, using heuristic: ${String(error)}`); + } finally { + backendCountInFlight = false; } } @@ -87,7 +96,7 @@ export function createTokenEstimator( } async function withTimeout(promise: Promise, timeoutMs: number): Promise { - let timeoutId!: ReturnType; + let timeoutId: ReturnType | undefined; const timeout = new Promise((_, reject) => { timeoutId = globalThis.setTimeout(() => { reject(new Error(`Timed out after ${String(timeoutMs)}ms`)); @@ -97,6 +106,8 @@ async function withTimeout(promise: Promise, timeoutMs: number): Promise Promise; }; aiBridge: AIBridge; + tauriProvider: TauriProvider; }; type CreateCoreUiBundleDeps = { @@ -116,6 +117,11 @@ export function createAppUI(deps: CreateAppUIDeps): AppUI { setSelectedModule: (category, moduleData) => { deps.stateStore.setSelectedModule(category, moduleData); }, + getIntegrationImportLastDirectory: () => + deps.stateStore.getIntegrationImportLastDirectory(), + setIntegrationImportLastDirectory: (path) => { + deps.stateStore.setIntegrationImportLastDirectory(path); + }, }, launchApp: async (category, app) => { await deps.bridge.launchApp(category, app); @@ -126,6 +132,10 @@ export function createAppUI(deps: CreateAppUIDeps): AppUI { stopAiProvider: () => { deps.aiBridge.stopProvider(); }, + reloadCatalog: async () => { + await deps.catalog.loadCatalog(); + }, + openExternalUrl: createExternalUrlOpener(deps.tauriProvider), }, ); } @@ -164,6 +174,7 @@ export function createCoreUiBundle(deps: CreateCoreUiBundleDeps): CoreUiBundle { bridge: deps.bridge, moduleSettingsUI: moduleSettingsGateway, aiBridge: deps.aiBridge, + tauriProvider: deps.tauriProvider, }); const windowUI = new WindowUI( deps.windowService, diff --git a/src/app/bridge.ts b/src/app/bridge.ts index 3e3f609b..f2c52c7b 100644 --- a/src/app/bridge.ts +++ b/src/app/bridge.ts @@ -16,24 +16,20 @@ export interface ICoreBridge { readonly tauriProvider: TauriProvider; } -type GlobalBridgeRuntime = {}; - /** * GlobalBridge keeps runtime transport concerns out of Core boot logic. */ export class GlobalBridge { private readonly _core: ICoreBridge; - constructor(core: ICoreBridge, _runtime?: GlobalBridgeRuntime) { + constructor(core: ICoreBridge) { this._core = core; } /** - * Initialize runtime interceptors. + * Lifecycle hook kept with other core services. */ - public init(): void { - /* no-op: legacy fetch interceptor removed */ - } + public init(): void {} public destroy(): void { /* no-op */ diff --git a/src/app/events.test.ts b/src/app/events.test.ts new file mode 100644 index 00000000..48712cde --- /dev/null +++ b/src/app/events.test.ts @@ -0,0 +1,240 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { EventHandler, type ICoreEvents } from './events'; + +async function flushAsyncNavigation(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function clickRequiredElement(selector: string): void { + const element = document.querySelector(selector); + if (element === null) { + throw new Error(`Required test element not found: ${selector}`); + } + element.click(); +} + +function createCoreEvents(): ICoreEvents { + return { + appUI: { + openAppSelection: vi.fn(), + closeAppSelection: vi.fn(), + }, + chatController: { + clearChat: vi.fn(), + pickChatFilesFromMenu: vi.fn(), + sendImageGenerationFromMenu: vi.fn(), + toggleAttachMenu: vi.fn(), + toggleVoiceInput: vi.fn(), + sendChat: vi.fn(), + }, + consoleUI: {}, + downloadUI: {}, + i18nUI: { + toggleMenu: vi.fn(), + setLanguage: vi.fn(), + selectLangInModal: vi.fn(), + confirmLanguage: vi.fn(), + }, + navigationUI: { + showPage: vi.fn().mockResolvedValue(undefined), + }, + moduleSettingsUI: { + close: vi.fn(), + }, + tracer: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + windowService: { + minimize: vi.fn(), + close: vi.fn(), + }, + windowUI: { + toggleMaximize: vi.fn(), + toggleSound: vi.fn(), + }, + } as unknown as ICoreEvents; +} + +describe('EventHandler', () => { + const runtime = { + addWindowListener: vi.fn(), + removeWindowListener: vi.fn(), + }; + + beforeEach(() => { + document.body.className = ''; + document.body.innerHTML = ''; + vi.clearAllMocks(); + }); + + it('blocks sidebar navigation while release download selection is open', async () => { + document.body.innerHTML = ``; + document.body.classList.add('download-selection-open'); + const core = createCoreEvents(); + const handler = new EventHandler(core, runtime); + handler.init(); + + const navButton = document.querySelector('[data-page]'); + if (navButton === null) { + throw new Error('Navigation button not found'); + } + navButton.click(); + await flushAsyncNavigation(); + + expect(core.navigationUI.showPage).not.toHaveBeenCalled(); + handler.destroy(); + }); + + it('allows sidebar navigation when release download selection is closed', async () => { + document.body.innerHTML = ``; + const core = createCoreEvents(); + const handler = new EventHandler(core, runtime); + handler.init(); + handler.init(); + + const navButton = document.querySelector('[data-page]'); + if (navButton === null) { + throw new Error('Navigation button not found'); + } + navButton.click(); + await flushAsyncNavigation(); + + expect(core.navigationUI.showPage).toHaveBeenCalledOnce(); + expect(runtime.addWindowListener).toHaveBeenCalledOnce(); + handler.destroy(); + handler.destroy(); + expect(runtime.removeWindowListener).toHaveBeenCalledOnce(); + }); + + it('delegates language menu and language selection clicks', async () => { + document.body.innerHTML = ` + + + `; + const core = createCoreEvents(); + const handler = new EventHandler(core, runtime); + handler.init(); + + clickRequiredElement('#current-lang-trigger'); + clickRequiredElement('.lang-btn'); + await flushAsyncNavigation(); + + expect(core.i18nUI.toggleMenu).toHaveBeenCalledOnce(); + expect(core.i18nUI.setLanguage).toHaveBeenCalledWith('ru'); + handler.destroy(); + }); + + it('opens module selection from add buttons and module cards', async () => { + document.body.innerHTML = ` + +
Services
+
+ `; + const core = createCoreEvents(); + const handler = new EventHandler(core, runtime); + handler.init(); + + clickRequiredElement('#ai-module-add-btn'); + clickRequiredElement('#services-module-card'); + clickRequiredElement('.module-settings-btn'); + await flushAsyncNavigation(); + + expect(core.appUI.openAppSelection).toHaveBeenNthCalledWith(1, 'ai'); + expect(core.appUI.openAppSelection).toHaveBeenNthCalledWith(2, 'services'); + expect(core.appUI.openAppSelection).toHaveBeenCalledTimes(2); + handler.destroy(); + }); + + it('delegates chat action clicks', async () => { + document.body.innerHTML = ` + + + + +
+ + +
+ `; + const core = createCoreEvents(); + const handler = new EventHandler(core, runtime); + handler.init(); + + clickRequiredElement('#clear-chat-btn'); + clickRequiredElement('[data-chat-attach-action="file"]'); + clickRequiredElement('[data-chat-attach-action="image"]'); + clickRequiredElement('#chat-attach-btn'); + clickRequiredElement('#chat-voice-btn'); + clickRequiredElement('#chat-send-btn'); + await flushAsyncNavigation(); + + expect(core.chatController.clearChat).toHaveBeenCalledOnce(); + expect(core.chatController.pickChatFilesFromMenu).toHaveBeenCalledOnce(); + expect(core.chatController.sendImageGenerationFromMenu).toHaveBeenCalledOnce(); + expect(core.chatController.toggleAttachMenu).toHaveBeenCalledOnce(); + expect(core.chatController.toggleVoiceInput).toHaveBeenCalledOnce(); + expect(core.chatController.sendChat).toHaveBeenCalledOnce(); + handler.destroy(); + }); + + it('binds modal, language confirmation, and window control actions', async () => { + document.body.innerHTML = ` + + + + + + `; + let windowClickHandler: EventListenerOrEventListenerObject | undefined; + const localRuntime: ConstructorParameters[1] = { + addWindowListener: vi.fn( + (event: string, handler: EventListenerOrEventListenerObject) => { + if (event === 'click') windowClickHandler = handler; + }, + ), + removeWindowListener: vi.fn(), + }; + const core = createCoreEvents(); + const handler = new EventHandler(core, localRuntime); + handler.init(); + + clickRequiredElement('#close-app-selection-btn'); + clickRequiredElement('#close-app-selection-btn-alt'); + clickRequiredElement('.lang-modal-btn'); + clickRequiredElement('#confirm-lang-btn'); + clickRequiredElement('#close-module-settings-btn'); + await flushAsyncNavigation(); + + expect(core.appUI.closeAppSelection).toHaveBeenCalledTimes(2); + expect(core.i18nUI.selectLangInModal).toHaveBeenCalledWith('en'); + expect(core.i18nUI.confirmLanguage).toHaveBeenCalledOnce(); + expect(core.moduleSettingsUI.close).toHaveBeenCalledOnce(); + + for (const id of ['minimize-btn', 'maximize-btn', 'close-btn', 'sound-toggle-btn']) { + const button = document.createElement('button'); + button.id = id; + const event = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(event, 'target', { value: button }); + if (typeof windowClickHandler === 'function') { + windowClickHandler(event); + } else { + windowClickHandler?.handleEvent(event); + } + } + await flushAsyncNavigation(); + + expect(core.windowService.minimize).toHaveBeenCalledOnce(); + expect(core.windowUI.toggleMaximize).toHaveBeenCalledOnce(); + expect(core.windowService.close).toHaveBeenCalledOnce(); + expect(core.windowUI.toggleSound).toHaveBeenCalledOnce(); + + handler.destroy(); + expect(localRuntime.removeWindowListener).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/events.ts b/src/app/events.ts index 855f3ecf..75d1ca71 100644 --- a/src/app/events.ts +++ b/src/app/events.ts @@ -4,7 +4,7 @@ */ import type { AppUI } from '@/shared/shell/AppUI'; -import type { ChatController } from '@/features/chat/chat'; +import type { ChatController } from '@/features/chat/ChatController'; import type { DownloadUI } from '@/features/downloads/ui/DownloadUI'; import type { I18nUI } from '@/infrastructure/i18n/I18nUI'; import type { NavigationUI } from '@/infrastructure/navigation/NavigationUI'; @@ -46,6 +46,7 @@ function createDefaultEventHandlerRuntime(): EventHandlerRuntime { export class EventHandler { private readonly _core: ICoreEvents; private _unsubscribers: (() => void)[] = []; + private _initialized = false; constructor( core: ICoreEvents, @@ -58,6 +59,11 @@ export class EventHandler { * Initializes all global event listeners. */ public init(): void { + if (this._initialized) { + return; + } + this._initialized = true; + this._initGlobalDelegation(); this._initWindowControls(); @@ -97,6 +103,10 @@ export class EventHandler { if (pageId === undefined) return false; e.preventDefault(); + if (document.body.classList.contains('download-selection-open')) { + return true; + } + this._core.tracer.debug(`[EventHandler] Navigating to: ${pageId}`); await this._core.navigationUI.showPage(pageId, navBtn); return true; @@ -154,8 +164,22 @@ export class EventHandler { return; } + const attachMenuAction = target.closest('[data-chat-attach-action]'); + const attachMenu = attachMenuAction?.closest('.chat-attach-menu'); + if (attachMenu instanceof HTMLElement && attachMenuAction instanceof HTMLElement) { + const action = attachMenuAction.dataset['chatAttachAction']; + if (action === 'file') { + await this._core.chatController.pickChatFilesFromMenu(); + return; + } + if (action === 'image') { + await this._core.chatController.sendImageGenerationFromMenu(); + return; + } + } + if (target.closest('#chat-attach-btn') !== null) { - await this._core.chatController.pickChatFiles(); + this._core.chatController.toggleAttachMenu(); return; } @@ -173,10 +197,15 @@ export class EventHandler { * Cleans up all event listeners. */ public destroy(): void { + if (!this._initialized) { + return; + } + this._unsubscribers.forEach((fn) => { fn(); }); this._unsubscribers = []; + this._initialized = false; this._core.tracer.debug('[EventHandler] Destroyed and listeners removed.'); } diff --git a/src/app/init.ts b/src/app/init.ts index 57ec95ce..116ab79b 100644 --- a/src/app/init.ts +++ b/src/app/init.ts @@ -2,6 +2,7 @@ import '@/styles/app.css'; import { tracer } from '@/infrastructure/logging/LoggerService'; import { createCoreAssembly, type CoreAssembly } from './CoreAssembly'; import { bindCoreEntry } from './CoreEntry'; +import type { CoreServices } from './CoreContainer'; export class Core { private readonly _assembly: CoreAssembly; @@ -37,18 +38,24 @@ export class Core { * Executes the core initialization sequence with hardened survival logic. */ public async init(): Promise { + if (this._isCoreDestroyed()) return; if (this._isInitialized) return; if (this._initPromise !== null) { await this._initPromise; return; } - this._initPromise = this._runInit(); + const initPromise = this._runInit(); + this._initPromise = initPromise; try { - await this._initPromise; - this._isInitialized = true; + await initPromise; + if (this._initPromise === initPromise && !this._isCoreDestroyed()) { + this._isInitialized = true; + } } finally { - this._initPromise = null; + if (this._initPromise === initPromise) { + this._initPromise = null; + } } } @@ -56,12 +63,36 @@ export class Core { await this._assembly.lifecycleController.runInit(); } - public destroy(): void { + private _isCoreDestroyed(): boolean { + return this._isDestroyed; + } + + public get aiBridge(): CoreServices['aiBridge'] { + return this._assembly.services.aiBridge; + } + + public get moduleService(): CoreServices['moduleService'] { + return this._assembly.services.moduleService; + } + + public get tauriProvider(): CoreServices['tauriProvider'] { + return this._assembly.services.tauriProvider; + } + + public async destroy(): Promise { if (this._isDestroyed) return; this._isDestroyed = true; this._isInitialized = false; + const pendingInit = this._initPromise; this._initPromise = null; - this._assembly.lifecycleController.destroy(); + if (pendingInit !== null) { + try { + await pendingInit; + } catch { + // Init failures are superseded by teardown. + } + } + await this._assembly.lifecycleController.destroy(); } } bindCoreEntry(() => new Core(), tracer); diff --git a/src/assets/fonts/Cubic_11.zh-subset.woff2 b/src/assets/fonts/Cubic_11.zh-subset.woff2 new file mode 100644 index 00000000..ee4bcdf4 Binary files /dev/null and b/src/assets/fonts/Cubic_11.zh-subset.woff2 differ diff --git a/src/assets/icons.ts b/src/assets/icons.ts index c9e247c8..3b60fe7c 100644 --- a/src/assets/icons.ts +++ b/src/assets/icons.ts @@ -278,13 +278,6 @@ export const svgIcons = ` - - - `; diff --git a/src/eslint.config.js b/src/eslint.config.js index 84167a76..f4e9c40c 100644 --- a/src/eslint.config.js +++ b/src/eslint.config.js @@ -12,16 +12,12 @@ export default [ '*.config.js', '*.config.ts', 'vite.config.ts', - 'test/**', - '**/test/**', '**/bindings.ts', - 'scripts/**', - '**/scripts/**', ], }, { linterOptions: { - reportUnusedDisableDirectives: 'off', + reportUnusedDisableDirectives: 'error', }, }, js.configs.recommended, @@ -31,25 +27,6 @@ export default [ parser: tsParser, globals: { ...globals.browser, - updateModuleSettings: 'writable', - openModuleSettings: 'writable', - closeModuleSettings: 'writable', - initTaskbarToggles: 'writable', - initMonitorToggles: 'writable', - loadCardWidths: 'writable', - toggleTaskbarItem: 'writable', - toggleNavItem: 'writable', - toggleMonitorItem: 'writable', - loadSdModels: 'writable', - diskUtil: 'writable', - formatBytes: 'writable', - agentLog: 'writable', - removeQuotes: 'writable', - updateRangeProgress: 'writable', - markUnsaved: 'writable', - updateSaveButton: 'writable', - showNotification: 'writable', - loadSettings: 'writable', __APP_VERSION__: 'readonly', }, parserOptions: { @@ -126,12 +103,15 @@ export default [ }, }, { - files: ['scripts/**/*.js'], + files: ['scripts/**/*.{js,mjs}', '../.github/**/*.{js,mjs}', '.github/**/*.{js,mjs}'], languageOptions: { globals: { ...globals.node, }, }, + rules: { + 'no-console': 'off', + }, }, eslintConfigPrettier, ]; diff --git a/src/features/ai/services/AIBridge.test.ts b/src/features/ai/services/AIBridge.test.ts index 1fe3d69b..a9493a86 100644 --- a/src/features/ai/services/AIBridge.test.ts +++ b/src/features/ai/services/AIBridge.test.ts @@ -17,14 +17,6 @@ const mockListen = vi.fn().mockResolvedValue(() => { }); const mockEmit = vi.fn(); -const tauriMock = { - core: { invoke: mockInvoke }, - event: { listen: mockListen, emit: mockEmit }, -}; - -// Set before import -(globalThis as unknown as Record)['__TAURI__'] = tauriMock; - // Mock Core dependency const mockCore = { tauriProvider: { @@ -44,7 +36,6 @@ const mockCore = { }), }, aiSettings: { - setAiSessionId: vi.fn(), setSelectedAIModel: vi.fn(), getSelectedAIModel: vi.fn(), getThinkingLevel: vi.fn().mockReturnValue('high'), @@ -69,6 +60,19 @@ const mockCore = { stateStore: { getSelectedModule: vi.fn().mockReturnValue(undefined), }, + 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' }, + ], + services: [], + }), + }, state: { get: vi.fn((key: string) => { if (key === 'ai_thinking_level') return {}; @@ -129,7 +133,7 @@ describe('AIBridge', () => { mockCore.tauriProvider.isTauri.mockReset(); mockCore.tauriProvider.isTauri.mockReturnValue(true); mockCore.aiSettings.getSelectedAIModel.mockReset(); - mockCore.aiSettings.getSelectedAIModel.mockReturnValue(undefined); + mockCore.aiSettings.getSelectedAIModel.mockReturnValue('gpt-4'); mockCore.aiSettings.getThinkingLevel.mockReset(); mockCore.aiSettings.getThinkingLevel.mockReturnValue('high'); mockCore.aiSettings.getInternetAccessEnabled.mockReset(); @@ -143,10 +147,10 @@ describe('AIBridge', () => { mockCore.settingsService.getSettings.mockReturnValue({}); mockCore.stateStore.getSelectedModule.mockClear(); mockCore.stateStore.getSelectedModule.mockReturnValue(undefined); - (globalThis as unknown as Record)['__TAURI__'] = tauriMock; + mockCore.catalog.getCatalog.mockClear(); localStorage.clear(); aiBridge = new AIBridge(mockTracer); - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + // eslint-disable-next-line @typescript-eslint/no-explicit-any aiBridge.setCore(mockCore as any); // Mock session ID for init @@ -187,7 +191,7 @@ 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, @typescript-eslint/no-unsafe-argument + // eslint-disable-next-line @typescript-eslint/no-explicit-any bridge2.setCore(mockCore as any); // eslint-disable-next-line @typescript-eslint/no-explicit-any const transportDestroySpy = vi.spyOn((bridge2 as any)._transport, 'destroy'); @@ -203,7 +207,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, @typescript-eslint/no-unsafe-argument + // eslint-disable-next-line @typescript-eslint/no-explicit-any bridge2.setCore(mockCore as any); mockInvoke.mockResolvedValueOnce('session-id'); @@ -284,7 +288,7 @@ describe('AIBridge', () => { expect(mockInvoke).not.toHaveBeenCalledWith('stop_engine', expect.any(Object)); }); - it('should stop conflicting local engine slots only for local providers', async () => { + it('should not stop engine slots when selecting a local provider', async () => { mockInvoke.mockImplementation(async (cmd: string) => { await Promise.resolve(); if (cmd === 'get_engine_config') return { context_size: 4096 }; @@ -293,9 +297,8 @@ describe('AIBridge', () => { await aiBridge.startProvider('llamacpp'); - expect(mockInvoke).toHaveBeenCalledWith('stop_engine_slot', { - capability: 'image', - }); + expect(mockInvoke).not.toHaveBeenCalledWith('stop_engine_slot', expect.any(Object)); + expect(mockInvoke).not.toHaveBeenCalledWith('stop_engine', expect.any(Object)); }); it('should NOT fallback to localStorage when backend returns null', async () => { @@ -617,11 +620,10 @@ describe('AIBridge', () => { expect(mockInvoke).toHaveBeenCalledWith('get_chat_history', expect.any(Object)); }); - it('should return empty array on error', async () => { + it('should surface history load errors', async () => { mockInvoke.mockRejectedValueOnce(new Error('History failed')); - const history = await aiBridge.getHistory(); - expect(history).toEqual([]); + await expect(aiBridge.getHistory()).rejects.toThrow('History failed'); }); it('should return empty array in web mode', async () => { @@ -738,6 +740,27 @@ describe('AIBridge', () => { expect(fn).toHaveBeenCalled(); }); + it('should continue cleanup when an unlistener throws', () => { + const throwingUnlistener = vi.fn(() => { + throw new Error('cleanup failed'); + }); + const healthyUnlistener = vi.fn(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const transportDestroySpy = vi.spyOn((aiBridge as any)._transport, 'destroy'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (aiBridge as any)._unlisteners.push(throwingUnlistener, healthyUnlistener); + + expect(() => aiBridge.destroy()).not.toThrow(); + + expect(throwingUnlistener).toHaveBeenCalledOnce(); + expect(healthyUnlistener).toHaveBeenCalledOnce(); + expect(transportDestroySpy).toHaveBeenCalledOnce(); + expect(mockTracer.warn).toHaveBeenCalledWith( + '[AIBridge] Stream cleanup listener failed:', + expect.any(Error), + ); + }); + it('should be safe to call multiple times', () => { aiBridge.destroy(); aiBridge.destroy(); @@ -783,10 +806,10 @@ describe('AIBridge', () => { mockCore.tauriProvider.isTauri.mockReturnValue(false); const bridge2 = new AIBridge(mockTracer); - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + // eslint-disable-next-line @typescript-eslint/no-explicit-any bridge2.setCore(mockCore as any); mockInvoke.mockResolvedValueOnce('session-id'); - await bridge2.init(); // should not throw, logs web mode active (line 81) + await bridge2.init(); // should not throw when IPC streaming is unavailable bridge2.stopProvider(); mockCore.tauriProvider.isTauri.mockReturnValue(true); @@ -795,7 +818,7 @@ describe('AIBridge', () => { it('should handle IPC initialization failure gracefully (line 86)', async () => { // Make onStream throw to trigger the catch block const bridge2 = new AIBridge(mockTracer); - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + // eslint-disable-next-line @typescript-eslint/no-explicit-any bridge2.setCore(mockCore as any); // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn((bridge2 as any)._transport, 'onStream').mockImplementation(() => { @@ -925,7 +948,7 @@ describe('AIBridge', () => { const tempBridge = new AIBridge(mockTracer); // eslint-disable-next-line @typescript-eslint/no-explicit-any (tempBridge as any)._transport = { setCore: vi.fn() }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + // 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 }); @@ -936,7 +959,7 @@ describe('AIBridge', () => { (import.meta.env as any).DEV = false; const tempBridge = new AIBridge(mockTracer); - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + // eslint-disable-next-line @typescript-eslint/no-explicit-any tempBridge.setCore(mockCore as any); mockInvoke.mockResolvedValueOnce('session'); await tempBridge.init(); @@ -945,11 +968,11 @@ describe('AIBridge', () => { (import.meta.env as any).DEV = orgDev; }); - it('should handle sendMessage when _core is null (Line 175)', async () => { + it('should reject sendMessage when _core is null and no model can be resolved', async () => { const tempBridge = new AIBridge(mockTracer); // Do NOT call setCore here to leave _core as null - // Bypass API key checks logic just to test the core check + // Bypass API key checks logic just to test missing core/model resolution. // eslint-disable-next-line @typescript-eslint/no-explicit-any Object.defineProperty((tempBridge as any)._manager, 'activeProviderId', { get: () => 'gemini', @@ -967,8 +990,9 @@ describe('AIBridge', () => { text: 'hi', }); - const res = await tempBridge.sendMessage('test message'); - expect(res.ok).toBe(true); + const result = await tempBridge.sendMessage('test message'); + expect(result.ok).toBe(false); + expect(result.error).toBe('No AI model selected'); }); 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 5d71bf98..4aa8c4e1 100644 --- a/src/features/ai/services/AIBridge.ts +++ b/src/features/ai/services/AIBridge.ts @@ -6,6 +6,7 @@ import type { IChunkHandler, IImageGenerationPreview, } from '../types/aiTypes'; +import type { IAIBridgeSendMessageOptions } from '../types/IAIBridge'; import { AIProviderManager } from './AIProviderManager'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import { AIChatTransport, type IChatTransport } from './AIChatTransport'; @@ -37,7 +38,9 @@ export class AIBridge implements IAIBridge { private readonly _transport: IChatTransport; private readonly _manager: AIProviderManager; private readonly _engineStatus: EngineStatusService; - private readonly _providerPolicy = new AIBridgeProviderPolicy(); + private readonly _providerPolicy = new AIBridgeProviderPolicy(() => + this._context?.catalog.getCatalog(), + ); private readonly _runtime: AIBridgeRuntime; private readonly _inactivityController: AIBridgeInactivityController; private readonly _messageController: AIBridgeMessageController; @@ -141,13 +144,6 @@ export class AIBridge implements IAIBridge { if (started && this._context?.tauriProvider.isTauri() === true) { this._inactivityController.reset(); - if (!this._providerPolicy.isCloudProvider(providerId)) { - await this._runtime.stopCrossSlotEngines({ - context: this._context, - providerId, - providerPolicy: this._providerPolicy, - }); - } await this._refreshLocalContextWindow(providerId); } @@ -187,6 +183,24 @@ export class AIBridge implements IAIBridge { } } + public async stopEngineSlot(capability: 'text' | 'image' | 'vision'): Promise { + const providerId = this._manager.activeProviderId; + await this._runtime.stopEngineSlot(this._context, capability); + if (this._manager.activeProviderId !== providerId) { + return; + } + + if ( + providerId !== null && + ((capability === 'image' && + this._providerPolicy.isManagedLocalImageEngine(providerId)) || + (capability === 'text' && this._providerPolicy.isLocalTextProvider(providerId))) + ) { + this._manager.stopProvider(); + this._engineStatus.setEngineState(providerId, 'idle'); + } + } + public isActive(): boolean { return this._manager.isActive(); } @@ -205,8 +219,19 @@ export class AIBridge implements IAIBridge { source: MessageSource = 'chat', attachments: { name: string; type: string; data_base64: string }[] = [], history: IChatMessage[] = [], + options: IAIBridgeSendMessageOptions = {}, ): Promise { - return await this._messageController.sendMessage(text, source, attachments, history); + return await this._messageController.sendMessage( + text, + source, + attachments, + history, + options, + ); + } + + public async prepareImagePrompt(text: string): Promise { + return await this._messageController.prepareImagePrompt(text); } public onMessage(listenerId: string, handler: MessageHandler): void { @@ -247,6 +272,7 @@ export class AIBridge implements IAIBridge { return await this._runtime.getHistory(this._context, this._manager.sessionId); } catch (e) { this._tracer.error('[AIBridge] Failed to load history:', e); + throw e; } } return []; @@ -265,17 +291,20 @@ export class AIBridge implements IAIBridge { } } - public async cancelImageGeneration(): Promise { + public async cancelImageGeneration(providerId?: string | null): Promise { if (this._context?.tauriProvider.isTauri() !== true) { return; } - const providerId = this._manager.activeProviderId; - if (providerId === null || !this._providerPolicy.isImageProvider(providerId)) { + const effectiveProviderId = providerId ?? this._manager.activeProviderId; + if ( + effectiveProviderId === null || + !this._providerPolicy.isImageProvider(effectiveProviderId) + ) { return; } - await this._runtime.cancelImageGeneration(this._context, providerId); + await this._runtime.cancelImageGeneration(this._context, effectiveProviderId); } public async cancelTextGeneration(): Promise { @@ -373,7 +402,11 @@ export class AIBridge implements IAIBridge { private _cleanupTransportState(): void { this._unlisteners.forEach((fn) => { - fn(); + try { + fn(); + } catch (error: unknown) { + this._tracer.warn('[AIBridge] Stream cleanup listener failed:', error); + } }); this._unlisteners.length = 0; this._transport.destroy(); diff --git a/src/features/ai/services/AIBridgeContext.ts b/src/features/ai/services/AIBridgeContext.ts index 70d84927..49f8167a 100644 --- a/src/features/ai/services/AIBridgeContext.ts +++ b/src/features/ai/services/AIBridgeContext.ts @@ -15,7 +15,6 @@ export type AIProviderManagerContext = AITransportContext & { aiSettings: { getSelectedAIModel: (appId: string) => string | undefined; setSelectedAIModel: (appId: string, modelKey: string) => void; - setAiSessionId: (sessionId: string | null) => void; getThinkingLevel: (appId: string) => ThinkingLevel; getInternetAccessEnabled: (appId: string) => boolean; }; diff --git a/src/features/ai/services/AIBridgeMessageController.test.ts b/src/features/ai/services/AIBridgeMessageController.test.ts index 3a30f73a..c918b14d 100644 --- a/src/features/ai/services/AIBridgeMessageController.test.ts +++ b/src/features/ai/services/AIBridgeMessageController.test.ts @@ -7,17 +7,35 @@ import { CUSTOM_TEXT_PROVIDER_ID, } from '@/shared/utils/customProviderSupport'; +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' }, + ], + })); +} + function createTextController() { const transport = { send: vi.fn().mockResolvedValue({ ok: true, text: 'done' }), + sendSilent: vi.fn().mockResolvedValue({ ok: true, text: 'prepared' }), generateImage: vi.fn(), - generateImageBackground: vi.fn(), }; const events = { broadcastResponse: vi.fn(), broadcastReplaceChunk: vi.fn(), }; - const manager = { + const manager: { + activeProviderId: string | null; + apiKey: string | null; + model: string; + sessionId: string; + maxOutputTokens: number | undefined; + refreshActiveApiKey: ReturnType; + isActive: ReturnType; + } = { activeProviderId: CUSTOM_TEXT_PROVIDER_ID, apiKey: '[secure]', model: 'deepseek/deepseek-r1-0528', @@ -41,32 +59,46 @@ function createTextController() { close: vi.fn().mockResolvedValue(undefined), }, }; + const showToast = vi.fn(); + const onActivity = vi.fn(); + const onLongActivityStart = vi.fn(); + const onLongActivityEnd = vi.fn(); const controller = new AIBridgeMessageController({ getContext: () => context as never, transport: transport as never, manager: manager as never, events: events as never, - providerPolicy: new AIBridgeProviderPolicy(), + providerPolicy: createProviderPolicy(), tracer: { error: vi.fn() }, translate: (_key, fallback) => fallback, - showToast: vi.fn(), - onActivity: vi.fn(), - onLongActivityStart: vi.fn(), - onLongActivityEnd: vi.fn(), + showToast, + onActivity, + onLongActivityStart, + onLongActivityEnd, onSuccessfulResponse: vi.fn(), }); - return { controller, transport, events, manager, context }; + return { + controller, + transport, + events, + manager, + context, + showToast, + onActivity, + onLongActivityStart, + onLongActivityEnd, + }; } function createImageController() { const transport = { send: vi.fn(), + sendSilent: vi.fn(), generateImage: vi .fn() .mockResolvedValue({ ok: true, images: ['data:image/png;base64,abc'] }), - generateImageBackground: vi.fn(), }; const events = { broadcastResponse: vi.fn(), @@ -104,7 +136,7 @@ function createImageController() { transport: transport as never, manager: manager as never, events: events as never, - providerPolicy: new AIBridgeProviderPolicy(), + providerPolicy: createProviderPolicy(), tracer: { error: vi.fn() }, translate: (_key, fallback) => fallback, showToast: vi.fn(), @@ -140,6 +172,26 @@ describe('AIBridgeMessageController custom providers', () => { ); }); + it('uses custom text provider settings for thinking and internet access', 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: 'gpt', + thinking_level: 'none', + web_search: { enabled: true }, + }), + ); + }); + it('routes custom image providers through image generation and keeps raw model ids', async () => { const { controller, transport, events, onLongActivityStart, onLongActivityEnd } = createImageController(); @@ -184,7 +236,7 @@ describe('AIBridgeMessageController custom providers', () => { broadcastResponse: vi.fn(), broadcastReplaceChunk: vi.fn(), } as never, - providerPolicy: new AIBridgeProviderPolicy(), + providerPolicy: createProviderPolicy(), tracer: { error: vi.fn() }, translate: (_key, fallback) => fallback, showToast: vi.fn(), @@ -299,4 +351,63 @@ describe('AIBridgeMessageController custom providers', () => { }); expect(transport.send).not.toHaveBeenCalled(); }); + + it('shows missing provider errors as toast without broadcasting chat text', async () => { + const { controller, events, manager, showToast } = createTextController(); + manager.activeProviderId = null; + + const response = await controller.sendMessage('hello', 'chat', [], []); + + expect(response).toEqual({ ok: false, error: 'No engine found' }); + expect(showToast).toHaveBeenCalledWith('No engine found', 'error'); + expect(events.broadcastResponse).not.toHaveBeenCalled(); + }); + + it('shows missing api key errors as toast without broadcasting chat text', async () => { + const { controller, events, manager, showToast } = createTextController(); + manager.apiKey = null; + manager.isActive.mockReturnValue(false); + + const response = await controller.sendMessage('hello', 'chat', [], []); + + expect(response).toEqual({ ok: false, error: 'API key missing' }); + expect(showToast).toHaveBeenCalledWith('API key missing', 'error'); + expect(events.broadcastResponse).not.toHaveBeenCalled(); + }); + + it('rejects cloud text messages when no model is selected', async () => { + const { controller, transport, events, manager, showToast } = createTextController(); + manager.model = ''; + + const response = await controller.sendMessage('hello', 'chat', [], []); + + expect(response).toEqual({ ok: false, error: 'No AI model selected' }); + expect(showToast).toHaveBeenCalledWith('No AI model selected', 'error'); + expect(transport.send).not.toHaveBeenCalled(); + expect(events.broadcastResponse).not.toHaveBeenCalled(); + }); + + it('rejects silent cloud prompt preparation when no model is selected', async () => { + const { controller, transport, manager, showToast } = createTextController(); + manager.model = ''; + + const response = await controller.prepareImagePrompt('rewrite image prompt'); + + expect(response).toEqual({ ok: false, error: 'No AI model selected' }); + expect(showToast).toHaveBeenCalledWith('No AI model selected', 'error'); + expect(transport.sendSilent).not.toHaveBeenCalled(); + }); + + it('marks silent image prompt preparation as provider activity', async () => { + const { controller, transport, onActivity, onLongActivityStart, onLongActivityEnd } = + createTextController(); + + const response = await controller.prepareImagePrompt('rewrite image prompt'); + + expect(response).toEqual({ ok: true, text: 'prepared' }); + expect(onActivity).toHaveBeenCalledOnce(); + expect(onLongActivityStart).toHaveBeenCalledOnce(); + expect(onLongActivityEnd).toHaveBeenCalledOnce(); + expect(transport.sendSilent).toHaveBeenCalledOnce(); + }); }); diff --git a/src/features/ai/services/AIBridgeMessageController.ts b/src/features/ai/services/AIBridgeMessageController.ts index 995a41a5..fc953ec3 100644 --- a/src/features/ai/services/AIBridgeMessageController.ts +++ b/src/features/ai/services/AIBridgeMessageController.ts @@ -13,6 +13,7 @@ import type { IChatTransport } from './AIChatTransport'; import type { AIProviderManager } from './AIProviderManager'; import type { AIBridgeEvents } from './AIBridgeEvents'; import type { AIBridgeProviderPolicy } from './AIBridgeProviderPolicy'; +import type { IAIBridgeSendMessageOptions } from '../types/IAIBridge'; import { resolveCustomProviderBackendId } from '@/shared/utils/customProviderSupport'; type AIBridgeMessageLogger = Pick; @@ -40,6 +41,7 @@ export class AIBridgeMessageController { source: MessageSource, attachments: { name: string; type: string; data_base64: string }[], history: IChatMessage[], + options: IAIBridgeSendMessageOptions = {}, ): Promise { if (this._deps.manager.activeProviderId === null) { return this._handleMissingProvider(source); @@ -58,7 +60,7 @@ export class AIBridgeMessageController { const isImageProvider = this._deps.providerPolicy.isImageProvider(providerId); if (isImageProvider) { - return await this._sendImageMessage(providerId, text, source); + return await this._sendImageMessage(providerId, text, source, options); } return await this._sendTextMessage(providerId, text, attachments, history, source); @@ -72,51 +74,100 @@ export class AIBridgeMessageController { } } + public async prepareImagePrompt(text: string): Promise { + if (this._deps.manager.activeProviderId === null) { + return this._silentMissingProviderResponse(); + } + + await this._deps.manager.refreshActiveApiKey(); + if (this._deps.manager.apiKey === null && this._deps.manager.isActive() === false) { + return this._silentMissingApiKeyResponse(); + } + + try { + this._deps.onActivity(); + + const providerId = this._deps.manager.activeProviderId; + const backendProviderId = resolveCustomProviderBackendId(providerId); + const requestModel = this._resolveRequestModel(providerId); + if (requestModel === null) { + return this._handleMissingModel(); + } + const requestOptions = this._deps.providerPolicy.buildRequestOptions({ + hasApiKey: this._deps.manager.apiKey !== null, + maxOutputTokens: Math.min(this._deps.manager.maxOutputTokens ?? 320, 420), + thinkingLevel: 'off', + webSearchEnabled: false, + }); + const request = constructChatRequest( + [], + { + role: 'user', + content: text, + }, + [], + { + providerId: backendProviderId, + model: requestModel, + apiKey: null, + sessionId: '', + ...requestOptions, + }, + ); + + this._deps.onLongActivityStart(); + const response = await this._deps.transport + .sendSilent(request) + .finally(this._deps.onLongActivityEnd); + return this._withModelContext(response, providerId, request.model); + } catch (error: unknown) { + const errorMsg = + error instanceof Error + ? error.message + : this._deps.translate('ui.ai.communication_failure', 'Communication failure'); + this._deps.tracer.error('[AIBridge] Silent prompt preparation failed:', error); + return { ok: false, error: errorMsg }; + } + } + + private _silentMissingProviderResponse(): IBridgeResponse { + return { + ok: false, + error: this._deps.translate('ui.ai.no_provider', 'No AI provider selected'), + }; + } + + private _silentMissingApiKeyResponse(): IBridgeResponse { + return { + ok: false, + error: this._deps.translate('ui.ai.missing_api_key', 'API key missing'), + }; + } + private async _sendImageMessage( providerId: string, text: string, source: MessageSource, + options: IAIBridgeSendMessageOptions, ): Promise { const context = this._deps.getContext(); - const settings = context?.settingsService.getSettings() as - | Record - | undefined; const selectedImageModule = context?.stateStore.getSelectedModule('ai_image'); const settingsKey = selectedImageModule?.id ?? providerId; - const performanceMode = this._deps.providerPolicy.isImagePerformanceModeEnabled( - settings, - settingsKey, - ); const backendProviderId = resolveCustomProviderBackendId(providerId); + const originalPrompt = options.originalPrompt?.trim(); const request: IImageGenerationRequest = { provider: backendProviderId, prompt: text, - original_prompt: text, - model: this._deps.manager.model || 'default', + original_prompt: + originalPrompt !== undefined && originalPrompt !== '' ? originalPrompt : text, + model: this._deps.manager.model, settings_key: settingsKey, session_id: this._deps.manager.sessionId, }; this._deps.events.broadcastReplaceChunk('image status=starting\n'); - if (performanceMode) { - const backgroundResponse = await this._deps.transport.generateImageBackground(request); - if (!backgroundResponse.ok) { - return this._handleTransportResponse( - this._withModelContext(backgroundResponse, providerId, request.model), - source, - ); - } - - this._deps.showToast( - this._deps.translate('ui.ai.performance_mode_active', 'Performance mode active'), - 'success', - ); - await context?.windowService.close(); - return { ok: true, text: '' }; - } - this._deps.onLongActivityStart(); const imageResponse = await this._deps.transport.generateImage(request).finally(() => { this._deps.onLongActivityEnd(); @@ -168,6 +219,10 @@ export class AIBridgeMessageController { const backendProviderId = resolveCustomProviderBackendId(providerId); const requestHistory = isLocalTextProvider ? this._toTextOnlyMessages(history) : history; const requestAttachments = isLocalTextProvider ? [] : attachments; + const requestModel = this._resolveRequestModel(providerId); + if (requestModel === null) { + return this._handleMissingModel(); + } const requestOptions = this._deps.providerPolicy.buildRequestOptions({ hasApiKey: this._deps.manager.apiKey !== null, maxOutputTokens: this._deps.manager.maxOutputTokens, @@ -176,7 +231,7 @@ export class AIBridgeMessageController { }); const request = constructChatRequest(requestHistory, newMessage, requestAttachments, { providerId: backendProviderId, - model: this._deps.manager.model || 'default', + model: requestModel, apiKey: null, sessionId: this._deps.manager.sessionId, ...requestOptions, @@ -230,6 +285,15 @@ export class AIBridgeMessageController { return part.name !== undefined ? `[File attached: ${part.name}]` : '[File attached]'; } + private _resolveRequestModel(providerId: string): string | null { + const model = this._deps.manager.model.trim(); + if (model !== '') { + return model; + } + + return this._deps.providerPolicy.isLocalTextProvider(providerId) ? 'default' : null; + } + private _withModelContext( response: IBridgeResponse, providerId: string, @@ -251,16 +315,21 @@ export class AIBridgeMessageController { }; } - private _handleMissingApiKey(source: MessageSource): IBridgeResponse { + private _handleMissingApiKey(_source: MessageSource): IBridgeResponse { const msg = this._deps.translate('ui.ai.no_api_key', 'API key missing'); - this._deps.events.broadcastResponse(`Error: ${msg}`, source); this._deps.showToast(msg, 'error'); return { ok: false, error: msg }; } - private _handleMissingProvider(source: MessageSource): IBridgeResponse { + private _handleMissingProvider(_source: MessageSource): IBridgeResponse { const msg = this._deps.translate('ui.ai.no_provider', 'No engine found'); - this._deps.events.broadcastResponse(msg, source); + this._deps.showToast(msg, 'error'); + return { ok: false, error: msg }; + } + + private _handleMissingModel(): IBridgeResponse { + const msg = this._deps.translate('ui.ai.no_model_selected', 'No AI model selected'); + this._deps.showToast(msg, 'error'); return { ok: false, error: msg }; } diff --git a/src/features/ai/services/AIBridgeProviderPolicy.test.ts b/src/features/ai/services/AIBridgeProviderPolicy.test.ts index 28694471..3625ef6c 100644 --- a/src/features/ai/services/AIBridgeProviderPolicy.test.ts +++ b/src/features/ai/services/AIBridgeProviderPolicy.test.ts @@ -6,7 +6,16 @@ import { } from '@/shared/utils/customProviderSupport'; describe('AIBridgeProviderPolicy', () => { - const policy = new 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' }, + ], + })); it('should classify cloud and image providers consistently', () => { expect(policy.isCloudProvider('gemini')).toBe(true); @@ -20,12 +29,26 @@ describe('AIBridgeProviderPolicy', () => { expect(policy.isImageProvider('gemini')).toBe(false); expect(policy.isImageProvider(CUSTOM_TEXT_PROVIDER_ID)).toBe(false); expect(policy.isManagedLocalImageEngine('sdcpp')).toBe(true); - expect(policy.isManagedLocalImageEngine('comfyui')).toBe(false); + expect(policy.isManagedLocalImageEngine('comfyui')).toBe(true); expect(policy.isLocalTextProvider('llamacpp')).toBe(true); expect(policy.isLocalTextProvider('sdcpp')).toBe(false); expect(policy.isLocalTextProvider(CUSTOM_TEXT_PROVIDER_ID)).toBe(false); }); + it('should prefer catalog capabilities for provider output type', () => { + const catalogPolicy = new AIBridgeProviderPolicy(() => ({ + ai: [ + { id: 'local-image-engine', capability: 'image' }, + { id: 'local-text-engine', capability: 'text' }, + ], + })); + + expect(catalogPolicy.isImageProvider('local-image-engine')).toBe(true); + expect(catalogPolicy.isLocalTextProvider('local-image-engine')).toBe(false); + expect(catalogPolicy.isImageProvider('local-text-engine')).toBe(false); + expect(catalogPolicy.isLocalTextProvider('local-text-engine')).toBe(true); + }); + it('should map off thinking level to explicit OpenRouter none effort', () => { expect( policy.buildRequestOptions({ @@ -51,24 +74,4 @@ describe('AIBridgeProviderPolicy', () => { }), ).toEqual({}); }); - - it('should resolve performance mode from module-specific or global settings', () => { - expect( - policy.isImagePerformanceModeEnabled( - { - comfyui_performance_mode: 'true', - }, - 'comfyui', - ), - ).toBe(true); - expect( - policy.isImagePerformanceModeEnabled( - { - sdcpp_performance_mode: true, - }, - 'stable-diffusion', - ), - ).toBe(true); - expect(policy.isImagePerformanceModeEnabled({}, 'comfyui')).toBe(false); - }); }); diff --git a/src/features/ai/services/AIBridgeProviderPolicy.ts b/src/features/ai/services/AIBridgeProviderPolicy.ts index 3005b88c..ea768b0d 100644 --- a/src/features/ai/services/AIBridgeProviderPolicy.ts +++ b/src/features/ai/services/AIBridgeProviderPolicy.ts @@ -1,8 +1,5 @@ -import { - isCloudProviderId, - isImageProviderId, - isManagedLocalImageProviderId, -} from '@/shared/utils/providerSupport'; +import { isCloudProviderId } from '@/shared/utils/providerSupport'; +import type { IApp } from '@/shared/types/coreTypes'; type ThinkingLevel = 'off' | 'low' | 'medium' | 'high'; type CloudReasoningEffort = 'none' | Exclude; @@ -20,17 +17,21 @@ type RequestOptionInput = { webSearchEnabled: boolean | undefined; }; +type ProviderCatalogGetter = () => { ai?: unknown[] } | null | undefined; + export class AIBridgeProviderPolicy { + public constructor(private readonly _getCatalog?: ProviderCatalogGetter) {} + public isCloudProvider(providerId: string): boolean { return isCloudProviderId(providerId); } public isImageProvider(providerId: string): boolean { - return isImageProviderId(providerId); + return this._catalogCapability(providerId) === 'image'; } public isManagedLocalImageEngine(providerId: string): boolean { - return isManagedLocalImageProviderId(providerId); + return !this.isCloudProvider(providerId) && this.isImageProvider(providerId); } public isLocalTextProvider(providerId: string): boolean { @@ -65,29 +66,21 @@ export class AIBridgeProviderPolicy { return requestOptions; } - public isImagePerformanceModeEnabled( - settings: Record | undefined, - settingsKey: string, - ): boolean { - return ( - this._readBooleanSetting(settings, `${settingsKey}_performance_mode`) || - this._readBooleanSetting(settings, 'sdcpp_performance_mode') - ); - } - - private _readBooleanSetting( - settings: Record | undefined, - key: string, - ): boolean { - const value = settings?.[key]; - if (typeof value === 'boolean') { - return value; - } - - if (typeof value === 'string') { - return value.trim().toLowerCase() === 'true'; + private _catalogCapability(providerId: string): IApp['capability'] | null { + const catalog = this._getCatalog?.(); + const ai = catalog?.ai; + if (!Array.isArray(ai)) { + return null; } - return false; + 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; } } diff --git a/src/features/ai/services/AIBridgeRuntime.test.ts b/src/features/ai/services/AIBridgeRuntime.test.ts index eef0c166..5c784291 100644 --- a/src/features/ai/services/AIBridgeRuntime.test.ts +++ b/src/features/ai/services/AIBridgeRuntime.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { buildImageGenerationProgressChunk, isActiveEngineLog } from './AIBridgeRuntime'; +import { AIBridgeRuntime } from './AIBridgeRuntime'; describe('AIBridgeRuntime', () => { it('normalizes image progress logs into machine-readable chunks', () => { @@ -33,10 +34,49 @@ describe('AIBridgeRuntime', () => { expect(buildImageGenerationProgressChunk('server ready')).toBeNull(); }); - it('accepts local image engine logs even when active provider alias differs', () => { - expect(isActiveEngineLog('custom_sd', 'sdcpp')).toBe(true); - expect(isActiveEngineLog(null, 'sdcpp')).toBe(true); + it('accepts selected image engine logs for image progress regardless of active text provider', () => { + expect(isActiveEngineLog('custom_text', 'local-image-engine', 'local-image-engine')).toBe( + true, + ); + expect(isActiveEngineLog(null, 'local-image-engine', 'local-image-engine')).toBe(true); + expect(isActiveEngineLog(null, 'local-image-engine', null)).toBe(false); expect(isActiveEngineLog('llamacpp', 'llamacpp')).toBe(true); expect(isActiveEngineLog('llamacpp', 'other')).toBe(false); }); + + it('cleans up partial stream subscriptions when initialization fails', async () => { + const cleanupLog = vi.fn(); + const runtime = new AIBridgeRuntime({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }); + const failure = new Error('stream subscription failed'); + + await expect( + runtime.initializeStreaming({ + context: { + tauriProvider: { + isTauri: () => true, + listen: vi.fn().mockResolvedValue(cleanupLog), + }, + }, + transport: { + onStream: vi.fn(() => { + throw failure; + }), + onThought: vi.fn(), + }, + events: { + broadcastReplaceChunk: vi.fn(), + }, + getActiveProviderId: () => null, + broadcastChunk: vi.fn(), + broadcastThought: vi.fn(), + } as never), + ).rejects.toBe(failure); + + expect(cleanupLog).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/features/ai/services/AIBridgeRuntime.ts b/src/features/ai/services/AIBridgeRuntime.ts index cb7a1f5b..79b10565 100644 --- a/src/features/ai/services/AIBridgeRuntime.ts +++ b/src/features/ai/services/AIBridgeRuntime.ts @@ -1,10 +1,8 @@ import type { IChatMessage, IChunkHandler, IImageGenerationPreview } from '../types/aiTypes'; import type { AIBridgeContext } from './AIBridgeContext'; import type { AIBridgeEvents } from './AIBridgeEvents'; -import type { AIBridgeProviderPolicy } from './AIBridgeProviderPolicy'; import type { IChatTransport } from './AIChatTransport'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; -import type { TauriProvider } from '@/infrastructure/tauri/TauriProvider'; import { resolveCustomProviderBackendId } from '@/shared/utils/customProviderSupport'; type AIBridgeRuntimeLogger = Pick; @@ -18,12 +16,6 @@ type InitializeStreamingArgs = { broadcastThought: IChunkHandler; }; -type StopCrossSlotEnginesArgs = { - context: AIBridgeContext | null; - providerId: string; - providerPolicy: AIBridgeProviderPolicy; -}; - type ImageGenerationLogProgress = { percent: number | null; step: number | null; @@ -82,10 +74,12 @@ export const buildImageGenerationProgressChunk = (line: string): string | null = return `${fields.join(' ')}\n`; }; -const LOCAL_IMAGE_ENGINE_IDS = new Set(['sdcpp', 'stable-diffusion']); - -export const isActiveEngineLog = (activeProviderId: string | null, engineId: string): boolean => { - if (LOCAL_IMAGE_ENGINE_IDS.has(engineId)) { +export const isActiveEngineLog = ( + activeProviderId: string | null, + engineId: string, + selectedImageProviderId: string | null = null, +): boolean => { + if (selectedImageProviderId !== null && selectedImageProviderId === engineId) { return true; } @@ -98,7 +92,7 @@ export const isActiveEngineLog = (activeProviderId: string | null, engineId: str return true; } - return LOCAL_IMAGE_ENGINE_IDS.has(activeBackendId) && LOCAL_IMAGE_ENGINE_IDS.has(engineId); + return false; }; export class AIBridgeRuntime { @@ -106,71 +100,61 @@ export class AIBridgeRuntime { public async initializeStreaming(args: InitializeStreamingArgs): Promise<(() => void)[]> { if (!args.context.tauriProvider.isTauri()) { - this._tracer.info('[AIBridge] Web mode active (Mocks)'); + this._tracer.info('[AIBridge] Tauri IPC unavailable; streaming disabled'); return []; } - const unlistenLog = await args.context.tauriProvider.listen<{ - engine_id: string; - line: string; - }>('ai:engine:log', (payload) => { - const line = payload.line; - if (!isActiveEngineLog(args.getActiveProviderId(), payload.engine_id)) { - return; - } - - const progressChunk = buildImageGenerationProgressChunk(line); - if (progressChunk !== null) { - args.events.broadcastReplaceChunk(progressChunk); - } - }); - - const unlistenChunk = args.transport.onStream((payload: string) => { - args.broadcastChunk(payload); - }); - - const unlistenThought = args.transport.onThought((payload: string) => { - args.broadcastThought(payload); - }); - - this._tracer.info('[AIBridge] Streaming active (IPC via Transport)'); - return [unlistenLog, unlistenChunk, unlistenThought]; - } + const cleanup: Array<() => void> = []; + try { + const unlistenLog = await args.context.tauriProvider.listen<{ + engine_id: string; + line: string; + }>('ai:engine:log', (payload) => { + const line = payload.line; + const selectedImageProviderId = + args.context.stateStore.getSelectedModule('ai_image')?.id ?? null; + if ( + !isActiveEngineLog( + args.getActiveProviderId(), + payload.engine_id, + selectedImageProviderId, + ) + ) { + return; + } - public async stopCrossSlotEngines(args: StopCrossSlotEnginesArgs): Promise { - if (args.context?.tauriProvider.isTauri() !== true) { - return; - } + const progressChunk = buildImageGenerationProgressChunk(line); + if (progressChunk !== null) { + args.events.broadcastReplaceChunk(progressChunk); + } + }); + cleanup.push(unlistenLog); - if (args.providerPolicy.isCloudProvider(args.providerId)) { - return; - } + cleanup.push( + args.transport.onStream((payload: string) => { + args.broadcastChunk(payload); + }), + ); - const isImageProvider = args.providerPolicy.isImageProvider(args.providerId); - const isManagedLocalImageEngine = args.providerPolicy.isManagedLocalImageEngine( - args.providerId, - ); + cleanup.push( + args.transport.onThought((payload: string) => { + args.broadcastThought(payload); + }), + ); - try { - if (isImageProvider) { - await args.context.tauriProvider.invoke('stop_engine_slot', { - capability: 'text', - }); - if (!isManagedLocalImageEngine) { - await args.context.tauriProvider.invoke('stop_engine_slot', { - capability: 'image', - }); + this._tracer.debug('[AIBridge] Streaming active (IPC via Transport)'); + return cleanup; + } catch (error) { + for (const dispose of cleanup.splice(0).reverse()) { + try { + dispose(); + } catch (cleanupError) { + this._tracer.warn( + `[AIBridge] Failed to cleanup partial stream subscription: ${String(cleanupError)}`, + ); } - return; } - - await args.context.tauriProvider.invoke('stop_engine_slot', { - capability: 'image', - }); - } catch (error) { - this._tracer.warn( - `[AIBridge] Failed to stop cross-slot engine for VRAM savings: ${String(error)}`, - ); + throw error; } } @@ -184,6 +168,17 @@ export class AIBridgeRuntime { }); } + public async stopEngineSlot( + context: AIBridgeContext | null, + capability: 'text' | 'image' | 'vision', + ): Promise { + if (context?.tauriProvider.isTauri() !== true) { + return; + } + + await context.tauriProvider.invoke('stop_engine_slot', { capability }); + } + public async getHistory( context: AIBridgeContext | null, sessionId: string, @@ -192,12 +187,9 @@ export class AIBridgeRuntime { return []; } - return await (context.tauriProvider as unknown as TauriProvider).invoke( - 'get_chat_history', - { - sessionId, - }, - ); + return await context.tauriProvider.invoke('get_chat_history', { + sessionId, + }); } public async clearHistory(context: AIBridgeContext | null, sessionId: string): Promise { @@ -205,7 +197,7 @@ export class AIBridgeRuntime { return; } - await (context.tauriProvider as unknown as TauriProvider).invoke('clear_chat_history', { + await context.tauriProvider.invoke('clear_chat_history', { sessionId, }); } @@ -243,11 +235,8 @@ export class AIBridgeRuntime { return null; } - return await (context.tauriProvider as unknown as TauriProvider).invoke( - 'rewind_last_turn', - { - sessionId, - }, - ); + return await context.tauriProvider.invoke('rewind_last_turn', { + sessionId, + }); } } diff --git a/src/features/ai/services/AIChatTransport.test.ts b/src/features/ai/services/AIChatTransport.test.ts index 9a830dd1..2582c1eb 100644 --- a/src/features/ai/services/AIChatTransport.test.ts +++ b/src/features/ai/services/AIChatTransport.test.ts @@ -38,11 +38,12 @@ function makeRequest(overrides: Partial = {}): IChatRequest { describe('AIChatTransport', () => { let transport: AIChatTransport; let mockCore: ReturnType; - let tracer: Pick; + let tracer: Pick; beforeEach(() => { vi.useFakeTimers(); tracer = { + debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), @@ -143,9 +144,17 @@ describe('AIChatTransport', () => { expect(result).toEqual({ ok: false, error: 'string error' }); }); - it('should timeout after 90 seconds', async () => { - // Invoke never resolves - mockCore.tauriProvider.invoke.mockReturnValue(new Promise(() => {})); + it('should timeout cloud requests after 90 seconds', async () => { + let requestId = ''; + mockCore.tauriProvider.invoke.mockImplementation( + (command: string, args: Record) => { + if (command === 'cancel_chat_generation') { + return Promise.resolve(true); + } + requestId = (args['request'] as { request_id: string }).request_id; + return new Promise(() => {}); + }, + ); const sendPromise = transport.send(makeRequest()); @@ -154,6 +163,38 @@ describe('AIChatTransport', () => { const result = await sendPromise; expect(result).toEqual({ ok: false, error: 'AI request timed out' }); + expect(mockCore.tauriProvider.invoke).toHaveBeenCalledWith('cancel_chat_generation', { + requestId, + }); + }); + + it('should keep local text requests 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: 'llamacpp', model: 'model.gguf' }), + ); + vi.advanceTimersByTime(90_001); + await Promise.resolve(); + + expect(mockCore.tauriProvider.invoke).not.toHaveBeenCalledWith( + 'cancel_chat_generation', + expect.anything(), + ); + + resolveInvoke({ ok: true, reply: { text: 'local done' } }); + await expect(sendPromise).resolves.toEqual({ ok: true, text: 'local done' }); }); it('should extract message from plain error objects', async () => { @@ -207,6 +248,125 @@ describe('AIChatTransport', () => { vi.advanceTimersByTime(90_001); await firstSend; }); + + it('should keep timed-out requests cancellable before clearing active state', async () => { + let requestId = ''; + mockCore.tauriProvider.invoke.mockImplementation( + (command: string, args: Record) => { + if (command === 'cancel_chat_generation') { + return Promise.resolve(true); + } + + requestId = (args['request'] as { request_id: string }).request_id; + return new Promise(() => {}); + }, + ); + + const sendPromise = transport.send(makeRequest()); + vi.advanceTimersByTime(90_001); + + await expect(sendPromise).resolves.toEqual({ + ok: false, + error: 'AI request timed out', + }); + expect(mockCore.tauriProvider.invoke).toHaveBeenCalledWith('cancel_chat_generation', { + requestId, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((transport as any)._activeChatRequestId).toBeNull(); + }); + }); + + describe('sendSilent', () => { + it('should register its request and cancel stale active work before sending', async () => { + let sendCalls = 0; + let firstRequestId = ''; + mockCore.tauriProvider.invoke.mockImplementation( + (command: string, args: Record) => { + if (command === 'cancel_chat_generation') { + return Promise.resolve(true); + } + + sendCalls += 1; + const request = args['request'] as { request_id: string }; + if (sendCalls === 1) { + firstRequestId = request.request_id; + return new Promise(() => {}); + } + return Promise.resolve({ ok: true, reply: { text: 'silent' } }); + }, + ); + + const firstSend = transport.send(makeRequest()); + await Promise.resolve(); + + await expect(transport.sendSilent(makeRequest())).resolves.toEqual({ + ok: true, + text: 'silent', + }); + expect(mockCore.tauriProvider.invoke).toHaveBeenCalledWith('cancel_chat_generation', { + requestId: firstRequestId, + }); + + vi.advanceTimersByTime(90_001); + await firstSend; + }); + + it('should cancel a timed-out silent cloud request before clearing active state', async () => { + let requestId = ''; + mockCore.tauriProvider.invoke.mockImplementation( + (command: string, args: Record) => { + if (command === 'cancel_chat_generation') { + return Promise.resolve(true); + } + + requestId = (args['request'] as { request_id: string }).request_id; + return new Promise(() => {}); + }, + ); + + const sendPromise = transport.sendSilent(makeRequest()); + vi.advanceTimersByTime(90_001); + + await expect(sendPromise).resolves.toEqual({ + ok: false, + error: 'AI request timed out', + }); + expect(mockCore.tauriProvider.invoke).toHaveBeenCalledWith('cancel_chat_generation', { + requestId, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((transport as any)._activeChatRequestId).toBeNull(); + }); + + it('should use the local timeout for silent local prompt preparation', 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.sendSilent( + makeRequest({ provider: 'llamacpp', model: 'model.gguf' }), + ); + vi.advanceTimersByTime(90_001); + await Promise.resolve(); + + expect(mockCore.tauriProvider.invoke).not.toHaveBeenCalledWith( + 'cancel_chat_generation', + expect.anything(), + ); + + resolveInvoke({ ok: true, reply: { text: 'prepared' } }); + await expect(sendPromise).resolves.toEqual({ ok: true, text: 'prepared' }); + }); }); describe('generateImage', () => { @@ -273,35 +433,6 @@ describe('AIChatTransport', () => { }); }); - describe('generateImageBackground', () => { - const request = { provider: 'sdcpp', prompt: 'city', model: 'default' } as Parameters< - AIChatTransport['generateImageBackground'] - >[0]; - - it('should reject in web mode', async () => { - mockCore.tauriProvider.isTauri.mockReturnValue(false); - await expect(transport.generateImageBackground(request)).resolves.toEqual({ - ok: false, - error: 'IPC host unavailable', - }); - }); - - it('should invoke background generation and normalize errors', async () => { - mockCore.tauriProvider.invoke.mockResolvedValueOnce(undefined); - await expect(transport.generateImageBackground(request)).resolves.toEqual({ ok: true }); - expect(mockCore.tauriProvider.invoke).toHaveBeenCalledWith( - 'generate_image_background', - { request }, - ); - - mockCore.tauriProvider.invoke.mockRejectedValueOnce('bg failed'); - await expect(transport.generateImageBackground(request)).resolves.toEqual({ - ok: false, - error: 'bg failed', - }); - }); - }); - // ---------------------------------------------------------- Stream Listeners (onStream, onThought) describe.each([ ['onStream', 'chatChannel'], @@ -432,6 +563,62 @@ describe('AIChatTransport', () => { expect(listener).toHaveBeenCalledWith('current'); }); + it('should keep notifying listeners when one stream listener throws', async () => { + const failingListener = vi.fn(() => { + throw new Error('listener failed'); + }); + const healthyListener = vi.fn(); + invokeMethod(failingListener); + invokeMethod(healthyListener); + mockCore.tauriProvider.invoke.mockImplementation( + (_cmd: string, args: Record) => { + const chatChannel = args['chatChannel'] as { + onmessage?: + | ((payload: { + request_id: string; + message_id: string; + kind: 'chat_chunk' | 'thought_chunk' | 'done'; + content: string; + }) => void) + | null; + }; + const channel = args[channelName] as { + onmessage?: + | ((payload: { + request_id: string; + message_id: string; + kind: 'chat_chunk' | 'thought_chunk' | 'done'; + content: string; + }) => void) + | null; + }; + const requestId = (args['request'] as { request_id: string }).request_id; + channel.onmessage?.({ + request_id: requestId, + message_id: 'msg-1', + kind: channelName === 'chatChannel' ? 'chat_chunk' : 'thought_chunk', + content: 'current', + }); + chatChannel.onmessage?.({ + request_id: requestId, + message_id: 'msg-1', + kind: 'done', + content: '', + }); + return Promise.resolve({ ok: true, reply: { text: 'done' } }); + }, + ); + + await transport.send(makeRequest()); + + expect(failingListener).toHaveBeenCalledWith('current'); + expect(healthyListener).toHaveBeenCalledWith('current'); + expect(tracer.error).toHaveBeenCalledWith( + '[AIChatTransport] Stream listener failed:', + expect.any(Error), + ); + }); + it('should NOT forward payload after unsubscribe', async () => { const listener = vi.fn(); const unsub = invokeMethod(listener); @@ -476,9 +663,11 @@ describe('AIChatTransport', () => { // ---------------------------------------------------------- missing branches describe('Missing branch cases', () => { it('should hit timeout error line (Line 45)', async () => { - // Need the timeout to actually reject - mockCore.tauriProvider.invoke.mockImplementation(() => { - return new Promise(() => {}); // never resolves + mockCore.tauriProvider.invoke.mockImplementation((command: string) => { + if (command === 'cancel_chat_generation') { + return Promise.resolve(true); + } + return new Promise(() => {}); }); // Start send diff --git a/src/features/ai/services/AIChatTransport.ts b/src/features/ai/services/AIChatTransport.ts index f032825f..77867135 100644 --- a/src/features/ai/services/AIChatTransport.ts +++ b/src/features/ai/services/AIChatTransport.ts @@ -7,10 +7,15 @@ import type { IImageGenerationResponse, } from '../types/aiTypes'; 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; +type AIChatTransportLogger = Pick; const STALE_REQUEST_CANCEL_TIMEOUT_MS = 750; +const AI_REQUEST_TIMEOUT_MESSAGE = 'AI request timed out'; +const CLOUD_CHAT_REQUEST_TIMEOUT_MS = 90_000; +const LOCAL_CHAT_REQUEST_TIMEOUT_MS = 30 * 60_000; /** * Safely extracts a human-readable error string from any error shape. @@ -28,19 +33,12 @@ function extractError(error: unknown): string { return JSON.stringify(error); } -interface IStreamChunkEnvelope { - request_id: string; - message_id: string; - kind: 'chat_chunk' | 'thought_chunk' | 'done'; - content: string; -} - export interface IChatTransport { init(): Promise; send(request: IChatRequest): Promise; + sendSilent(request: IChatRequest): Promise; cancelActiveChatRequest(): Promise; generateImage(request: IImageGenerationRequest): Promise; - generateImageBackground(request: IImageGenerationRequest): Promise; onStream(listener: (chunk: string) => void): () => void; onThought(listener: (chunk: string) => void): () => void; setContext(context: AITransportContext): void; @@ -74,7 +72,7 @@ export class AIChatTransport implements IChatTransport { // Setup global listener for streaming chunks if needed here, // or let the bridge handle the subscription via onStream. // For now, we follow the pattern that Transport manages the low-level listener. - this._tracer.info('[AIChatTransport] Transport initialized'); + this._tracer.debug('[AIChatTransport] Transport initialized'); } await Promise.resolve(); } @@ -97,11 +95,18 @@ export class AIChatTransport implements IChatTransport { request_id: requestId, }; this._activeChatRequestId = requestId; + let streamDoneResolved = false; let resolveStreamDone: (() => void) | null = null; const streamDone = new Promise((resolve) => { - resolveStreamDone = resolve; + resolveStreamDone = () => { + if (streamDoneResolved) { + return; + } + streamDoneResolved = true; + resolve(); + }; }); - const chatChannel = new Channel(); + const chatChannel = new Channel(); chatChannel.onmessage = (payload) => { if (!this._isPayloadForRequest(payload, requestId)) { return; @@ -119,12 +124,18 @@ export class AIChatTransport implements IChatTransport { this._emitListeners(this._streamListeners, payload.content); }; - const thoughtChannel = new Channel(); + const thoughtChannel = new Channel(); thoughtChannel.onmessage = (payload) => { - if ( - !this._isPayloadForRequest(payload, requestId) || - payload.kind !== 'thought_chunk' - ) { + if (!this._isPayloadForRequest(payload, requestId)) { + return; + } + + if (payload.kind === 'done') { + resolveStreamDone?.(); + return; + } + + if (payload.kind !== 'thought_chunk') { return; } @@ -138,8 +149,8 @@ export class AIChatTransport implements IChatTransport { chatChannel, thoughtChannel, }), - 90000, - 'AI request timed out', + this._chatRequestTimeoutMs(requestWithId), + AI_REQUEST_TIMEOUT_MESSAGE, ); if ( @@ -153,6 +164,53 @@ export class AIChatTransport implements IChatTransport { } catch (error: unknown) { const errorMsg = extractError(error); this._tracer.error('[AIChatTransport] IPC error:', error); + if (errorMsg === AI_REQUEST_TIMEOUT_MESSAGE) { + await this._cancelStaleActiveRequest(requestId); + } + return { ok: false, error: errorMsg }; + } finally { + if (this._activeChatRequestId === requestId) { + this._activeChatRequestId = null; + } + } + } + + public async sendSilent(request: IChatRequest): Promise { + if (this._context?.tauriProvider.isTauri() !== true) { + return { ok: false, error: 'IPC host unavailable' }; + } + + if (this._activeChatRequestId !== null) { + await this._cancelStaleActiveRequest(this._activeChatRequestId); + } + + const requestId = this._generateRequestId(); + const requestWithId: IChatRequest = { + ...request, + request_id: requestId, + }; + this._activeChatRequestId = requestId; + const chatChannel = new Channel(); + const thoughtChannel = new Channel(); + + try { + const response = await this._runWithTimeout( + this._context.tauriProvider.invoke('send_chat_message', { + request: requestWithId, + chatChannel, + thoughtChannel, + }), + this._chatRequestTimeoutMs(requestWithId), + AI_REQUEST_TIMEOUT_MESSAGE, + ); + + return this._normalizeResponse(response); + } catch (error: unknown) { + const errorMsg = extractError(error); + this._tracer.error('[AIChatTransport] Silent IPC error:', error); + if (errorMsg === AI_REQUEST_TIMEOUT_MESSAGE) { + await this._cancelStaleActiveRequest(requestId); + } return { ok: false, error: errorMsg }; } finally { if (this._activeChatRequestId === requestId) { @@ -163,14 +221,22 @@ export class AIChatTransport implements IChatTransport { private async _cancelStaleActiveRequest(requestId: string): Promise { try { - await this._runWithTimeout( + const cancelled = await this._runWithTimeout( this._context?.tauriProvider.invoke('cancel_chat_generation', { requestId, }) ?? Promise.resolve(false), STALE_REQUEST_CANCEL_TIMEOUT_MS, 'Stale AI request cancel timed out', ); - this._tracer.info('[AIChatTransport] Cancelled stale active request before restart'); + if (cancelled) { + this._tracer.info( + '[AIChatTransport] Cancelled stale active request before restart', + ); + } else { + this._tracer.warn( + '[AIChatTransport] Stale active request was not registered for cancellation', + ); + } } catch (error: unknown) { this._tracer.warn('[AIChatTransport] Failed to cancel stale active request:', error); } finally { @@ -191,9 +257,17 @@ export class AIChatTransport implements IChatTransport { } try { - return await this._context.tauriProvider.invoke('cancel_chat_generation', { - requestId, - }); + const cancelled = await this._runWithTimeout( + this._context.tauriProvider.invoke('cancel_chat_generation', { + requestId, + }), + STALE_REQUEST_CANCEL_TIMEOUT_MS, + 'AI request cancel timed out', + ); + if (cancelled && this._activeChatRequestId === requestId) { + this._activeChatRequestId = null; + } + return cancelled; } catch (error: unknown) { this._tracer.error('[AIChatTransport] IPC cancel error:', error); return false; @@ -212,7 +286,11 @@ export class AIChatTransport implements IChatTransport { return await this._context.tauriProvider .invoke('generate_image', { request }) .then((response) => { - if (response.ok && response.images.length > 0) { + if ( + response.ok && + Array.isArray(response.images) && + response.images.length > 0 + ) { return { ok: true, images: response.images }; } return { ok: false, error: response.error ?? 'Failed to generate image' }; @@ -224,26 +302,6 @@ export class AIChatTransport implements IChatTransport { } } - /** - * Starts an image generation job that survives window closure. - */ - public async generateImageBackground( - request: IImageGenerationRequest, - ): Promise { - if (this._context?.tauriProvider.isTauri() !== true) { - return { ok: false, error: 'IPC host unavailable' }; - } - - try { - await this._context.tauriProvider.invoke('generate_image_background', { request }); - return { ok: true }; - } catch (error: unknown) { - const errorMsg = extractError(error); - this._tracer.error('[AIChatTransport] IPC background image error:', error); - return { ok: false, error: errorMsg }; - } - } - /** * Subscribes to the AI stream events. * Returns an unlisten function. @@ -282,7 +340,7 @@ export class AIChatTransport implements IChatTransport { timeoutMs: number, timeoutMessage: string, ): Promise { - let timeoutId!: ReturnType; + let timeoutId: ReturnType | undefined; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new Error(timeoutMessage)); @@ -292,12 +350,14 @@ export class AIChatTransport implements IChatTransport { try { return await Promise.race([operation, timeoutPromise]); } finally { - clearTimeout(timeoutId); + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } } } private async _waitForStreamFinalization(streamDone: Promise): Promise { - let timeoutId!: ReturnType; + let timeoutId: ReturnType | undefined; const timeout = new Promise<'timeout'>((resolve) => { timeoutId = setTimeout(() => { resolve('timeout'); @@ -310,7 +370,9 @@ export class AIChatTransport implements IChatTransport { this._tracer.warn('[AIChatTransport] Stream finalization marker was not received'); } } finally { - clearTimeout(timeoutId); + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } } } @@ -362,14 +424,24 @@ export class AIChatTransport implements IChatTransport { private _emitListeners(listeners: ReadonlySet<(chunk: string) => void>, payload: string): void { listeners.forEach((listener) => { - listener(payload); + try { + listener(payload); + } catch (error) { + this._tracer.error('[AIChatTransport] Stream listener failed:', error); + } }); } - private _isPayloadForRequest(payload: IStreamChunkEnvelope, requestId: string): boolean { + private _isPayloadForRequest(payload: StreamChunkPayload, requestId: string): boolean { return payload.request_id === requestId; } + private _chatRequestTimeoutMs(request: IChatRequest): number { + return isCloudProviderId(request.provider) + ? CLOUD_CHAT_REQUEST_TIMEOUT_MS + : LOCAL_CHAT_REQUEST_TIMEOUT_MS; + } + public destroy(): void { void this.cancelActiveChatRequest(); this._unlisteners.forEach((fn) => fn()); diff --git a/src/features/ai/services/AIProviderManager.test.ts b/src/features/ai/services/AIProviderManager.test.ts index c17dd499..8203538e 100644 --- a/src/features/ai/services/AIProviderManager.test.ts +++ b/src/features/ai/services/AIProviderManager.test.ts @@ -34,7 +34,6 @@ function createMockCore( getCatalog: vi.fn().mockReturnValue({ ai: [] }), }, aiSettings: { - setAiSessionId: vi.fn(), setSelectedAIModel: vi.fn(), getSelectedAIModel: vi.fn().mockReturnValue(undefined), getThinkingLevel: vi.fn().mockReturnValue('auto'), @@ -71,7 +70,6 @@ describe('AIProviderManager', () => { 'ai_session_id', expect.any(String), ); - expect(mockCore.aiSettings.setAiSessionId).toHaveBeenCalledWith(expect.any(String)); expect(manager.sessionId).not.toBe('default'); }); @@ -85,6 +83,50 @@ describe('AIProviderManager', () => { expect(manager.sessionId).toBe('existing-session-abc'); }); + it('should generate session ID when secure storage is empty', async () => { + const mockCore = createMockCore(() => Promise.resolve(null)); + manager.setCore(mockCore); + + await manager.init(); + + expect(mockCore.tauriProvider.saveSecureKey).toHaveBeenCalledWith( + 'ai_session_id', + manager.sessionId, + ); + expect(manager.sessionId).not.toBe('default'); + }); + + it('should generate session ID when secure read fails', async () => { + const mockCore = createMockCore(() => Promise.reject(new Error('secure read failed'))); + manager.setCore(mockCore); + + await manager.init(); + + expect(mockCore.tauriProvider.getSecureKey).toHaveBeenCalledTimes(1); + expect(mockCore.tauriProvider.saveSecureKey).toHaveBeenCalledWith( + 'ai_session_id', + manager.sessionId, + ); + expect(manager.sessionId).not.toBe('default'); + expect(tracer.error).toHaveBeenCalledWith( + '[AIProviderManager] Failed to read ai_session_id:', + expect.any(Error), + ); + }); + + it('should continue with generated session ID when secure persistence fails', async () => { + const mockCore = createMockCore(() => Promise.resolve(null)); + vi.mocked(mockCore.tauriProvider.saveSecureKey ?? vi.fn()).mockRejectedValueOnce( + new Error('secure unavailable'), + ); + manager.setCore(mockCore); + + await expect(manager.init()).resolves.toBeUndefined(); + + expect(manager.sessionId).not.toBe('default'); + expect(tracer.error).toHaveBeenCalled(); + }); + it('should work without core set (generates UUID session)', async () => { await expect(manager.init()).resolves.not.toThrow(); // Without core, _getSecureVal returns null → randomUUID is generated @@ -102,6 +144,25 @@ describe('AIProviderManager', () => { const result = await manager.startProvider('gemini'); expect(result).toBe(true); + expect(mockCore.tauriProvider.hasSecureKey).toHaveBeenCalledTimes(2); + }); + + it('should deactivate a cloud provider when its key was removed before restart', async () => { + let hasKey = true; + const mockCore = createMockCore( + () => Promise.resolve(hasKey ? 'sk-key' : null), + () => Promise.resolve(hasKey), + ); + manager.setCore(mockCore); + await manager.startProvider('gemini'); + + hasKey = false; + const result = await manager.startProvider('gemini'); + + expect(result).toBe(false); + expect(manager.activeProviderId).toBeNull(); + expect(manager.apiKey).toBeNull(); + expect(manager.isActive()).toBe(false); }); it('should stop previous provider when switching', async () => { @@ -216,6 +277,7 @@ describe('AIProviderManager', () => { await manager.refreshActiveApiKey(); expect(manager.apiKey).toBeNull(); + expect(manager.activeProviderId).toBeNull(); expect(mockCore.tauriProvider.hasSecureKey).toHaveBeenLastCalledWith( 'openrouter_api_key', ); @@ -248,7 +310,16 @@ describe('AIProviderManager', () => { expect(manager.maxOutputTokens).toBeUndefined(); }); - it('getProviderDisplayName should return known names', () => { + it('getProviderDisplayName should prefer catalog names', () => { + const mockCore = createMockCore(); + vi.mocked(mockCore.catalog.getCatalog).mockReturnValue({ + ai: [ + { id: 'gpt', name: 'OpenAI GPT' }, + { id: 'gemini', name: 'Google Gemini' }, + ], + }); + manager.setCore(mockCore); + expect(manager.getProviderDisplayName('gpt')).toBe('OpenAI GPT'); expect(manager.getProviderDisplayName('gemini')).toBe('Google Gemini'); expect(manager.getProviderDisplayName(CUSTOM_TEXT_PROVIDER_ID)).toBe('Custom'); @@ -294,7 +365,7 @@ describe('AIProviderManager', () => { expect(manager.model).toBe('default'); }); - it('should ignore empty persisted models and fall back to a non-empty default', async () => { + 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); @@ -305,6 +376,18 @@ describe('AIProviderManager', () => { expect(manager.model).toBe('default'); }); + it('should not invent a cloud model when catalog and persisted settings are empty', async () => { + const mockCore = createMockCore(() => Promise.resolve('sk-key')); + vi.mocked(mockCore.aiSettings.getSelectedAIModel).mockReturnValue(''); + manager.setCore(mockCore); + + const result = await manager.startProvider('gemini'); + + expect(result).toBe(true); + expect(manager.model).toBe(''); + expect(mockCore.aiSettings.setSelectedAIModel).toHaveBeenCalledWith('gemini', ''); + }); + it('should reflect model changes from settings without restarting the provider', async () => { let selectedModel = 'gemini-3.1-pro'; const mockCore = createMockCore(() => Promise.resolve('sk-key')); diff --git a/src/features/ai/services/AIProviderManager.ts b/src/features/ai/services/AIProviderManager.ts index 7963cb1d..16cf760b 100644 --- a/src/features/ai/services/AIProviderManager.ts +++ b/src/features/ai/services/AIProviderManager.ts @@ -29,23 +29,33 @@ export class AIProviderManager { } public async init(): Promise { - // Initialize Session ID using Secure Storage - let sid = await this._getSecureVal('ai_session_id'); - if (sid === null || sid === '') { + // Initialize Session ID from secure storage. + const secureSid = await this._getSecureVal('ai_session_id').catch((error: unknown) => { + this._tracer.error('[AIProviderManager] Failed to read ai_session_id:', error); + return null; + }); + let sid = secureSid; + if (!this._isValidSessionId(sid)) { sid = crypto.randomUUID(); + } + + if (secureSid !== sid) { await this._saveSecureVal('ai_session_id', sid); } this._sessionId = sid; - - // Sync UI state - if (this._context) { - this._context.aiSettings.setAiSessionId(sid); - } } public async startProvider(providerId: string): Promise { - if (this._activeProviderId === providerId) return true; + if (this._activeProviderId === providerId) { + await this.refreshActiveApiKey(); + if (!this.isActive()) { + this.stopProvider(); + return false; + } + + return true; + } this._tracer.info(`[AIProviderManager] Switching provider to: ${providerId}`); @@ -141,11 +151,8 @@ export class AIProviderManager { return customDisplayName; } - const providers: Record = { - gpt: 'OpenAI GPT', - gemini: 'Google Gemini', - }; - return providers[id] ?? id; + const catalogProvider = this._getAiCatalogApps().find((provider) => provider.id === id); + return catalogProvider?.name ?? id; } /** @@ -156,6 +163,9 @@ export class AIProviderManager { if (this._activeProviderId !== null) { const hasApiKey = await this._resolveHasApiKey(this._activeProviderId); this._hasApiKey = this._isLocalProvider(this._activeProviderId) || hasApiKey; + if (!this._hasApiKey && !this._isLocalProvider(this._activeProviderId)) { + this.stopProvider(); + } } } @@ -199,16 +209,11 @@ export class AIProviderManager { return catalogModel; } - const fallbacks: Record = { - gpt: 'gpt-5.5', - gemini: 'gemini-3-pro', - local: 'llama-4-maverick', - }; if (this._isLocalProvider(providerId)) { return 'default'; } - return fallbacks[providerId] ?? 'default'; + return ''; } private _resolveModel(providerId: string): string { @@ -248,7 +253,15 @@ export class AIProviderManager { private async _saveSecureVal(key: string, value: string): Promise { if (this._context?.tauriProvider.saveSecureKey) { - await this._context.tauriProvider.saveSecureKey(key, value); + try { + await this._context.tauriProvider.saveSecureKey(key, value); + } catch (error: unknown) { + this._tracer.error(`[AIProviderManager] Failed to persist ${key}:`, error); + } } } + + private _isValidSessionId(value: string | null): value is string { + return typeof value === 'string' && value.trim() !== ''; + } } diff --git a/src/features/ai/services/EngineConfigService.test.ts b/src/features/ai/services/EngineConfigService.test.ts index ebbed4cd..cfc83ec4 100644 --- a/src/features/ai/services/EngineConfigService.test.ts +++ b/src/features/ai/services/EngineConfigService.test.ts @@ -68,12 +68,12 @@ describe('EngineConfigService', () => { expect(tauri.invoke).not.toHaveBeenCalled(); }); - it('saves config and swallows backend errors', async () => { + it('saves config and propagates backend errors', async () => { vi.mocked(tauri.invoke).mockResolvedValue(undefined); await expect(service.setConfig(config)).resolves.toBeUndefined(); expect(tauri.invoke).toHaveBeenCalledWith('set_engine_config', { config }); vi.mocked(tauri.invoke).mockRejectedValueOnce(new Error('save failed')); - await expect(service.setConfig(config)).resolves.toBeUndefined(); + await expect(service.setConfig(config)).rejects.toThrow('save failed'); }); }); diff --git a/src/features/ai/services/EngineConfigService.ts b/src/features/ai/services/EngineConfigService.ts index 54a52741..175ed9c6 100644 --- a/src/features/ai/services/EngineConfigService.ts +++ b/src/features/ai/services/EngineConfigService.ts @@ -10,21 +10,26 @@ import type { TauriProvider } from '@/infrastructure/tauri/TauriProvider'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; +import type { + EngineConfig as BindingEngineConfig, + EngineSettingsPayload as BindingEngineSettingsPayload, +} from '@/shared/types/bindings'; type EngineConfigLogger = Pick; -/** Subset of EngineConfig that the frontend can read and write. */ -export interface EngineConfig { - engine_id: string; - compute_mode: 'gpu' | 'cpu'; +/** + * Backend returns a fully merged config, while Specta marks serde-defaulted fields + * optional for request compatibility. UI code can rely on these fields after reads. + */ +export type EngineConfig = BindingEngineConfig & { + compute_mode: NonNullable; context_size: number; - model_path: string | null; extra_args: string[]; -} +}; -export interface EngineSettingsPayload { +export type EngineSettingsPayload = Omit & { config: EngineConfig; -} +}; export class EngineConfigService { constructor( @@ -65,7 +70,7 @@ export class EngineConfigService { /** * Persists the user's engine configuration. - * Fires-and-forgets the Tauri command; errors are logged but not re-thrown. + * Save failures are re-thrown so the settings UI can show a failed state. */ public async setConfig(config: EngineConfig): Promise { if (!this._tauri.isTauri()) return; @@ -73,6 +78,7 @@ export class EngineConfigService { await this._tauri.invoke('set_engine_config', { config }); } catch (e) { this._tracer.error('[EngineConfigService] Failed to save engine config:', e); + throw e; } } } diff --git a/src/features/ai/services/EngineStatusService.test.ts b/src/features/ai/services/EngineStatusService.test.ts index 9a57763e..108e092c 100644 --- a/src/features/ai/services/EngineStatusService.test.ts +++ b/src/features/ai/services/EngineStatusService.test.ts @@ -7,13 +7,13 @@ describe('EngineStatusService', () => { let service: EngineStatusService; let listeners: Record void>; let core: EngineStatusContext; - let tracer: Pick; + let tracer: Pick; beforeEach(() => { listeners = {}; document.body.innerHTML = ''; (globalThis as unknown as { CSS: { escape: (value: string) => string } }).CSS = { - escape: (value: string) => value, + escape: (value: string) => value.replace(/["\\]/gu, '\\$&'), }; core = { @@ -33,6 +33,7 @@ describe('EngineStatusService', () => { } as unknown as EngineStatusContext; tracer = { + debug: vi.fn(), info: vi.fn(), error: vi.fn(), }; @@ -123,6 +124,27 @@ describe('EngineStatusService', () => { expect((card as HTMLElement | null)?.dataset['runtimeStatus']).toBe('idle'); }); + it('updates cards for engine ids that need selector escaping', () => { + const engineId = 'engine"quoted\\id'; + const appCard = document.createElement('div'); + appCard.className = 'app-card selected'; + appCard.dataset['appId'] = engineId; + appCard.innerHTML = + '
'; + const dashboardCard = document.createElement('div'); + dashboardCard.className = 'module-slot-card selected'; + dashboardCard.dataset['currentModule'] = engineId; + document.body.append(appCard, dashboardCard); + + service.init(); + listeners['ai:engine:ready']?.({ engine_id: engineId, endpoint: '/engine' }); + + expect(appCard.classList.contains('engine-ready')).toBe(true); + expect(appCard.querySelector('button')?.textContent).toBe('Remove'); + expect(dashboardCard.classList.contains('module-running')).toBe(true); + expect(dashboardCard.dataset['runtimeStatus']).toBe('running'); + }); + it('falls back to untranslated labels and handles cards without modal buttons', () => { (core as unknown as { i18n: { t: ReturnType } }).i18n.t.mockImplementation( (_: string, fallback: string = ''): string => fallback, @@ -167,6 +189,57 @@ describe('EngineStatusService', () => { expect(service.hasActiveEngines).toBe(false); }); + it('ignores stale backend refresh results after destroy', async () => { + let resolveRefresh: (state: unknown) => void = () => { + throw new Error('refresh was not started'); + }; + vi.mocked(core.tauriProvider.invoke).mockImplementation( + () => + new Promise((resolve) => { + resolveRefresh = resolve; + }), + ); + service.init(); + service.destroy(); + + resolveRefresh({ + ready: { + slots: [ + { + engine: { + id: 'llamacpp', + endpoint: 'http://127.0.0.1:8080', + healthy: true, + }, + }, + ], + }, + }); + await Promise.resolve(); + + expect(service.activeEngineIds).toEqual([]); + }); + + it('does not reset unrelated launcher cards when backend reports idle', async () => { + document.body.innerHTML = ` +
+
+
+ `; + vi.mocked(core.tauriProvider.invoke).mockResolvedValueOnce('idle'); + + await service.refreshFromBackend(); + + const unrelated = document.querySelector('.app-card:not([data-app-id])'); + const engineCard = document.querySelector('[data-app-id="llamacpp"]'); + const slotCard = document.querySelector('[data-current-module="llamacpp"]'); + expect(unrelated?.classList.contains('engine-idle')).toBe(false); + expect(unrelated?.classList.contains('module-running')).toBe(true); + expect(engineCard?.classList.contains('engine-idle')).toBe(true); + expect(slotCard?.classList.contains('module-stopped')).toBe(true); + expect(slotCard?.dataset['runtimeStatus']).toBe('idle'); + }); + it('returns noop unlisten and handles listen promise failure branches', async () => { const webCore = { tauriProvider: { diff --git a/src/features/ai/services/EngineStatusService.ts b/src/features/ai/services/EngineStatusService.ts index f0174ffc..a270316b 100644 --- a/src/features/ai/services/EngineStatusService.ts +++ b/src/features/ai/services/EngineStatusService.ts @@ -1,7 +1,8 @@ import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { EngineStatusContext } from './AIBridgeContext'; +import { escapeCssSelectorValue } from '@/shared/utils/cssSelectors'; -type EngineStatusLogger = Pick; +type EngineStatusLogger = Pick; type EngineState = 'idle' | 'starting' | 'swapping' | 'ready' | 'error'; type BackendEngineState = @@ -49,6 +50,7 @@ export class EngineStatusService { private readonly _unlisteners: (() => void)[] = []; private _domObserver: MutationObserver | null = null; private _domSyncFrame: number | null = null; + private _refreshGeneration = 0; private _initialized = false; public constructor(private readonly _tracer: EngineStatusLogger) {} @@ -70,6 +72,7 @@ export class EngineStatusService { } if (this._context?.tauriProvider.isTauri() !== true) return; + this._refreshGeneration += 1; this._unlisteners.push( this._listen('ai:engine:swapping', (payload) => { this._tracer.info(`[EngineStatus] Swapping from ${payload.from} to ${payload.to}`); @@ -100,21 +103,19 @@ export class EngineStatusService { }), ); - this._tracer.info('[EngineStatusService] Listening for engine events'); + this._tracer.debug('[EngineStatusService] Listening for engine events'); this._initialized = true; this._startDomSyncObserver(); void this.refreshFromBackend(); } public destroy(): void { + this._refreshGeneration += 1; this._unlisteners.forEach((fn) => fn()); this._unlisteners.length = 0; this._domObserver?.disconnect(); this._domObserver = null; - if (this._domSyncFrame !== null) { - cancelAnimationFrame(this._domSyncFrame); - this._domSyncFrame = null; - } + this._cancelDomSyncFrame(); this._activeSlots.clear(); this._initialized = false; } @@ -153,11 +154,18 @@ export class EngineStatusService { return; } + const refreshGeneration = this._refreshGeneration; try { const state = await this._context.tauriProvider.invoke('get_engine_state'); + if (refreshGeneration !== this._refreshGeneration) { + return; + } this._applyBackendState(state); } catch (error) { + if (refreshGeneration !== this._refreshGeneration) { + return; + } this._tracer.error('[EngineStatusService] Failed to refresh engine state:', error); } } @@ -173,7 +181,7 @@ export class EngineStatusService { /** Updates all cards matching `engineId` with the given state class. */ private _setCardState(engineId: string, state: EngineState): void { - const escapedEngineId = this._escapeSelectorValue(engineId); + const escapedEngineId = escapeCssSelectorValue(engineId); const cards = document.querySelectorAll(`[data-app-id="${escapedEngineId}"]`); cards.forEach((card) => { @@ -187,7 +195,7 @@ export class EngineStatusService { } private _setDashboardCardState(engineId: string, state: EngineState): void { - const escapedEngineId = this._escapeSelectorValue(engineId); + const escapedEngineId = escapeCssSelectorValue(engineId); const cards = document.querySelectorAll( `[data-current-module="${escapedEngineId}"]`, ); @@ -204,6 +212,8 @@ export class EngineStatusService { private _startDomSyncObserver(): void { this._domObserver?.disconnect(); + this._cancelDomSyncFrame(); + this._domObserver = new MutationObserver((records) => { if (this._retargetDomSyncObserver(records)) { this._scheduleDomSync(); @@ -214,13 +224,14 @@ export class EngineStatusService { this._scheduleDomSync(); } }); - const target = this._getDomSyncTarget(); - this._domObserver.observe(target, { + const observeOptions: MutationObserverInit = { childList: true, subtree: true, attributes: true, attributeFilter: ['data-app-id', 'data-current-module'], - }); + }; + const target = this._getDomSyncTarget(); + this._domObserver.observe(target, observeOptions); } private _getDomSyncTarget(): HTMLElement { @@ -289,22 +300,31 @@ export class EngineStatusService { return element.hasAttribute('data-app-id') || element.hasAttribute('data-current-module'); } + private _applyActiveStatesToDom(): void { + this._activeSlots.forEach((_endpoint, engineId) => { + this._setCardState(engineId, 'ready'); + this._setDashboardCardState(engineId, 'ready'); + }); + } + private _scheduleDomSync(): void { if (this._domSyncFrame !== null) { return; } - this._domSyncFrame = requestAnimationFrame(() => { + this._domSyncFrame = globalThis.requestAnimationFrame(() => { this._domSyncFrame = null; this._applyActiveStatesToDom(); }); } - private _applyActiveStatesToDom(): void { - this._activeSlots.forEach((_endpoint, engineId) => { - this._setCardState(engineId, 'ready'); - this._setDashboardCardState(engineId, 'ready'); - }); + private _cancelDomSyncFrame(): void { + if (this._domSyncFrame === null) { + return; + } + + globalThis.cancelAnimationFrame(this._domSyncFrame); + this._domSyncFrame = null; } private _applyBackendState(state: BackendEngineState): void { @@ -360,24 +380,35 @@ export class EngineStatusService { activeIds.forEach((engineId) => { this.setEngineState(engineId, 'idle'); }); - document.querySelectorAll('.app-card, .module-slot-card').forEach((card) => { - this._resetCardClasses(card); - card.classList.add('engine-idle'); - card.classList.remove('module-running'); - if (card.classList.contains('module-slot-card')) { - card.classList.add('module-stopped'); - card.dataset['runtimeStatus'] = 'idle'; - } - }); - } + document + .querySelectorAll( + [ + '[data-app-id]', + '[data-current-module]', + '.engine-idle', + '.engine-starting', + '.engine-swapping', + '.engine-ready', + '.engine-error', + ].join(', '), + ) + .forEach((card) => { + if (!this._isEngineBoundCard(card)) { + return; + } - private _escapeSelectorValue(value: string): string { - const cssApi = (globalThis as { CSS?: { escape?: (selector: string) => string } }).CSS; - if (typeof cssApi?.escape === 'function') { - return cssApi.escape(value); - } + this._resetCardClasses(card); + card.classList.add('engine-idle'); + card.classList.remove('module-running'); + if (card.dataset['currentModule'] !== undefined) { + card.classList.add('module-stopped'); + card.dataset['runtimeStatus'] = 'idle'; + } + }); + } - return value.replace(/["\\]/gu, '\\$&'); + private _isEngineBoundCard(card: HTMLElement): boolean { + return card.dataset['appId'] !== undefined || card.dataset['currentModule'] !== undefined; } private _resetCardClasses(card: HTMLElement): void { diff --git a/src/features/ai/types/IAIBridge.ts b/src/features/ai/types/IAIBridge.ts index 2c958cb2..1fca7447 100644 --- a/src/features/ai/types/IAIBridge.ts +++ b/src/features/ai/types/IAIBridge.ts @@ -7,6 +7,10 @@ import type { IImageGenerationPreview, } from './aiTypes'; +export type IAIBridgeSendMessageOptions = { + originalPrompt?: string; +}; + export interface IAIBridge { isActive(): boolean; getActiveProvider(): { id: string; name: string } | null; @@ -15,13 +19,16 @@ export interface IAIBridge { source?: MessageSource, attachments?: { name: string; type: string; data_base64: string }[], history?: IChatMessage[], + options?: IAIBridgeSendMessageOptions, ): Promise; startProvider(providerId: string): Promise; stopProvider(): void; + stopEngineSlot(capability: 'text' | 'image' | 'vision'): Promise; clearHistory(): Promise; getHistory(): Promise; + prepareImagePrompt(text: string): Promise; cancelTextGeneration(): Promise; - cancelImageGeneration(): Promise; + cancelImageGeneration(providerId?: string | null): Promise; getImageGenerationPreview(): Promise; rewindLastTurn(): Promise; getState(): { activeProviderId: string | null; isRunning: boolean }; diff --git a/src/features/ai/types/aiTypes.ts b/src/features/ai/types/aiTypes.ts index 21b6ba1c..ec66fdb0 100644 --- a/src/features/ai/types/aiTypes.ts +++ b/src/features/ai/types/aiTypes.ts @@ -173,11 +173,11 @@ export interface IAIModelStats { } /** - * Tiered pricing configuration for token-based resource distribution. + * General input/output pricing shown in the model selector. */ export interface IAIModelPricing { - input_per_1m?: number | null; - output_per_1m?: number | null; + input?: number | null; + output?: number | null; currency?: string | null; notes?: string | null; } @@ -209,7 +209,6 @@ export interface IAIModelData { releaseDate?: string | null; contextWindow?: number | null; maxOutputTokens?: number | null; - deprecated?: boolean | null; pricing?: IAIModelPricing | null; capabilities?: IAIModelCapabilities | null; diff --git a/src/features/ai/ui/AISettingsKeyController.test.ts b/src/features/ai/ui/AISettingsKeyController.test.ts index 913f5fce..22cb641c 100644 --- a/src/features/ai/ui/AISettingsKeyController.test.ts +++ b/src/features/ai/ui/AISettingsKeyController.test.ts @@ -9,11 +9,13 @@ describe('AISettingsKeyController', () => { getSecureKeyMeta: vi.fn(), getSecureKey: vi.fn(), saveSecureKey: vi.fn(), + removeSecureKey: vi.fn(), validateApiKey: vi.fn(), validateStoredApiKey: vi.fn(), }; const getTranslator = () => (key: string, fallback: string) => `${key}:${fallback}`; + const showToast = vi.fn(); const scheduleButtonReset = vi.fn((_button: HTMLButtonElement, callback: () => void) => callback(), ); @@ -22,7 +24,7 @@ describe('AISettingsKeyController', () => { getSettingsService: () => settingsService as never, getTranslator, scheduleButtonReset, - showToast: vi.fn(), + showToast, icons: { visible: '', hidden: '', @@ -75,4 +77,183 @@ describe('AISettingsKeyController', () => { expect(button.disabled).toBe(false); expect(button.innerHTML).toBe('Check'); }); + + it('should not mask typed keys as stored when settings service disappears before save', async () => { + const input = document.createElement('input'); + const button = document.createElement('button'); + button.innerHTML = 'Check'; + document.body.append(input, button); + const validateOnlyService = { + ...settingsService, + validateApiKey: vi.fn().mockResolvedValue(true), + saveSecureKey: vi.fn(), + }; + const getSettingsService = vi + .fn() + .mockReturnValueOnce(validateOnlyService) + .mockReturnValue(null); + const controllerWithDisappearingSettings = new AISettingsKeyController({ + getSettingsService, + getTranslator, + scheduleButtonReset, + showToast, + icons: { + visible: '', + hidden: '', + check: '', + x: '', + spinner: '', + }, + tracer, + }); + + input.value = 'typed-key'; + input.dataset['keyDirty'] = 'true'; + + await controllerWithDisappearingSettings.checkKey(input, button, 'openrouter'); + + expect(validateOnlyService.validateApiKey).toHaveBeenCalledWith('openrouter', 'typed-key'); + expect(validateOnlyService.saveSecureKey).not.toHaveBeenCalled(); + expect(input.dataset['storedMasked']).toBeUndefined(); + expect(input.value).toBe('typed-key'); + expect(showToast).toHaveBeenCalledWith( + 'ui.settings.key_check_error:Key check error', + 'error', + ); + }); + + it('should remove stored keys when a dirty key input is cleared', async () => { + const input = document.createElement('input'); + const button = document.createElement('button'); + button.innerHTML = 'Check'; + document.body.append(input, button); + + input.dataset['keyDirty'] = 'true'; + input.value = ''; + settingsService.removeSecureKey.mockResolvedValue(undefined); + + await controller.checkKey(input, button, 'openrouter'); + + expect(settingsService.removeSecureKey).toHaveBeenCalledWith('openrouter'); + expect(settingsService.saveSecureKey).not.toHaveBeenCalled(); + expect(input.dataset['storedMasked']).toBeUndefined(); + expect(input.value).toBe(''); + expect(button.disabled).toBe(false); + }); + + it('should remove cleared stored keys immediately', async () => { + const input = document.createElement('input'); + input.dataset['storedMasked'] = 'true'; + input.value = ''; + settingsService.removeSecureKey.mockResolvedValue(undefined); + + const removed = await controller.removeClearedStoredKey(input, 'openrouter'); + + expect(removed).toBe(true); + expect(settingsService.removeSecureKey).toHaveBeenCalledWith('openrouter'); + expect(input.dataset['storedMasked']).toBeUndefined(); + expect(input.dataset['storedRevealed']).toBeUndefined(); + expect(input.dataset['keyDirty']).toBeUndefined(); + expect(input.value).toBe(''); + expect(showToast).toHaveBeenCalledWith( + 'ui.settings.key_removed:API key removed', + 'success', + ); + }); + + it('should keep the local key state when immediate removal fails', async () => { + const input = document.createElement('input'); + input.dataset['storedMasked'] = 'true'; + input.value = ''; + settingsService.removeSecureKey.mockRejectedValue(new Error('secure storage failed')); + + const removed = await controller.removeClearedStoredKey(input, 'openrouter'); + + expect(removed).toBe(false); + expect(input.dataset['storedMasked']).toBe('true'); + expect(showToast).toHaveBeenCalledWith( + 'ui.settings.key_remove_error:Key remove error', + 'error', + ); + expect(tracer.error).toHaveBeenCalledWith( + '[AISettingsKeyController] Key removal failed:', + expect.any(Error), + ); + }); + + it('should not clear local key state when settings service is unavailable', async () => { + const input = document.createElement('input'); + input.dataset['storedMasked'] = 'true'; + input.value = ''; + const controllerWithoutSettings = new AISettingsKeyController({ + getSettingsService: () => null, + getTranslator, + scheduleButtonReset, + showToast, + icons: { + visible: '', + hidden: '', + check: '', + x: '', + spinner: '', + }, + tracer, + }); + + const removed = await controllerWithoutSettings.removeClearedStoredKey(input, 'openrouter'); + + expect(removed).toBe(false); + expect(input.dataset['storedMasked']).toBe('true'); + expect(showToast).toHaveBeenCalledWith( + 'ui.settings.key_remove_error:Key remove error', + 'error', + ); + }); + + it('should report failure when checking a cleared key without settings service', async () => { + const input = document.createElement('input'); + const button = document.createElement('button'); + button.innerHTML = 'Check'; + document.body.append(input, button); + input.dataset['keyDirty'] = 'true'; + input.value = ''; + const controllerWithoutSettings = new AISettingsKeyController({ + getSettingsService: () => null, + getTranslator, + scheduleButtonReset, + showToast, + icons: { + visible: '', + hidden: '', + check: '', + x: '', + spinner: '', + }, + tracer, + }); + + await controllerWithoutSettings.checkKey(input, button, 'openrouter'); + + expect(input.dataset['keyDirty']).toBe('true'); + expect(showToast).toHaveBeenCalledWith( + 'ui.settings.key_check_error:Key check error', + 'error', + ); + }); + + it('should reset key check buttons to their idle state', () => { + const button = document.createElement('button'); + button.disabled = true; + button.style.width = '48px'; + button.classList.add('success', 'checking'); + button.innerHTML = ''; + + controller.resetButtonState(button, 'Check'); + + expect(button.disabled).toBe(false); + expect(button.style.width).toBe(''); + expect(button.classList.contains('success')).toBe(false); + expect(button.classList.contains('checking')).toBe(false); + expect(button.textContent).toBe('Check'); + }); }); diff --git a/src/features/ai/ui/AISettingsKeyController.ts b/src/features/ai/ui/AISettingsKeyController.ts index 8971d3e2..2b5c1244 100644 --- a/src/features/ai/ui/AISettingsKeyController.ts +++ b/src/features/ai/ui/AISettingsKeyController.ts @@ -38,9 +38,48 @@ export class AISettingsKeyController { target.value = normalizedValue; } delete target.dataset['storedMasked']; + delete target.dataset['storedRevealed']; target.dataset['keyDirty'] = 'true'; } + public async removeClearedStoredKey(input: KeyInput, providerId: string): Promise { + if (input.value.trim() !== '') { + return false; + } + + if (input.dataset['keyRemoveInFlight'] === 'true') { + return false; + } + + input.dataset['keyRemoveInFlight'] = 'true'; + try { + const settingsService = this._requireSettingsService(); + await settingsService.removeSecureKey(providerId); + this.clearStoredKeyMask(input); + this._showToast( + this._options.getTranslator()('ui.settings.key_removed', 'API key removed'), + 'success', + ); + return true; + } catch (error: unknown) { + this._options.tracer.error('[AISettingsKeyController] Key removal failed:', error); + this._showToast( + this._options.getTranslator()('ui.settings.key_remove_error', 'Key remove error'), + 'error', + ); + return false; + } finally { + delete input.dataset['keyRemoveInFlight']; + } + } + + public resetButtonState(button: KeyButton, label: string): void { + button.disabled = false; + button.style.width = ''; + button.classList.remove('success', 'error', 'checking'); + button.textContent = label; + } + public maybeClearStoredMask(event: Event): void { const target = event.target as KeyInput; const inputType = (event as InputEvent).inputType; @@ -113,22 +152,27 @@ export class AISettingsKeyController { const isStoredMask = input.dataset['storedMasked'] === 'true'; const isDirtyReplacement = input.dataset['keyDirty'] === 'true'; const key = input.value.trim(); + const shouldRemoveStoredKey = isDirtyReplacement && key === ''; const shouldValidateTypedKey = (isDirtyReplacement && key !== '') || (!isStoredMask && key !== ''); const shouldValidateStoredKey = !isDirtyReplacement && isStoredMask && key !== ''; let isValid = false; - if (shouldValidateTypedKey) { + if (shouldRemoveStoredKey) { + await this._requireSettingsService().removeSecureKey(providerId); + 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); } else if (shouldValidateStoredKey) { - isValid = Boolean( - await this._options.getSettingsService()?.validateStoredApiKey(providerId), - ); + isValid = await this._requireSettingsService().validateStoredApiKey(providerId); } if (isValid) { if (shouldValidateTypedKey && key !== '') { - await this._options.getSettingsService()?.saveSecureKey(providerId, key); + await this._requireSettingsService().saveSecureKey(providerId, key); this.applyStoredKeyMask(input, key.length); } this.updateButtonState(button, 'success', this._options.icons.check); @@ -173,6 +217,7 @@ export class AISettingsKeyController { public clearStoredKeyMask(input: KeyInput): void { delete input.dataset['storedMasked']; delete input.dataset['storedRevealed']; + delete input.dataset['keyDirty']; input.value = ''; input.classList.add('is-masked'); input.placeholder = this._options.getTranslator()( @@ -206,6 +251,15 @@ export class AISettingsKeyController { } } + private _requireSettingsService(): SettingsService { + const settingsService = this._options.getSettingsService(); + if (settingsService === null) { + throw new Error('Settings service is unavailable'); + } + + return settingsService; + } + private _showToast(message: string, type: string): void { this._options.showToast(message, type as 'success' | 'error' | 'warning' | 'info'); } diff --git a/src/features/ai/ui/AISettingsMarkup.ts b/src/features/ai/ui/AISettingsMarkup.ts index 0bce3715..970d9acc 100644 --- a/src/features/ai/ui/AISettingsMarkup.ts +++ b/src/features/ai/ui/AISettingsMarkup.ts @@ -7,13 +7,9 @@ import type { AISettingsViewPolicy } from './AISettingsViewPolicy'; type TranslateFunc = (key: string, fallback: string) => string; interface IAIModelPricing { - input_per_1m?: number; - output_per_1m?: number; + input?: number; + output?: number; currency?: string; - tier?: string; - note?: string; - in?: number; - out?: number; } const PURIFY_CONFIG = { @@ -223,10 +219,6 @@ export function renderModelStats(modelData: IAIModelData | null, translate: Tran function renderPricing(pricing: unknown, translate: TranslateFunc): string { if (pricing === null || pricing === undefined) return ''; - if (Array.isArray(pricing)) { - return renderLegacyPricing(pricing as IAIModelPricing[]); - } - if (typeof pricing === 'object') { return renderNewPricing(pricing as IAIModelPricing, translate); } @@ -250,27 +242,14 @@ function renderContextWindow( `; } -function renderLegacyPricing(pricing: IAIModelPricing[]): string { - return pricing - .map( - (price) => ` -
- ${price.tier ?? ''} - ${price.note ?? `${String(price.in ?? 0)} / ${String(price.out ?? 0)}`} -
- `, - ) - .join(''); -} - function renderNewPricing(pricing: IAIModelPricing, translate: TranslateFunc): string { let html = ''; const currency = pricing.currency ?? '$'; const displayCurrency = currency === 'USD' ? '$' : currency; const separator = displayCurrency.length > 1 ? ' ' : ''; - const inputCost = pricing.input_per_1m ?? 0; - const outputCost = pricing.output_per_1m ?? 0; + const inputCost = pricing.input ?? 0; + const outputCost = pricing.output ?? 0; const isFree = inputCost === 0 && outputCost === 0; if (isFree) { @@ -281,20 +260,20 @@ function renderNewPricing(pricing: IAIModelPricing, translate: TranslateFunc): s `; } else { const inPrice = - pricing.input_per_1m === undefined + pricing.input === undefined ? null - : `${displayCurrency}${separator}${String(pricing.input_per_1m)}`; + : `${displayCurrency}${separator}${String(pricing.input)}`; const outPrice = - pricing.output_per_1m === undefined + pricing.output === undefined ? null - : `${displayCurrency}${separator}${String(pricing.output_per_1m)}`; + : `${displayCurrency}${separator}${String(pricing.output)}`; if (inPrice !== null && outPrice !== null) { html += `
- ${translate('ui.settings.price_input', 'In')}: ${inPrice} - ${translate('ui.settings.price_output', 'Out')}: ${outPrice} + ${translate('ui.settings.price_input', 'Input')}: ${inPrice} + ${translate('ui.settings.price_output', 'Output')}: ${outPrice}
`; } diff --git a/src/features/ai/ui/AISettingsRenderer.test.ts b/src/features/ai/ui/AISettingsRenderer.test.ts index 7011b126..b7d270bd 100644 --- a/src/features/ai/ui/AISettingsRenderer.test.ts +++ b/src/features/ai/ui/AISettingsRenderer.test.ts @@ -21,6 +21,7 @@ describe('AISettingsRenderer', () => { getSecureKeyMeta: vi.fn(), getSecureKey: vi.fn(), saveSecureKey: vi.fn(), + removeSecureKey: vi.fn(), hasSecureKey: vi.fn(), validateApiKey: vi.fn(), validateStoredApiKey: vi.fn(), @@ -65,7 +66,7 @@ describe('AISettingsRenderer', () => { name: 'Reasoner', desc: 'Reasoning model', descKey: 'model.reasoner', - pricing: { input_per_1m: 1, output_per_1m: 2, currency: 'USD' }, + pricing: { input: 1, output: 2, currency: 'USD' }, contextWindow: 128000, capabilities: { reasoning: true }, stats: { speed: 8, logic: 10, creative: 6 }, @@ -74,7 +75,7 @@ describe('AISettingsRenderer', () => { id: 'fast', name: 'Fast', desc: 'Fast model', - pricing: [{ tier: 'free', note: '0 / 0' }], + pricing: { input: 0, output: 0, currency: 'USD' }, contextWindow: 8000, capabilities: { reasoning: false }, }, @@ -86,6 +87,7 @@ describe('AISettingsRenderer', () => { settingsService.getSecureKeyMeta.mockResolvedValue({ exists: true, length: 16 }); settingsService.getSecureKey.mockResolvedValue('stored-secret'); settingsService.saveSecureKey.mockResolvedValue(undefined); + settingsService.removeSecureKey.mockResolvedValue(undefined); settingsService.hasSecureKey.mockResolvedValue(true); settingsService.validateApiKey.mockResolvedValue(true); settingsService.validateStoredApiKey.mockResolvedValue(true); @@ -273,6 +275,22 @@ describe('AISettingsRenderer', () => { vi.advanceTimersByTime(3000); expect(button.disabled).toBe(false); + button.classList.add('success'); + button.innerHTML = ''; + input.value = ''; + input.dispatchEvent(new Event('input', { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + expect(settingsService.removeSecureKey).toHaveBeenCalledWith('openrouter'); + expect(showToast).toHaveBeenCalledWith( + 'ui.settings.key_removed:API key removed', + 'success', + ); + expect(input.value).toBe(''); + expect(input.dataset['storedMasked']).toBeUndefined(); + expect(button.classList.contains('success')).toBe(false); + expect(button.textContent).toBe('ui.gpt.key_check_btn:Check'); + input.value = 'bad-key'; input.dispatchEvent(new Event('input', { bubbles: true })); settingsService.validateApiKey.mockRejectedValueOnce(new Error('boom')); @@ -298,6 +316,33 @@ describe('AISettingsRenderer', () => { ); }); + it('does not reset a success check state when secure key removal fails', async () => { + const container = document.getElementById('root') as HTMLElement; + await aiSettingsRenderer.render(container, { + id: 'gpt', + name: 'GPT', + apiProviderData: { models }, + } as never); + + const input = document.getElementById('gpt-api-key-input') as HTMLInputElement; + const button = document.getElementById('gpt-key-check-btn') as HTMLButtonElement; + button.classList.add('success'); + button.innerHTML = ''; + settingsService.removeSecureKey.mockRejectedValueOnce(new Error('secure storage failed')); + + input.value = ''; + input.dispatchEvent(new Event('input', { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + + expect(button.classList.contains('success')).toBe(true); + expect(button.innerHTML).toContain('check'); + expect(showToast).toHaveBeenCalledWith( + 'ui.settings.key_remove_error:Key remove error', + 'error', + ); + }); + it('hides model stats for custom providers', async () => { const container = document.getElementById('root') as HTMLElement; diff --git a/src/features/ai/ui/AISettingsRenderer.ts b/src/features/ai/ui/AISettingsRenderer.ts index e6bcff09..8454a49c 100644 --- a/src/features/ai/ui/AISettingsRenderer.ts +++ b/src/features/ai/ui/AISettingsRenderer.ts @@ -35,6 +35,11 @@ type AISettingsActiveRenderTarget = { container: HTMLElement; app: IApp; }; +type KeyInput = HTMLInputElement | HTMLTextAreaElement; + +function isKeyInput(value: EventTarget | null): value is KeyInput { + return value instanceof HTMLInputElement || value instanceof HTMLTextAreaElement; +} // IAISettingsGlobal removed as it's no longer used for strictness reasons @@ -165,8 +170,8 @@ class AISettingsRenderer extends BaseComponent { showCustomModelComposer: isCustomProviderId(appId), translate: t, viewPolicy: this._viewPolicy, - supportsInternetAccess: this._viewPolicy.supportsInternetAccess(appId), - supportsThinking: this._viewPolicy.supportsThinking(appId), + supportsInternetAccess: this._viewPolicy.supportsInternetAccess(appId, app.capability), + supportsThinking: this._viewPolicy.supportsThinking(appId, models), thinkingLevel: this._selectionController.getThinkingLevel(appId, this._aiSettings), internetAccessEnabled: this._selectionController.getInternetAccessEnabled( appId, @@ -224,7 +229,18 @@ class AISettingsRenderer extends BaseComponent { container, signal: renderSignal, normalizeKeyInput: (event) => { + const target = event.target; + if (!isKeyInput(target)) { + return; + } + + const hadStoredKey = + target.dataset['storedMasked'] === 'true' || + target.dataset['storedRevealed'] === 'true'; this._keyController.normalizeInput(event); + if (hadStoredKey) { + void this._removeClearedStoredKey(target, keyProviderId, appId); + } }, maybeClearStoredMask: (event) => { this._keyController.maybeClearStoredMask(event); @@ -279,6 +295,17 @@ class AISettingsRenderer extends BaseComponent { await this._keyController.checkKey(input, btn, this._getKeyProviderId(appId)); } + private async _removeClearedStoredKey( + input: KeyInput, + keyProviderId: string, + appId: string, + ): Promise { + const removed = await this._keyController.removeClearedStoredKey(input, keyProviderId); + if (removed && input.value.trim() === '') { + this._resetKeyCheckButton(appId); + } + } + /** * Resolves and persists model selection transitions. * @@ -496,6 +523,24 @@ class AISettingsRenderer extends BaseComponent { this._buttonResetTimers.set(btn, timer); } + private _resetKeyCheckButton(appId: string): void { + const button = this._queryActiveElement(`#${appId}-key-check-btn`); + if (button === null) { + return; + } + + const existingTimer = this._buttonResetTimers.get(button); + if (existingTimer !== undefined) { + clearTimeout(existingTimer); + this._buttonResetTimers.delete(button); + } + + this._keyController.resetButtonState( + button, + this._getTranslator()('ui.gpt.key_check_btn', 'Check'), + ); + } + private _cleanupRenderScope(): void { if (this._renderAbortController !== null) { this._renderAbortController.abort(); diff --git a/src/features/ai/ui/AISettingsSelectionController.ts b/src/features/ai/ui/AISettingsSelectionController.ts index 8881c0c2..4cab18c1 100644 --- a/src/features/ai/ui/AISettingsSelectionController.ts +++ b/src/features/ai/ui/AISettingsSelectionController.ts @@ -69,7 +69,7 @@ export class AISettingsSelectionController { viewPolicy: AISettingsViewPolicy, ): string { const modelData = this.getModelData(appId, modelKey); - void viewPolicy.isImageOnlyProvider(appId); + void viewPolicy; return renderModelStats(modelData, translate); } diff --git a/src/features/ai/ui/AISettingsViewPolicy.test.ts b/src/features/ai/ui/AISettingsViewPolicy.test.ts index 78cf76ad..9e7bb5ad 100644 --- a/src/features/ai/ui/AISettingsViewPolicy.test.ts +++ b/src/features/ai/ui/AISettingsViewPolicy.test.ts @@ -12,16 +12,20 @@ describe('AISettingsViewPolicy', () => { expect(policy.isCleanApp('axelate')).toBe(true); expect(policy.isCleanApp('sample-integration')).toBe(false); expect(policy.isCleanApp('gpt')).toBe(false); - expect(policy.supportsInternetAccess('gpt')).toBe(true); + expect(policy.supportsInternetAccess('gpt', 'text')).toBe(true); expect(policy.supportsInternetAccess('axelate')).toBe(false); - expect(policy.supportsInternetAccess('gemini-image')).toBe(false); - expect(policy.supportsInternetAccess('seedream-image')).toBe(false); - expect(policy.supportsInternetAccess(CUSTOM_TEXT_PROVIDER_ID)).toBe(true); - expect(policy.supportsThinking('gpt')).toBe(true); - expect(policy.supportsThinking('openrouter')).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.supportsThinking('gpt', [ + { id: 'reasoner', capabilities: { reasoning: true } } as never, + ]), + ).toBe(true); + expect(policy.supportsThinking('openrouter', [])).toBe(false); expect(policy.supportsThinking(CUSTOM_TEXT_PROVIDER_ID)).toBe(true); - expect(policy.isImageOnlyProvider('gemini-image')).toBe(true); - expect(policy.isImageOnlyProvider('seedream-image')).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); diff --git a/src/features/ai/ui/AISettingsViewPolicy.ts b/src/features/ai/ui/AISettingsViewPolicy.ts index fc273341..867be8d2 100644 --- a/src/features/ai/ui/AISettingsViewPolicy.ts +++ b/src/features/ai/ui/AISettingsViewPolicy.ts @@ -3,38 +3,28 @@ import { isCustomProviderId, isCustomImageProviderId, } from '@/shared/utils/customProviderSupport'; +import type { IAIModelData } from '../types/aiTypes'; export class AISettingsViewPolicy { private static readonly _cleanAppIds = new Set(['axelate', 'axelate-platform']); - private static readonly _thinkingProviders = new Set(['gemini', 'claude', 'gpt', 'deepseek']); - private static readonly _imageOnlyProviders = new Set([ - 'gemini-image', - 'gpt-image', - 'seedream-image', - ]); public isCleanApp(appId: string): boolean { return AISettingsViewPolicy._cleanAppIds.has(appId); } - public supportsInternetAccess(appId: string): boolean { - return ( - !this.isCleanApp(appId) && - !AISettingsViewPolicy._imageOnlyProviders.has(appId) && - !isCustomImageProviderId(appId) - ); + public supportsInternetAccess(appId: string, capability?: 'text' | 'image'): boolean { + return !this.isCleanApp(appId) && capability !== 'image' && !isCustomImageProviderId(appId); } - public supportsThinking(appId: string): boolean { + public supportsThinking(appId: string, models: readonly IAIModelData[] = []): boolean { return ( - AISettingsViewPolicy._thinkingProviders.has(appId) || appId === CUSTOM_TEXT_PROVIDER_ID + models.some((model) => model.capabilities?.reasoning === true) || + appId === CUSTOM_TEXT_PROVIDER_ID ); } - public isImageOnlyProvider(appId: string): boolean { - return ( - AISettingsViewPolicy._imageOnlyProviders.has(appId) || isCustomImageProviderId(appId) - ); + public isImageOnlyProvider(appId: string, capability?: 'text' | 'image'): boolean { + return capability === 'image' || isCustomImageProviderId(appId); } public shouldShowModelStats(appId: string): boolean { diff --git a/src/features/ai/utils/catalogHelpers.test.ts b/src/features/ai/utils/catalogHelpers.test.ts index efac375e..4e1be9c5 100644 --- a/src/features/ai/utils/catalogHelpers.test.ts +++ b/src/features/ai/utils/catalogHelpers.test.ts @@ -78,11 +78,11 @@ describe('catalogHelpers', () => { createAppMock('gpt', [ { id: 'gpt-5.5', - pricing: { input_per_1m: 5, output_per_1m: 30 }, + pricing: { input: 5, output: 30 }, }, { id: 'gpt-5.5-pro', - pricing: { input_per_1m: 30, output_per_1m: 180 }, + pricing: { input: 30, output: 180 }, }, ]), ]; @@ -94,8 +94,8 @@ describe('catalogHelpers', () => { it('sorts models by total token price descending', () => { const models = [ { id: 'free' }, - { id: 'regular', pricing: { input_per_1m: 5, output_per_1m: 30 } }, - { id: 'pro', pricing: { input_per_1m: 30, output_per_1m: 180 } }, + { id: 'regular', pricing: { input: 5, output: 30 } }, + { id: 'pro', pricing: { input: 30, output: 180 } }, ]; expect(sortModelsByPrice(models as never).map((model) => model.id)).toEqual([ diff --git a/src/features/ai/utils/catalogHelpers.ts b/src/features/ai/utils/catalogHelpers.ts index 4b01568d..71fe3f1f 100644 --- a/src/features/ai/utils/catalogHelpers.ts +++ b/src/features/ai/utils/catalogHelpers.ts @@ -58,8 +58,8 @@ export function getModelsFromProvider( } export function getModelPriceRank(model: IAIModelData): number { - const inputPrice = model.pricing?.input_per_1m ?? 0; - const outputPrice = model.pricing?.output_per_1m ?? 0; + const inputPrice = model.pricing?.input ?? 0; + const outputPrice = model.pricing?.output ?? 0; return inputPrice + outputPrice; } diff --git a/src/features/ai/utils/chatRequestUtils.test.ts b/src/features/ai/utils/chatRequestUtils.test.ts index ba2c949b..fa878feb 100644 --- a/src/features/ai/utils/chatRequestUtils.test.ts +++ b/src/features/ai/utils/chatRequestUtils.test.ts @@ -164,5 +164,16 @@ describe('chatRequestUtils', () => { enabled: true, }); }); + + it('should omit blank session ids for non-persistent utility requests', () => { + const request = constructChatRequest([], mockMessage, [], { + providerId: 'gpt', + model: 'gpt-5.5', + apiKey: null, + sessionId: ' ', + }); + + expect(request.session_id).toBeUndefined(); + }); }); }); diff --git a/src/features/ai/utils/chatRequestUtils.ts b/src/features/ai/utils/chatRequestUtils.ts index 958cb188..5b6c91bc 100644 --- a/src/features/ai/utils/chatRequestUtils.ts +++ b/src/features/ai/utils/chatRequestUtils.ts @@ -36,7 +36,7 @@ export function constructChatRequest( providerId: string; model: string; apiKey: string | null; - sessionId: string; + sessionId?: string; thinkingLevel?: RequestThinkingLevel; maxTokens?: number | undefined; webSearchEnabled?: boolean; @@ -59,11 +59,15 @@ export function constructChatRequest( thought_signature: message.thought_signature, }, ], - session_id: sessionId, api_key: apiKey, attachments, }; + const normalizedSessionId = sessionId?.trim(); + if (normalizedSessionId !== undefined && normalizedSessionId !== '') { + request.session_id = normalizedSessionId; + } + if (thinkingLevel !== undefined) { request.thinking_level = thinkingLevel; } diff --git a/src/features/chat/chat.test.ts b/src/features/chat/ChatController.test.ts similarity index 71% rename from src/features/chat/chat.test.ts rename to src/features/chat/ChatController.test.ts index f3d0e581..776a6514 100644 --- a/src/features/chat/chat.test.ts +++ b/src/features/chat/ChatController.test.ts @@ -5,6 +5,8 @@ const clearUi = vi.fn(); const updateTokenCount = vi.fn(); const mockChatUiInstances: Array<{ renderHistory: ReturnType; + createImageGenerationMessage: ReturnType; + showToast: ReturnType; }> = []; const mockChatFileHandlerInstances: Array<{ clear: ReturnType; @@ -33,10 +35,20 @@ vi.mock('./ui/ChatUI', () => ({ public clear = clearUi; public updateTokenCount = updateTokenCount; public updateContextTokenCount = vi.fn(); + public createImageGenerationMessage = vi.fn(() => ({ + setStatus: vi.fn(), + setPreview: vi.fn(), + finalize: vi.fn(), + fail: vi.fn(), + cancel: vi.fn(), + discard: vi.fn(), + })); public constructor() { mockChatUiInstances.push({ renderHistory: this.renderHistory, + createImageGenerationMessage: this.createImageGenerationMessage, + showToast: this.showToast, }); } }, @@ -87,16 +99,17 @@ vi.mock('./services/ChatFileHandler', () => ({ }, })); -import { ChatController } from './chat'; +import { ChatController } from './ChatController'; import { ChatContentHelper } from './services/ChatContentHelper'; import { ChatUiStateHelper } from './services/ChatUiStateHelper'; import { EventBus } from '@/shared/services/EventBus'; type ChatControllerTestAccess = { init: () => Promise; - sendChat: () => Promise; + sendChat: () => Promise; clearChat: () => Promise; destroy: () => void; + toggleAttachMenu: () => void; }; describe('ChatController', () => { @@ -110,6 +123,9 @@ describe('ChatController', () => { clearHistory: vi.fn().mockResolvedValue(undefined), startProvider: vi.fn().mockResolvedValue(false), rewindLastTurn: vi.fn().mockResolvedValue('last prompt'), + getImageGenerationPreview: vi.fn().mockResolvedValue(null), + cancelTextGeneration: vi.fn().mockResolvedValue(true), + cancelImageGeneration: vi.fn().mockResolvedValue(undefined), }; const i18n = { @@ -125,7 +141,6 @@ describe('ChatController', () => { isTauriRuntime: vi.fn().mockReturnValue(false), openExternalUrl: vi.fn().mockResolvedValue(undefined), copyText: vi.fn().mockResolvedValue(undefined), - readClipboardText: vi.fn().mockResolvedValue(null), getPendingChatRevealStore: vi.fn().mockReturnValue(null), estimateTokens: vi.fn((text: string) => Promise.resolve(Math.max(1, Math.ceil(text.length / 4))), @@ -163,7 +178,7 @@ describe('ChatController', () => { return new ChatUiStateHelper({ aiBridge: aiBridge as never, i18n: i18n as never, - appendAssistantError: appendMessage, + showErrorToast: vi.fn(), getChatInput: () => document.getElementById('chat-input') as HTMLTextAreaElement | null, maxInputHeightPx: 200, baseInputHeightPx: 42, @@ -189,6 +204,7 @@ describe('ChatController', () => { vi.advanceTimersByTime(500); expect(appendMessage).not.toHaveBeenCalled(); + expect(mockChatUiInstances[0]?.showToast).not.toHaveBeenCalled(); vi.useRealTimers(); }); @@ -203,11 +219,155 @@ describe('ChatController', () => { vi.advanceTimersByTime(500); expect(appendMessage).not.toHaveBeenCalled(); + expect(mockChatUiInstances[0]?.showToast).not.toHaveBeenCalled(); expect(clearUi).toHaveBeenCalledTimes(1); expect(updateTokenCount).toHaveBeenCalledWith(0, undefined); vi.useRealTimers(); }); + it('should show inactive-ai errors as toast instead of chat messages', async () => { + vi.useFakeTimers(); + document.body.innerHTML = ''; + aiBridge.isActive.mockReturnValue(false); + const controller = createController(); + + await controller.sendChat(); + vi.advanceTimersByTime(500); + + expect(appendMessage).not.toHaveBeenCalled(); + expect(mockChatUiInstances[0]?.showToast).toHaveBeenCalledWith( + 'No AI module running. Please select and launch a module first.', + 'error', + 5000, + ); + vi.useRealTimers(); + }); + + it('should show send failures as toast instead of persistent chat messages', () => { + const controller = createController(); + const internals = controller as unknown as { + _handleError: (error: unknown) => void; + }; + + internals._handleError('Provider activation failed'); + + expect(appendMessage).not.toHaveBeenCalled(); + expect(mockChatUiInstances[0]?.showToast).toHaveBeenCalledWith( + 'Provider activation failed', + 'error', + 5000, + ); + }); + + it('should cancel active generation before clearing chat', async () => { + const controller = createController(); + const internals = controller as unknown as { + _state: { isSending: boolean; currentGenerationProviderId: string | null }; + _sendController: { cancelActiveSend: () => Promise }; + }; + internals._state.isSending = true; + internals._state.currentGenerationProviderId = 'gpt'; + const cancelSpy = vi + .spyOn(internals._sendController, 'cancelActiveSend') + .mockResolvedValue(undefined); + + await controller.clearChat(); + + expect(cancelSpy).toHaveBeenCalledOnce(); + expect(clearUi).toHaveBeenCalledOnce(); + expect(internals._state.isSending).toBe(false); + expect(internals._state.currentGenerationProviderId).toBeNull(); + }); + + it('should still clear chat when active send cancellation fails', async () => { + const controller = createController(); + const internals = controller as unknown as { + _state: { isSending: boolean; currentGenerationProviderId: string | null }; + _sendController: { cancelActiveSend: () => Promise }; + }; + internals._state.isSending = true; + internals._state.currentGenerationProviderId = 'gpt'; + vi.spyOn(internals._sendController, 'cancelActiveSend').mockRejectedValueOnce( + new Error('cancel failed'), + ); + + await controller.clearChat(); + + expect(clearUi).toHaveBeenCalledOnce(); + expect(internals._state.isSending).toBe(false); + expect(internals._state.currentGenerationProviderId).toBeNull(); + expect(chatDeps.tracer.warn).toHaveBeenCalledWith( + '[Chat] Failed to cancel active send during clear:', + expect.any(Error), + ); + }); + + it('should not restore an image generation placeholder if a send starts while probing preview', async () => { + let resolvePreview: ( + value: Awaited>, + ) => void = () => { + throw new Error('preview promise was not started'); + }; + aiBridge.getImageGenerationPreview.mockReturnValueOnce( + new Promise((resolve) => { + resolvePreview = resolve; + }), + ); + const controller = createController(); + const internals = controller as unknown as { + _state: { isSending: boolean }; + }; + + const initPromise = controller.init(); + internals._state.isSending = true; + resolvePreview({ + data_url: 'data:image/png;base64,abc', + updated_at_ms: 1, + progress: 0.5, + step: null, + total: null, + speed: null, + eta_relative: null, + }); + await initPromise; + await Promise.resolve(); + await Promise.resolve(); + + expect(mockChatUiInstances[0]?.createImageGenerationMessage).not.toHaveBeenCalled(); + }); + + it('should not reload restored image history after destruction during preview polling', async () => { + let resolvePreview: ( + value: Awaited>, + ) => void = () => { + throw new Error('preview promise was not started'); + }; + aiBridge.getImageGenerationPreview.mockReturnValueOnce( + new Promise((resolve) => { + resolvePreview = resolve; + }), + ); + const controller = createController(); + const internals = controller as unknown as { + _state: { isDestroyed: boolean; isSending: boolean }; + _checkRestoredImageGeneration: () => Promise; + _historyController: { loadHistory: () => Promise }; + _generationController: { stopImagePreviewPolling: () => void }; + }; + internals._state.isSending = true; + const loadHistorySpy = vi.spyOn(internals._historyController, 'loadHistory'); + const stopPollingSpy = vi.spyOn(internals._generationController, 'stopImagePreviewPolling'); + + const checkPromise = internals._checkRestoredImageGeneration(); + await Promise.resolve(); + internals._state.isDestroyed = true; + resolvePreview(null); + await checkPromise; + + expect(loadHistorySpy).not.toHaveBeenCalled(); + expect(stopPollingSpy).not.toHaveBeenCalled(); + }); + it('should clear file update callback on destroy', () => { const controller = createController(); @@ -217,6 +377,32 @@ describe('ChatController', () => { expect(mockChatFileHandlerInstances[0]?.clearUpdateCallback).toHaveBeenCalledTimes(1); }); + it('should remove attach menu listeners on destroy', () => { + const addEventListener = vi.spyOn(document, 'addEventListener'); + document.body.innerHTML = ` +
+ +
+ `; + const controller = createController(); + + controller.toggleAttachMenu(); + const listenerOptions = addEventListener.mock.calls.find( + ([eventName]) => eventName === 'mousedown', + )?.[2]; + if ( + typeof listenerOptions !== 'object' || + !('signal' in listenerOptions) || + !(listenerOptions.signal instanceof AbortSignal) + ) { + throw new Error('attach menu listener signal was not registered'); + } + controller.destroy(); + + expect(document.querySelector('.chat-attach-menu')).toBeNull(); + expect(listenerOptions.signal.aborted).toBe(true); + }); + it('should restore multimodal history without flattening stored content', async () => { aiBridge.getHistory.mockResolvedValueOnce([ { diff --git a/src/features/chat/chat.ts b/src/features/chat/ChatController.ts similarity index 67% rename from src/features/chat/chat.ts rename to src/features/chat/ChatController.ts index 36a47515..757dd3bf 100644 --- a/src/features/chat/chat.ts +++ b/src/features/chat/ChatController.ts @@ -1,5 +1,5 @@ /** - * @module chat/chat + * @module chat/ChatController * @description Main controller for the Chat module. * Composes VoiceController and FilePickerController for SRP compliance. */ @@ -47,7 +47,6 @@ type ChatControllerDeps = { isTauriRuntime: () => boolean; openExternalUrl: (url: string) => Promise; copyText: (text: string) => Promise; - readClipboardText: () => Promise; getPendingChatRevealStore: () => PendingChatRevealStore | null; estimateTokens: (text: string, model?: string) => Promise; hostBridge: IBridge; @@ -79,6 +78,9 @@ export class ChatController { private readonly _generationController: ChatGenerationController; private readonly _sendController: ChatSendController; private readonly _state = new ChatControllerState(); + private _restoredImageGenerationTimer: ReturnType | null = null; + private _attachMenuAbortController: AbortController | null = null; + private _forceImageGeneration = false; private _contextTokenTotal = 0; private _contextTokenVersion = 0; private readonly _boundFileInputChange = (e: Event) => this._filePicker.handleFileSelect(e); @@ -139,7 +141,7 @@ export class ChatController { ); this._filePicker = this._createFilePicker(_i18n, deps); this._historyController = this._createHistoryController(deps); - this._generationController = this._createGenerationController(_aiBridge, _i18n); + this._generationController = this._createGenerationController(_aiBridge, _i18n, deps); this._sendController = this._createSendController(_aiBridge, deps); this._activationCoordinator = this._createActivationCoordinator(_aiBridge); } @@ -161,7 +163,6 @@ export class ChatController { isTauriRuntime: deps.isTauriRuntime, openExternalUrl: deps.openExternalUrl, copyText: deps.copyText, - readClipboardText: deps.readClipboardText, }); } @@ -169,8 +170,8 @@ export class ChatController { return new ChatUiStateHelper({ aiBridge, i18n, - appendAssistantError: (message) => { - this._ui.appendMessage('assistant', message, { error: true }); + showErrorToast: (message) => { + this._ui.showToast(message, 'error', 5000); }, getChatInput: () => this._inputCoordinator.getInput(), maxInputHeightPx: ChatController._maxInputHeightPx, @@ -268,6 +269,7 @@ export class ChatController { private _createGenerationController( aiBridge: AIBridge, i18n: I18nService, + deps: ChatControllerDeps, ): ChatGenerationController { return this._factory.createGenerationController({ aiBridge, @@ -296,6 +298,8 @@ export class ChatController { }, isDestroyed: () => this._state.isDestroyed, isSending: () => this._state.isSending, + isImageProvider: (providerId) => + providerId !== null && deps.getSelectedModule('ai_image')?.id === providerId, tracer: this._tracer, }); } @@ -309,6 +313,7 @@ export class ChatController { fileHandler: this._fileHandler, service: this._service, getHistory: () => this._state.history, + estimateTokens: async (text) => await deps.estimateTokens(text), pushUserMessage: (content) => { this._state.pushHistoryMessage({ role: 'user', content }); }, @@ -316,13 +321,7 @@ export class ChatController { this._ui.removeTyping(typingId); return this._ui.createStreamingMessage('assistant'); }, - createImageHandle: () => - this._ui.createImageGenerationMessage({ - onCancel: async () => { - this._generationController.stopImagePreviewPolling(); - await this._aiBridge.cancelImageGeneration(); - }, - }), + createImageHandle: () => this._ui.createImageGenerationMessage(), translate: (key, fallback) => this._i18n.t(key, fallback), showTyping: (typingId) => { this._ui.showTyping(typingId); @@ -346,8 +345,17 @@ export class ChatController { appendUserMessage: (text, attachments, tokens) => { this._ui.appendMessage('user', text, { attachments, tokens }); }, + rollbackOptimisticSend: (historySnapshot, inputText) => { + this._historyController.restoreLocalHistorySnapshot(historySnapshot); + this._inputCoordinator.restore(inputText); + void this._syncContextTokensFromHistory(historySnapshot); + }, getSelectedModule: (category) => deps.getSelectedModule(category), getPreferredAiCategory: () => deps.getPreferredAiCategory(), + isForceImageGeneration: () => this._forceImageGeneration, + clearForceImageGeneration: () => { + this._forceImageGeneration = false; + }, handleResponse: async (response, streamingHandle, imageHandle) => await this._generationController.handleChatResponse( response, @@ -363,11 +371,14 @@ export class ChatController { startImagePreviewPolling: (handle) => { this._generationController.startImagePreviewPolling(handle); }, - cancelTextGeneration: async () => { - const providerId = this._aiBridge.getState().activeProviderId; + cancelTextGeneration: async (providerIdFromSend) => { + const providerId = + providerIdFromSend ?? + this._state.currentGenerationProviderId ?? + this._aiBridge.getState().activeProviderId; if (this._generationController.isImageProvider(providerId)) { this._generationController.stopImagePreviewPolling(); - await this._aiBridge.cancelImageGeneration(); + await this._aiBridge.cancelImageGeneration(providerId); return true; } @@ -384,6 +395,12 @@ export class ChatController { isSending: () => this._state.isSending, setSending: (value) => { this._state.isSending = value; + if (value) { + this._state.currentGenerationProviderId = + this._aiBridge.getState().activeProviderId; + } else { + this._state.currentGenerationProviderId = null; + } }, tracer: this._tracer, }); @@ -406,10 +423,8 @@ export class ChatController { this._state.isInitialized = true; this._state.isDestroyed = false; - this._tracer.info('[Chat] Initializing TS Controller...'); - await this._ui.init().catch((err: unknown) => { - this._tracer.error(`[Chat] UI init failed: ${String(err)}`); - }); + this._tracer.debug('[Chat] Initializing TS Controller...'); + await this._ui.init(); this._ui.setEditMessageHandler(async (text) => { await this._historyController.editLastTurn(this._state.isSending, text); }); @@ -417,15 +432,18 @@ export class ChatController { await this.regenerateLastResponse(); }); this._lifecycleHelper.start(); + void this._restoreActiveImageGeneration(); } public destroy(): void { if (this._state.isDestroyed) return; this._state.isDestroyed = true; this._state.isInitialized = false; + this._clearRestoredImageGenerationTimer(); this._lifecycleHelper.stop(); this._viewHelper.unbindEvents(); this._generationController.stopImagePreviewPolling(); + this._closeAttachMenu(); this._uiStateHelper.dispose(); this._sendController.destroy(); this._historyController.destroy(); @@ -434,12 +452,168 @@ export class ChatController { this._ui.destroy(); } + private async _restoreActiveImageGeneration(): Promise { + if (this._shouldSkipRestoredImageGeneration()) { + return; + } + + const previewProvider = this._aiBridge as { + getImageGenerationPreview?: () => Promise< + Awaited> + >; + }; + if (typeof previewProvider.getImageGenerationPreview !== 'function') { + return; + } + + let preview: Awaited>; + try { + preview = await previewProvider.getImageGenerationPreview(); + } catch (error: unknown) { + this._tracer.error('[Chat] Failed to restore active image generation:', error); + return; + } + if (this._shouldSkipRestoredImageGeneration()) { + return; + } + if (preview === null) { + return; + } + + const imageHandle = this._ui.createImageGenerationMessage(); + imageHandle.setStatus('image status=running elapsed=0s'); + if (preview.data_url.trim() !== '') { + imageHandle.setPreview(preview.data_url); + } + + this._state.isSending = true; + this._state.currentGenerationProviderId = this._aiBridge.getState().activeProviderId; + this._generationController.startImagePreviewPolling(imageHandle); + this._scheduleRestoredImageGenerationCheck(); + } + + private _scheduleRestoredImageGenerationCheck(): void { + this._clearRestoredImageGenerationTimer(); + this._restoredImageGenerationTimer = globalThis.setTimeout(() => { + this._restoredImageGenerationTimer = null; + void this._checkRestoredImageGeneration(); + }, 1200); + } + + private async _checkRestoredImageGeneration(): Promise { + if (this._shouldSkipRestoredImageGeneration()) { + return; + } + + try { + const preview = await this._aiBridge.getImageGenerationPreview(); + if (this._shouldSkipRestoredImageGeneration()) { + return; + } + + if (preview !== null) { + this._scheduleRestoredImageGenerationCheck(); + return; + } + + this._generationController.stopImagePreviewPolling(); + this._state.isSending = false; + this._state.currentGenerationProviderId = null; + await this._historyController.loadHistory(); + } catch (error: unknown) { + this._generationController.stopImagePreviewPolling(); + this._state.isSending = false; + this._state.currentGenerationProviderId = null; + this._tracer.error('[Chat] Restored image generation check failed:', error); + } + } + + private _clearRestoredImageGenerationTimer(): void { + if (this._restoredImageGenerationTimer === null) { + return; + } + + globalThis.clearTimeout(this._restoredImageGenerationTimer); + this._restoredImageGenerationTimer = null; + } + + private _shouldSkipRestoredImageGeneration(): boolean { + return this._state.isDestroyed || this._state.isSending; + } + + private _closeAttachMenu(): void { + this._attachMenuAbortController?.abort(); + this._attachMenuAbortController = null; + document.querySelector('.chat-attach-menu')?.remove(); + } + // --- Public Actions --- public async pickChatFiles(): Promise { await this._filePicker.pick(); } + public toggleAttachMenu(): void { + const existing = document.querySelector('.chat-attach-menu'); + if (existing instanceof HTMLElement) { + this._closeAttachMenu(); + return; + } + + this._closeAttachMenu(); + + const button = document.getElementById('chat-attach-btn'); + const compose = document.getElementById('chat-compose'); + if (!(button instanceof HTMLElement) || !(compose instanceof HTMLElement)) { + return; + } + + const menu = document.createElement('div'); + menu.className = 'chat-attach-menu'; + menu.setAttribute('role', 'menu'); + + const fileButton = this._createAttachMenuButton( + 'file', + this._i18n.t('ui.chat.attach_file', 'Add file'), + '#icon-paperclip', + ); + const imageButton = this._createAttachMenuButton( + 'image', + this._i18n.t('ui.chat.generate_image', 'Generate image'), + '#icon-ai', + ); + + menu.append(fileButton, imageButton); + compose.appendChild(menu); + + const controller = new AbortController(); + const close = (event: MouseEvent) => { + if (event.target instanceof Node && menu.contains(event.target)) { + return; + } + if (event.target instanceof Node && button.contains(event.target)) { + return; + } + this._closeAttachMenu(); + }; + this._attachMenuAbortController = controller; + document.addEventListener('mousedown', close, { + capture: true, + signal: controller.signal, + }); + } + + public async pickChatFilesFromMenu(): Promise { + this._closeAttachMenu(); + await this.pickChatFiles(); + } + + public async sendImageGenerationFromMenu(): Promise { + this._closeAttachMenu(); + this._forceImageGeneration = true; + await this.sendChat(); + } + public toggleVoiceInput(): void { this._voice.toggle((text) => { this._inputCoordinator.appendVoiceText(text); @@ -452,7 +626,17 @@ export class ChatController { public async clearChat(): Promise { this._activationCoordinator.clearInactiveAiErrorTimeout(); + this._clearRestoredImageGenerationTimer(); + if (this._state.isSending) { + try { + await this._sendController.cancelActiveSend(); + } catch (error: unknown) { + this._tracer.warn('[Chat] Failed to cancel active send during clear:', error); + } + } this._generationController.stopImagePreviewPolling(); + this._state.isSending = false; + this._state.currentGenerationProviderId = null; this._state.clearHistory(); this._contextTokenTotal = 0; this._contextTokenVersion += 1; @@ -470,26 +654,32 @@ export class ChatController { // --- Send Message --- - public async sendChat(): Promise { + public async sendChat(): Promise { if (this._state.isSending) { await this._sendController.cancelActiveSend(); - return; + this._forceImageGeneration = false; + return false; } const input = this._inputCoordinator.getInput(); const text = input?.value.trim() ?? ''; if (!this._sendController.validateInput(text)) { + this._forceImageGeneration = false; this._ui.showToast( this._i18n.t('ui.chat.input_required', 'Enter a message or attach a file'), 'error', ); - return; + return false; } - const isActive = await this._activationCoordinator.ensureActive(input); - if (!isActive) return; + const activationPrompt = this._forceImageGeneration ? `generate image ${text}` : undefined; + const isActive = await this._activationCoordinator.ensureActive(input, activationPrompt); + if (!isActive) { + this._forceImageGeneration = false; + return false; + } - await this._sendController.sendChat(input); + return await this._sendController.sendChat(input); } public async regenerateLastResponse(): Promise { @@ -497,8 +687,21 @@ export class ChatController { return; } - const text = await this._historyController.regenerateLastTurn(false); + if (!this._historyController.canRegenerateLastTurnFromText()) { + this._ui.showToast( + this._i18n.t( + 'ui.chat.regenerate_structured_unsupported', + 'Regeneration is available only for text-only messages', + ), + 'error', + ); + return; + } + + const historySnapshot = this._historyController.getLocalHistorySnapshot(); + const text = await this._historyController.regenerateLastTurn(this._state.isSending); if (text === null || text.trim() === '') { + this._historyController.restoreLocalHistorySnapshot(historySnapshot); this._ui.showToast( this._i18n.t('ui.chat.regenerate_failed', 'Failed to regenerate response'), 'error', @@ -507,7 +710,20 @@ export class ChatController { } this._inputCoordinator.restore(text); - await this.sendChat(); + let started: boolean; + try { + started = await this.sendChat(); + } catch (error: unknown) { + this._historyController.restoreLocalHistorySnapshot(historySnapshot); + this._inputCoordinator.restore(text); + this._tracer.error('[Chat] Failed to resend regenerated turn:', error); + throw error; + } + + if (!started) { + this._historyController.restoreLocalHistorySnapshot(historySnapshot); + this._inputCoordinator.restore(text); + } } // --- Greeting --- @@ -526,6 +742,29 @@ export class ChatController { this._scheduleAutoResizeInput(); } + private _createAttachMenuButton(action: string, label: string, iconHref: string): HTMLElement { + const button = document.createElement('button'); + button.className = 'chat-attach-menu-item'; + button.type = 'button'; + button.dataset['chatAttachAction'] = action; + button.setAttribute('role', 'menuitem'); + + const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + icon.setAttribute('class', 'icon'); + icon.setAttribute('viewBox', '0 0 24 24'); + icon.setAttribute('aria-hidden', 'true'); + icon.setAttribute('focusable', 'false'); + const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); + use.setAttribute('href', iconHref); + icon.appendChild(use); + + const text = document.createElement('span'); + text.textContent = label; + + button.append(icon, text); + return button; + } + private _pushAssistantMessage( content: IChatMessage['content'], thoughtSignature?: string, @@ -543,7 +782,7 @@ export class ChatController { private _handleError(errorMsg: unknown = 'Unknown Error', _model?: string): void { const msgStr = this._contentHelper.extractText(errorMsg) || 'Unknown Error'; - this._ui.appendMessage('assistant', msgStr, { error: true }); + this._ui.showToast(msgStr, 'error', 5000); } private _addContextTokens(tokens: number): void { diff --git a/src/features/chat/controllers/ChatGenerationController.test.ts b/src/features/chat/controllers/ChatGenerationController.test.ts index 0b3fdfc0..07a3b818 100644 --- a/src/features/chat/controllers/ChatGenerationController.test.ts +++ b/src/features/chat/controllers/ChatGenerationController.test.ts @@ -1,12 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatGenerationController } from './ChatGenerationController'; -import { CUSTOM_IMAGE_PROVIDER_ID } from '@/shared/utils/customProviderSupport'; describe('ChatGenerationController', () => { const aiBridge = { getImageGenerationPreview: vi.fn(), removeChunkListener: vi.fn(), removeReplaceChunkListener: vi.fn(), + removeThoughtListener: vi.fn(), }; const baseOptions = { @@ -25,6 +25,7 @@ describe('ChatGenerationController', () => { handleError: vi.fn(), isDestroyed: vi.fn().mockReturnValue(false), isSending: vi.fn().mockReturnValue(true), + isImageProvider: vi.fn((providerId: string | null) => providerId === 'selected-image'), tracer: { debug: vi.fn(), }, @@ -164,12 +165,10 @@ describe('ChatGenerationController', () => { vi.useRealTimers(); }); - it('treats cloud and custom image providers as image flows', () => { + it('uses the injected image-provider resolver', () => { const controller = new ChatGenerationController(baseOptions as never); - expect(controller.isImageProvider('gpt-image')).toBe(true); - expect(controller.isImageProvider('seedream-image')).toBe(true); - expect(controller.isImageProvider(CUSTOM_IMAGE_PROVIDER_ID)).toBe(true); + expect(controller.isImageProvider('selected-image')).toBe(true); expect(controller.isImageProvider('gpt')).toBe(false); expect(controller.isImageProvider(null)).toBe(false); }); @@ -194,13 +193,35 @@ describe('ChatGenerationController', () => { expect(baseOptions.handleError).toHaveBeenCalled(); }); + it('removes failed image generation placeholders and reports the error as a toast', async () => { + const controller = new ChatGenerationController(baseOptions as never); + const imageHandle = { + setStatus: vi.fn(), + setPreview: vi.fn(), + finalize: vi.fn(), + fail: vi.fn(), + cancel: vi.fn(), + discard: vi.fn(), + }; + + await controller.handleChatResponse( + { ok: false, error: 'image failed', model: 'sdcpp' } as never, + null, + imageHandle, + ); + + expect(imageHandle.discard).toHaveBeenCalledOnce(); + expect(imageHandle.fail).not.toHaveBeenCalled(); + expect(baseOptions.handleError).toHaveBeenCalled(); + }); + it('adds assistant text tokens to the context counter', async () => { const controller = new ChatGenerationController(baseOptions as never); await controller.handleChatResponse( { ok: true, - message: 'answer', + reply: { text: 'answer' }, usage: { prompt_tokens: 11, completion_tokens: 7, total_tokens: 18 }, } as never, null, @@ -215,7 +236,7 @@ describe('ChatGenerationController', () => { const controller = new ChatGenerationController(baseOptions as never); await controller.handleChatResponse( - { ok: true, message: 'answer', thought_signature: 'sig-1' } as never, + { ok: true, reply: { text: 'answer' }, thought_signature: 'sig-1' } as never, null, null, ); diff --git a/src/features/chat/controllers/ChatGenerationController.ts b/src/features/chat/controllers/ChatGenerationController.ts index 4f2287dd..2a03926e 100644 --- a/src/features/chat/controllers/ChatGenerationController.ts +++ b/src/features/chat/controllers/ChatGenerationController.ts @@ -1,5 +1,4 @@ import type { AIBridge } from '@/features/ai/services/AIBridge'; -import { AIBridgeProviderPolicy } from '@/features/ai/services/AIBridgeProviderPolicy'; import type { I18nService } from '@/infrastructure/i18n/I18nService'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { IChatMessage, IChatResponse } from '../types/chatTypes'; @@ -43,6 +42,7 @@ type ChatGenerationControllerOptions = { handleError: (errorMsg: unknown, model?: string) => void; isDestroyed: () => boolean; isSending: () => boolean; + isImageProvider: (providerId: string | null) => boolean; tracer: ChatGenerationLogger; }; @@ -55,19 +55,19 @@ export class ChatGenerationController { private _lastImagePreviewUpdatedAtMs = 0; private _imageGenerationStartedAtMs = 0; private _lastConcreteImageProgressAtMs = 0; - private readonly _providerPolicy = new AIBridgeProviderPolicy(); constructor(private readonly _options: ChatGenerationControllerOptions) {} public cleanupStreamingState(listenerId: string, typingId: string): void { this._options.aiBridge.removeChunkListener(listenerId); this._options.aiBridge.removeReplaceChunkListener(listenerId); + this._options.aiBridge.removeThoughtListener(listenerId); this.stopImagePreviewPolling(); this._options.removeTyping(typingId); } public isImageProvider(providerId: string | null): boolean { - return providerId !== null && this._providerPolicy.isImageProvider(providerId); + return this._options.isImageProvider(providerId); } public startImagePreviewPolling(handle: ImageGenerationHandle): void { @@ -228,13 +228,12 @@ export class ChatGenerationController { await this.handleSuccessfulChatResponse(response, streamingHandle, imageHandle); } - private async handleSuccessfulChatResponse( response: IChatResponse, streamingHandle?: StreamingMessageHandle | null, imageHandle?: ImageGenerationHandle | null, ): Promise { - const rawReply = response.message ?? response.reply?.text ?? ''; + const rawReply = response.reply?.text ?? ''; const replyText = this._options.extractText(rawReply); const generatedImages = response.reply?.images ?? []; @@ -283,9 +282,7 @@ export class ChatGenerationController { replyText: string, streamingHandle?: StreamingMessageHandle | null, ): Promise { - const tokens = - this._resolveBackendCompletionTokens(response) ?? - (await this._options.estimateReplyTokens(replyText)); + const tokens = await this._resolveBackendCompletionTokens(response, replyText); if (tokens > 0) { this._options.addContextTokens(tokens); } @@ -299,10 +296,21 @@ export class ChatGenerationController { this._options.pushAssistantMessage(replyText, response.thought_signature); } - private _resolveBackendCompletionTokens(response: IChatResponse): number | null { + private async _resolveBackendCompletionTokens( + response: IChatResponse, + replyText: string, + ): Promise { const completionTokens = response.usage?.completion_tokens; if (typeof completionTokens !== 'number' || !Number.isFinite(completionTokens)) { - return null; + try { + const estimatedTokens = await this._options.estimateReplyTokens(replyText); + if (!Number.isFinite(estimatedTokens)) { + return 0; + } + return Math.max(0, Math.trunc(estimatedTokens)); + } catch { + return 0; + } } return Math.max(0, Math.trunc(completionTokens)); @@ -318,7 +326,8 @@ export class ChatGenerationController { response.model, ); if (imageHandle !== null && imageHandle !== undefined) { - imageHandle.fail(friendlyMsg); + imageHandle.discard(); + this._options.handleError(friendlyMsg, response.model); return; } diff --git a/src/features/chat/controllers/ChatHistoryController.test.ts b/src/features/chat/controllers/ChatHistoryController.test.ts index 7e0b0714..4a93576a 100644 --- a/src/features/chat/controllers/ChatHistoryController.test.ts +++ b/src/features/chat/controllers/ChatHistoryController.test.ts @@ -34,7 +34,7 @@ function createController(stateOverrides?: Partial) { isDestroyed: vi.fn(() => false), getPendingChatRevealStore: vi.fn(() => null), tracer: { - info: vi.fn(), + debug: vi.fn(), error: vi.fn(), }, }; @@ -73,6 +73,56 @@ describe('ChatHistoryController', () => { expect(deps.renderHistory).toHaveBeenLastCalledWith(secondHistory); }); + it('should load the new session when session id changes during an in-flight restore', async () => { + const firstHistory: IChatMessage[] = [{ role: 'user', content: 'first' }]; + const secondHistory: IChatMessage[] = [{ role: 'assistant', content: 'second' }]; + let resolveFirstLoad: (history: IChatMessage[]) => void = () => {}; + const { controller, deps, aiBridge, state } = createController({ + history: firstHistory, + }); + aiBridge.getHistory + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstLoad = resolve; + }), + ) + .mockImplementationOnce(() => Promise.resolve(secondHistory)); + + const firstLoad = controller.ensureHistoryLoaded(); + state.sessionId = 'session-2'; + const secondLoad = controller.ensureHistoryLoaded(); + resolveFirstLoad(firstHistory); + await Promise.all([firstLoad, secondLoad]); + + expect(aiBridge.getHistory).toHaveBeenCalledTimes(2); + expect(deps.renderHistory).not.toHaveBeenCalledWith(firstHistory); + expect(deps.setHistory).toHaveBeenLastCalledWith(secondHistory); + expect(deps.renderHistory).toHaveBeenLastCalledWith(secondHistory); + }); + + it('should ignore an in-flight history restore after destroy', async () => { + const restoredHistory: IChatMessage[] = [{ role: 'user', content: 'late' }]; + let destroyed = false; + let resolveLoad: (history: IChatMessage[]) => void = () => {}; + const { controller, deps, aiBridge } = createController(); + deps.isDestroyed.mockImplementation(() => destroyed); + aiBridge.getHistory.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + + const load = controller.ensureHistoryLoaded(); + destroyed = true; + resolveLoad(restoredHistory); + await load; + + expect(deps.setHistory).not.toHaveBeenCalled(); + expect(deps.renderHistory).not.toHaveBeenCalled(); + }); + it('should clear rendered chat when the current session has no persisted history', async () => { const { controller, deps, state } = createController({ history: [{ role: 'user', content: 'stale' }], @@ -104,6 +154,65 @@ describe('ChatHistoryController', () => { expect(deps.renderHistory).toHaveBeenLastCalledWith(defaultHistory); }); + it('should isolate multimodal history snapshots from later mutations', () => { + const { controller, state } = createController({ + history: [ + { + role: 'user', + content: [ + { type: 'text', text: 'look' }, + { + type: 'image_url', + image_url: { url: 'data:image/png;base64,old', detail: 'auto' }, + }, + ], + }, + ], + }); + + const snapshot = controller.getLocalHistorySnapshot(); + const snapshotContent = snapshot[0]?.content; + if (!Array.isArray(snapshotContent) || snapshotContent[1]?.type !== 'image_url') { + throw new Error('snapshot did not keep image content'); + } + snapshotContent[1].image_url.url = 'data:image/png;base64,mutated'; + + const currentContent = state.history[0]?.content; + expect(Array.isArray(currentContent) ? currentContent[1] : undefined).toEqual({ + type: 'image_url', + image_url: { url: 'data:image/png;base64,old', detail: 'auto' }, + }); + }); + + it('should isolate restored multimodal history from caller-owned objects', () => { + const { controller, state } = createController(); + const snapshot: IChatMessage[] = [ + { + role: 'user', + content: [ + { type: 'text', text: 'look' }, + { + type: 'image_url', + image_url: { url: 'data:image/png;base64,old', detail: 'high' }, + }, + ], + }, + ]; + + controller.restoreLocalHistorySnapshot(snapshot); + const snapshotContent = snapshot[0]?.content; + if (!Array.isArray(snapshotContent) || snapshotContent[1]?.type !== 'image_url') { + throw new Error('snapshot did not keep image content'); + } + snapshotContent[1].image_url.url = 'data:image/png;base64,mutated'; + + const currentContent = state.history[0]?.content; + expect(Array.isArray(currentContent) ? currentContent[1] : undefined).toEqual({ + type: 'image_url', + image_url: { url: 'data:image/png;base64,old', detail: 'high' }, + }); + }); + it('should rewind the last turn and return text for regeneration', async () => { const { controller, deps, aiBridge } = createController({ history: [ diff --git a/src/features/chat/controllers/ChatHistoryController.ts b/src/features/chat/controllers/ChatHistoryController.ts index e2c79fee..837414b5 100644 --- a/src/features/chat/controllers/ChatHistoryController.ts +++ b/src/features/chat/controllers/ChatHistoryController.ts @@ -2,7 +2,7 @@ import type { AIBridge } from '@/features/ai/services/AIBridge'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { IChatMessage } from '../types/chatTypes'; -type ChatHistoryLogger = Pick; +type ChatHistoryLogger = Pick; type PendingChatRevealStore = { getState: () => { pending_chat_reveal?: boolean }; @@ -46,6 +46,9 @@ export class ChatHistoryController { if (this._historyLoaded && this._loadedSessionId === sessionId) return; if (this._historyLoadInFlight !== null) { await this._historyLoadInFlight; + if (this._loadedSessionId !== this._options.aiBridge.getSessionId()) { + await this.ensureHistoryLoaded(); + } return; } @@ -95,6 +98,22 @@ export class ChatHistoryController { } } + public canRegenerateLastTurnFromText(): boolean { + const lastUserMessage = [...this._options.getHistory()] + .reverse() + .find((message) => message.role === 'user'); + return typeof lastUserMessage?.content === 'string'; + } + + public getLocalHistorySnapshot(): IChatMessage[] { + return this._cloneHistory(this._options.getHistory()); + } + + public restoreLocalHistorySnapshot(history: IChatMessage[]): void { + this._options.setHistory(this._cloneHistory(history)); + this._options.renderHistory(this._options.getHistory()); + } + public rewindLocalHistory(): void { const history = [...this._options.getHistory()]; @@ -118,6 +137,12 @@ export class ChatHistoryController { try { const sessionId = this._options.aiBridge.getSessionId(); const history = await this._options.aiBridge.getHistory(); + if ( + this._options.isDestroyed() || + this._options.aiBridge.getSessionId() !== sessionId + ) { + return; + } this._historyLoaded = true; this._loadedSessionId = sessionId; @@ -125,23 +150,24 @@ export class ChatHistoryController { ? history .filter((msg) => msg.role === 'user' || msg.role === 'assistant') .map((msg) => { - const historyMessage: IChatMessage = { + const historyMessage = this._cloneMessage({ role: msg.role as 'user' | 'assistant', content: msg.content, - }; + }); if (msg.thought_signature !== undefined) { historyMessage.thought_signature = msg.thought_signature; } return historyMessage; }) : []; + const visibleHistory = this._stripPersistedImagePromptPreparation(nextHistory); - this._options.setHistory(nextHistory); - this._options.renderHistory(nextHistory); + this._options.setHistory(visibleHistory); + this._options.renderHistory(visibleHistory); - if (nextHistory.length > 0) { - this._options.tracer.info( - `[ChatController] Restoring ${String(nextHistory.length)} messages from persistence`, + if (visibleHistory.length > 0) { + this._options.tracer.debug( + `[ChatController] Restoring ${String(visibleHistory.length)} messages from persistence`, ); } @@ -156,6 +182,70 @@ export class ChatHistoryController { } } + private _stripPersistedImagePromptPreparation(history: IChatMessage[]): IChatMessage[] { + const visible: IChatMessage[] = []; + for (let index = 0; index < history.length; index += 1) { + const message = history[index]; + if (message === undefined) { + continue; + } + + if (message.role === 'user' && this._isImagePromptPreparationRequest(message.content)) { + const next = history[index + 1]; + if (next?.role === 'assistant') { + index += 1; + } + continue; + } + + visible.push(message); + } + + return visible; + } + + private _isImagePromptPreparationRequest(content: IChatMessage['content']): boolean { + if (typeof content !== 'string') { + return false; + } + + return ( + content.includes('Stable Diffusion') && + content.includes('Return only the final prompt text') + ); + } + + private _cloneHistory(history: IChatMessage[]): IChatMessage[] { + return history.map((message) => this._cloneMessage(message)); + } + + private _cloneMessage(message: IChatMessage): IChatMessage { + const clone: IChatMessage = { + role: message.role, + content: this._cloneContent(message.content), + }; + if (message.thought_signature !== undefined) { + clone.thought_signature = message.thought_signature; + } + return clone; + } + + private _cloneContent(content: IChatMessage['content']): IChatMessage['content'] { + if (typeof content === 'string') { + return content; + } + + return content.map((part) => { + if (part.type === 'image_url') { + return { + ...part, + image_url: { ...part.image_url }, + }; + } + return { ...part }; + }); + } + public scheduleRevealLatestMessage(): void { this.clearRevealLatestMessageTimeout(); if (this._revealLatestMessageFrame !== null) { diff --git a/src/features/chat/controllers/ChatSendController.test.ts b/src/features/chat/controllers/ChatSendController.test.ts index 7907ef93..a770b443 100644 --- a/src/features/chat/controllers/ChatSendController.test.ts +++ b/src/features/chat/controllers/ChatSendController.test.ts @@ -1,6 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatSendController } from './ChatSendController'; +import type { IChatMessage } from '../types/chatTypes'; describe('ChatSendController', () => { const createController = () => { @@ -23,6 +24,9 @@ describe('ChatSendController', () => { const aiBridge = { getState: vi.fn(() => ({ activeProviderId: 'gpt', isRunning: true })), onChunk: vi.fn(), + startProvider: vi.fn().mockResolvedValue(true), + prepareImagePrompt: vi.fn().mockResolvedValue({ ok: true, text: 'prepared prompt' }), + stopEngineSlot: vi.fn().mockResolvedValue(undefined), }; const sendMessage = vi.fn().mockResolvedValue({ ok: true, message: 'done' }); const options = { @@ -39,7 +43,8 @@ describe('ChatSendController', () => { service: { sendMessage, } as never, - getHistory: vi.fn(() => []), + getHistory: vi.fn<() => IChatMessage[]>(() => []), + estimateTokens: vi.fn((text: string) => Promise.resolve(Math.ceil(text.length / 4))), pushUserMessage: vi.fn(), createStreamingHandle: vi.fn(() => streamingHandle), createImageHandle: vi.fn(() => imageHandle), @@ -49,14 +54,17 @@ describe('ChatSendController', () => { clearInput: vi.fn(), addContextTokens: vi.fn(), appendUserMessage: vi.fn(), + rollbackOptimisticSend: vi.fn(), getSelectedModule: vi.fn(), getPreferredAiCategory: vi.fn(() => 'ai_text' as const), + isForceImageGeneration: vi.fn(() => false), + clearForceImageGeneration: vi.fn(), handleResponse: vi.fn().mockResolvedValue(undefined), cleanupStreamingState: vi.fn(), stopImagePreviewPolling: vi.fn(), startImagePreviewPolling: vi.fn(), cancelTextGeneration: vi.fn().mockResolvedValue(true), - isImageProvider: vi.fn(() => false), + isImageProvider: vi.fn((_providerId: string | null) => false), lockUi: vi.fn(() => ({ input: null, sendBtn: null, @@ -76,6 +84,7 @@ describe('ChatSendController', () => { options, aiBridge, streamingHandle, + imageHandle, sendMessage, }; }; @@ -84,6 +93,23 @@ describe('ChatSendController', () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not send empty chat messages without attachments', async () => { + const { controller, options, sendMessage } = createController(); + const input = document.createElement('textarea'); + input.value = ' '; + + const result = await controller.sendChat(input); + + expect(result).toBe(false); + expect(options.lockUi).not.toHaveBeenCalled(); + expect(options.appendUserMessage).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + }); + it('shows a pending response state until the first text chunk', async () => { const { controller, options, aiBridge, streamingHandle } = createController(); const input = document.createElement('textarea'); @@ -109,6 +135,124 @@ describe('ChatSendController', () => { expect(streamingHandle.update).toHaveBeenCalledWith('hi'); }); + it('adds user text tokens to the context count', async () => { + const { controller, options } = createController(); + const input = document.createElement('textarea'); + input.value = 'hello world'; + + await controller.sendChat(input); + + expect(options.addContextTokens).toHaveBeenCalledWith(3); + }); + + it('does not clear input or append a user turn when image provider activation fails', async () => { + const { controller, options, aiBridge, sendMessage } = createController(); + aiBridge.startProvider.mockResolvedValueOnce(false); + vi.mocked(options.isForceImageGeneration).mockReturnValue(true); + vi.mocked(options.getSelectedModule).mockImplementation( + (category: 'ai_text' | 'ai_image') => + category === 'ai_image' ? { id: 'sdcpp' } : undefined, + ); + const input = document.createElement('textarea'); + input.value = 'draw a cat'; + + const result = await controller.sendChat(input); + + expect(result).toBe(false); + expect(options.clearInput).not.toHaveBeenCalled(); + expect(options.addContextTokens).not.toHaveBeenCalled(); + expect(options.appendUserMessage).not.toHaveBeenCalled(); + expect(options.pushUserMessage).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + expect(options.handleError).toHaveBeenCalled(); + expect(options.rollbackOptimisticSend).not.toHaveBeenCalled(); + }); + + it('rolls back the optimistic user turn when the provider returns an error', async () => { + const { controller, options, sendMessage, streamingHandle } = createController(); + const existingHistory: IChatMessage[] = [{ role: 'assistant', content: 'previous' }]; + options.getHistory.mockReturnValue(existingHistory); + sendMessage.mockResolvedValueOnce({ ok: false, error: 'provider failed' }); + const input = document.createElement('textarea'); + input.value = 'hello'; + + const result = await controller.sendChat(input); + + expect(result).toBe(false); + expect(options.handleResponse).toHaveBeenCalledWith( + { ok: false, error: 'provider failed' }, + streamingHandle, + null, + ); + expect(options.rollbackOptimisticSend).toHaveBeenCalledWith(existingHistory, 'hello'); + expect(options.handleError).not.toHaveBeenCalled(); + }); + + it('rolls back the optimistic user turn when sending throws after render', async () => { + const { controller, options, sendMessage } = createController(); + const existingHistory: IChatMessage[] = [{ role: 'assistant', content: 'previous' }]; + options.getHistory.mockReturnValue(existingHistory); + sendMessage.mockRejectedValueOnce(new Error('network failed')); + const input = document.createElement('textarea'); + input.value = 'hello'; + + const result = await controller.sendChat(input); + + expect(result).toBe(false); + expect(options.rollbackOptimisticSend).toHaveBeenCalledWith(existingHistory, 'hello'); + expect(options.handleError).toHaveBeenCalledWith(new Error('network failed')); + }); + + it('uses distinct stream listener ids when sends start in the same millisecond', async () => { + vi.spyOn(Date, 'now').mockReturnValue(123); + const { controller, aiBridge } = createController(); + const firstInput = document.createElement('textarea'); + firstInput.value = 'first'; + const secondInput = document.createElement('textarea'); + secondInput.value = 'second'; + + await controller.sendChat(firstInput); + await controller.sendChat(secondInput); + + expect(aiBridge.onChunk).toHaveBeenCalledTimes(2); + expect(aiBridge.onChunk.mock.calls[0]?.[0]).not.toBe(aiBridge.onChunk.mock.calls[1]?.[0]); + }); + + it('counts image attachment tokens without double-counting text attachments', async () => { + const { controller, options } = createController(); + const processForSend = ( + options.fileHandler as unknown as { + processForSend: ReturnType; + } + ).processForSend; + processForSend.mockResolvedValueOnce({ + combinedText: 'hello extracted file content', + attachments: [ + { + name: 'doc.txt', + type: 'text/plain', + size: 4, + data_base64: '', + tokens: 50, + }, + { + name: 'photo.png', + type: 'image/png', + size: 4, + data_base64: 'base64', + tokens: 258, + }, + ], + }); + vi.mocked(options.estimateTokens).mockResolvedValueOnce(7); + const input = document.createElement('textarea'); + input.value = 'hello'; + + await controller.sendChat(input); + + expect(options.addContextTokens).toHaveBeenCalledWith(265); + }); + it('cleans active stream listeners when destroyed during a send', async () => { let resolveSend: (value: { ok: true; message: string }) => void = () => { throw new Error('sendMessage promise was not started'); @@ -167,8 +311,103 @@ describe('ChatSendController', () => { await sendPromise; expect(options.cancelTextGeneration).toHaveBeenCalledOnce(); + expect(options.cancelTextGeneration).toHaveBeenCalledWith('gpt'); + expect(streamingHandle.cancel).toHaveBeenCalledOnce(); + expect(options.handleResponse).not.toHaveBeenCalled(); + expect(options.handleError).not.toHaveBeenCalled(); + }); + + it('cleans the pending text bubble when a cancelled request rejects', async () => { + let rejectSend: (error: unknown) => void = () => { + throw new Error('sendMessage promise was not started'); + }; + const { controller, options, sendMessage, streamingHandle } = createController(); + sendMessage.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectSend = reject; + }), + ); + const input = document.createElement('textarea'); + input.value = 'hello'; + + const sendPromise = controller.sendChat(input); + for (let index = 0; index < 10 && sendMessage.mock.calls.length === 0; index += 1) { + await Promise.resolve(); + } + + await controller.cancelActiveSend(); + rejectSend(new Error('transport aborted')); + await sendPromise; + expect(streamingHandle.cancel).toHaveBeenCalledOnce(); expect(options.handleResponse).not.toHaveBeenCalled(); expect(options.handleError).not.toHaveBeenCalled(); }); + + it('stops the image engine after a successful image send', async () => { + const { controller, options, aiBridge } = createController(); + aiBridge.getState.mockReturnValue({ activeProviderId: 'sdcpp', isRunning: true }); + options.isImageProvider.mockReturnValue(true); + options.getSelectedModule.mockImplementation((category: 'ai_text' | 'ai_image') => + category === 'ai_image' ? { id: 'sdcpp', type: 'local' } : undefined, + ); + const input = document.createElement('textarea'); + input.value = 'draw image'; + + await controller.sendChat(input); + + expect(options.startImagePreviewPolling).toHaveBeenCalledOnce(); + expect(options.handleResponse).toHaveBeenCalledOnce(); + expect(aiBridge.stopEngineSlot).toHaveBeenCalledWith('image'); + }); + + it('stops the image engine after an image send throws', async () => { + const { controller, options, aiBridge, sendMessage } = createController(); + aiBridge.getState.mockReturnValue({ activeProviderId: 'sdcpp', isRunning: true }); + options.isImageProvider.mockReturnValue(true); + options.getSelectedModule.mockImplementation((category: 'ai_text' | 'ai_image') => + category === 'ai_image' ? { id: 'sdcpp', type: 'local' } : undefined, + ); + sendMessage.mockRejectedValueOnce(new Error('generation failed')); + const input = document.createElement('textarea'); + input.value = 'draw image'; + + await controller.sendChat(input); + + expect(options.handleError).toHaveBeenCalled(); + expect(aiBridge.stopEngineSlot).toHaveBeenCalledWith('image'); + }); + + it('prepares image prompts with the selected text provider before local image generation', async () => { + const { controller, options, aiBridge, sendMessage } = createController(); + aiBridge.getState + .mockReturnValueOnce({ activeProviderId: 'sdcpp', isRunning: true }) + .mockReturnValueOnce({ activeProviderId: 'sdcpp', isRunning: true }) + .mockReturnValueOnce({ activeProviderId: 'text-model', isRunning: true }) + .mockReturnValue({ activeProviderId: 'sdcpp', isRunning: true }); + vi.mocked(options.isImageProvider).mockImplementation( + (providerId: string | null) => providerId === 'sdcpp', + ); + options.getSelectedModule.mockImplementation((category: 'ai_text' | 'ai_image') => + category === 'ai_text' ? { id: 'text-model' } : { id: 'sdcpp' }, + ); + aiBridge.prepareImagePrompt.mockResolvedValueOnce({ + ok: true, + text: 'cinematic cat, rain, neon', + }); + sendMessage.mockResolvedValueOnce({ ok: true, message: 'done' }); + const input = document.createElement('textarea'); + input.value = 'сгенерируй кота под дождем'; + + await controller.sendChat(input); + + expect(aiBridge.startProvider).toHaveBeenNthCalledWith(1, 'text-model'); + expect(aiBridge.startProvider).toHaveBeenNthCalledWith(2, 'sdcpp'); + expect(aiBridge.prepareImagePrompt).toHaveBeenCalledOnce(); + expect(sendMessage).toHaveBeenCalledOnce(); + expect(sendMessage).toHaveBeenCalledWith('cinematic cat, rain, neon', [], [], { + originalPrompt: 'сгенерируй кота под дождем', + }); + }); }); diff --git a/src/features/chat/controllers/ChatSendController.ts b/src/features/chat/controllers/ChatSendController.ts index 9c47b838..9253a54d 100644 --- a/src/features/chat/controllers/ChatSendController.ts +++ b/src/features/chat/controllers/ChatSendController.ts @@ -31,10 +31,15 @@ type ImageGenerationHandle = { }; type ChatSendControllerOptions = { - aiBridge: AIBridge; + aiBridge: AIBridge & { + prepareImagePrompt?: ( + text: string, + ) => Promise<{ ok: boolean; text?: string; error?: string }>; + }; fileHandler: Pick; service: ChatService; getHistory: () => IChatMessage[]; + estimateTokens: (text: string) => Promise; pushUserMessage: (content: IChatMessage['content']) => void; createStreamingHandle: (typingId: string) => StreamingMessageHandle; createImageHandle: () => ImageGenerationHandle; @@ -48,8 +53,11 @@ type ChatSendControllerOptions = { clearInput: () => void; addContextTokens: (count: number) => void; appendUserMessage: (text: string, attachments: IChatAttachment[], tokens: number) => void; + rollbackOptimisticSend: (historySnapshot: IChatMessage[], inputText: string) => void; getSelectedModule: (category: 'ai_text' | 'ai_image') => Partial | undefined; getPreferredAiCategory: () => 'ai_text' | 'ai_image'; + isForceImageGeneration: () => boolean; + clearForceImageGeneration: () => void; handleResponse: ( response: Awaited>, streamingHandle: StreamingMessageHandle | null, @@ -58,7 +66,7 @@ type ChatSendControllerOptions = { cleanupStreamingState: (listenerId: string, typingId: string) => void; stopImagePreviewPolling: () => void; startImagePreviewPolling: (handle: ImageGenerationHandle) => void; - cancelTextGeneration: () => Promise; + cancelTextGeneration: (providerId: string | null) => Promise; isImageProvider: (providerId: string | null) => boolean; lockUi: (input: HTMLTextAreaElement | null) => { input: HTMLTextAreaElement | null; @@ -76,6 +84,19 @@ type ChatSendControllerOptions = { type UiLock = ReturnType; +const IMAGE_PROMPT_REWRITE_TEMPLATE = [ + 'You are preparing a prompt for Stable Diffusion.', + 'Task: translate the user request into English, preserve the exact subject and intent, and lightly enhance it with useful visual details.', + 'Rules:', + '- Remove command words like generate, draw, create, please, сгенерируй, нарисуй, сделай.', + '- Do not invent extra people, objects, actions, identities, or locations that the user did not ask for.', + '- You may add concise visual quality details: composition, lighting, camera, mood, texture, style, and render quality.', + '- Keep it as one prompt, 12-45 words.', + '- Return only the final prompt text. No quotes, no markdown, no explanation.', + '', + 'User request: {{prompt}}', +].join('\n'); + export class ChatSendController { private readonly _autoStartHelper: ChatAutoStartHelper; private readonly _sendFlow: ChatSendFlow; @@ -85,6 +106,8 @@ export class ChatSendController { >(); private _isDestroyed = false; private _cancelRequested = false; + private _activeProviderId: string | null = null; + private _sendSequence = 0; constructor(private readonly _options: ChatSendControllerOptions) { this._autoStartHelper = new ChatAutoStartHelper({ @@ -96,6 +119,7 @@ export class ChatSendController { this._sendFlow = new ChatSendFlow({ fileHandler: _options.fileHandler, getHistory: _options.getHistory, + estimateTokens: _options.estimateTokens, }); } @@ -117,7 +141,11 @@ export class ChatSendController { } this._cancelRequested = true; - await this._options.cancelTextGeneration(); + try { + await this._options.cancelTextGeneration(this._activeProviderId); + } catch (error: unknown) { + this._options.tracer.info(`Chat cancellation request failed: ${String(error)}`); + } } public validateInput(text: string): boolean { @@ -132,28 +160,68 @@ export class ChatSendController { if (this._isDestroyed || this._options.isSending()) return false; const text = input?.value.trim() ?? ''; + if (!this.validateInput(text)) { + return false; + } const uiElements = this._options.lockUi(input); - const typingId = `typing-${String(Date.now())}`; - const listenerId = `chat-stream-${String(Date.now())}`; - const activeProviderId = this._options.aiBridge.getState().activeProviderId; - const isImageProvider = this._options.isImageProvider(activeProviderId); + const sendId = this._createSendId(); + const typingId = `typing-${sendId}`; + const listenerId = `chat-stream-${sendId}`; + let activeProviderId = this._options.aiBridge.getState().activeProviderId; + let isImageProvider = + this._options.isForceImageGeneration() || + this._options.isImageProvider(activeProviderId); this._cancelRequested = false; + this._activeProviderId = activeProviderId; this._options.setSending(true); this._activeStreamingStates.set(listenerId, { listenerId, typingId }); let streamingHandle: StreamingMessageHandle | null = null; let imageHandle: ImageGenerationHandle | null = null; + let shouldStopImageEngine = false; + let optimisticUserAppended = false; + const historySnapshot = this._cloneHistory(this._options.getHistory()); try { const sendPlan = await this._sendFlow.prepare(text); if (this._wasDestroyed()) return false; + let imagePrompt = sendPlan.combinedText; + if (isImageProvider) { + const preparedPrompt = await this._prepareImagePromptWithTextProvider( + sendPlan.combinedText, + ); + if (this._wasDestroyed()) return false; + imagePrompt = preparedPrompt; + + const imageProviderId = this._getSelectedModuleId('ai_image'); + if ( + imageProviderId !== null && + this._options.aiBridge.getState().activeProviderId !== imageProviderId + ) { + const started = await this._options.aiBridge.startProvider(imageProviderId); + if (!started) { + throw new Error( + this._options.translate( + 'ui.ai.provider_activation_failed', + 'Provider activation failed', + ), + ); + } + } + + activeProviderId = this._options.aiBridge.getState().activeProviderId; + this._activeProviderId = activeProviderId; + isImageProvider = this._options.isImageProvider(activeProviderId); + } + this._options.clearInput(); this._options.addContextTokens(sendPlan.tokenCount); this._options.appendUserMessage(text, sendPlan.attachments, sendPlan.tokenCount); this._options.pushUserMessage(sendPlan.userContent); + optimisticUserAppended = true; const ensureStreamingHandle = (): StreamingMessageHandle => { streamingHandle ??= this._options.createStreamingHandle(typingId); @@ -161,17 +229,22 @@ export class ChatSendController { }; if (isImageProvider) { + shouldStopImageEngine = + activeProviderId !== null && + this._isSelectedLocalImageProvider(activeProviderId); imageHandle = this._options.createImageHandle(); this._options.startImagePreviewPolling(imageHandle); } else { + let hasRenderedTextChunk = false; const handle = ensureStreamingHandle(); handle.setStatus(this._options.translate('ui.chat.thinking', 'Thinking...')); this._options.aiBridge.onChunk(listenerId, (chunk) => { - if (String(chunk).trim() === '') { + if (!hasRenderedTextChunk && String(chunk).trim() === '') { return; } + hasRenderedTextChunk = true; ensureStreamingHandle().update(chunk); }); } @@ -179,13 +252,14 @@ export class ChatSendController { this._options.registerReplaceChunk( listenerId, () => imageHandle, - () => streamingHandle ?? ensureStreamingHandle(), + () => streamingHandle, ); const response = await this._options.service.sendMessage( - sendPlan.combinedText, + imagePrompt, sendPlan.historyHead, sendPlan.attachments, + isImageProvider ? { originalPrompt: sendPlan.combinedText } : {}, ); this._cleanupStreamingState(listenerId, typingId); @@ -197,6 +271,10 @@ export class ChatSendController { } await this._options.handleResponse(response, streamingHandle, imageHandle); + if (!response.ok) { + this._rollbackOptimisticSend(historySnapshot, text); + return false; + } return true; } catch (error: unknown) { this._cleanupStreamingState(listenerId, typingId); @@ -206,6 +284,9 @@ export class ChatSendController { return false; } if (!this._wasDestroyed()) { + if (optimisticUserAppended) { + this._rollbackOptimisticSend(historySnapshot, text); + } this._options.handleError(error); } else { this._cancelStreamingHandle(streamingHandle); @@ -216,13 +297,79 @@ export class ChatSendController { if (imageHandle !== null) { this._options.stopImagePreviewPolling(); } + if (shouldStopImageEngine) { + await this._stopImageEngineAfterCompletion(); + } if (!this._wasDestroyed()) { this._options.unlockUi(uiElements); } this._options.setSending(false); + this._activeProviderId = null; + this._options.clearForceImageGeneration(); } } + private async _prepareImagePromptWithTextProvider(prompt: string): Promise { + const textProviderId = this._getSelectedModuleId('ai_text'); + const imageProviderId = this._getSelectedModuleId('ai_image'); + if ( + textProviderId === null || + imageProviderId === null || + textProviderId === imageProviderId || + prompt.trim() === '' + ) { + return prompt; + } + + const currentProviderId = this._options.aiBridge.getState().activeProviderId; + if (currentProviderId !== textProviderId) { + const started = await this._options.aiBridge.startProvider(textProviderId); + if (!started) { + return prompt; + } + } + + const response = + typeof this._options.aiBridge.prepareImagePrompt === 'function' + ? await this._options.aiBridge.prepareImagePrompt( + this._buildImagePromptRewriteRequest(prompt), + ) + : { ok: false }; + if (!response.ok) { + return prompt; + } + + const prepared = this._extractPreparedPrompt(response); + return prepared === '' ? prompt : this._stripPromptEnvelope(prepared); + } + + private _extractPreparedPrompt(response: { ok: boolean; text?: string }): string { + return (response.text ?? '').trim(); + } + + private _buildImagePromptRewriteRequest(prompt: string): string { + return IMAGE_PROMPT_REWRITE_TEMPLATE.replace('{{prompt}}', prompt); + } + + private _stripPromptEnvelope(prompt: string): string { + return prompt + .replace(/^```(?:text|markdown)?\s*/iu, '') + .replace(/```\s*$/u, '') + .replace(/^["'`]+|["'`]+$/gu, '') + .trim(); + } + + private _getSelectedModuleId(category: 'ai_text' | 'ai_image'): string | null { + const module = this._options.getSelectedModule(category); + const id = module?.id; + return typeof id === 'string' && id.trim() !== '' ? id : null; + } + + private _isSelectedLocalImageProvider(providerId: string): boolean { + const module = this._options.getSelectedModule('ai_image'); + return module?.id === providerId && module.type !== 'api'; + } + private _wasDestroyed(): boolean { return this._isDestroyed; } @@ -243,7 +390,48 @@ export class ChatSendController { handle?.cancel(); } + private _createSendId(): string { + this._sendSequence += 1; + return `${Date.now().toString(36)}-${this._sendSequence.toString(36)}`; + } + + private _rollbackOptimisticSend(historySnapshot: IChatMessage[], inputText: string): void { + this._options.rollbackOptimisticSend(this._cloneHistory(historySnapshot), inputText); + } + + private _cloneHistory(history: IChatMessage[]): IChatMessage[] { + return history.map((message) => ({ + ...message, + content: this._cloneContent(message.content), + })); + } + + private _cloneContent(content: IChatMessage['content']): IChatMessage['content'] { + if (typeof content === 'string') { + return content; + } + + return content.map((part) => { + if (part.type === 'image_url') { + return { + ...part, + image_url: { ...part.image_url }, + }; + } + + return { ...part }; + }); + } + public async tryAutoStartAi(prompt?: string): Promise { return await this._autoStartHelper.startSelectedModule(prompt); } + + private async _stopImageEngineAfterCompletion(): Promise { + try { + await this._options.aiBridge.stopEngineSlot('image'); + } catch { + /* stopping the local image engine must not replace the generation result */ + } + } } diff --git a/src/features/chat/controllers/FilePickerController.ts b/src/features/chat/controllers/FilePickerController.ts index a7b4d66d..f7c22ba0 100644 --- a/src/features/chat/controllers/FilePickerController.ts +++ b/src/features/chat/controllers/FilePickerController.ts @@ -1,6 +1,6 @@ /** * @module chat/controllers/FilePickerController - * @description Handles file picking (native + web), token counting, and file-to-File conversion. + * @description Handles file picking, token counting, and file-to-File conversion. * Extracted from ChatController for SRP compliance. */ @@ -9,7 +9,6 @@ import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { I18nService } from '@/infrastructure/i18n/I18nService'; import type { ChatUI } from '../ui/ChatUI'; -// Tauri imports — only used at runtime if in Tauri context import { desktopDir, dirname } from '@tauri-apps/api/path'; import { open } from '@tauri-apps/plugin-dialog'; import { readFile } from '@tauri-apps/plugin-fs'; @@ -34,12 +33,12 @@ export class FilePickerController { ) {} /** - * Entry point for picking files (Native or Web fallback). + * Entry point for picking files. */ public async pick(): Promise { if (this._isNativeRuntime()) { - const success = await this._pickNative(); - if (success) return; + await this._pickNative(); + return; } const input = document.getElementById('chat-file-input') as HTMLInputElement | null; @@ -100,7 +99,7 @@ export class FilePickerController { // --- Private helpers --- - private async _pickNative(): Promise { + private async _pickNative(): Promise { try { const defaultPath = await this._resolveInitialDirectory(); const dialogOptions: { @@ -135,10 +134,8 @@ export class FilePickerController { void this.updateTokenCount(); } } - return true; } catch (err) { this._tracer.error('[FilePickerController] Native file picker failed:', err); - return false; } } diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index 184d4bb5..e635af48 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -7,5 +7,5 @@ export type * from './types/chatTypes'; export * from './ui/ChatUI'; export * from './services/ChatService'; // Services -export { ChatController } from './chat'; +export { ChatController } from './ChatController'; export { ChatFileHandler } from './services/ChatFileHandler'; diff --git a/src/features/chat/services/ChatActivationCoordinator.ts b/src/features/chat/services/ChatActivationCoordinator.ts index 429351aa..64beb5fd 100644 --- a/src/features/chat/services/ChatActivationCoordinator.ts +++ b/src/features/chat/services/ChatActivationCoordinator.ts @@ -17,8 +17,11 @@ export class ChatActivationCoordinator { this._deps.uiStateHelper.clearInactiveAiErrorTimeout(); } - public async ensureActive(input: HTMLTextAreaElement | null): Promise { - const prompt = input?.value.trim() ?? ''; + public async ensureActive( + input: HTMLTextAreaElement | null, + promptOverride?: string, + ): Promise { + const prompt = promptOverride?.trim() ?? input?.value.trim() ?? ''; const selectedProviderId = this._deps.getSelectedProviderId(prompt); const { activeProviderId } = this._deps.aiBridge.getState(); diff --git a/src/features/chat/services/ChatControllerFactory.ts b/src/features/chat/services/ChatControllerFactory.ts index 1e660bb9..562a9153 100644 --- a/src/features/chat/services/ChatControllerFactory.ts +++ b/src/features/chat/services/ChatControllerFactory.ts @@ -8,7 +8,7 @@ import type { AIBridge } from '@/features/ai/services/AIBridge'; import type { I18nService } from '@/infrastructure/i18n/I18nService'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { EventBus } from '@/shared/services/EventBus'; -import type { PendingChatRevealStore } from '../chat'; +import type { PendingChatRevealStore } from '../ChatController'; import type { ChatContent } from '@/features/ai/types/aiTypes'; import type { IApp } from '@/shared/types/coreTypes'; import type { IChatAttachment, IChatMessage, IChatResponse } from '../types/chatTypes'; @@ -32,7 +32,6 @@ type ChatUiFactoryDeps = ChatFactoryDeps & { isTauriRuntime: () => boolean; openExternalUrl: (url: string) => Promise; copyText: (text: string) => Promise; - readClipboardText: () => Promise; }; type ChatLifecycleFactoryDeps = { @@ -84,6 +83,7 @@ type ChatGenerationFactoryDeps = { handleError: (errorMsg: unknown, model?: string) => void; isDestroyed: () => boolean; isSending: () => boolean; + isImageProvider: (providerId: string | null) => boolean; tracer: ChatTracer; }; @@ -92,6 +92,7 @@ type ChatSendFactoryDeps = { fileHandler: ChatFileHandler; service: ChatService; getHistory: () => IChatMessage[]; + estimateTokens: (text: string) => Promise; pushUserMessage: (content: IChatMessage['content']) => void; createStreamingHandle: (typingId: string) => { setStatus: (text: string) => void; @@ -122,8 +123,11 @@ type ChatSendFactoryDeps = { clearInput: () => void; addContextTokens: (count: number) => void; appendUserMessage: (text: string, attachments: IChatAttachment[], tokens: number) => void; + rollbackOptimisticSend: (historySnapshot: IChatMessage[], inputText: string) => void; getSelectedModule: (category: 'ai_text' | 'ai_image') => Partial | undefined; getPreferredAiCategory: () => 'ai_text' | 'ai_image'; + isForceImageGeneration: () => boolean; + clearForceImageGeneration: () => void; handleResponse: ( response: IChatResponse, streamingHandle?: ReturnType | null, @@ -134,7 +138,7 @@ type ChatSendFactoryDeps = { startImagePreviewPolling: ( handle: ReturnType, ) => void; - cancelTextGeneration: () => Promise; + cancelTextGeneration: (providerId: string | null) => Promise; isImageProvider: (providerId: string | null) => boolean; lockUi: (input: HTMLTextAreaElement | null) => { input: HTMLTextAreaElement | null; @@ -166,7 +170,6 @@ export class ChatControllerFactory { isTauriRuntime: () => deps.isTauriRuntime(), openExternalUrl: async (url) => await deps.openExternalUrl(url), copyText: async (text) => await deps.copyText(text), - readClipboardText: async () => await deps.readClipboardText(), tracer: deps.tracer, }); } @@ -253,6 +256,7 @@ export class ChatControllerFactory { }, isDestroyed: () => deps.isDestroyed(), isSending: () => deps.isSending(), + isImageProvider: (providerId) => deps.isImageProvider(providerId), tracer: deps.tracer, }); } @@ -263,6 +267,7 @@ export class ChatControllerFactory { fileHandler: deps.fileHandler, service: deps.service, getHistory: () => deps.getHistory(), + estimateTokens: async (text) => await deps.estimateTokens(text), pushUserMessage: (content) => { deps.pushUserMessage(content); }, @@ -284,8 +289,15 @@ export class ChatControllerFactory { appendUserMessage: (text, attachments, tokens) => { deps.appendUserMessage(text, attachments, tokens); }, + rollbackOptimisticSend: (historySnapshot, inputText) => { + deps.rollbackOptimisticSend(historySnapshot, inputText); + }, getSelectedModule: (category) => deps.getSelectedModule(category), getPreferredAiCategory: () => deps.getPreferredAiCategory(), + isForceImageGeneration: () => deps.isForceImageGeneration(), + clearForceImageGeneration: () => { + deps.clearForceImageGeneration(); + }, handleResponse: async (response, streamingHandle, imageHandle) => await deps.handleResponse(response, streamingHandle, imageHandle), cleanupStreamingState: (listenerId, typingId) => { @@ -297,7 +309,7 @@ export class ChatControllerFactory { startImagePreviewPolling: (handle) => { deps.startImagePreviewPolling(handle); }, - cancelTextGeneration: async () => await deps.cancelTextGeneration(), + cancelTextGeneration: async (providerId) => await deps.cancelTextGeneration(providerId), isImageProvider: (providerId) => deps.isImageProvider(providerId), lockUi: (input) => deps.lockUi(input), unlockUi: (els) => { diff --git a/src/features/chat/services/ChatControllerState.ts b/src/features/chat/services/ChatControllerState.ts index f8dd8143..2f313dda 100644 --- a/src/features/chat/services/ChatControllerState.ts +++ b/src/features/chat/services/ChatControllerState.ts @@ -7,6 +7,7 @@ export class ChatControllerState { private _eventsBound = false; private _isInitialized = false; private _isDestroyed = false; + private _currentGenerationProviderId: string | null = null; public get history(): IChatMessage[] { return this._history; @@ -63,4 +64,12 @@ export class ChatControllerState { public set isDestroyed(value: boolean) { this._isDestroyed = value; } + + public get currentGenerationProviderId(): string | null { + return this._currentGenerationProviderId; + } + + public set currentGenerationProviderId(value: string | null) { + this._currentGenerationProviderId = value; + } } diff --git a/src/features/chat/services/ChatFileHandler.test.ts b/src/features/chat/services/ChatFileHandler.test.ts index a11425d7..99987e9f 100644 --- a/src/features/chat/services/ChatFileHandler.test.ts +++ b/src/features/chat/services/ChatFileHandler.test.ts @@ -179,9 +179,9 @@ describe('ChatFileHandler', () => { }); }); - // ---------------------------------------------------------- processForSend (web fallback) - describe('processForSend (web)', () => { - it('should process text files via web fallback', async () => { + // ---------------------------------------------------------- processForSend (File API) + describe('processForSend (File API)', () => { + it('should process text files via File API', async () => { (isTextFile as unknown as Mock).mockReturnValue(true); (readFileAsText as unknown as Mock).mockResolvedValue('hello world'); @@ -196,7 +196,7 @@ describe('ChatFileHandler', () => { expect(handler.getCount()).toBe(0); }); - it('should process image files via web fallback', async () => { + it('should process image files via File API', async () => { (isTextFile as unknown as Mock).mockReturnValue(false); (readFileAsBase64 as unknown as Mock).mockResolvedValue('imgbase64=='); @@ -370,8 +370,8 @@ describe('ChatFileHandler', () => { expect(result.attachments).toHaveLength(1); }); - it('should process text file with empty type via web fallback (L232)', async () => { - // Force web fallback + it('should process text file with empty type via File API (L232)', async () => { + // Force the File API path. mockBridge.isTauri.mockReturnValue(false); // Override isTextFile mock — real impl checks extension, not MIME type (isTextFile as Mock).mockReturnValueOnce(true); @@ -411,25 +411,6 @@ describe('ChatFileHandler', () => { }); }); - // ---------------------------------------------------------- calculateCombinedContext - describe('calculateCombinedContext', () => { - it('should return file list as attachments', async () => { - handler.addFiles([createFile('a.txt', 'aaa'), createImageFile('b.png')]); - const result = await handler.calculateCombinedContext('Base'); - - expect(result.attachments).toHaveLength(2); - expect(result.attachments[0]?.name).toBe('a.txt'); - expect(result.attachments[1]?.name).toBe('b.png'); - expect(result.combinedText).toContain('[Files attached]'); - }); - - it('should work with no files', async () => { - const result = await handler.calculateCombinedContext('Base'); - expect(result.attachments).toHaveLength(0); - expect(result.combinedText).toContain('Base'); - }); - }); - // ---------------------------------------------------------- getFileTokenEstimate describe('getFileTokenEstimate', () => { it('should return 258 for image files', async () => { diff --git a/src/features/chat/services/ChatFileHandler.ts b/src/features/chat/services/ChatFileHandler.ts index 7279422d..fb08a7de 100644 --- a/src/features/chat/services/ChatFileHandler.ts +++ b/src/features/chat/services/ChatFileHandler.ts @@ -282,8 +282,6 @@ export class ChatFileHandler { return { error: `\n[Skipped: ${file.name} - Not supported in Web Mode]` }; } - // Removed private ZIP methods (_processZipFile, _extractZipEntries, _validateZipEntry, etc.) - public async getTotalTokenEstimate(baseText: string): Promise { let total = await this._estimateTokens(baseText); for (const file of this._files) { @@ -292,21 +290,6 @@ export class ChatFileHandler { return total; } - // calculateCombinedContext also needs update or removal of preview logic - public calculateCombinedContext( - baseText: string, - ): Promise<{ combinedText: string; attachments: IChatAttachment[] }> { - // Without processing, we can't show "Smart Unpacked". - // Just show list. - const attachments: IChatAttachment[] = this._files.map((f) => ({ - name: f.name, - type: f.type, - size: f.size, - data_base64: '', - })); - return Promise.resolve({ combinedText: `${baseText}\n[Files attached]`, attachments }); - } - public async getFileTokenEstimate(file: File): Promise { if (this._isImageFile(file)) return 258; if (this._bridge?.isTauri() === true) { diff --git a/src/features/chat/services/ChatSendFlow.ts b/src/features/chat/services/ChatSendFlow.ts index 8a805b08..1c615211 100644 --- a/src/features/chat/services/ChatSendFlow.ts +++ b/src/features/chat/services/ChatSendFlow.ts @@ -3,14 +3,10 @@ import { createMultimodalContent } from '@/features/ai/utils/chatRequestUtils'; import type { ChatFileHandler } from './ChatFileHandler'; import type { IChatAttachment, IChatMessage } from '../types/chatTypes'; -const estimateTextTokens = (text: string): number => { - const normalized = text.trim(); - return normalized === '' ? 0 : Math.max(1, Math.ceil(normalized.length / 4)); -}; - type ChatSendFlowDeps = { fileHandler: Pick; getHistory: () => IChatMessage[]; + estimateTokens: (text: string) => Promise; }; export type PreparedChatSend = { @@ -27,18 +23,35 @@ export class ChatSendFlow { public async prepare(text: string): Promise { const { attachments, combinedText } = await this._deps.fileHandler.processForSend(text); const historyHead = this._deps.getHistory().slice(-40); - const attachmentTokens = attachments.reduce((total, attachment) => { + const imageTokens = attachments.reduce((total, attachment) => { + if (!attachment.type.startsWith('image/')) { + return total; + } const tokens = attachment.tokens; return total + (typeof tokens === 'number' && Number.isFinite(tokens) ? tokens : 0); }, 0); - const textTokens = estimateTextTokens(text); + const textTokens = await this._resolveTextTokens(combinedText); return { - tokenCount: textTokens + attachmentTokens, + tokenCount: textTokens + imageTokens, attachments, combinedText, historyHead, userContent: createMultimodalContent(combinedText, attachments), }; } + + private async _resolveTextTokens(text: string): Promise { + const trimmed = text.trim(); + if (trimmed === '') { + return 0; + } + + try { + const tokens = await this._deps.estimateTokens(trimmed); + return Number.isFinite(tokens) ? Math.max(0, Math.trunc(tokens)) : 0; + } catch { + return 0; + } + } } diff --git a/src/features/chat/services/ChatService.test.ts b/src/features/chat/services/ChatService.test.ts index 4f993e22..74cb3567 100644 --- a/src/features/chat/services/ChatService.test.ts +++ b/src/features/chat/services/ChatService.test.ts @@ -89,7 +89,7 @@ describe('ChatService', () => { const result = await chatService.sendMessage('Hi', [], []); expect(result.ok).toBe(true); - expect(result.message).toBe('Hello there'); + expect(result.reply?.text).toBe('Hello there'); }); it('should return error if AIBridge throws', async () => { @@ -116,7 +116,7 @@ describe('ChatService', () => { const result = await chatService.sendMessage('Hello', [], []); expect(result.ok).toBe(true); - expect(result.message).toBe(''); + expect(result.reply?.text).toBe(''); }); it('should handle non-Error throw in sendMessage (L52)', async () => { diff --git a/src/features/chat/services/ChatService.ts b/src/features/chat/services/ChatService.ts index 84633396..204e1922 100644 --- a/src/features/chat/services/ChatService.ts +++ b/src/features/chat/services/ChatService.ts @@ -4,6 +4,9 @@ import type { IAIBridge } from '@/features/ai/types/IAIBridge'; import type { I18nService } from '@/infrastructure/i18n/I18nService'; type ChatServiceLogger = Pick; +export type ChatSendOptions = { + originalPrompt?: string; +}; function parseGeneratedImages( images: string[] | undefined, @@ -50,6 +53,7 @@ export class ChatService { text: string, history: IChatMessage[], attachments: IChatAttachment[], + options: ChatSendOptions = {}, ): Promise { // Validation if ((text === '' || text.trim() === '') && attachments.length === 0) { @@ -69,7 +73,13 @@ export class ChatService { try { // Send through AIBridge - const response = await this._aiBridge.sendMessage(text, 'chat', attachments, history); + const response = await this._aiBridge.sendMessage( + text, + 'chat', + attachments, + history, + options, + ); if (!response.ok) { const result: IChatResponse = { @@ -82,17 +92,17 @@ export class ChatService { return result; } + const reply: NonNullable = { + text: response.text ?? '', + type: 'markdown', + }; const result: IChatResponse = { ok: true, - message: response.text ?? '', + reply, }; const generatedImages = parseGeneratedImages(response.images); if (generatedImages !== undefined) { - result.reply = { - text: response.text ?? '', - type: 'markdown', - images: generatedImages, - }; + reply.images = generatedImages; } if (response.thought_signature !== undefined) { result.thought_signature = response.thought_signature; diff --git a/src/features/chat/services/ChatUiStateHelper.ts b/src/features/chat/services/ChatUiStateHelper.ts index b2b4c981..f805cfd6 100644 --- a/src/features/chat/services/ChatUiStateHelper.ts +++ b/src/features/chat/services/ChatUiStateHelper.ts @@ -4,7 +4,7 @@ import type { I18nService } from '@/infrastructure/i18n/I18nService'; type ChatUiDeps = { aiBridge: AIBridge; i18n: I18nService; - appendAssistantError: (message: string) => void; + showErrorToast: (message: string) => void; getChatInput: () => HTMLTextAreaElement | null; maxInputHeightPx: number; baseInputHeightPx: number; @@ -38,7 +38,7 @@ export class ChatUiStateHelper { if (this._deps.aiBridge.isActive()) { return; } - this._deps.appendAssistantError( + this._deps.showErrorToast( this._deps.i18n.t( 'ui.ai.no_provider', 'No AI module running. Please select and launch a module first.', diff --git a/src/features/chat/services/VoiceInputService.test.ts b/src/features/chat/services/VoiceInputService.test.ts index 4606d00d..53de2b5a 100644 --- a/src/features/chat/services/VoiceInputService.test.ts +++ b/src/features/chat/services/VoiceInputService.test.ts @@ -50,6 +50,7 @@ describe('VoiceInputService', () => { listen: vi.fn(), isTauri: vi.fn(() => true), }; + Object.assign(bridge, { hasCapability: vi.fn(() => true) }); }); const createService = (getLang: () => string = () => 'en') => diff --git a/src/features/chat/services/VoiceInputService.ts b/src/features/chat/services/VoiceInputService.ts index 4ac1b0ae..618a2077 100644 --- a/src/features/chat/services/VoiceInputService.ts +++ b/src/features/chat/services/VoiceInputService.ts @@ -34,6 +34,7 @@ export class VoiceInputService { private _sessionId = 0; private _onStateChange: VoiceStateCallback | null = null; private _onError: VoiceErrorCallback | null = null; + private _nativeRecognitionActive = false; public constructor( private readonly _tracer: VoiceInputLogger, @@ -46,7 +47,18 @@ export class VoiceInputService { * Native voice input is currently available only in the Windows Tauri host. */ public isSupported(): boolean { - return this._hostBridge.isTauri() && document.body.dataset['platform'] === 'windows'; + const capabilityBridge = this._hostBridge as IBridge & { + hasCapability?: (capability: string) => boolean; + }; + if (!this._hostBridge.isTauri()) { + return false; + } + + if (document.body.dataset['platform'] !== 'windows') { + return false; + } + + return capabilityBridge.hasCapability?.('speechRecognition') ?? true; } /** @@ -60,7 +72,7 @@ export class VoiceInputService { * Starts one native voice recognition request. */ public start(onResult: VoiceResultCallback, callbacks: VoiceSessionCallbacks = {}): boolean { - if (this.isActive()) { + if (this.isActive() || this._nativeRecognitionActive) { this.stop(); return false; } @@ -72,6 +84,7 @@ export class VoiceInputService { const sessionId = ++this._sessionId; this._onStateChange = callbacks.onStateChange ?? null; this._onError = callbacks.onError ?? null; + this._nativeRecognitionActive = true; this._setState('starting'); this._setState('listening'); @@ -83,7 +96,7 @@ export class VoiceInputService { * Stops the current frontend session and ignores the pending native result. */ public stop(): void { - if (!this.isActive()) { + if (!this.isActive() && !this._nativeRecognitionActive) { return; } @@ -93,7 +106,10 @@ export class VoiceInputService { ); }); this._sessionId += 1; - this._setState('stopping'); + this._nativeRecognitionActive = false; + if (this.isActive()) { + this._setState('stopping'); + } this._finishSession('user'); } @@ -114,7 +130,13 @@ export class VoiceInputService { const text = response.text.trim(); if (text.length > 0) { - onResult(text); + try { + onResult(text); + } catch (err) { + this._tracer.error( + `[VoiceInputService] onResult handler threw: ${String(err)}`, + ); + } } this._finishSession('ended'); } catch (error) { @@ -126,6 +148,10 @@ export class VoiceInputService { this._tracer.error(`[VoiceInputService] Native recognition error: ${payload.message}`); this._onError?.(payload); this._finishSession(payload.code === 'startup_failed' ? 'startup_failed' : 'error'); + } finally { + if (this._sessionId === sessionId) { + this._nativeRecognitionActive = false; + } } } diff --git a/src/features/chat/types/chatTypes.ts b/src/features/chat/types/chatTypes.ts index e0c6012a..0e14c085 100644 --- a/src/features/chat/types/chatTypes.ts +++ b/src/features/chat/types/chatTypes.ts @@ -53,8 +53,6 @@ export interface IChatResponse { /** Optional generated images */ images?: { mime: string; data_base64: string }[]; }; - /** Legacy or fallback message field */ - message?: string; /** Error message if ok is false */ error?: string; /** Model identifier used for response */ diff --git a/src/features/chat/ui/ChatAttachmentRenderer.ts b/src/features/chat/ui/ChatAttachmentRenderer.ts index df06909d..b5dc71d9 100644 --- a/src/features/chat/ui/ChatAttachmentRenderer.ts +++ b/src/features/chat/ui/ChatAttachmentRenderer.ts @@ -3,13 +3,13 @@ import DOMPurify from 'dompurify'; import type { ChatFileHandler } from '../services/ChatFileHandler'; import type { IChatAttachment } from '../types/chatTypes'; import { getFileIcon } from '../utils/chatUtils'; -import type { TTranslateFunction } from '@/shared/types/global_bridge_types'; +import type { ChatTranslateFunction } from './ChatUiTypes'; type ChatAttachmentRendererDeps = { fileHandler: Pick; isDestroyed: () => boolean; getRenderVersion: () => number; - translate: TTranslateFunction; + translate: ChatTranslateFunction; }; export class ChatAttachmentRenderer { @@ -41,7 +41,9 @@ export class ChatAttachmentRenderer { if (hiddenCount > 0) { const moreCard = document.createElement('div'); moreCard.className = 'chat-media-card more-card'; - moreCard.innerHTML = `+${String(hiddenCount)}`; + const label = document.createElement('span'); + label.textContent = `+${String(hiddenCount)}`; + moreCard.appendChild(label); attachContainer.appendChild(moreCard); } @@ -77,7 +79,9 @@ export class ChatAttachmentRenderer { if (hiddenCount > 0) { const moreCard = document.createElement('div'); moreCard.className = 'chat-media-card more-card'; - moreCard.innerHTML = `+${String(hiddenCount)}`; + const label = document.createElement('span'); + label.textContent = `+${String(hiddenCount)}`; + moreCard.appendChild(label); container.appendChild(moreCard); } } @@ -115,10 +119,10 @@ export class ChatAttachmentRenderer { card.appendChild(badge); } } else { - card.innerHTML = this.createFilePillHtml(file.name, fileTokens, name); + card.appendChild(this.createFilePill(file.name, fileTokens, name)); } } else { - card.innerHTML = this.createFilePillHtml(file.name, fileTokens, name); + card.appendChild(this.createFilePill(file.name, fileTokens, name)); } container.appendChild(card); @@ -174,7 +178,7 @@ export class ChatAttachmentRenderer { card.appendChild(badge); } } else { - card.innerHTML = this.createFilePillHtml(file.name, fileTokens, name); + card.appendChild(this.createFilePill(file.name, fileTokens, name)); } const btn = document.createElement('button'); @@ -231,7 +235,12 @@ export class ChatAttachmentRenderer { return `${name.substring(0, 20)}..`; } - private createFilePillHtml(originalName: string, tokens: number, displayName: string): string { + private createFilePill( + originalName: string, + tokens: number, + displayName: string, + ): DocumentFragment { + const fragment = document.createDocumentFragment(); const iconSvg = (() => { try { return getFileIcon(originalName); @@ -240,19 +249,27 @@ export class ChatAttachmentRenderer { } })(); + const icon = document.createElement('div'); + icon.className = 'media-icon'; + icon.innerHTML = DOMPurify.sanitize(iconSvg); + + const info = document.createElement('div'); + info.className = 'media-info'; + + const name = document.createElement('div'); + name.className = 'media-name'; + name.textContent = displayName; + info.appendChild(name); + const tokensLabel = this._deps.translate('ui.launcher.web.tokens', 'tokens'); - const safeTokensLabel = DOMPurify.sanitize(tokensLabel); - const tokensHtml = - tokens > 0 - ? `
${String(tokens)} ${safeTokensLabel}
` - : ''; - - return ` -
${DOMPurify.sanitize(iconSvg)}
-
-
${DOMPurify.sanitize(displayName)}
- ${tokensHtml} -
- `; + if (tokens > 0) { + const tokenCount = document.createElement('div'); + tokenCount.className = 'media-tokens'; + tokenCount.textContent = `${String(tokens)} ${tokensLabel}`; + info.appendChild(tokenCount); + } + + fragment.append(icon, info); + return fragment; } } diff --git a/src/features/chat/ui/ChatImageController.ts b/src/features/chat/ui/ChatImageController.ts index 45ef327c..0011e98a 100644 --- a/src/features/chat/ui/ChatImageController.ts +++ b/src/features/chat/ui/ChatImageController.ts @@ -1,8 +1,9 @@ import DOMPurify from 'dompurify'; -import { invoke } from '@tauri-apps/api/core'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; -import type { TTranslateFunction } from '@/shared/types/global_bridge_types'; +import { invokeSafe } from '@/shared/api/invoke'; +import { commands, type SavedChatImage as SavedChatImageResult } from '@/shared/types/bindings'; +import type { ChatTranslateFunction } from './ChatUiTypes'; type ChatImageLogger = Pick; @@ -25,7 +26,7 @@ type ChatImageControllerDeps = { type?: 'success' | 'error' | 'warning' | 'info', duration?: number, ) => void; - translate: TTranslateFunction; + translate: ChatTranslateFunction; tracer: ChatImageLogger; }; @@ -57,11 +58,27 @@ export class ChatImageController { private readonly _boundImageViewerKeydown: (event: KeyboardEvent) => void; private _imageViewerOverlay: HTMLElement | null = null; private _imageViewerImage: HTMLImageElement | null = null; + private _imageViewerPrevButton: HTMLButtonElement | null = null; + private _imageViewerNextButton: HTMLButtonElement | null = null; + private _imageViewerCounter: HTMLElement | null = null; + private _imageViewerSources: string[] = []; + private _imageViewerIndex = 0; public constructor(private readonly _deps: ChatImageControllerDeps) { this._boundImageViewerKeydown = (event: KeyboardEvent) => { + if (event.ctrlKey && ['+', '-', '=', '0'].includes(event.key)) { + return; + } if (event.key === 'Escape') { this.closeImageViewer(); + return; + } + if (event.key === 'ArrowLeft') { + this._showAdjacentImage(-1); + return; + } + if (event.key === 'ArrowRight') { + this._showAdjacentImage(1); } }; } @@ -71,13 +88,18 @@ export class ChatImageController { this._imageViewerOverlay?.remove(); this._imageViewerOverlay = null; this._imageViewerImage = null; + this._imageViewerPrevButton = null; + this._imageViewerNextButton = null; + this._imageViewerCounter = null; + this._imageViewerSources = []; + this._imageViewerIndex = 0; } public handleImageClick(event: MouseEvent): boolean { const target = event.target; if (!(target instanceof HTMLElement)) return false; - let image = target.closest('.chat-img, .chat-attachment-img'); + let image = target.closest('.chat-img, .chat-generated-image, .chat-attachment-img'); if (!(image instanceof HTMLImageElement)) { image = target @@ -110,7 +132,7 @@ export class ChatImageController { const saveBtn = document.createElement('button'); saveBtn.type = 'button'; saveBtn.className = 'chat-save-image-btn'; - saveBtn.title = this._deps.translate('ui.chat.save_image', 'Save Image'); + this._syncImageActionLabel(saveBtn, 'ui.chat.save_image', 'Save Image'); saveBtn.innerHTML = ChatImageController._downloadIcon; saveBtn.addEventListener('contextmenu', (event) => { event.preventDefault(); @@ -140,23 +162,20 @@ export class ChatImageController { } private async _performSaveImage(b64: string, mime: string): Promise { - const result = await invoke<{ file_path: string; folder_path: string }>( - 'save_chat_image_default', - { - base64Data: b64, - mimeType: mime, - }, + const saved = await this._invokeCommand( + commands.saveChatImageDefault(b64, mime), + 'saveChatImageDefault', ); if ( - typeof result.file_path === 'string' && - result.file_path.length > 0 && - typeof result.folder_path === 'string' && - result.folder_path.length > 0 + typeof saved.file_path === 'string' && + saved.file_path.length > 0 && + typeof saved.folder_path === 'string' && + saved.folder_path.length > 0 ) { return { - filePath: result.file_path, - folderPath: result.folder_path, + filePath: saved.file_path, + folderPath: saved.folder_path, }; } @@ -214,32 +233,73 @@ export class ChatImageController { +
+ +
`); const closeButton = overlay.querySelector('.chat-image-viewer-close'); const label = this._deps.translate('ui.chat.close_image_preview', 'Close image preview'); closeButton?.setAttribute('aria-label', label); + closeButton?.setAttribute('title', label); + + const prevButton = overlay.querySelector('.chat-image-viewer-prev'); + const nextButton = overlay.querySelector('.chat-image-viewer-next'); + prevButton?.setAttribute( + 'aria-label', + this._deps.translate('ui.chat.previous_image', 'Previous image'), + ); + nextButton?.setAttribute( + 'aria-label', + this._deps.translate('ui.chat.next_image', 'Next image'), + ); overlay.addEventListener('click', (event) => { const eventTarget = event.target; if (!(eventTarget instanceof HTMLElement)) return; if ( eventTarget === overlay || + eventTarget.closest('.chat-image-viewer-stage') instanceof HTMLElement || eventTarget.closest('.chat-image-viewer-close') instanceof HTMLElement ) { this.closeImageViewer(); } }); + prevButton?.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + this._showAdjacentImage(-1); + }); + nextButton?.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + this._showAdjacentImage(1); + }); + this._resolveImageViewerHost().appendChild(overlay); this._imageViewerOverlay = overlay; this._imageViewerImage = overlay.querySelector('.chat-image-viewer-img'); + this._imageViewerPrevButton = prevButton; + this._imageViewerNextButton = nextButton; + this._imageViewerCounter = overlay.querySelector('.chat-image-viewer-counter'); this._imageViewerImage?.setAttribute( 'alt', this._deps.translate('ui.chat.image_preview', 'Image preview'), ); + this._imageViewerImage?.addEventListener('click', (event) => { + event.stopPropagation(); + }); } private openImageViewer(src: string): void { @@ -253,7 +313,16 @@ export class ChatImageController { return; } - this._imageViewerImage.src = src; + this._imageViewerSources = this._collectImageViewerSources(); + const sourceIndex = this._imageViewerSources.indexOf(src); + if (sourceIndex === -1) { + this._imageViewerSources = [src]; + this._imageViewerIndex = 0; + } else { + this._imageViewerIndex = sourceIndex; + } + + this._setImageViewerSource(src, 0); this._imageViewerOverlay.classList.remove('hidden'); document.body.classList.add('chat-image-viewer-open'); document.addEventListener('keydown', this._boundImageViewerKeydown); @@ -268,6 +337,78 @@ export class ChatImageController { document.removeEventListener('keydown', this._boundImageViewerKeydown); } + private _collectImageViewerSources(): string[] { + const sources: string[] = []; + const seen = new Set(); + document + .querySelectorAll( + '#chat-messages img.chat-img, #chat-messages img.chat-generated-image, #chat-messages img.chat-attachment-img, #chat-attachments img.chat-attachment-img', + ) + .forEach((image) => { + const src = this._resolveImageSource(image); + if (src === null || seen.has(src)) return; + seen.add(src); + sources.push(src); + }); + return sources; + } + + private _resolveImageSource(image: HTMLImageElement): string | null { + const currentSrc = image.currentSrc.trim(); + const attributeSrc = image.getAttribute('src'); + const fallbackSrc = image.src.trim(); + const src = + currentSrc.length > 0 + ? image.currentSrc + : attributeSrc !== null && attributeSrc.trim().length > 0 + ? attributeSrc + : fallbackSrc; + return src.trim().length > 0 ? src : null; + } + + private _showAdjacentImage(direction: -1 | 1): void { + if (this._imageViewerSources.length <= 1) return; + const nextIndex = + (this._imageViewerIndex + direction + this._imageViewerSources.length) % + this._imageViewerSources.length; + this._imageViewerIndex = nextIndex; + this._setImageViewerSource(this._imageViewerSources[nextIndex] ?? '', direction); + } + + private _setImageViewerSource(src: string, direction: -1 | 0 | 1): void { + if (!(this._imageViewerImage instanceof HTMLImageElement)) return; + if (src.trim().length === 0) return; + + this._imageViewerImage.classList.remove( + 'is-entering-forward', + 'is-entering-backward', + 'is-opening', + ); + this._imageViewerImage.src = src; + const animationClass = + direction > 0 + ? 'is-entering-forward' + : direction < 0 + ? 'is-entering-backward' + : 'is-opening'; + requestAnimationFrame(() => { + this._imageViewerImage?.classList.add(animationClass); + }); + this._syncImageViewerNavigation(); + } + + private _syncImageViewerNavigation(): void { + const hasMany = this._imageViewerSources.length > 1; + this._imageViewerPrevButton?.classList.toggle('hidden', !hasMany); + this._imageViewerNextButton?.classList.toggle('hidden', !hasMany); + if (this._imageViewerCounter instanceof HTMLElement) { + this._imageViewerCounter.classList.toggle('hidden', !hasMany); + this._imageViewerCounter.textContent = hasMany + ? `${this._imageViewerIndex + 1} / ${this._imageViewerSources.length}` + : ''; + } + } + private _promoteSaveButtonToFolder( saveBtn: HTMLButtonElement, filePath: string, @@ -282,7 +423,7 @@ export class ChatImageController { saveBtn.classList.add('chat-open-image-folder-btn'); saveBtn.dataset['filePath'] = filePath; saveBtn.dataset['folderPath'] = folderPath; - saveBtn.title = this._deps.translate('ui.chat.open_image_folder', 'Open image folder'); + this._syncImageActionLabel(saveBtn, 'ui.chat.open_image_folder', 'Open image folder'); saveBtn.innerHTML = ChatImageController._folderIcon; }, ChatImageController._imageResetDelayMs); } @@ -298,7 +439,7 @@ export class ChatImageController { saveBtn.classList.add('chat-save-image-btn'); delete saveBtn.dataset['filePath']; delete saveBtn.dataset['folderPath']; - saveBtn.title = this._deps.translate('ui.chat.save_image', 'Save Image'); + this._syncImageActionLabel(saveBtn, 'ui.chat.save_image', 'Save Image'); saveBtn.innerHTML = ChatImageController._downloadIcon; } @@ -317,10 +458,17 @@ export class ChatImageController { saveBtn.classList.add('chat-open-image-folder-btn'); saveBtn.dataset['filePath'] = filePath; saveBtn.dataset['folderPath'] = folderPath; - saveBtn.title = this._deps.translate('ui.chat.open_image_folder', 'Open image folder'); + this._syncImageActionLabel(saveBtn, 'ui.chat.open_image_folder', 'Open image folder'); saveBtn.innerHTML = ChatImageController._folderIcon; } + private _syncImageActionLabel(saveBtn: HTMLButtonElement, key: string, fallback: string): void { + const label = this._deps.translate(key, fallback); + saveBtn.title = label; + saveBtn.dataset['tooltip'] = label; + saveBtn.setAttribute('aria-label', label); + } + private _animateFolderButtonReset(saveBtn: HTMLButtonElement): void { if (!saveBtn.classList.contains('chat-open-image-folder-btn')) return; if (saveBtn.classList.contains('is-resetting')) return; @@ -348,7 +496,10 @@ export class ChatImageController { const animationDelay = new Promise((resolve) => { globalThis.setTimeout(resolve, ChatImageController._imageResetDelayMs); }); - const deleteRequest = invoke('delete_chat_image', { filePath }); + const deleteRequest = this._invokeCommand( + commands.deleteChatImage(filePath), + 'deleteChatImage', + ); try { await Promise.all([deleteRequest, animationDelay]); @@ -371,11 +522,6 @@ export class ChatImageController { ): Promise { const handleMissing = async (): Promise => { this._restoreFolderButtonToSave(saveBtn); - try { - await invoke('open_chat_image_location', { filePath: folderPath, folderPath }); - } catch { - /* ignore fallback folder-open errors */ - } this._deps.showToast( this._deps.translate( 'ui.chat.image_missing_resave', @@ -383,10 +529,15 @@ export class ChatImageController { ), 'warning', ); + try { + await this._openSavedImageLocation(folderPath, folderPath); + } catch { + /* ignore fallback folder-open errors */ + } }; try { - await invoke('open_chat_image_location', { filePath, folderPath }); + await this._openSavedImageLocation(filePath, folderPath); } catch (error) { const message = this._deps.extractErrorMessage(error); const normalized = message.toLowerCase(); @@ -410,4 +561,23 @@ export class ChatImageController { ); } } + + private async _openSavedImageLocation(filePath: string, folderPath: string): Promise { + await this._invokeCommand( + commands.openChatImageLocation(filePath, folderPath), + 'openChatImageLocation', + ); + } + + private async _invokeCommand( + command: Promise<{ status: 'ok'; data: T } | { status: 'error'; error: unknown }>, + label: string, + ): Promise { + const result = await invokeSafe(command); + if (result.status === 'ok') { + return result.data; + } + + throw new Error(`${label} failed: ${result.error.message}`); + } } diff --git a/src/features/chat/ui/ChatImageGenerationMessage.ts b/src/features/chat/ui/ChatImageGenerationMessage.ts index 84c64343..5fdf4dce 100644 --- a/src/features/chat/ui/ChatImageGenerationMessage.ts +++ b/src/features/chat/ui/ChatImageGenerationMessage.ts @@ -1,7 +1,9 @@ -type ChatImagePayload = { - mime: string; - data_base64: string; -}; +import { + buildSafeImageDataUrl, + isSafeImageDataUrl, + normalizeImagePayload, + type ChatImagePayload, +} from './ChatImagePayload'; type ChatTranslate = ( key: string, @@ -23,6 +25,7 @@ type CreateChatImageGenerationMessageDeps = { isDestroyed: () => boolean; tracer: { error: (message: string, error?: unknown) => void }; scrollToBottom: (sticky?: boolean) => void; + isNearBottom: () => boolean; appendRow: (row: HTMLElement) => void; createMessageBubble: (opts: Record) => HTMLElement; appendMessageActions: ( @@ -31,9 +34,6 @@ type CreateChatImageGenerationMessageDeps = { image: ChatImagePayload | null, ) => { actionBar: HTMLElement; copyBtn: HTMLElement; editBtn: HTMLElement | null } | null; scheduleBubbleImageActions: (bubble: HTMLElement, actionBar: HTMLElement) => void; - opts: { - onCancel: () => void | Promise; - }; }; type ImageGenerationProgress = { @@ -44,6 +44,17 @@ type ImageGenerationProgress = { elapsed: string | null; }; +const normalizeGeneratedCaption = (text: string, translate: ChatTranslate): string => { + const trimmed = text.trim(); + const readyLabels = new Set([ + translate('ui.chat.image_ready', 'Generated image').trim(), + 'Generated image', + 'Image ready', + 'Изображение готово', + ]); + return readyLabels.has(trimmed) ? '' : trimmed; +}; + const parseImageGenerationProgress = (text: string): ImageGenerationProgress => { let percent = 0; let step: number | null = null; @@ -81,22 +92,37 @@ export function createChatImageGenerationMessage( deps: CreateChatImageGenerationMessageDeps, ): ImageGenerationMessageHandle { const row = document.createElement('div'); - row.className = 'chat-row bot'; + row.className = 'chat-row bot chat-row--generated-image'; - const bubble = deps.createMessageBubble({ mediaFirst: true }); + const bubble = deps.createMessageBubble({}); bubble.classList.add('chat-image-generation'); const media = document.createElement('div'); media.className = 'chat-generated-media hidden'; const image = document.createElement('img'); - image.className = 'chat-img chat-generated-image'; + image.className = 'chat-generated-image'; image.alt = 'Generated preview'; image.width = 512; image.height = 512; image.decoding = 'async'; media.appendChild(image); + let keepPinnedAfterImageLoad = false; + + const syncMediaSizeToImage = (): void => { + const naturalWidth = image.naturalWidth; + const naturalHeight = image.naturalHeight; + if (naturalWidth <= 0 || naturalHeight <= 0) return; + + media.style.aspectRatio = `${String(naturalWidth)} / ${String(naturalHeight)}`; + if (keepPinnedAfterImageLoad) { + deps.scrollToBottom(); + keepPinnedAfterImageLoad = false; + } + }; + image.addEventListener('load', syncMediaSizeToImage); + const status = document.createElement('div'); status.className = 'chat-generated-status'; status.textContent = deps.translate('ui.chat.image_generating', 'Rendering image'); @@ -119,36 +145,8 @@ export function createChatImageGenerationMessage( const caption = document.createElement('div'); caption.className = 'chat-generated-caption markdown-body hidden'; - const controls = document.createElement('div'); - controls.className = 'chat-generated-controls'; - - const cancelBtn = document.createElement('button'); - cancelBtn.type = 'button'; - cancelBtn.className = 'chat-generated-control is-cancel'; - cancelBtn.textContent = deps.translate('ui.chat.image_cancel', 'Cancel'); - cancelBtn.title = deps.translate('ui.chat.image_cancel', 'Cancel'); - cancelBtn.setAttribute('aria-label', deps.translate('ui.chat.image_cancel', 'Cancel')); - - const invokeControl = (button: HTMLButtonElement, action: () => void | Promise): void => { - button.disabled = true; - Promise.resolve(action()) - .catch((error: unknown) => { - deps.tracer.error('[ChatUI] Image generation control failed', error); - }) - .finally(() => { - if (!deps.isDestroyed() && button.isConnected) { - button.disabled = false; - } - }); - }; - - cancelBtn.addEventListener('click', () => { - handle.cancel(); - invokeControl(cancelBtn, deps.opts.onCancel); - }); - controls.append(cancelBtn); - bubble.append(media, statusRow, progress, caption, controls); - row.appendChild(bubble); + bubble.append(statusRow, progress, caption); + row.append(media, bubble); deps.appendRow(row); deps.scrollToBottom(); @@ -160,6 +158,12 @@ export function createChatImageGenerationMessage( let finalImage: ChatImagePayload | null = null; let isCancelled = false; + const detachMediaFromBubble = (): void => { + if (media.parentElement === bubble) { + row.insertBefore(media, bubble); + } + }; + const setProgressFromStatus = (text: string): void => { const { elapsed, percent, speed, step, total } = parseImageGenerationProgress(text); progressFill.style.width = `${String(percent)}%`; @@ -182,15 +186,18 @@ export function createChatImageGenerationMessage( const showPreview = (dataUrl: string): void => { if (dataUrl.trim() === '') return; + if (!isSafeImageDataUrl(dataUrl)) return; if (image.src === dataUrl) return; + const shouldKeepPinned = deps.isNearBottom(); + keepPinnedAfterImageLoad = shouldKeepPinned; + detachMediaFromBubble(); + if (media.style.aspectRatio === '') { + media.style.aspectRatio = '1 / 1'; + } image.src = dataUrl; + syncMediaSizeToImage(); media.classList.remove('hidden'); - bubble.classList.add('chat-bubble--media'); - deps.scrollToBottom(true); - }; - - const hideControls = (): void => { - controls.classList.add('hidden'); + deps.scrollToBottom(!shouldKeepPinned); }; const hideProgress = (): void => { @@ -205,9 +212,10 @@ export function createChatImageGenerationMessage( return; } actions ??= deps.appendMessageActions(content, 'assistant', finalImage); - if (actions !== null && !bubble.contains(actions.actionBar)) { - bubble.appendChild(actions.actionBar); - deps.scheduleBubbleImageActions(bubble, actions.actionBar); + if (actions !== null && !row.contains(actions.actionBar)) { + const target = bubble.classList.contains('has-no-caption') ? media : bubble; + target.appendChild(actions.actionBar); + deps.scheduleBubbleImageActions(target, actions.actionBar); } }; @@ -223,9 +231,13 @@ export function createChatImageGenerationMessage( }, finalize: (result: { text: string; images: ChatImagePayload[] }) => { if (isCancelled) return; - finalImage = result.images[0] ?? null; - if (finalImage !== null) { - showPreview(`data:${finalImage.mime};base64,${finalImage.data_base64}`); + const shouldKeepPinned = deps.isNearBottom(); + finalImage = + result.images[0] === undefined ? null : normalizeImagePayload(result.images[0]); + const finalImageDataUrl = + finalImage === null ? null : buildSafeImageDataUrl(finalImage); + if (finalImageDataUrl !== null) { + showPreview(finalImageDataUrl); } status.textContent = deps.translate('ui.chat.image_ready', 'Generated image'); @@ -233,36 +245,40 @@ export function createChatImageGenerationMessage( progressSummary.textContent = '100%'; progress.classList.add('is-complete'); - caption.textContent = result.text; - caption.classList.toggle('hidden', result.text.trim() === ''); + const captionText = normalizeGeneratedCaption(result.text, deps.translate); + caption.textContent = captionText; + const hasCaption = captionText !== ''; + caption.classList.toggle('hidden', !hasCaption); + bubble.classList.toggle('has-caption', hasCaption); + bubble.classList.toggle('has-no-caption', !hasCaption); + row.classList.add('is-complete'); bubble.classList.add('is-complete'); - hideControls(); ensureImageActions(result.text); - deps.scrollToBottom(); + deps.scrollToBottom(!shouldKeepPinned); }, fail: (message: string) => { if (isCancelled) { return; } + const shouldKeepPinned = deps.isNearBottom(); bubble.classList.add('chat-error'); status.textContent = message; hideProgress(); caption.classList.add('hidden'); - hideControls(); - deps.scrollToBottom(); + deps.scrollToBottom(!shouldKeepPinned); }, cancel: ( message = deps.translate('ui.chat.image_cancelled', 'Image generation cancelled'), ) => { + const shouldKeepPinned = deps.isNearBottom(); isCancelled = true; bubble.classList.remove('chat-error'); bubble.classList.add('is-cancelled'); status.textContent = message; hideProgress(); caption.classList.add('hidden'); - hideControls(); - deps.scrollToBottom(); + deps.scrollToBottom(!shouldKeepPinned); }, discard: () => { row.remove(); diff --git a/src/features/chat/ui/ChatImagePayload.ts b/src/features/chat/ui/ChatImagePayload.ts new file mode 100644 index 00000000..716be9ae --- /dev/null +++ b/src/features/chat/ui/ChatImagePayload.ts @@ -0,0 +1,57 @@ +export type ChatImagePayload = { + mime: string; + data_base64: string; +}; + +const allowedImageMimePattern = /^image\/(?:png|jpe?g|gif|webp|bmp|avif)$/iu; + +export function normalizeImageMime(mime: string): string | null { + const normalized = mime.trim().toLowerCase(); + return allowedImageMimePattern.test(normalized) ? normalized : null; +} + +export function normalizeImageBase64(data: string): string | null { + const normalized = data.replaceAll(/\s+/gu, ''); + if (normalized === '') { + return null; + } + return /^[A-Za-z0-9+/]+={0,2}$/u.test(normalized) ? normalized : null; +} + +export function normalizeImagePayload(image: ChatImagePayload): ChatImagePayload | null { + const mime = normalizeImageMime(image.mime); + const dataBase64 = normalizeImageBase64(image.data_base64); + if (mime === null || dataBase64 === null) { + return null; + } + + return { + mime, + data_base64: dataBase64, + }; +} + +export function buildSafeImageDataUrl(image: ChatImagePayload): string | null { + const normalized = normalizeImagePayload(image); + if (normalized === null) { + return null; + } + + return `data:${normalized.mime};base64,${normalized.data_base64}`; +} + +export function isSafeImageDataUrl(dataUrl: string): boolean { + const match = /^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/iu.exec(dataUrl.trim()); + if (match === null) { + return false; + } + + const mime = match[1]; + const dataBase64 = match[2]; + return ( + mime !== undefined && + dataBase64 !== undefined && + normalizeImageMime(mime) !== null && + normalizeImageBase64(dataBase64) !== null + ); +} diff --git a/src/features/chat/ui/ChatInputContextMenu.test.ts b/src/features/chat/ui/ChatInputContextMenu.test.ts deleted file mode 100644 index bfc7e63d..00000000 --- a/src/features/chat/ui/ChatInputContextMenu.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { ChatInputContextMenu } from './ChatInputContextMenu'; - -const translate = (key: string, fallback?: string) => `t:${key}:${fallback ?? ''}`; - -function setupMenu(options?: { canPaste?: boolean; clipboardText?: string | null }) { - document.body.innerHTML = ` -
-
- -
Ask something
-
-
- `; - const input = document.getElementById('chat-input') as HTMLTextAreaElement; - const field = document.querySelector('.chat-input-field') as HTMLElement; - const placeholder = document.getElementById('chat-input-placeholder') as HTMLElement; - const copyText = vi.fn().mockResolvedValue(undefined); - const readClipboardText = vi.fn().mockResolvedValue(options?.clipboardText ?? ' pasted'); - const warn = vi.fn(); - const menu = new ChatInputContextMenu({ - translate, - copyText, - readClipboardText, - canPaste: () => options?.canPaste ?? false, - tracer: { warn }, - }); - menu.bind(input); - return { input, field, placeholder, menu, copyText, readClipboardText, warn }; -} - -function openMenu(target: HTMLElement): MouseEvent { - const event = new MouseEvent('contextmenu', { - bubbles: true, - cancelable: true, - button: 2, - clientX: 24, - clientY: 32, - }); - target.dispatchEvent(event); - return event; -} - -async function openMenuAndFlush(target: HTMLElement): Promise { - const event = openMenu(target); - await Promise.resolve(); - return event; -} - -function getButton(action: string): HTMLButtonElement { - const button = document.querySelector( - `.chat-input-context-menu-item[data-action="${action}"]`, - ); - if (!(button instanceof HTMLButtonElement)) { - throw new Error(`Context menu action not found: ${action}`); - } - return button; -} - -describe('ChatInputContextMenu', () => { - beforeEach(() => { - vi.clearAllMocks(); - document.body.innerHTML = ''; - Object.defineProperty(globalThis.navigator, 'clipboard', { - configurable: true, - value: { - readText: vi.fn().mockResolvedValue(' pasted'), - }, - }); - }); - - it('opens a custom menu and prevents the browser context menu', async () => { - const { input, placeholder } = setupMenu(); - input.setSelectionRange(0, 5); - - const event = await openMenuAndFlush(placeholder); - - expect(event.defaultPrevented).toBe(true); - expect(document.querySelector('.chat-input-context-menu')).toBeInstanceOf(HTMLElement); - expect(getButton('copy').disabled).toBe(false); - expect(document.querySelector('[data-action="paste"]')).toBeNull(); - }); - - it('opens from the whole input bar in expanded chat state', async () => { - const { field } = setupMenu(); - const bar = document.querySelector('.chat-input-bar') as HTMLElement; - field.remove(); - - const event = await openMenuAndFlush(bar); - - expect(event.defaultPrevented).toBe(true); - expect(document.querySelector('.chat-input-context-menu')).toBeInstanceOf(HTMLElement); - }); - - it('pastes through the injected clipboard reader when available', async () => { - const { input, readClipboardText } = setupMenu({ canPaste: true, clipboardText: ' Tauri' }); - input.setSelectionRange(5, 5); - await openMenuAndFlush(input); - - getButton('paste').click(); - await Promise.resolve(); - - expect(readClipboardText).toHaveBeenCalledTimes(1); - expect(input.value).toBe('hello Tauri world'); - }); - - it('disables paste when the Tauri clipboard is empty', async () => { - const { input } = setupMenu({ canPaste: true, clipboardText: '' }); - - await openMenuAndFlush(input); - - expect(getButton('paste').disabled).toBe(true); - }); - - it('copies and cuts selected text', async () => { - const { input, copyText } = setupMenu(); - input.setSelectionRange(0, 5); - await openMenuAndFlush(input); - - getButton('copy').click(); - await Promise.resolve(); - - expect(copyText).toHaveBeenCalledWith('hello'); - - input.setSelectionRange(6, 11); - await openMenuAndFlush(input); - getButton('cut').click(); - await Promise.resolve(); - - expect(copyText).toHaveBeenLastCalledWith('world'); - expect(input.value).toBe('hello '); - }); - - it('selects all text and closes on Escape', async () => { - const { input } = setupMenu(); - await openMenuAndFlush(input); - - getButton('selectAll').click(); - - expect(input.selectionStart).toBe(0); - expect(input.selectionEnd).toBe(input.value.length); - expect(document.querySelector('.chat-input-context-menu')).toBeNull(); - - await openMenuAndFlush(input); - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - - expect(document.querySelector('.chat-input-context-menu')).toBeNull(); - }); -}); diff --git a/src/features/chat/ui/ChatInputContextMenu.ts b/src/features/chat/ui/ChatInputContextMenu.ts deleted file mode 100644 index 2d448c84..00000000 --- a/src/features/chat/ui/ChatInputContextMenu.ts +++ /dev/null @@ -1,326 +0,0 @@ -type ChatInputContextMenuTranslate = (key: string, fallback?: string) => string; - -type ChatInputContextMenuDeps = { - translate: ChatInputContextMenuTranslate; - copyText: (text: string) => Promise; - readClipboardText: () => Promise; - canPaste: () => boolean; - tracer: { - warn: (message: string, ...args: unknown[]) => void; - }; -}; - -type ChatInputContextMenuAction = 'cut' | 'copy' | 'paste' | 'selectAll'; - -type ChatInputContextMenuItem = { - action: ChatInputContextMenuAction; - labelKey: string; - fallback: string; - shortcut?: string; - dividerBefore?: boolean; -}; - -const CHAT_INPUT_CONTEXT_MENU_ITEMS: ChatInputContextMenuItem[] = [ - { - action: 'cut', - labelKey: 'ui.chat.input_menu.cut', - fallback: 'Вырезать', - shortcut: 'Ctrl+X', - }, - { - action: 'copy', - labelKey: 'ui.chat.input_menu.copy', - fallback: 'Копировать', - shortcut: 'Ctrl+C', - }, - { - action: 'paste', - labelKey: 'ui.chat.input_menu.paste', - fallback: 'Вставить', - shortcut: 'Ctrl+V', - }, - { - action: 'selectAll', - labelKey: 'ui.chat.input_menu.select_all', - fallback: 'Выбрать все', - shortcut: 'Ctrl+A', - dividerBefore: true, - }, -]; - -export class ChatInputContextMenu { - private _openRequestId = 0; - private _menu: HTMLDivElement | null = null; - private _input: HTMLTextAreaElement | null = null; - private _target: HTMLElement | null = null; - private _clipboardText: string | null = null; - private _boundContextMenu: (event: MouseEvent) => void; - private _boundDocumentPointerDown: (event: PointerEvent) => void; - private _boundKeyDown: (event: KeyboardEvent) => void; - private _boundClose: () => void; - - public constructor(private readonly _deps: ChatInputContextMenuDeps) { - this._boundContextMenu = (event) => { - this._handleContextMenu(event); - }; - this._boundDocumentPointerDown = (event) => { - const target = event.target; - if (target instanceof Node && this._menu?.contains(target) === true) { - return; - } - this.close(); - }; - this._boundKeyDown = (event) => { - if (event.key === 'Escape') { - this.close(); - } - }; - this._boundClose = () => { - this.close(); - }; - } - - public bind(input: HTMLTextAreaElement | null): void { - if (this._input === input) { - return; - } - - this.destroy(); - this._input = input; - this._target = this._resolveContextTarget(input); - this._target?.addEventListener('contextmenu', this._boundContextMenu); - } - - public destroy(): void { - this._target?.removeEventListener('contextmenu', this._boundContextMenu); - this._target = null; - this._input = null; - this.close(); - } - - public close(): void { - this._openRequestId += 1; - this._menu?.remove(); - this._menu = null; - this._clipboardText = null; - document.removeEventListener('pointerdown', this._boundDocumentPointerDown, { - capture: true, - }); - document.removeEventListener('keydown', this._boundKeyDown, { capture: true }); - window.removeEventListener('blur', this._boundClose); - window.removeEventListener('resize', this._boundClose); - document.removeEventListener('scroll', this._boundClose, { capture: true }); - } - - private _handleContextMenu(event: MouseEvent): void { - const input = this._input; - if (input === null || input.disabled || input.readOnly) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - input.focus(); - this._open(input, event.clientX, event.clientY); - } - - private _resolveContextTarget(input: HTMLTextAreaElement | null): HTMLElement | null { - if (input === null) { - return null; - } - - return ( - input.closest('.chat-input-bar') ?? - input.closest('.chat-input-field') ?? - input - ); - } - - private _open(input: HTMLTextAreaElement, clientX: number, clientY: number): void { - this.close(); - const openRequestId = this._openRequestId; - void this._openWithClipboardState(input, clientX, clientY, openRequestId); - } - - private async _openWithClipboardState( - input: HTMLTextAreaElement, - clientX: number, - clientY: number, - openRequestId: number, - ): Promise { - const clipboardText = await this._readClipboardForMenu(); - if (openRequestId !== this._openRequestId || this._input !== input) { - return; - } - - this._clipboardText = clipboardText; - - const menu = document.createElement('div'); - menu.className = 'chat-input-context-menu'; - menu.setAttribute('role', 'menu'); - menu.tabIndex = -1; - - const state = this._getState(input); - this._getVisibleItems().forEach((item) => { - if (item.dividerBefore === true) { - const divider = document.createElement('div'); - divider.className = 'chat-input-context-menu-divider'; - divider.setAttribute('role', 'separator'); - menu.appendChild(divider); - } - - menu.appendChild(this._createButton(input, item, state)); - }); - - document.body.appendChild(menu); - this._menu = menu; - this._positionMenu(menu, clientX, clientY); - menu.focus({ preventScroll: true }); - - document.addEventListener('pointerdown', this._boundDocumentPointerDown, { - capture: true, - }); - document.addEventListener('keydown', this._boundKeyDown, { capture: true }); - window.addEventListener('blur', this._boundClose); - window.addEventListener('resize', this._boundClose); - document.addEventListener('scroll', this._boundClose, { capture: true }); - } - - private async _readClipboardForMenu(): Promise { - if (!this._deps.canPaste()) { - return null; - } - - try { - return await this._deps.readClipboardText(); - } catch (error) { - this._deps.tracer.warn('[ChatInputContextMenu] Clipboard read failed:', error); - return null; - } - } - - private _createButton( - input: HTMLTextAreaElement, - item: ChatInputContextMenuItem, - state: { hasSelection: boolean; hasText: boolean }, - ): HTMLButtonElement { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'chat-input-context-menu-item'; - button.dataset['action'] = item.action; - button.setAttribute('role', 'menuitem'); - button.disabled = this._isDisabled(item.action, state); - - const label = document.createElement('span'); - label.className = 'chat-input-context-menu-label'; - label.textContent = this._deps.translate(item.labelKey, item.fallback); - button.appendChild(label); - - if (item.shortcut !== undefined) { - const shortcut = document.createElement('span'); - shortcut.className = 'chat-input-context-menu-shortcut'; - shortcut.textContent = item.shortcut; - button.appendChild(shortcut); - } - - button.addEventListener('click', () => { - void this._runAction(input, item.action); - }); - - return button; - } - - private _getState(input: HTMLTextAreaElement): { - hasSelection: boolean; - hasText: boolean; - } { - return { - hasSelection: input.selectionStart !== input.selectionEnd, - hasText: input.value.length > 0, - }; - } - - private _isDisabled( - action: ChatInputContextMenuAction, - state: { hasSelection: boolean; hasText: boolean }, - ): boolean { - if (action === 'cut' || action === 'copy') { - return !state.hasSelection; - } - if (action === 'selectAll') { - return !state.hasText; - } - return this._clipboardText === null || this._clipboardText === ''; - } - - private async _runAction( - input: HTMLTextAreaElement, - action: ChatInputContextMenuAction, - ): Promise { - try { - if (action === 'selectAll') { - input.focus(); - input.select(); - this.close(); - return; - } - - if (action === 'copy' || action === 'cut') { - const selectedText = input.value.slice(input.selectionStart, input.selectionEnd); - if (selectedText === '') { - this.close(); - return; - } - - await this._deps.copyText(selectedText); - if (action === 'cut') { - input.setRangeText('', input.selectionStart, input.selectionEnd, 'start'); - this._dispatchInputChange(input); - } - this.close(); - return; - } - - const text = this._clipboardText; - if (text !== null && text !== '') { - input.focus(); - input.setRangeText(text, input.selectionStart, input.selectionEnd, 'end'); - this._dispatchInputChange(input); - } - this.close(); - } catch (error) { - this._deps.tracer.warn('[ChatInputContextMenu] Action failed:', error); - this.close(); - } - } - - private _getVisibleItems(): ChatInputContextMenuItem[] { - if (this._deps.canPaste()) { - return CHAT_INPUT_CONTEXT_MENU_ITEMS; - } - - return CHAT_INPUT_CONTEXT_MENU_ITEMS.filter((item) => item.action !== 'paste'); - } - - private _dispatchInputChange(input: HTMLTextAreaElement): void { - input.dispatchEvent(new Event('input', { bubbles: true })); - input.dispatchEvent(new Event('change', { bubbles: true })); - } - - private _positionMenu(menu: HTMLElement, clientX: number, clientY: number): void { - const viewportPadding = 8; - const rect = menu.getBoundingClientRect(); - const left = Math.min( - Math.max(clientX, viewportPadding), - window.innerWidth - rect.width - viewportPadding, - ); - const top = Math.min( - Math.max(clientY, viewportPadding), - window.innerHeight - rect.height - viewportPadding, - ); - - menu.style.left = `${Math.round(left)}px`; - menu.style.top = `${Math.round(top)}px`; - } -} diff --git a/src/features/chat/ui/ChatMessageInteractionController.ts b/src/features/chat/ui/ChatMessageInteractionController.ts index 3577ed95..c2ea03b2 100644 --- a/src/features/chat/ui/ChatMessageInteractionController.ts +++ b/src/features/chat/ui/ChatMessageInteractionController.ts @@ -2,7 +2,7 @@ import DOMPurify from 'dompurify'; import type { ChatImageController } from './ChatImageController'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; -import type { TTranslateFunction } from '@/shared/types/global_bridge_types'; +import type { ChatTranslateFunction } from './ChatUiTypes'; type ChatMessageInteractionLogger = Pick; @@ -25,7 +25,7 @@ type ChatMessageInteractionControllerDeps = { getRegenerateMessageHandler: () => (() => void | Promise) | null; setLastEditableUserActionBar: (actionBar: HTMLElement) => void; setLastRegeneratableAssistantActionBar: (actionBar: HTMLElement) => void; - translate: TTranslateFunction; + translate: ChatTranslateFunction; tracer: ChatMessageInteractionLogger; }; diff --git a/src/features/chat/ui/ChatMessageRenderer.ts b/src/features/chat/ui/ChatMessageRenderer.ts index ee010bbb..e256cedd 100644 --- a/src/features/chat/ui/ChatMessageRenderer.ts +++ b/src/features/chat/ui/ChatMessageRenderer.ts @@ -2,18 +2,18 @@ import DOMPurify from 'dompurify'; import { marked } from 'marked'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; -import type { TTranslateFunction } from '@/shared/types/global_bridge_types'; +import type { ChatTranslateFunction } from './ChatUiTypes'; +import { + buildSafeImageDataUrl, + normalizeImagePayload, + type ChatImagePayload, +} from './ChatImagePayload'; type ChatMessageRendererLogger = Pick; -type ChatImagePayload = { - mime: string; - data_base64: string; -}; - type ChatMessageRendererDeps = { onImageLoad: () => void; - translate: TTranslateFunction; + translate: ChatTranslateFunction; tracer: ChatMessageRendererLogger; }; @@ -37,7 +37,7 @@ export class ChatMessageRenderer { typeof (item as { mime?: unknown }).mime === 'string', ) as ChatImagePayload | undefined; - return candidate ?? null; + return candidate === undefined ? null : normalizeImagePayload(candidate); } public extractImageFromBubble(bubble: HTMLElement): ChatImagePayload | null { @@ -56,7 +56,7 @@ export class ChatMessageRenderer { return null; } - return { mime, data_base64 }; + return normalizeImagePayload({ mime, data_base64 }); } public createMessageTextNode(content: string, opts: Record): HTMLElement { @@ -93,7 +93,7 @@ export class ChatMessageRenderer { images.forEach((img) => { try { - const imageDataUrl = this._buildImageDataUrl(img); + const imageDataUrl = buildSafeImageDataUrl(img); if (imageDataUrl === null) return; const wrapper = document.createElement('div'); @@ -171,14 +171,4 @@ export class ChatMessageRenderer { .filter((className) => className !== '') .join(' '); } - - private _buildImageDataUrl(image: ChatImagePayload): string | null { - const mime = image.mime || 'image/png'; - const base64 = image.data_base64 || ''; - if (base64 === '') { - return null; - } - - return `data:${mime};base64,${base64}`; - } } diff --git a/src/features/chat/ui/ChatStreamingMessage.ts b/src/features/chat/ui/ChatStreamingMessage.ts index 28874d46..875b9b28 100644 --- a/src/features/chat/ui/ChatStreamingMessage.ts +++ b/src/features/chat/ui/ChatStreamingMessage.ts @@ -111,23 +111,23 @@ export function createChatStreamingMessage( .then((rawHtml) => { if (!isStreamingTargetLive(version)) return; textNode.innerHTML = DOMPurify.sanitize(rawHtml); - if (scrollToBottom) deps.scrollToBottom(); + if (scrollToBottom) deps.scrollToBottom(true); }) .catch(() => { if (!isStreamingTargetLive(version)) return; textNode.textContent = sourceText; - if (scrollToBottom) deps.scrollToBottom(); + if (scrollToBottom) deps.scrollToBottom(true); }); return; } if (!isStreamingTargetLive(version)) return; textNode.innerHTML = DOMPurify.sanitize(parseResult); - if (scrollToBottom) deps.scrollToBottom(); + if (scrollToBottom) deps.scrollToBottom(true); } catch { if (!isStreamingTargetLive(version)) return; textNode.textContent = sourceText; - if (scrollToBottom) deps.scrollToBottom(); + if (scrollToBottom) deps.scrollToBottom(true); } }; @@ -142,7 +142,7 @@ export function createChatStreamingMessage( ) { if (!isStreamingTargetLive(version)) return; textNode.textContent = accumulatedText; - if (scrollToBottom) deps.scrollToBottom(); + if (scrollToBottom) deps.scrollToBottom(true); } else { renderMarkdown(accumulatedText, version, scrollToBottom); } @@ -247,7 +247,7 @@ export function createChatStreamingMessage( if (actions !== null && !bubble.contains(actions.actionBar)) { bubble.appendChild(actions.actionBar); } - deps.scrollToBottom(); + deps.scrollToBottom(true); }, discard: () => { isDiscarded = true; diff --git a/src/features/chat/ui/ChatTranslationRefresher.ts b/src/features/chat/ui/ChatTranslationRefresher.ts index a5d47622..303123f8 100644 --- a/src/features/chat/ui/ChatTranslationRefresher.ts +++ b/src/features/chat/ui/ChatTranslationRefresher.ts @@ -50,18 +50,11 @@ export function refreshChatTranslations( refreshMessageActions(translate); document.querySelectorAll('.chat-save-image-btn').forEach((btn) => { - btn.title = translate('ui.chat.save_image', 'Save Image'); + syncTooltipButton(btn, translate('ui.chat.save_image', 'Save Image')); }); document.querySelectorAll('.chat-open-image-folder-btn').forEach((btn) => { - btn.title = translate('ui.chat.open_image_folder', 'Open image folder'); - }); - - document.querySelectorAll('.chat-generated-control.is-cancel').forEach((btn) => { - const label = translate('ui.chat.image_cancel', 'Cancel'); - btn.textContent = label; - btn.title = label; - btn.setAttribute('aria-label', label); + syncTooltipButton(btn, translate('ui.chat.open_image_folder', 'Open image folder')); }); const viewerClose = document.querySelector('.chat-image-viewer-close'); @@ -73,6 +66,19 @@ export function refreshChatTranslations( viewerClose.title = translate('ui.chat.close_image_preview', 'Close image preview'); } + syncButtonLabel( + document.querySelector('.chat-image-viewer-prev'), + translate, + 'ui.chat.previous_image', + 'Previous image', + ); + syncButtonLabel( + document.querySelector('.chat-image-viewer-next'), + translate, + 'ui.chat.next_image', + 'Next image', + ); + document.querySelectorAll('.media-remove').forEach((btn) => { btn.title = translate('ui.launcher.web.remove_attachment', 'Remove attachment'); }); @@ -100,3 +106,9 @@ function syncButtonLabel( button.title = title; button.setAttribute('aria-label', ariaLabel); } + +function syncTooltipButton(button: HTMLElement, label: string): void { + button.title = label; + button.dataset['tooltip'] = label; + button.setAttribute('aria-label', label); +} diff --git a/src/features/chat/ui/ChatUI.test.ts b/src/features/chat/ui/ChatUI.test.ts index 93855c2f..0efde56b 100644 --- a/src/features/chat/ui/ChatUI.test.ts +++ b/src/features/chat/ui/ChatUI.test.ts @@ -55,13 +55,6 @@ function createChatUI(options?: { await navigator.clipboard.writeText(text); }, - readClipboardText: async () => { - if (!isTauriRuntime) { - return null; - } - - return await invoke('plugin:clipboard-manager|read_text'); - }, tracer: chatUiTracer, }); } @@ -86,7 +79,7 @@ function requireSaveImageButton(): HTMLButtonElement { } function requireChatImage(): HTMLImageElement { - const image = document.querySelector('.chat-img'); + const image = document.querySelector('.chat-img, .chat-generated-image'); if (!(image instanceof HTMLImageElement)) { throw new TypeError('chat image not found'); } @@ -104,6 +97,16 @@ function requireImageViewer(): { overlay: HTMLElement; preview: HTMLImageElement return { overlay, preview }; } +function requireImageViewerNav(): { prev: HTMLButtonElement; next: HTMLButtonElement } { + const prev = document.querySelector('.chat-image-viewer-prev'); + const next = document.querySelector('.chat-image-viewer-next'); + if (!(prev instanceof HTMLButtonElement) || !(next instanceof HTMLButtonElement)) { + throw new TypeError('image viewer navigation not found'); + } + + return { prev, next }; +} + async function renderAssistantImage(ui: ChatUI, initialize = false): Promise { renderImageChatBody(); if (initialize) { @@ -119,7 +122,7 @@ async function saveGeneratedImage(saveButton: HTMLButtonElement): Promise folder_path: savedImageFolderPath, }); saveButton.click(); - await flushPromises(2); + await flushPromises(3); } async function convertSaveButtonToFolderAction(saveButton: HTMLButtonElement): Promise { @@ -180,6 +183,25 @@ describe('ChatUI lifecycle', () => { expect(unlisten).toHaveBeenCalledTimes(1); }); + it('should unsubscribe retry status listener when init resolves after destroy', async () => { + const unlisten = vi.fn(); + let resolveListen: (unlisten: () => void) => void = () => {}; + vi.mocked(listen).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveListen = resolve; + }), + ); + + ui = createChatUI(); + const init = ui.init(); + ui.destroy(); + resolveListen(unlisten); + await init; + + expect(unlisten).toHaveBeenCalledTimes(1); + }); + it('should refresh localized titles for existing chat action buttons', () => { document.body.innerHTML = `
@@ -229,9 +251,19 @@ describe('ChatUI lifecycle', () => { expect((document.querySelector('.chat-save-image-btn') as HTMLButtonElement).title).toBe( 't:ui.chat.save_image:Save Image', ); + expect( + (document.querySelector('.chat-save-image-btn') as HTMLButtonElement).dataset[ + 'tooltip' + ], + ).toBe('t:ui.chat.save_image:Save Image'); expect( (document.querySelector('.chat-open-image-folder-btn') as HTMLButtonElement).title, ).toBe('t:ui.chat.open_image_folder:Open image folder'); + expect( + (document.querySelector('.chat-open-image-folder-btn') as HTMLButtonElement).dataset[ + 'tooltip' + ], + ).toBe('t:ui.chat.open_image_folder:Open image folder'); expect((document.querySelector('.media-remove') as HTMLButtonElement).title).toBe( 't:ui.launcher.web.remove_attachment:Remove attachment', ); @@ -327,13 +359,28 @@ describe('ChatUI lifecycle', () => { expect(document.querySelector('.markdown-body')?.textContent).toBe('hello'); }); + it('should not force cancelled partial streaming text to bottom when the user scrolled up', () => { + document.body.innerHTML = '
'; + const messages = document.getElementById('chat-messages') as HTMLDivElement; + Object.defineProperty(messages, 'clientHeight', { configurable: true, value: 400 }); + Object.defineProperty(messages, 'scrollHeight', { configurable: true, value: 1200 }); + + ui = createChatUI(); + const handle = ui.createStreamingMessage('assistant'); + handle.update('partial answer'); + messages.scrollTop = 100; + + handle.cancel(); + + expect(messages.scrollTop).toBe(100); + expect(document.querySelector('.chat-row')).not.toBeNull(); + }); + it('should finalize generated images without regenerate controls', () => { document.body.innerHTML = '
'; ui = createChatUI(); - const handle = ui.createImageGenerationMessage({ - onCancel: vi.fn(), - }); + const handle = ui.createImageGenerationMessage(); handle.finalize({ text: 'caption', @@ -341,23 +388,95 @@ describe('ChatUI lifecycle', () => { }); expect(document.querySelector('.chat-generated-control.is-regenerate')).toBeNull(); + expect(document.querySelector('.chat-generated-controls')).toBeNull(); + expect(document.querySelector('.chat-generated-caption')?.textContent).toBe('caption'); + expect((document.querySelector('.chat-generated-image') as HTMLImageElement).src).toContain( + 'data:image/png;base64,ZmFrZQ==', + ); + }); + + it('should suppress default generated image ready caption', () => { + document.body.innerHTML = '
'; + + ui = createChatUI(); + const handle = ui.createImageGenerationMessage(); + + handle.finalize({ + text: 'Generated image', + images: [{ mime: 'image/png', data_base64: 'ZmFrZQ==' }], + }); + + expect(document.querySelector('.chat-generated-caption')?.textContent).toBe(''); expect( - document.querySelector('.chat-generated-controls')?.classList.contains('hidden'), + document.querySelector('.chat-image-generation')?.classList.contains('has-no-caption'), ).toBe(true); - expect(document.querySelector('.chat-generated-caption')?.textContent).toBe('caption'); + }); + + it('should restore assistant image history with generated image layout', () => { + document.body.innerHTML = '
'; + + ui = createChatUI(); + ui.renderHistory([ + { + role: 'assistant', + content: 'Generated image', + opts: { + images: [{ mime: 'image/png', data_base64: 'ZmFrZQ==' }], + }, + }, + ]); + + expect(document.querySelector('.chat-row--generated-image')).not.toBeNull(); + expect(document.querySelector('.chat-bubble--media')).toBeNull(); expect((document.querySelector('.chat-generated-image') as HTMLImageElement).src).toContain( 'data:image/png;base64,ZmFrZQ==', ); + expect( + document.querySelector('.chat-image-generation')?.classList.contains('has-no-caption'), + ).toBe(true); }); - it('should render image progress percent and speed separately from status text', () => { + it('should ignore generated image payloads with unsafe mime or base64', () => { document.body.innerHTML = '
'; ui = createChatUI(); - const handle = ui.createImageGenerationMessage({ - onCancel: vi.fn(), + ui.appendMessage('assistant', 'image', { + images: [{ mime: 'image/svg+xml', data_base64: '' }], + skipAnimation: true, }); + expect(document.querySelector('.chat-img, .chat-generated-image')).toBeNull(); + expect(document.querySelector('.chat-save-image-btn')).toBeNull(); + }); + + it('should render attachment file names as text only', () => { + document.body.innerHTML = '
'; + + ui = createChatUI(); + ui.appendMessage('user', 'uploaded', { + attachments: [ + { + name: '.txt', + type: 'text/plain', + size: 4, + data_base64: '', + tokens: 3, + }, + ], + skipAnimation: true, + }); + + const name = document.querySelector('.media-name'); + expect(name?.textContent).toBe('.txt'); + expect(name?.querySelector('img')).toBeNull(); + }); + + it('should render image progress percent and speed separately from status text', () => { + document.body.innerHTML = '
'; + + ui = createChatUI(); + const handle = ui.createImageGenerationMessage(); + handle.setStatus('image status=running percent=40 step=8 total=20 speed=1.25it/s'); expect(document.querySelector('.chat-generated-status')?.textContent).toBe( @@ -375,9 +494,7 @@ describe('ChatUI lifecycle', () => { document.body.innerHTML = '
'; ui = createChatUI(); - const handle = ui.createImageGenerationMessage({ - onCancel: vi.fn(), - }); + const handle = ui.createImageGenerationMessage(); handle.setStatus('image status=running elapsed=12s'); @@ -393,16 +510,9 @@ describe('ChatUI lifecycle', () => { document.body.innerHTML = '
'; ui = createChatUI(); - const handle = ui.createImageGenerationMessage({ - onCancel: vi.fn(), - }); - - const cancelBtn = document.querySelector('.chat-generated-control.is-cancel'); - if (!(cancelBtn instanceof HTMLButtonElement)) { - throw new Error('cancel button not found'); - } + const handle = ui.createImageGenerationMessage(); - cancelBtn.click(); + handle.cancel(); handle.fail('error sending request for url (http://localhost:8082/sdapi/v1/txt2img)'); expect( @@ -419,6 +529,80 @@ describe('ChatUI lifecycle', () => { ).toBe(true); }); + it('should keep generated image preview pinned when the chat was already at bottom', () => { + document.body.innerHTML = '
'; + const messages = document.getElementById('chat-messages') as HTMLDivElement; + + Object.defineProperty(messages, 'clientHeight', { configurable: true, value: 400 }); + Object.defineProperty(messages, 'scrollHeight', { + configurable: true, + get: () => + document.querySelector('.chat-generated-media:not(.hidden)') === null ? 1000 : 2000, + }); + + ui = createChatUI(); + const handle = ui.createImageGenerationMessage(); + expect(messages.scrollTop).toBe(1000); + + handle.setPreview('data:image/png;base64,dGVzdA=='); + + expect(messages.scrollTop).toBe(2000); + }); + + it('should not force generated image preview to bottom when the user scrolled up', () => { + document.body.innerHTML = '
'; + const messages = document.getElementById('chat-messages') as HTMLDivElement; + + Object.defineProperty(messages, 'clientHeight', { configurable: true, value: 400 }); + Object.defineProperty(messages, 'scrollHeight', { + configurable: true, + get: () => + document.querySelector('.chat-generated-media:not(.hidden)') === null ? 1000 : 2000, + }); + + ui = createChatUI(); + const handle = ui.createImageGenerationMessage(); + messages.scrollTop = 100; + + handle.setPreview('data:image/png;base64,dGVzdA=='); + + expect(messages.scrollTop).toBe(100); + }); + + it('should not force generated image finalization to bottom when the user scrolled up', () => { + document.body.innerHTML = '
'; + const messages = document.getElementById('chat-messages') as HTMLDivElement; + + Object.defineProperty(messages, 'clientHeight', { configurable: true, value: 400 }); + Object.defineProperty(messages, 'scrollHeight', { + configurable: true, + get: () => + document.querySelector('.chat-generated-media:not(.hidden)') === null ? 1000 : 2000, + }); + + ui = createChatUI(); + const handle = ui.createImageGenerationMessage(); + messages.scrollTop = 100; + + handle.finalize({ + text: 'done', + images: [{ mime: 'image/png', data_base64: 'dGVzdA==' }], + }); + + expect(messages.scrollTop).toBe(100); + }); + + it('should ignore unsafe live generated image preview URLs', () => { + document.body.innerHTML = '
'; + + ui = createChatUI(); + const handle = ui.createImageGenerationMessage(); + handle.setPreview('data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='); + + expect(document.querySelector('.chat-generated-media:not(.hidden)')).toBeNull(); + expect(document.querySelector('.chat-generated-image')?.src).toBe(''); + }); + it('should scroll chat history to the bottom after restore', () => { vi.useFakeTimers(); document.body.innerHTML = '
'; @@ -523,7 +707,7 @@ describe('ChatUI lifecycle', () => { vi.mocked(invoke).mockRejectedValueOnce(new TypeError('Saved image does not exist')); saveButton.click(); - await flushPromises(2); + await flushPromises(6); expect(saveButton.classList.contains('chat-save-image-btn')).toBe(true); expect(saveButton.classList.contains('chat-open-image-folder-btn')).toBe(false); @@ -554,7 +738,7 @@ describe('ChatUI lifecycle', () => { expect(saveButton.classList.contains('is-trash-state')).toBe(true); vi.advanceTimersByTime(300); - await flushPromises(2); + await flushPromises(4); expect(invoke).toHaveBeenLastCalledWith('delete_chat_image', { filePath: savedImageFilePath, @@ -584,6 +768,95 @@ describe('ChatUI lifecycle', () => { expect(preview.getAttribute('src')).toBeNull(); }); + it('should close image preview when clicking empty viewer stage', async () => { + ui = createChatUI(); + await renderAssistantImage(ui, true); + + const image = requireChatImage(); + image.click(); + await flushPromises(); + + const { overlay, preview } = requireImageViewer(); + const stage = document.querySelector('.chat-image-viewer-stage'); + if (!(stage instanceof HTMLElement)) { + throw new TypeError('image viewer stage not found'); + } + + stage.click(); + await flushPromises(); + + expect(overlay.classList.contains('hidden')).toBe(true); + expect(preview.getAttribute('src')).toBeNull(); + }); + + it('should switch between chat images from the image viewer', async () => { + renderImageChatBody(); + ui = createChatUI(); + await ui.init(); + + ui.appendMessage('assistant', 'image', { + images: [ + { mime: 'image/png', data_base64: 'b25l' }, + { mime: 'image/png', data_base64: 'dHdv' }, + ], + skipAnimation: true, + }); + + const firstImage = document.querySelector('.chat-img'); + if (!(firstImage instanceof HTMLImageElement)) { + throw new TypeError('first chat image not found'); + } + + firstImage.click(); + await flushPromises(); + + const { overlay, preview } = requireImageViewer(); + const { next, prev } = requireImageViewerNav(); + + expect(overlay.classList.contains('hidden')).toBe(false); + expect(preview.src.startsWith('data:image/png;base64,b25l')).toBe(true); + + next.click(); + await flushPromises(); + + expect(overlay.classList.contains('hidden')).toBe(false); + expect(preview.src.startsWith('data:image/png;base64,dHdv')).toBe(true); + + prev.click(); + await flushPromises(); + + expect(preview.src.startsWith('data:image/png;base64,b25l')).toBe(true); + }); + + it('should preserve native browser zoom shortcuts while image preview is open', async () => { + ui = createChatUI(); + await renderAssistantImage(ui, true); + + const image = requireChatImage(); + image.click(); + await flushPromises(); + + const wheelEvent = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + ctrlKey: true, + deltaY: 100, + }); + document.dispatchEvent(wheelEvent); + + expect(wheelEvent.defaultPrevented).toBe(false); + + const keyEvent = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + ctrlKey: true, + key: '+', + }); + document.dispatchEvent(keyEvent); + + expect(keyEvent.defaultPrevented).toBe(false); + }); + it('should not open image preview for thumbnails without a usable source', async () => { renderImageChatBody(); document diff --git a/src/features/chat/ui/ChatUI.ts b/src/features/chat/ui/ChatUI.ts index 50d99928..96c3fa59 100644 --- a/src/features/chat/ui/ChatUI.ts +++ b/src/features/chat/ui/ChatUI.ts @@ -3,7 +3,6 @@ import { ChatAttachmentRenderer } from './ChatAttachmentRenderer'; import { extractErrorMessage, safeExtractText } from './ChatContentFormatter'; import { createChatImageGenerationMessage } from './ChatImageGenerationMessage'; import { ChatImageController } from './ChatImageController'; -import { ChatInputContextMenu } from './ChatInputContextMenu'; import { configureChatMarkdown } from './ChatMarkdown'; import { ChatMessageInteractionController } from './ChatMessageInteractionController'; import { ChatMessageRenderer } from './ChatMessageRenderer'; @@ -40,7 +39,6 @@ type ChatUIDeps = { isTauriRuntime: () => boolean; openExternalUrl: (url: string) => Promise; copyText: (text: string) => Promise; - readClipboardText: () => Promise; tracer: Pick; }; @@ -67,7 +65,6 @@ export class ChatUI { private _attachmentRenderVersion = 0; private readonly _attachmentRenderer: ChatAttachmentRenderer; private readonly _imageController: ChatImageController; - private readonly _inputContextMenu: ChatInputContextMenu; private readonly _messageInteractionController: ChatMessageInteractionController; private readonly _messageRenderer: ChatMessageRenderer; private readonly _typingController: ChatTypingController; @@ -102,25 +99,6 @@ export class ChatUI { translate: this._translate, tracer: deps.tracer, }); - this._inputContextMenu = new ChatInputContextMenu({ - translate: this._translate, - copyText: (text) => deps.copyText(text), - readClipboardText: () => deps.readClipboardText(), - canPaste: () => { - const browserGlobals = globalThis as unknown as { - navigator?: { - clipboard?: { - readText?: unknown; - }; - }; - }; - return ( - deps.isTauriRuntime() || - typeof browserGlobals.navigator?.clipboard?.readText === 'function' - ); - }, - tracer: deps.tracer, - }); this._attachmentRenderer = new ChatAttachmentRenderer({ fileHandler: deps.fileHandler, isDestroyed: () => this._isDestroyed, @@ -174,7 +152,6 @@ export class ChatUI { if (this._isInitialized || this._isDestroyed) return; this._isInitialized = true; document.addEventListener('click', this._boundDocumentClick); - this._inputContextMenu.bind(this._dom.chatInput); await this._retryStatusListener.bind(); } @@ -184,7 +161,6 @@ export class ChatUI { this._isInitialized = false; document.removeEventListener('click', this._boundDocumentClick); - this._inputContextMenu.destroy(); this._retryStatusListener.destroy(); this._imageController.destroy(); this._attachmentRenderer.revokeAttachmentObjectUrls(); @@ -235,17 +211,26 @@ export class ChatUI { ): void { this._prepareContainer(); + const rawImages = opts['images']; + const primaryImage = this._getPrimaryImage(rawImages); + const isSingleAssistantImage = + role === 'assistant' && Array.isArray(rawImages) && rawImages.length === 1; + if (isSingleAssistantImage && primaryImage !== null) { + const handle = this.createImageGenerationMessage(); + handle.finalize({ + text: safeExtractText(content, this._translate), + images: rawImages as ChatImagePayload[], + }); + return; + } + const row = document.createElement('div'); row.className = `chat-row ${role === 'user' ? 'user' : 'bot'}`; const safeContent = safeExtractText(content, this._translate); const bubble = this._createMessageBubble(opts); - const actions = this._appendMessageActions( - safeContent, - role, - this._getPrimaryImage(opts['images']), - ); + const actions = this._appendMessageActions(safeContent, role, primaryImage); const textNode = this._createMessageTextNode(safeContent, opts); bubble.appendChild(textNode); @@ -307,16 +292,14 @@ export class ChatUI { }); } - public createImageGenerationMessage(opts: { - onCancel: () => void | Promise; - }): ImageGenerationMessageHandle { + public createImageGenerationMessage(): ImageGenerationMessageHandle { this._prepareContainer(); return createChatImageGenerationMessage({ - opts, translate: this._translate, isDestroyed: () => this._isDestroyed, tracer: this._deps.tracer, scrollToBottom: (sticky) => this._scrollToBottom(sticky), + isNearBottom: () => this._isNearBottom(), appendRow: (row) => { this._dom.messagesContainer?.appendChild(row); }, @@ -340,6 +323,10 @@ export class ChatUI { this._viewportController.scrollToBottom(this._dom.messagesContainer, sticky); } + private _isNearBottom(): boolean { + return this._viewportController.isNearBottom(this._dom.messagesContainer); + } + public revealLatestMessage(): void { this._scrollToBottom(); this._setManagedTimeout(() => { diff --git a/src/features/chat/ui/ChatUiRetryStatusListener.ts b/src/features/chat/ui/ChatUiRetryStatusListener.ts index c074f5fc..5781126b 100644 --- a/src/features/chat/ui/ChatUiRetryStatusListener.ts +++ b/src/features/chat/ui/ChatUiRetryStatusListener.ts @@ -12,6 +12,7 @@ type ChatUiRetryStatusListenerDeps = { export class ChatUiRetryStatusListener { private _unlisten: (() => void) | null = null; + private _bindToken: { cancelled: boolean } | null = null; public constructor(private readonly _deps: ChatUiRetryStatusListenerDeps) {} @@ -20,12 +21,24 @@ export class ChatUiRetryStatusListener { return; } - this._unlisten = await listen('ai:status:retry', (event) => { + const bindToken = { cancelled: false }; + this._bindToken = bindToken; + const unlisten = await listen('ai:status:retry', (event) => { this._deps.onRetryStatus(event.payload); }); + if (bindToken.cancelled) { + unlisten(); + return; + } + + this._unlisten = unlisten; } public destroy(): void { + if (this._bindToken !== null) { + this._bindToken.cancelled = true; + this._bindToken = null; + } this._unlisten?.(); this._unlisten = null; } diff --git a/src/features/chat/ui/ChatUiTypes.ts b/src/features/chat/ui/ChatUiTypes.ts new file mode 100644 index 00000000..72340f0e --- /dev/null +++ b/src/features/chat/ui/ChatUiTypes.ts @@ -0,0 +1 @@ +export type ChatTranslateFunction = (key: string, defaultValue?: string) => string; diff --git a/src/features/chat/ui/ChatViewportController.ts b/src/features/chat/ui/ChatViewportController.ts index e278dc17..7d4626c8 100644 --- a/src/features/chat/ui/ChatViewportController.ts +++ b/src/features/chat/ui/ChatViewportController.ts @@ -28,19 +28,24 @@ export class ChatViewportController { return; } - if (sticky) { - const threshold = 150; - const isAtBottom = - messagesContainer.scrollHeight - - messagesContainer.scrollTop - - messagesContainer.clientHeight < - threshold; - - if (!isAtBottom) { - return; - } + if (sticky && !this.isNearBottom(messagesContainer)) { + return; } messagesContainer.scrollTop = messagesContainer.scrollHeight; } + + public isNearBottom(messagesContainer: HTMLElement | null): boolean { + if (messagesContainer === null) { + return false; + } + + const threshold = 150; + return ( + messagesContainer.scrollHeight - + messagesContainer.scrollTop - + messagesContainer.clientHeight < + threshold + ); + } } diff --git a/src/features/console/services/ConsoleLogNormalizer.ts b/src/features/console/services/ConsoleLogNormalizer.ts index 4946deb0..85ad8443 100644 --- a/src/features/console/services/ConsoleLogNormalizer.ts +++ b/src/features/console/services/ConsoleLogNormalizer.ts @@ -63,6 +63,22 @@ export class ConsoleLogNormalizer { }; } + const plainRuntimeLevelMatch = rawMessage.match( + /^(?:(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+)?(TRACE|DEBUG|INFO|WARN|WARNING|ERROR)\s+(?:\[([^\]]+)\]\s+)?([\s\S]+)$/i, + ); + if (plainRuntimeLevelMatch !== null) { + const rawTime = plainRuntimeLevelMatch[1] ?? null; + const rawLevel = plainRuntimeLevelMatch[2] ?? log.level; + const rawScope = plainRuntimeLevelMatch[3]?.trim() ?? null; + const rawBody = plainRuntimeLevelMatch[4] ?? rawMessage; + return { + time: rawTime !== null && rawTime.trim() !== '' ? rawTime.slice(11) : null, + level: this._normalizeLevel(rawLevel), + scope: rawScope, + message: rawBody.trim(), + }; + } + const scopedMatch = rawMessage.match(/^\[([^\]]+)\]\s+([\s\S]+)$/); if (scopedMatch !== null) { return { diff --git a/src/features/console/services/ConsoleLogService.test.ts b/src/features/console/services/ConsoleLogService.test.ts index 3ec2179d..9c920953 100644 --- a/src/features/console/services/ConsoleLogService.test.ts +++ b/src/features/console/services/ConsoleLogService.test.ts @@ -1,24 +1,17 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ConsoleLogService, type ILogEntry } from './ConsoleLogService'; import type { IBridge } from '@/shared/types/IBridge'; import { createMockBridge } from '@/test/mocks/mockBridge'; import * as invokeModule from '@/shared/api/invoke'; -function setupTauri(bridge: IBridge, isTauri = true, invokeReturn?: unknown) { + +function setupTauri(bridge: IBridge, isTauri = true): void { vi.mocked(bridge.isTauri).mockReturnValue(isTauri); - if (invokeReturn !== undefined) { - vi.mocked(bridge.invoke).mockResolvedValue(invokeReturn); - } } describe('ConsoleLogService', () => { let bridge: IBridge; let service: ConsoleLogService; - const mockLogs: ILogEntry[] = [ - { timestamp: 100, source: 'TEST', level: 'INFO', message: 'Test log 1' }, - { timestamp: 200, source: 'TEST', level: 'ERROR', message: 'Test log 2' }, - ]; - beforeEach(() => { vi.restoreAllMocks(); bridge = createMockBridge(); @@ -28,633 +21,146 @@ describe('ConsoleLogService', () => { }); }); - it('should fetch logs via generic bridge when isTauri is true', async () => { - setupTauri(bridge, true, mockLogs); - - const logs = await service.fetchLogs(); - - expect(bridge.invoke).toHaveBeenCalledWith('get_logs', { since: 0 }); - expect(logs).toHaveLength(2); - expect(logs[0]?.message).toBe('Test log 1'); - }); - - it('should trust backend-filtered log payloads without extra frontend filtering', async () => { - const filteredLogs: ILogEntry[] = [ - { timestamp: 200, source: 'TEST', level: 'ERROR', message: 'Real Error' }, - ]; - setupTauri(bridge, true, filteredLogs); - - const logs = await service.fetchLogs(); - - expect(logs).toHaveLength(1); - expect(logs[0]?.message).toBe('Real Error'); - }); - - it('should clear logs via bridge', async () => { - setupTauri(bridge, true); - - await service.clearLogs(); - - expect(bridge.invoke).toHaveBeenCalledWith('clear_console_logs', { viewId: 'general' }); - expect(service.getLogs()).toHaveLength(0); - }); - - it('should fetch logs via bridge mock when bridge is not Tauri', async () => { - setupTauri(bridge, false); - vi.mocked(bridge.invoke).mockResolvedValue(mockLogs); - - const logs = await service.fetchLogs(); - - expect(bridge.invoke).toHaveBeenCalledWith('get_logs', { since: 0 }); - expect(logs).toHaveLength(2); - }); - - it.each([ - [ - 'Tauri invoke error', - true, - () => vi.mocked(bridge.invoke).mockRejectedValue(new Error('Backend down')), - ], - [ - 'non-Tauri bridge error', - false, - () => vi.mocked(bridge.invoke).mockRejectedValue(new Error('Network')), - ], - ])('should return empty array on fetchLogs %s', async (_, isTauriFlag, setupMock) => { - setupTauri(bridge, isTauriFlag); - setupMock(); - const logs = await service.fetchLogs(); - expect(logs).toHaveLength(0); - }); - - it('should clear logs via bridge when not Tauri', async () => { - setupTauri(bridge, false); - vi.mocked(bridge.invoke).mockResolvedValue(null); - const result = await service.clearLogs(); - expect(result).toBe(true); - expect(bridge.invoke).toHaveBeenCalledWith('clear_console_logs', { viewId: 'general' }); - }); - - it('should clear only the selected engine view', async () => { - setupTauri(bridge, true); - vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ - status: 'ok', - data: { - views: [ - { id: 'general', label: 'General' }, - { id: 'engine:sdcpp', label: 'Stable Diffusion.cpp' }, - ], - status_items: [], - }, - }); - vi.mocked(bridge.invoke).mockImplementation((command) => { - if (command === 'get_logs') { - return Promise.resolve([ - { timestamp: 1, source: 'frontend', level: 'ERROR', message: 'launcher' }, - { timestamp: 2, source: 'sdcpp', level: 'INFO', message: 'engine' }, - ]); - } - return Promise.resolve(undefined); - }); - - await service.fetchLogs(); - await service.getAvailableViews(); - await service.clearLogs('engine:stable-diffusion'); - - expect(bridge.invoke).toHaveBeenCalledWith('clear_console_logs', { - viewId: 'engine:sdcpp', - }); - expect(service.getLogsForView('general')).toEqual([ - expect.objectContaining({ message: 'launcher' }), - ]); - expect(service.getLogsForView('engine:sdcpp')).toEqual([]); - }); - - it('should return false on clearLogs error', async () => { + it('fetches only the requested console view in Tauri mode', async () => { setupTauri(bridge, true); - vi.mocked(bridge.invoke).mockRejectedValue(new Error('Clear fail')); - const result = await service.clearLogs(); - expect(result).toBe(false); - }); - - it('should handle empty or non-array processLogs', async () => { - setupTauri(bridge, true, []); - const logs = await service.fetchLogs(); - expect(logs).toHaveLength(0); - }); - - it('should truncate logs beyond 2000', async () => { - setupTauri(bridge, true); - // Fill logs with 2001 entries across multiple fetches - const batch = Array.from({ length: 1500 }, (_, i) => ({ - timestamp: i, - source: 'TEST', - level: 'INFO', - message: `Log ${i}`, - })); - vi.mocked(bridge.invoke).mockResolvedValue(batch); - await service.fetchLogs(); - // Add another batch to exceed 2000 - const batch2 = Array.from({ length: 600 }, (_, i) => ({ - timestamp: 1500 + i, - source: 'TEST', - level: 'INFO', - message: `Log ${1500 + i}`, - })); - vi.mocked(bridge.invoke).mockResolvedValue(batch2); - await service.fetchLogs(); - expect(service.getLogs().length).toBeLessThanOrEqual(1000); - }); - - it('should handle logs with null/undefined message and source (L63-64)', async () => { - const logsWithNulls: ILogEntry[] = [ + vi.mocked(bridge.invoke).mockResolvedValue([ { timestamp: 100, - source: undefined as unknown as string, + source: 'module:axelate-telegram-parser', level: 'INFO', - message: undefined as unknown as string, - }, - ]; - setupTauri(bridge, true, logsWithNulls); - const logs = await service.fetchLogs(); - // Should not crash — null/undefined safely coerced to '' - expect(logs).toHaveLength(1); - }); - - it('should return empty when backend returns no new logs', async () => { - setupTauri(bridge, true, []); - const logs = await service.fetchLogs(); - expect(logs).toHaveLength(0); - }); - - it('should handle non-array bridge payloads as empty logs', async () => { - setupTauri(bridge, false, '' as unknown as ILogEntry[]); - const logs = await service.fetchLogs(); - expect(logs).toHaveLength(0); - }); - - it('should use previous lastTimestamp when last log has no timestamp (L93)', async () => { - setupTauri(bridge, true); - // First call sets lastTimestamp - const firstBatch: ILogEntry[] = [ - { timestamp: 500, source: 'APP', level: 'INFO', message: 'ok' }, - ]; - vi.mocked(bridge.invoke).mockResolvedValueOnce(firstBatch); - await service.fetchLogs(); - - // Second call has a log without a timestamp at the end - const secondBatch: ILogEntry[] = [ - { - timestamp: undefined as unknown as number, - source: 'SYS', - level: 'WARN', - message: 'no ts', + message: '2026-04-29 14:33:32 [INFO] telegram_parser: started', + module_id: 'axelate-telegram-parser', }, - ]; - vi.mocked(bridge.invoke).mockResolvedValueOnce(secondBatch); - const logs = await service.fetchLogs(); - expect(logs).toHaveLength(1); - }); + ] satisfies ILogEntry[]); - it('should subscribe to engine events and append engine logs in Tauri mode', async () => { - const unlisten = vi.fn(); - const listeners = new Map void>(); + const logs = await service.fetchLogs('module:axelate-telegram-parser'); - setupTauri(bridge, true); - vi.mocked(bridge.listen).mockImplementation( - (event: string, callback: (payload: T) => void) => { - listeners.set(event, callback as (payload: unknown) => void); - return Promise.resolve(unlisten); - }, - ); - - await service.init(); - - listeners.get('ai:engine:starting')?.({ engine_id: 'llamacpp' }); - listeners.get('ai:engine:log')?.({ - engine_id: 'llamacpp', - line: 'ready line', - }); - listeners.get('ai:engine:error')?.({ - engine_id: 'llamacpp', - message: 'boom', + expect(bridge.invoke).toHaveBeenCalledWith('get_console_logs', { + viewId: 'module:axelate-telegram-parser', + since: 0, }); - - expect(service.getLogsForView('engine:llamacpp')).toEqual([ + expect(logs).toEqual([ expect.objectContaining({ - source: 'llamacpp', - level: 'info', - message: 'Engine is starting...', + module_id: 'axelate-telegram-parser', + message: 'started', + scope: 'telegram_parser', }), - expect.objectContaining({ - source: 'llamacpp', - level: 'info', - message: 'ready line', - }), - expect.objectContaining({ - source: 'llamacpp', - level: 'error', - message: 'boom', - }), - ]); - - service.destroy(); - expect(unlisten).toHaveBeenCalledTimes(4); - }); - - it('should ignore noisy engine events', async () => { - const listeners = new Map void>(); - - setupTauri(bridge, true); - vi.mocked(bridge.listen).mockImplementation( - (event: string, callback: (payload: T) => void) => { - listeners.set(event, callback as (payload: unknown) => void); - return Promise.resolve(vi.fn()); - }, - ); - - await service.init(); - listeners.get('ai:engine:log')?.({ - engine_id: 'llamacpp', - line: '[AIBridge] Stream chunk received', - }); - - expect(service.getLogsForView('engine:llamacpp')).toEqual([]); - }); - - it('should expose General plus ready engine tabs', async () => { - setupTauri(bridge, true); - const invokeSafeSpy = vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ - status: 'ok', - data: { - views: [ - { id: 'general', label: 'General' }, - { id: 'engine:llamacpp', label: 'LLaMA.cpp' }, - ], - status_items: [], - }, - }); - - const views = await service.getAvailableViews(); - - expect(invokeSafeSpy).toHaveBeenCalledWith('get_console_overview'); - expect(views).toEqual([ - { id: 'general', label: 'General' }, - { id: 'engine:llamacpp', label: 'LLaMA.cpp' }, ]); + expect(service.getLogsForView('module:axelate-telegram-parser')).toHaveLength(1); + expect(service.getLogsForView('general')).toEqual([]); }); - it('should trust backend-computed views when running in tauri', async () => { + it('normalizes plain backend timestamp level lines', async () => { setupTauri(bridge, true); - vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ - status: 'ok', - data: { - views: [ - { id: 'general', label: 'General' }, - { id: 'module:sample-integration', label: 'Sample Integration' }, - ], - status_items: [], + vi.mocked(bridge.invoke).mockResolvedValue([ + { + timestamp: 100, + source: 'backend', + level: 'INFO', + message: '2026-05-05 19:21:40 ERROR [ModuleService] Control failed', }, - }); - vi.mocked(bridge.invoke).mockImplementation((command) => { - if (command === 'get_logs') { - return Promise.resolve([ - { - timestamp: 1, - source: 'module:sample-integration', - level: 'INFO', - message: 'Started', - module_id: 'sample-integration', - }, - ]); - } - return Promise.resolve(undefined); - }); - - await service.fetchLogs(); - const views = await service.getAvailableViews(); - - expect(views).toEqual([ - { id: 'general', label: 'General' }, - { id: 'module:sample-integration', label: 'Sample Integration' }, - ]); - }); - - it('should keep General limited to useful launcher logs and route module logs to module tabs', async () => { - setupTauri(bridge, true); - vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ - status: 'ok', - data: { - views: [ - { id: 'general', label: 'General' }, - { id: 'engine:llamacpp', label: 'LLaMA.cpp' }, - { id: 'module:llamacpp', label: 'Llamacpp' }, - ], - status_items: [], + { + timestamp: 101, + source: 'backend', + level: 'INFO', + message: '2026-05-05 19:21:40 WARN [GlobalBridge] Failed to start local module', }, - }); - vi.mocked(bridge.invoke).mockImplementation((command) => { - if (command === 'get_logs') { - return Promise.resolve([ - { - timestamp: 1, - source: 'frontend', - level: 'INFO', - message: '[NavigationService] Navigating to: console', - }, - { - timestamp: 2, - source: 'frontend', - level: 'INFO', - message: '[AIBridge] Starting provider: llamacpp', - module_id: 'llamacpp', - }, - { - timestamp: 3, - source: 'llamacpp', - level: 'INFO', - message: 'ready line', - }, - { - timestamp: 4, - source: 'frontend', - level: 'WARN', - message: '[WindowService] setSize failed', - }, - ]); - } - return Promise.resolve(undefined); - }); + ] satisfies ILogEntry[]); - await service.fetchLogs(); - await service.getAvailableViews(); + const logs = await service.fetchLogs('general'); - expect(service.getLogsForView('general')).toEqual([ - expect.objectContaining({ - source: 'frontend', - message: 'setSize failed', - }), - ]); - expect(service.getLogsForView('module:llamacpp')).toEqual([ + expect(logs).toEqual([ expect.objectContaining({ - source: 'frontend', - message: 'Starting provider: llamacpp', + display_time: '19:21:40', + normalized_level: 'ERROR', + scope: 'ModuleService', + message: 'Control failed', }), - ]); - expect(service.getLogsForView('engine:llamacpp')).toEqual([ expect.objectContaining({ - source: 'llamacpp', - message: 'ready line', + display_time: '19:21:40', + normalized_level: 'WARN', + scope: 'GlobalBridge', + message: 'Failed to start local module', }), ]); }); - it('should filter noisy startup and progress logs before rendering', async () => { + it('tracks timestamps per view without cross-view filtering', async () => { setupTauri(bridge, true); - vi.mocked(bridge.invoke).mockImplementation((command) => { - if (command === 'get_logs') { - return Promise.resolve([ - { - timestamp: 1, - source: 'frontend', - level: 'INFO', - message: '[CoreRuntimeSupport] Critical services hydrated.', - }, - { - timestamp: 2, - source: 'frontend', - level: 'INFO', - message: '[CatalogService] Catalog initialized. AI: 10, Services: 1', - }, - { - timestamp: 3, - source: 'sdcpp', - level: 'INFO', - message: '2026-04-24 07:00:00 [INFO] |====> | 8/28 - 1.03it/s', - }, - { - timestamp: 4, - source: 'frontend', - level: 'ERROR', - message: '[CatalogService] Failed to load catalog: boom', - }, - ]); - } - return Promise.resolve(undefined); - }); + vi.mocked(bridge.invoke) + .mockResolvedValueOnce([ + { timestamp: 10, source: 'frontend', level: 'INFO', message: 'platform' }, + ] satisfies ILogEntry[]) + .mockResolvedValueOnce([ + { timestamp: 20, source: 'sdcpp', level: 'INFO', message: 'engine' }, + ] satisfies ILogEntry[]) + .mockResolvedValueOnce([ + { timestamp: 12, source: 'frontend', level: 'INFO', message: 'platform later' }, + ] satisfies ILogEntry[]); - await service.fetchLogs(); + await service.fetchLogs('general'); + await service.fetchLogs('engine:sdcpp'); + await service.fetchLogs('general'); - expect(service.getLogsForView('general')).toEqual([ - expect.objectContaining({ - level: 'ERROR', - message: 'Failed to load catalog: boom', - }), - ]); - expect(service.getLogsForView('engine:sdcpp')).toEqual([]); - }); - - it('should route stable-diffusion alias logs into the sdcpp engine tab', async () => { - setupTauri(bridge, true); - vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ - status: 'ok', - data: { - views: [ - { id: 'general', label: 'General' }, - { id: 'engine:sdcpp', label: 'Stable Diffusion.cpp' }, - ], - status_items: [], - }, + expect(bridge.invoke).toHaveBeenNthCalledWith(1, 'get_console_logs', { + viewId: 'general', + since: 0, }); - vi.mocked(bridge.invoke).mockImplementation((command) => { - if (command === 'get_logs') { - return Promise.resolve([ - { - timestamp: 1, - source: 'stable-diffusion', - level: 'INFO', - message: '2026-04-24 07:00:00 [INFO] loaded model', - }, - ]); - } - return Promise.resolve(undefined); + expect(bridge.invoke).toHaveBeenNthCalledWith(2, 'get_console_logs', { + viewId: 'engine:sdcpp', + since: 0, }); - - await service.fetchLogs(); - await service.getAvailableViews(); - - expect(service.getLogsForView('engine:sdcpp')).toEqual([ - expect.objectContaining({ - source: 'sdcpp', - message: 'loaded model', - }), - ]); - expect(service.getLogsForView('general')).toEqual([]); - }); - - it('should collapse duplicate engine views by label', async () => { - setupTauri(bridge, true); - vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ - status: 'ok', - data: { - views: [ - { id: 'general', label: 'General' }, - { id: 'engine:first-backend', label: 'Shared Engine' }, - { id: 'engine:second-backend', label: 'Shared Engine' }, - { id: 'engine:other-backend', label: 'Other Engine' }, - ], - status_items: [], - }, + expect(bridge.invoke).toHaveBeenNthCalledWith(3, 'get_console_logs', { + viewId: 'general', + since: 10, }); - - const views = await service.getAvailableViews(); - - expect(views).toEqual([ - { id: 'general', label: 'General' }, - { id: 'engine:first-backend', label: 'Shared Engine' }, - { id: 'engine:other-backend', label: 'Other Engine' }, - ]); }); - it('should dedupe engine logs received from live events and runtime files', async () => { - const listeners = new Map void>(); - + it('uses backend-computed views without hiding custom providers', async () => { setupTauri(bridge, true); - vi.mocked(bridge.listen).mockImplementation( - (event: string, callback: (payload: T) => void) => { - listeners.set(event, callback as (payload: unknown) => void); - return Promise.resolve(vi.fn()); - }, - ); vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ status: 'ok', data: { views: [ - { id: 'general', label: 'General' }, - { id: 'engine:sdcpp', label: 'Stable Diffusion.cpp' }, + { id: 'general', label: 'Platform' }, + { id: 'module:openrouter-custom-text', label: 'Custom' }, + { id: 'module:axelate-telegram-parser', label: 'Parser' }, ], status_items: [], }, }); - vi.mocked(bridge.invoke).mockImplementation((command) => { - if (command === 'get_logs') { - return Promise.resolve([ - { - timestamp: 2, - source: 'sdcpp', - level: 'INFO', - message: '2026-04-24 07:00:00 [INFO] 8/28 - 1.03it/s', - }, - ]); - } - return Promise.resolve(undefined); - }); - - await service.init(); - listeners.get('ai:engine:log')?.({ - engine_id: 'stable-diffusion', - line: '8/28 - 1.03it/s', - }); - await service.fetchLogs(); - await service.getAvailableViews(); - expect(service.getLogsForView('engine:sdcpp')).toEqual([ - expect.objectContaining({ - source: 'sdcpp', - message: '8/28 - 1.03it/s', - }), - ]); - }); - - it('should build runtime status items for engines and modules', async () => { - setupTauri(bridge, true); - vi.spyOn(invokeModule, 'invokeSafe').mockResolvedValue({ - status: 'ok', - data: { - views: [ - { id: 'general', label: 'General' }, - { id: 'engine:llamacpp', label: 'LLaMA.cpp' }, - { id: 'module:llamacpp', label: 'Llamacpp' }, - ], - status_items: [ - { - id: 'engine:llamacpp', - label: 'LLaMA.cpp', - kind: 'engine', - status: 'running', - detail: 'text', - }, - { - id: 'module:llamacpp', - label: 'Llamacpp', - kind: 'module', - status: 'running', - detail: 'Running', - }, - ], - }, - }); - const items = await service.getStatusItems(); - - expect(items).toEqual([ - { - id: 'engine:llamacpp', - label: 'LLaMA.cpp', - kind: 'engine', - status: 'running', - detail: 'text', - }, - { - id: 'module:llamacpp', - label: 'Llamacpp', - kind: 'module', - status: 'running', - detail: 'Running', - }, + await expect(service.getAvailableViews()).resolves.toEqual([ + { id: 'general', label: 'Platform' }, + { id: 'module:openrouter-custom-text', label: 'Custom' }, + { id: 'module:axelate-telegram-parser', label: 'Parser' }, ]); }); - it('should cache module paths and open module folder', async () => { + it('clears only the requested view', async () => { setupTauri(bridge, true); - vi.mocked(bridge.invoke).mockImplementation((command) => { - if (command === 'get_module_path') { - return Promise.resolve('C:/modules/llamacpp'); - } - if (command === 'plugin:shell|open') { - return Promise.resolve(undefined); - } - return Promise.resolve(undefined); - }); + vi.mocked(bridge.invoke) + .mockResolvedValueOnce([ + { timestamp: 1, source: 'frontend', level: 'INFO', message: 'platform' }, + ] satisfies ILogEntry[]) + .mockResolvedValueOnce(undefined); - const firstPath = await service.getModulePath('llamacpp'); - const secondPath = await service.getModulePath('llamacpp'); - const opened = await service.openModuleFolder('llamacpp'); + await service.fetchLogs('general'); + const cleared = await service.clearLogs('general'); - expect(firstPath).toBe('C:/modules/llamacpp'); - expect(secondPath).toBe('C:/modules/llamacpp'); - expect(opened).toBe(true); - expect(bridge.invoke).toHaveBeenCalledWith('plugin:shell|open', { - path: 'C:/modules/llamacpp', + expect(cleared).toBe(true); + expect(bridge.invoke).toHaveBeenLastCalledWith('clear_console_logs', { + viewId: 'general', }); - expect( - vi - .mocked(bridge.invoke) - .mock.calls.filter(([command]) => command === 'get_module_path'), - ).toHaveLength(1); + expect(service.getLogsForView('general')).toEqual([]); }); - it('should open the selected logs folder', async () => { + it('opens engine log folders', async () => { setupTauri(bridge, true); - vi.mocked(bridge.invoke).mockImplementation((command) => { - if (command === 'open_console_log_target') { - return Promise.resolve(undefined); - } - return Promise.resolve(undefined); - }); + vi.mocked(bridge.invoke).mockResolvedValue(undefined); - const opened = await service.openLogsFolder('engine:stable-diffusion'); + await expect(service.openLogsFolder('engine:sdcpp')).resolves.toBe(true); - expect(opened).toBe(true); expect(bridge.invoke).toHaveBeenCalledWith('open_console_log_target', { viewId: 'engine:sdcpp', }); diff --git a/src/features/console/services/ConsoleLogService.ts b/src/features/console/services/ConsoleLogService.ts index 2885343b..593485e2 100644 --- a/src/features/console/services/ConsoleLogService.ts +++ b/src/features/console/services/ConsoleLogService.ts @@ -46,82 +46,37 @@ type ConsoleOverviewPayload = { }>; }; -type EngineEventPayloadMap = { - 'ai:engine:log': { engine_id: string; line: string }; - 'ai:engine:starting': { engine_id: string }; - 'ai:engine:ready': { engine_id: string; endpoint: string }; - 'ai:engine:error': { engine_id: string; message: string }; -}; - -type EngineEventName = keyof EngineEventPayloadMap; type ConsoleLogServiceLogger = Pick; export class ConsoleLogService { - private static readonly _MAX_LOG_COUNT = 1000; - private static readonly _TRIM_THRESHOLD = 2000; - private static readonly _NOISE_PATTERNS = [ - /\[AIBridge\] Stream chunk received/i, - /\[AIBridge\] Thought chunk received/i, - /\bCritical services hydrated\b/i, - /\bCore Ready\b/i, - /\bReady\.\s*$/i, - /\bLoading\s+[a-z-]+\.{3}$/i, - /\bLanguage changed to\b.*\bnotifications dispatched\b/i, - /\bSettings container found\b.*\bInitializing renderers\b/i, - /\bGeneralSettingsRenderer\b.*\bInitializing\b/i, - /\bInitializing taskbar toggles\b/i, - /\bInitializing monitor toggles\b/i, - /\bStarted listening to system_stats\b/i, - /\bNavigation initialized\b/i, - /\bPage\s+[a-z0-9._-]+\b/i, - /\bRestore page\s+[a-z0-9._-]+\b/i, - /\bNavigating to:\s*[a-z0-9._-]+\b/i, - /\bResolution changed:\b/i, - /\bFetched\s+\d+\s+modules\b/i, - /\bMapping config\b/i, - /\bAfter mapping\b/i, - /\b_ensureValidConfig passed\b/i, - /\bCatalog hydrated successfully\b/i, - /\bCatalog initialized\b/i, - /\bTransport initialized\b/i, - /\bListening for engine events\b/i, - /^\s*\|?[=>\s]+\|?\s*\d+\s*\/\s*\d+\s*-\s*\d+(?:\.\d+)?\s*(?:it\/s|s\/it)\s*$/i, - ]; - - private logs: ILogEntry[] = []; - private lastTimestamp = 0; - private readonly _engineUnlisteners: Array<() => void> = []; + private static readonly _MAX_LOG_COUNT_PER_VIEW = 1200; + + private readonly _logsByView = new Map(); + private readonly _lastTimestampByView = new Map(); private readonly _modulePathCache = new Map(); - private readonly _knownEngineIds = new Set(); - private readonly _knownModuleIds = new Set(); private readonly _normalizer = new ConsoleLogNormalizer(); - private _initialized = false; constructor( private readonly bridge: IBridge, private readonly _tracer: ConsoleLogServiceLogger, ) {} - public async init(): Promise { - if (this._initialized || !this.bridge.isTauri()) { - return; - } - - this._initialized = true; - await this._registerEngineListeners(); + public init(): Promise { + return Promise.resolve(); } public destroy(): void { - this._engineUnlisteners.splice(0).forEach((unlisten) => { - unlisten(); - }); - this._initialized = false; + this._logsByView.clear(); + this._lastTimestampByView.clear(); } - public async fetchLogs(): Promise { + public async fetchLogs(viewId = 'general'): Promise { + const normalizedViewId = this._canonicalViewId(viewId); + const since = this._lastTimestampByView.get(normalizedViewId) ?? 0; + try { - const logs = await this._fetchTauriLogs(); - return this._processLogs(logs); + const logs = await this._fetchTauriLogs(normalizedViewId, since); + return this._appendLogs(normalizedViewId, logs); } catch (error) { this._tracer.error('[ConsoleLogService] Fetch logs failed:', error); return []; @@ -130,10 +85,11 @@ export class ConsoleLogService { public async clearLogs(viewId = 'general'): Promise { const normalizedViewId = this._canonicalViewId(viewId); - this.logs = this.logs.filter((entry) => !this._isLogInView(entry, normalizedViewId)); try { await this.bridge.invoke('clear_console_logs', { viewId: normalizedViewId }); + this._logsByView.set(normalizedViewId, []); + this._lastTimestampByView.set(normalizedViewId, 0); return true; } catch (error) { this._tracer.error('[ConsoleLogService] Clear logs failed:', error); @@ -141,24 +97,35 @@ export class ConsoleLogService { } } + public async clearAllLogs(): Promise { + try { + await this.bridge.invoke('clear_logs'); + this._logsByView.clear(); + this._lastTimestampByView.clear(); + return true; + } catch (error) { + this._tracer.error('[ConsoleLogService] Clear all logs failed:', error); + return false; + } + } + public getLogs(): ILogEntry[] { - return this.logs; + return [...this._logsByView.values()].flat(); + } + + public getLogsForView(viewId: string): ILogEntry[] { + return [...(this._logsByView.get(this._canonicalViewId(viewId)) ?? [])]; } public async getAvailableViews(): Promise { if (!this.bridge.isTauri()) { - const views: IConsoleLogView[] = [{ id: 'general', label: 'General' }]; - const moduleLabels = new Map(); - this._hydrateModuleMetadata(moduleLabels); - return [...views, ...this._buildModuleViews(moduleLabels)]; + return [{ id: 'general', label: 'Platform' }]; } try { const result = await invokeSafe('get_console_overview'); if (result.status === 'ok') { - const views = this._normalizeViews(result.data.views); - this._hydrateKnownRuntimeIds(views); - return views; + return this._normalizeViews(result.data.views); } } catch (error) { this._tracer.warn( @@ -166,103 +133,7 @@ export class ConsoleLogService { ); } - const views: IConsoleLogView[] = [{ id: 'general', label: 'General' }]; - const moduleLabels = new Map(); - this._hydrateModuleMetadata(moduleLabels); - return [...views, ...this._buildModuleViews(moduleLabels)]; - } - - private _hydrateModuleMetadata(moduleLabels: Map): void { - for (const log of this.logs) { - const moduleId = this._getModuleId(log); - if (moduleId === null) { - continue; - } - - if (!moduleLabels.has(moduleId)) { - moduleLabels.set(moduleId, this._getModuleLabel(moduleId)); - } - } - } - - private _buildModuleViews(moduleLabels: ReadonlyMap): IConsoleLogView[] { - this._knownModuleIds.clear(); - - return [...moduleLabels.entries()].map(([moduleId, label]) => { - this._knownModuleIds.add(moduleId); - return { - id: `module:${moduleId}`, - label, - }; - }); - } - - private _normalizeViews(views: readonly IConsoleLogView[]): IConsoleLogView[] { - const seenIds = new Set(); - const seenLabels = new Set(); - const normalizedViews: IConsoleLogView[] = []; - - for (const view of views) { - const id = this._canonicalViewId(view.id); - const labelKey = this._normalizeViewLabel(view.label); - if (seenIds.has(id) || seenLabels.has(labelKey)) { - continue; - } - - seenIds.add(id); - seenLabels.add(labelKey); - normalizedViews.push({ ...view, id }); - } - - return normalizedViews; - } - - private _canonicalViewId(viewId: string): string { - const engineView = viewId.match(/^engine:(.+)$/); - if (engineView !== null) { - return `engine:${this._canonicalEngineId(engineView[1] ?? '')}`; - } - - return viewId; - } - - private _normalizeViewLabel(label: string): string { - return label.trim().toLowerCase().replaceAll(/\s+/gu, ' '); - } - - private _getModuleLabel(moduleId: string): string { - return moduleId - .replace(/^axelate-/, '') - .split('-') - .filter(Boolean) - .map((part) => part[0]?.toUpperCase() + part.slice(1)) - .join(' '); - } - - public getLogsForView(viewId: string): ILogEntry[] { - if (viewId === 'general') { - return this.logs.filter( - (entry) => this._getModuleId(entry) === null && !this._isKnownEngineLog(entry), - ); - } - - const moduleView = viewId.match(/^module:(.+)$/); - if (moduleView !== null) { - const moduleId = moduleView[1] ?? ''; - return this.logs.filter((entry) => this._getModuleId(entry) === moduleId); - } - - const engineView = viewId.match(/^engine:(.+)$/); - if (engineView !== null) { - const engineId = this._canonicalEngineId(engineView[1] ?? ''); - return this.logs.filter( - (entry) => - this._getModuleId(entry) === null && - this._canonicalEngineId(entry.source) === engineId, - ); - } - - return this.logs.filter((entry) => this._getModuleId(entry) === viewId); + return [{ id: 'general', label: 'Platform' }]; } public async getStatusItems(): Promise { @@ -273,11 +144,17 @@ export class ConsoleLogService { try { const result = await invokeSafe('get_console_overview'); if (result.status === 'ok') { - return this._mapOverviewStatusItems(result.data); + return result.data.status_items.map((item) => ({ + id: this._canonicalViewId(item.id), + label: item.label, + kind: item.kind === 'module' ? 'module' : 'engine', + status: this._toRuntimeStatus(item.status), + detail: item.detail, + })); } } catch (error) { this._tracer.warn( - `[ConsoleLogService] Failed to resolve engine status: ${String(error)}`, + `[ConsoleLogService] Failed to resolve runtime status: ${String(error)}`, ); } @@ -341,218 +218,86 @@ export class ConsoleLogService { } } - private async _registerEngineListeners(): Promise { - await this._listenToEngineEvent('ai:engine:log', (payload) => { - this._pushLog(payload.line, this._canonicalEngineId(payload.engine_id), 'info'); - }); - await this._listenToEngineEvent('ai:engine:starting', (payload) => { - this._pushLog( - 'Engine is starting...', - this._canonicalEngineId(payload.engine_id), - 'info', - ); - }); - await this._listenToEngineEvent('ai:engine:ready', (payload) => { - this._pushLog( - `Engine is ready at ${payload.endpoint}`, - this._canonicalEngineId(payload.engine_id), - 'info', - ); - }); - await this._listenToEngineEvent('ai:engine:error', (payload) => { - this._pushLog(payload.message, this._canonicalEngineId(payload.engine_id), 'error'); - }); - } - - private async _listenToEngineEvent( - eventName: TEvent, - handler: (payload: EngineEventPayloadMap[TEvent]) => void, - ): Promise { - const unlisten = await this.bridge.listen( - eventName, - handler, - ); - this._engineUnlisteners.push(unlisten); - } + private async _fetchTauriLogs(viewId: string, since: number): Promise { + if (!this.bridge.isTauri()) { + return await this.bridge.invoke('get_logs', { since }); + } - private async _fetchTauriLogs(): Promise { - return await this.bridge.invoke('get_logs', { - since: this.lastTimestamp, - }); + return await this.bridge.invoke('get_console_logs', { viewId, since }); } - private _processLogs(newLogs: ILogEntry[]): ILogEntry[] { + private _appendLogs(viewId: string, newLogs: ILogEntry[]): ILogEntry[] { if (!Array.isArray(newLogs) || newLogs.length === 0) { return []; } - this.lastTimestamp = newLogs.at(-1)?.timestamp ?? this.lastTimestamp; - const seenBatchKeys = new Set(); - const visibleLogs = newLogs - .filter((entry) => !this._isNoise(entry)) - .map((entry) => - this._normalizer.normalize({ - ...entry, - source: this._canonicalRuntimeSource(entry.source), - }), - ) - .filter((entry) => { - if (this._isNoise(entry)) { - return false; - } - const key = this._dedupeKey(entry); - if (seenBatchKeys.has(key) || this._hasDuplicateLog(entry)) { - return false; - } - seenBatchKeys.add(key); - return true; - }); - if (visibleLogs.length === 0) { - return []; - } - - this.logs.push(...visibleLogs); - this._trimLogs(); - return visibleLogs; - } + const normalizedLogs = newLogs.map((entry) => this._normalizer.normalize(entry)); + const previousLogs = this._logsByView.get(viewId) ?? []; + const existingKeys = new Set(previousLogs.map((entry) => this._dedupeKey(entry))); + const appendedLogs = normalizedLogs.filter((entry) => { + const key = this._dedupeKey(entry); + if (existingKeys.has(key)) { + return false; + } + existingKeys.add(key); + return true; + }); - private _isNoise(entry: ILogEntry): boolean { - const levelSource = entry.normalized_level ?? entry.level; - const level = levelSource.toUpperCase(); - if (level === 'ERROR' || level === 'WARN' || level === 'WARNING') { - return false; + if (appendedLogs.length === 0) { + this._lastTimestampByView.set( + viewId, + newLogs.at(-1)?.timestamp ?? this._lastTimestampByView.get(viewId) ?? 0, + ); + return []; } - return ConsoleLogService._NOISE_PATTERNS.some((pattern) => - pattern.test(String(entry.message)), + const nextLogs = [...previousLogs, ...appendedLogs].slice( + -ConsoleLogService._MAX_LOG_COUNT_PER_VIEW, ); + this._logsByView.set(viewId, nextLogs); + this._lastTimestampByView.set( + viewId, + newLogs.at(-1)?.timestamp ?? this._lastTimestampByView.get(viewId) ?? 0, + ); + return appendedLogs; } - private _pushLog(message: string, source: string, level: string): void { - const normalizedSource = this._canonicalRuntimeSource(source); - this._knownEngineIds.add(normalizedSource); - const entry: ILogEntry = { - timestamp: Date.now() / 1000, - source: normalizedSource, - level, - message, - }; - - if (this._isNoise(entry)) { - return; - } - - const normalized = this._normalizer.normalize(entry); - if (this._hasDuplicateLog(normalized)) { - return; - } - - this.logs.push(normalized); - this._trimLogs(); - } - - private _trimLogs(): void { - if (this.logs.length > ConsoleLogService._TRIM_THRESHOLD) { - this.logs = this.logs.slice(-ConsoleLogService._MAX_LOG_COUNT); - } - } - - private _mapOverviewStatusItems(payload: ConsoleOverviewPayload): IConsoleStatusItem[] { - return payload.status_items.map((item) => ({ - id: item.id, - label: item.label, - kind: item.kind === 'module' ? 'module' : 'engine', - status: this._toRuntimeStatus(item.status), - detail: item.detail, - })); - } - - private _hydrateKnownRuntimeIds(views: readonly IConsoleLogView[]): void { + private _normalizeViews(views: readonly IConsoleLogView[]): IConsoleLogView[] { + const byId = new Map(); for (const view of views) { - const engineId = view.id.match(/^engine:(.+)$/)?.[1]?.trim(); - if (engineId !== undefined && engineId !== '') { - this._knownEngineIds.add(this._canonicalEngineId(engineId)); - } - - const moduleId = view.id.match(/^module:(.+)$/)?.[1]?.trim(); - if (moduleId !== undefined && moduleId !== '') { - this._knownModuleIds.add(moduleId); + const id = this._canonicalViewId(view.id); + if (id === '') { + continue; } + byId.set(id, { ...view, id }); } + return [...byId.values()]; } - private _isKnownEngineLog(entry: ILogEntry): boolean { - return this._knownEngineIds.has(this._canonicalEngineId(entry.source)); - } - - private _isLogInView(entry: ILogEntry, viewId: string): boolean { - if (viewId === 'general') { - return this._getModuleId(entry) === null && !this._isKnownEngineLog(entry); - } - - const moduleView = viewId.match(/^module:(.+)$/); - if (moduleView !== null) { - return this._getModuleId(entry) === (moduleView[1] ?? ''); - } - - const engineView = viewId.match(/^engine:(.+)$/); + private _canonicalViewId(viewId: string): string { + const trimmed = viewId.trim(); + const engineView = trimmed.match(/^engine:(.+)$/); if (engineView !== null) { - const engineId = this._canonicalEngineId(engineView[1] ?? ''); - return ( - this._getModuleId(entry) === null && - this._canonicalEngineId(entry.source) === engineId - ); - } - - return this._getModuleId(entry) === viewId; - } - - private _getModuleId(entry: ILogEntry): string | null { - const moduleId = entry.module_id?.trim(); - if (moduleId !== undefined && moduleId !== '') { - this._knownModuleIds.add(moduleId); - return moduleId; - } - - const source = this._canonicalEngineId(entry.source); - if (this._knownEngineIds.has(source)) { - return null; - } - - if (source !== '' && this._knownModuleIds.has(source)) { - return source; - } - - return null; - } - - private _canonicalRuntimeSource(source: unknown): string { - const trimmed = typeof source === 'string' ? source.trim() : ''; - if (trimmed.startsWith('module:')) { - return trimmed; + return `engine:${this._canonicalEngineId(engineView[1] ?? '')}`; } - - return this._canonicalEngineId(trimmed); - } - - private _canonicalEngineId(engineId: unknown): string { - const normalized = typeof engineId === 'string' ? engineId.trim() : ''; - return normalized === 'stable-diffusion' ? 'sdcpp' : normalized; + return trimmed; } - private _hasDuplicateLog(candidate: ILogEntry): boolean { - const candidateKey = this._dedupeKey(candidate); - return this.logs.some((entry) => { - return this._dedupeKey(entry) === candidateKey; - }); + private _canonicalEngineId(engineId: string): string { + const key = engineId + .trim() + .toLowerCase() + .replaceAll(/[\s_]+/gu, '-'); + return key; } private _dedupeKey(entry: ILogEntry): string { return [ - this._canonicalRuntimeSource(entry.source), + entry.timestamp, + entry.source, entry.module_id ?? '', entry.normalized_level ?? entry.level, - typeof entry.message === 'string' ? entry.message : '', + entry.message, ].join('\u0000'); } diff --git a/src/features/console/ui/ConsoleFilterControlHelper.ts b/src/features/console/ui/ConsoleFilterControlHelper.ts index 065e99e3..9f8cb1c3 100644 --- a/src/features/console/ui/ConsoleFilterControlHelper.ts +++ b/src/features/console/ui/ConsoleFilterControlHelper.ts @@ -3,6 +3,7 @@ type ConsoleFilterControlHelperDeps = { allLevels: readonly Level[]; registerCleanup: (cleanup: () => void) => void; onClearLogs: () => void; + onClearAllLogs: () => void; onCopyLogs: () => void; onOpenLogsFolder: () => void; onFiltersChanged: () => void; @@ -54,11 +55,27 @@ export class ConsoleFilterControlHelper { this._deps.onFiltersChanged(); } }; + const handleContextMenu = (event: Event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + const clearButton = target.closest('#clear-logs-btn'); + if (!(clearButton instanceof HTMLButtonElement)) { + return; + } + + event.preventDefault(); + this._handleClearAllButton(clearButton); + }; controls.addEventListener('click', handleClick); + controls.addEventListener('contextmenu', handleContextMenu); this.syncButtons(); this._deps.registerCleanup(() => { controls.removeEventListener('click', handleClick); + controls.removeEventListener('contextmenu', handleContextMenu); this._resetClearConfirmation(); }); } @@ -124,7 +141,10 @@ export class ConsoleFilterControlHelper { } private _hasMultiSelectModifier(event: Event): boolean { - return event instanceof MouseEvent && (event.ctrlKey === true || event.metaKey === true); + return ( + event instanceof MouseEvent && + (event.ctrlKey === true || event.metaKey === true || event.shiftKey === true) + ); } private _handleClearButton(button: HTMLButtonElement): void { @@ -134,6 +154,7 @@ export class ConsoleFilterControlHelper { return; } + delete button.dataset['confirmingAll']; button.dataset['confirming'] = 'true'; button.classList.add('confirming'); button.setAttribute('aria-label', 'Confirm clear console logs'); @@ -143,6 +164,23 @@ export class ConsoleFilterControlHelper { }, 2200); } + private _handleClearAllButton(button: HTMLButtonElement): void { + if (button.dataset['confirmingAll'] === 'true') { + this._resetClearConfirmation(); + this._deps.onClearAllLogs(); + return; + } + + 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'; + this._clearConfirmationTimeout = setTimeout(() => { + this._resetClearConfirmation(); + }, 2200); + } + private _resetClearConfirmation(): void { if (this._clearConfirmationTimeout !== null) { clearTimeout(this._clearConfirmationTimeout); @@ -155,6 +193,7 @@ 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'; diff --git a/src/features/console/ui/ConsoleRefreshCoordinator.ts b/src/features/console/ui/ConsoleRefreshCoordinator.ts index ce46afda..fe73c7bb 100644 --- a/src/features/console/ui/ConsoleRefreshCoordinator.ts +++ b/src/features/console/ui/ConsoleRefreshCoordinator.ts @@ -2,6 +2,7 @@ import type { ConsoleLogService } from '../services/ConsoleLogService'; type ConsoleRefreshCoordinatorDeps = { service: Pick; + getActiveViewId: () => string; refreshLogViews: () => Promise; renderLogs: (clear?: boolean) => void; }; @@ -10,14 +11,14 @@ export class ConsoleRefreshCoordinator { public constructor(private readonly _deps: ConsoleRefreshCoordinatorDeps) {} public async refreshOnOpen(): Promise { - await this._deps.service.fetchLogs(); await this._deps.refreshLogViews(); + await this._deps.service.fetchLogs(this._deps.getActiveViewId()); this._deps.renderLogs(true); } public async refreshFromPolling(): Promise { - const newLogs = await this._deps.service.fetchLogs(); const viewsChanged = await this._deps.refreshLogViews(); + const newLogs = await this._deps.service.fetchLogs(this._deps.getActiveViewId()); if (viewsChanged || newLogs.length > 0) { this._deps.renderLogs(); diff --git a/src/features/console/ui/ConsoleUI.test.ts b/src/features/console/ui/ConsoleUI.test.ts index 09e4188f..4ceda708 100644 --- a/src/features/console/ui/ConsoleUI.test.ts +++ b/src/features/console/ui/ConsoleUI.test.ts @@ -8,6 +8,7 @@ describe('ConsoleUI lifecycle', () => { let ui: ConsoleUI | null = null; let testEventBus: EventBus; let showToastMock: ReturnType; + let copyTextMock: ReturnType; const normalizer = new ConsoleLogNormalizer(); function normalizeLogs(logs: ILogEntry[]): ILogEntry[] { @@ -59,6 +60,7 @@ describe('ConsoleUI lifecycle', () => { fallback, ) => `${key}:${fallback}`; showToastMock = vi.fn(); + copyTextMock = vi.fn().mockResolvedValue(undefined); vi.clearAllMocks(); }); @@ -93,7 +95,7 @@ describe('ConsoleUI lifecycle', () => { )(message, type, duration); }, copyText: async (text: string) => { - await navigator.clipboard.writeText(text); + await (copyTextMock as unknown as (value: string) => Promise)(text); }, }; } @@ -103,6 +105,7 @@ describe('ConsoleUI lifecycle', () => { init: ReturnType; destroy: ReturnType; clearLogs: ReturnType; + clearAllLogs: ReturnType; getLogs: ReturnType; getLogsForView: ReturnType; getAvailableViews: ReturnType; @@ -117,6 +120,7 @@ describe('ConsoleUI lifecycle', () => { init: vi.fn().mockResolvedValue(undefined), destroy: vi.fn(), clearLogs: vi.fn().mockResolvedValue(true), + clearAllLogs: vi.fn().mockResolvedValue(true), getLogs: vi.fn().mockReturnValue([]), getLogsForView: vi.fn().mockReturnValue([]), getAvailableViews: vi.fn().mockResolvedValue([{ id: 'general', label: 'General' }]), @@ -190,7 +194,7 @@ describe('ConsoleUI lifecycle', () => { expect(dropzone.textContent).toContain('drop_here'); }); - it('should clear, copy and render logs through browser clipboard fallback', async () => { + it('should clear, copy and render logs through the injected clipboard writer', async () => { const service = createServiceMock({ getLogs: vi.fn().mockReturnValue( normalizeLogs([ @@ -214,18 +218,13 @@ describe('ConsoleUI lifecycle', () => { }); ui = new ConsoleUI(service, createDeps()); - const clipboardWrite = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(globalThis.navigator, 'clipboard', { - configurable: true, - value: { writeText: clipboardWrite }, - }); ui.init(); await ui.clearLogs(); expect(service.clearLogs).toHaveBeenCalledWith('general'); await ui.copyLogs(); - expect(clipboardWrite).toHaveBeenCalledWith('hello\nboom'); + expect(copyTextMock).toHaveBeenCalledWith('hello\nboom'); (service.getLogsForView as ReturnType).mockReturnValue([]); await ui.copyLogs(); @@ -259,6 +258,30 @@ describe('ConsoleUI lifecycle', () => { vi.useRealTimers(); }); + it('should require a second right click before clearing all logs', async () => { + vi.useFakeTimers(); + const service = createServiceMock(); + + ui = new ConsoleUI(service, createDeps()); + ui.init(); + + const clearButton = document.getElementById('clear-logs-btn') as HTMLButtonElement; + clearButton.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true })); + + expect(clearButton.classList.contains('confirming')).toBe(true); + expect(service.clearAllLogs).not.toHaveBeenCalled(); + expect(service.clearLogs).not.toHaveBeenCalled(); + + clearButton.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true })); + await vi.runOnlyPendingTimersAsync(); + + expect(clearButton.classList.contains('confirming')).toBe(false); + expect(service.clearAllLogs).toHaveBeenCalledTimes(1); + expect(service.clearLogs).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); + it('should reset clear action confirmation after timeout', () => { vi.useFakeTimers(); const service = createServiceMock(); @@ -276,6 +299,24 @@ describe('ConsoleUI lifecycle', () => { vi.useRealTimers(); }); + it('should forward wheel scrolling from the console controls to the logs area', () => { + const service = createServiceMock(); + const container = document.getElementById('console-container') as HTMLDivElement; + Object.defineProperty(container, 'scrollHeight', { configurable: true, value: 720 }); + Object.defineProperty(container, 'clientHeight', { configurable: true, value: 200 }); + container.scrollTop = 0; + + ui = new ConsoleUI(service, createDeps()); + ui.init(); + + const panel = document.querySelector('.console-controls-panel') as HTMLElement; + panel.dispatchEvent( + new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 96 }), + ); + + expect(container.scrollTop).toBe(96); + }); + it('should open logs folder from the console actions', async () => { const service = createServiceMock({ openLogsFolder: vi.fn().mockResolvedValue(true), @@ -492,12 +533,6 @@ describe('ConsoleUI lifecycle', () => { }); it('should copy only logs from the active view', async () => { - const clipboardWrite = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(globalThis.navigator, 'clipboard', { - configurable: true, - value: { writeText: clipboardWrite }, - }); - const service = createServiceMock({ getLogsForView: vi.fn((view: string) => normalizeLogs( @@ -534,7 +569,7 @@ describe('ConsoleUI lifecycle', () => { moduleTab.click(); await ui.copyLogs(); - const copiedText = clipboardWrite.mock.calls[0]?.[0] as string; + const copiedText = copyTextMock.mock.calls[0]?.[0] as string; expect(copiedText).toContain('engine line'); expect(copiedText).not.toContain('general line'); }); @@ -670,7 +705,7 @@ describe('ConsoleUI lifecycle', () => { expect(document.getElementById('logs-general')?.textContent).toContain('Page settings'); }); - it('should allow multi-select level filters with ctrl click', async () => { + it('should allow multi-select level filters with ctrl or shift click', async () => { const service = createServiceMock({ getLogsForView: vi.fn().mockReturnValue( normalizeLogs([ @@ -728,6 +763,13 @@ describe('ConsoleUI lifecycle', () => { ); expect(document.getElementById('logs-general')?.textContent).toContain('Page settings'); expect(document.getElementById('logs-general')?.textContent).not.toContain('Page modules'); + + const debugButton = document.querySelector( + '.console-filter-chip[data-level="DEBUG"]', + ) as HTMLButtonElement; + debugButton.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })); + + expect(document.getElementById('logs-general')?.textContent).toContain('Page modules'); }); it('should hide launcher source labels like frontend from rendered logs', async () => { diff --git a/src/features/console/ui/ConsoleUI.ts b/src/features/console/ui/ConsoleUI.ts index f46fa3f5..69ebccbc 100644 --- a/src/features/console/ui/ConsoleUI.ts +++ b/src/features/console/ui/ConsoleUI.ts @@ -96,6 +96,9 @@ export class ConsoleUI { onClearLogs: () => { void this.clearLogs(); }, + onClearAllLogs: () => { + void this.clearAllLogs(); + }, onCopyLogs: () => { void this.copyLogs(); }, @@ -121,6 +124,7 @@ export class ConsoleUI { }); this._refreshCoordinator = new ConsoleRefreshCoordinator({ service: this.service, + getActiveViewId: () => this._viewState.activeViewId, refreshLogViews: async () => await this.refreshLogViews(), renderLogs: (clear) => { this.renderLogs(clear); @@ -144,6 +148,7 @@ export class ConsoleUI { this._interactionHelper.bindDropzone(); this.bindTabs(); this._bindTabScrollControls(); + this._bindWorkspaceWheelForwarding(); this._filterControlHelper.bindControls(); this._syncPollingForActivePage(); void this.refreshLogViews(); @@ -244,6 +249,38 @@ export class ConsoleUI { }); } + private _bindWorkspaceWheelForwarding(): void { + const workspace = document.querySelector('.console-workspace'); + const scrollContainer = document.getElementById('console-container'); + if (!(workspace instanceof HTMLElement) || !(scrollContainer instanceof HTMLElement)) { + return; + } + + const handleWheel = (event: WheelEvent) => { + const target = event.target; + if ( + target instanceof Element && + target.closest('.console-logs-area') instanceof HTMLElement + ) { + return; + } + if ( + event.deltaY === 0 || + scrollContainer.scrollHeight <= scrollContainer.clientHeight + ) { + return; + } + + scrollContainer.scrollTop += event.deltaY; + event.preventDefault(); + }; + + workspace.addEventListener('wheel', handleWheel, { passive: false }); + this.unsubscribers.push(() => { + workspace.removeEventListener('wheel', handleWheel); + }); + } + public setTab(tabId: string, btn?: HTMLElement): void { this._activateTab('.debug-tab', '.debug-tab-content', `debug-${tabId}-tab`, btn); } @@ -252,10 +289,44 @@ export class ConsoleUI { this._viewState.activeViewId = view; this._activateTab('.console-tab', '.logs-pane', `logs-${view}`, btn); this.renderLogs(true); + const requestedView = view; + void this.service + .fetchLogs(requestedView) + .then(() => { + if (this._viewState.activeViewId === requestedView) { + this.renderLogs(true); + } + }) + .catch((error: unknown) => { + // eslint-disable-next-line no-console + console.error('[ConsoleUI] Failed to fetch logs for view:', error); + }); } public async clearLogs(): Promise { - await this.service.clearLogs(this._viewState.activeViewId); + const success = await this.service.clearLogs(this._viewState.activeViewId); + if (!success) { + this._showToast( + this._translate('ui.debug.logs_clear_failed', 'Failed to clear logs'), + 'error', + ); + return; + } + + this.renderLogs(true); + this._clipboardHelper.showLogsCleared(); + } + + public async clearAllLogs(): Promise { + const success = await this.service.clearAllLogs(); + if (!success) { + this._showToast( + this._translate('ui.debug.logs_clear_failed', 'Failed to clear logs'), + 'error', + ); + return; + } + this.renderLogs(true); this._clipboardHelper.showLogsCleared(); } diff --git a/src/features/downloads/ui/DownloadCardRenderer.ts b/src/features/downloads/ui/DownloadCardRenderer.ts index e4197f52..f4a3257a 100644 --- a/src/features/downloads/ui/DownloadCardRenderer.ts +++ b/src/features/downloads/ui/DownloadCardRenderer.ts @@ -1,6 +1,7 @@ import DOMPurify from 'dompurify'; import type { IModuleDownloadState as ModuleDownloadState } from '@/shared/types/coreTypes'; +import { escapeCssSelectorValue } from '@/shared/utils/cssSelectors'; type DownloadCardTranslate = (key: string, fallback: string) => string; @@ -21,7 +22,7 @@ type DownloadCardRendererDeps = { export class DownloadCardRenderer { private static readonly _purifyConfig = { ALLOWED_TAGS: ['div', 'span', 'button', 'svg', 'use'], - ALLOWED_ATTR: ['aria-label', 'class', 'href', 'style', 'title'], + ALLOWED_ATTR: ['aria-label', 'class', 'href', 'style', 'title', 'type'], ALLOW_DATA_ATTR: false, }; @@ -37,8 +38,9 @@ export class DownloadCardRenderer { } for (const [moduleId, state] of activeDownloads) { + const escapedModuleId = escapeCssSelectorValue(moduleId); const existing = list.querySelector( - `.download-item-card[data-module-id="${moduleId}"]`, + `.download-item-card[data-module-id="${escapedModuleId}"]`, ); if (existing !== null) { this.patchCard(existing, state); @@ -223,7 +225,7 @@ export class DownloadCardRenderer { } if (this._deps.isCancellableStatus(status)) { buttons.push( - this.renderActionButton('download-cancel-btn cancel', cancelTitle, '#icon-stop'), + this.renderActionButton('download-cancel-btn cancel', cancelTitle, '#icon-trash'), ); } @@ -240,7 +242,7 @@ export class DownloadCardRenderer { private renderActionButton(className: string, title: string, iconHref: string): string { return ` - `; diff --git a/src/features/downloads/ui/DownloadProgressPresenter.ts b/src/features/downloads/ui/DownloadProgressPresenter.ts index f839fb7d..21ec41a7 100644 --- a/src/features/downloads/ui/DownloadProgressPresenter.ts +++ b/src/features/downloads/ui/DownloadProgressPresenter.ts @@ -131,15 +131,13 @@ export class DownloadProgressPresenter { } public displayModuleName(moduleId: string): string { - const knownNames: Record = { - llamacpp: 'llama.cpp', - sdcpp: 'stable-diffusion.cpp', - }; - - const known = knownNames[moduleId.toLowerCase()]; - if (known !== undefined) return known; - - return moduleId.replaceAll(/[_-]+/g, ' ').trim(); + return moduleId + .replaceAll(/[_-]+/g, ' ') + .trim() + .split(/\s+/u) + .filter((part) => part !== '') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); } public isActiveStatus(status: string): boolean { diff --git a/src/features/downloads/ui/DownloadUI.test.ts b/src/features/downloads/ui/DownloadUI.test.ts index 9410849c..16f04399 100644 --- a/src/features/downloads/ui/DownloadUI.test.ts +++ b/src/features/downloads/ui/DownloadUI.test.ts @@ -730,10 +730,13 @@ describe('DownloadUI', () => { ); const list = document.getElementById('downloads-dynamic-list'); - const cancelBtn = list?.querySelector('.download-cancel-btn'); + const cancelBtn = + list?.querySelector('.download-cancel-btn') ?? null; expect(cancelBtn).not.toBeNull(); + expect(cancelBtn?.type).toBe('button'); + expect(cancelBtn?.querySelector('use')?.getAttribute('href')).toBe('#icon-trash'); - if (cancelBtn !== null) (cancelBtn as HTMLElement).click(); + if (cancelBtn !== null) cancelBtn.click(); expect(cancelFn).toHaveBeenCalledWith('mod-cancel'); }); @@ -753,10 +756,12 @@ describe('DownloadUI', () => { ); const list = document.getElementById('downloads-dynamic-list'); - const pauseBtn = list?.querySelector('.download-pause-btn'); + const pauseBtn = list?.querySelector('.download-pause-btn') ?? null; expect(pauseBtn).not.toBeNull(); + expect(pauseBtn?.type).toBe('button'); + expect(pauseBtn?.querySelector('use')?.getAttribute('href')).toBe('#icon-pause'); - if (pauseBtn !== null) (pauseBtn as HTMLElement).click(); + if (pauseBtn !== null) pauseBtn.click(); expect(pauseFn).toHaveBeenCalledWith('mod-pause'); }); @@ -857,6 +862,53 @@ describe('DownloadUI', () => { expect(list?.querySelectorAll('.download-item-card').length).toBe(0); }); + it('should return downloads layout to empty state after final cleanup', () => { + ui.init(); + + globalThis.dispatchEvent( + new CustomEvent('download-progress-update', { + detail: { + module_id: 'mod-final', + progress: 0.4, + status: 'downloading', + }, + }), + ); + + expect( + document + .getElementById('downloads-container') + ?.classList.contains('active-download'), + ).toBe(true); + + globalThis.dispatchEvent( + new CustomEvent('download-progress-update', { + detail: { + module_id: 'mod-final', + progress: 1, + status: 'complete', + }, + }), + ); + + vi.advanceTimersByTime(2100); + + expect( + document + .getElementById('downloads-container') + ?.classList.contains('active-download'), + ).toBe(false); + expect( + document.getElementById('downloads-body')?.classList.contains('empty-state'), + ).toBe(true); + expect( + document.getElementById('downloads-empty-text')?.classList.contains('hidden'), + ).toBe(false); + expect(document.getElementById('downloads-item-label')?.textContent).toBe( + 'No active downloads', + ); + }); + it('should cancel stale terminal cleanup when the same module restarts downloading', () => { ui.init(); @@ -894,6 +946,38 @@ describe('DownloadUI', () => { expect(card?.querySelector('.downloads-status-pill')?.textContent).toBe('Downloading'); }); + it('should patch existing download cards for module ids that need selector escaping', () => { + ui.init(); + const moduleId = 'mod"quoted\\id'; + + globalThis.dispatchEvent( + new CustomEvent('download-progress-update', { + detail: { + module_id: moduleId, + progress: 0.2, + status: 'downloading', + }, + }), + ); + globalThis.dispatchEvent( + new CustomEvent('download-progress-update', { + detail: { + module_id: moduleId, + progress: 0.6, + status: 'downloading', + }, + }), + ); + + const list = document.getElementById('downloads-dynamic-list'); + const cards = list?.querySelectorAll('.download-item-card'); + expect(cards?.length).toBe(1); + expect(cards?.[0]?.getAttribute('data-module-id')).toBe(moduleId); + expect(cards?.[0]?.querySelector('.downloads-progress-percent')?.textContent).toBe( + '60%', + ); + }); + it('should render error status card', () => { ui.init(); diff --git a/src/features/downloads/ui/DownloadUI.ts b/src/features/downloads/ui/DownloadUI.ts index 569f3318..6ff7e519 100644 --- a/src/features/downloads/ui/DownloadUI.ts +++ b/src/features/downloads/ui/DownloadUI.ts @@ -63,6 +63,7 @@ export class DownloadUI { (moduleId) => { this._stateController.delete(moduleId); this._renderDownloadStateList(); + this._renderPrimaryDownloadState(); }, ); this._eventController = new DownloadUiEventController(this._createEventControllerDeps()); @@ -358,21 +359,20 @@ export class DownloadUI { this.renderDownloadsProgress(progress); } - private _refreshTranslations(): void { - this._renderDownloadStateList(); - + private _renderPrimaryDownloadState(): void { const firstEntry = this._stateController.getPrimaryEntry(); - if (firstEntry === undefined) { this.renderDownloadsProgress({ hasActive: false }); return; } - const [moduleId, firstDownload] = firstEntry; + const [moduleId, state] = firstEntry; + this._renderProgressFromModuleState(moduleId, state); + } - this.renderDownloadsProgress({ - ...this._presenter.buildProgressFromModuleState(moduleId, firstDownload), - }); + private _refreshTranslations(): void { + this._renderDownloadStateList(); + this._renderPrimaryDownloadState(); } /** diff --git a/src/features/monitoring/services/MonitoringService.test.ts b/src/features/monitoring/services/MonitoringService.test.ts index 70f93836..374dbb1e 100644 --- a/src/features/monitoring/services/MonitoringService.test.ts +++ b/src/features/monitoring/services/MonitoringService.test.ts @@ -105,23 +105,25 @@ describe('MonitoringService', () => { expect(mockUnlisten).toHaveBeenCalled(); }); - it('should start fallback polling when not in Tauri', async () => { + it('should not poll when event transport is unavailable outside Tauri', async () => { vi.mocked(mockTauri.isTauri).mockReturnValue(false); vi.useFakeTimers(); vi.mocked(mockTauri.invoke).mockResolvedValue(mockStats); await service.startMonitoring(); - - // Fast-forward time to trigger interval await vi.advanceTimersByTimeAsync(2100); - expect(mockTauri.invoke).toHaveBeenCalledWith('get_system_stats'); + expect(mockTauri.invoke).not.toHaveBeenCalled(); + expect(tracer.warn).toHaveBeenCalledWith( + '[MonitoringService] Event transport unavailable outside Tauri', + ); vi.useRealTimers(); }); it('should stop polling on stopMonitoring', async () => { - vi.mocked(mockTauri.isTauri).mockReturnValue(false); + vi.mocked(mockTauri.isTauri).mockReturnValue(true); + vi.mocked(mockTauri.listen).mockRejectedValue(new Error('Listen fail')); vi.useFakeTimers(); const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); @@ -196,8 +198,9 @@ describe('MonitoringService', () => { expect((service as unknown as { listeners: unknown[] }).listeners).toHaveLength(1); }); - it('should handle invoke error in fallback polling', async () => { - vi.mocked(mockTauri.isTauri).mockReturnValue(false); + it('should handle invoke error in polling after event subscription fails', async () => { + vi.mocked(mockTauri.isTauri).mockReturnValue(true); + vi.mocked(mockTauri.listen).mockRejectedValue(new Error('Listen fail')); vi.useFakeTimers(); vi.mocked(mockTauri.invoke).mockRejectedValue(new Error('Network error')); @@ -215,13 +218,14 @@ describe('MonitoringService', () => { await service.startMonitoring(); await vi.advanceTimersByTimeAsync(2100); - expect(mockTauri.invoke).toHaveBeenCalledWith('get_system_stats'); + expect(mockTauri.invoke).not.toHaveBeenCalled(); vi.useRealTimers(); }); it('should clear pollingInterval via stopMonitoring (L82)', async () => { - vi.mocked(mockTauri.isTauri).mockReturnValue(false); + vi.mocked(mockTauri.isTauri).mockReturnValue(true); + vi.mocked(mockTauri.listen).mockRejectedValue(new Error('Listen fail')); vi.useFakeTimers(); vi.mocked(mockTauri.invoke).mockResolvedValue(mockStats); @@ -233,7 +237,8 @@ describe('MonitoringService', () => { }); it('should not start fallback twice if already polling (L118)', async () => { - vi.mocked(mockTauri.isTauri).mockReturnValue(false); + vi.mocked(mockTauri.isTauri).mockReturnValue(true); + vi.mocked(mockTauri.listen).mockRejectedValue(new Error('Listen fail')); vi.useFakeTimers(); vi.mocked(mockTauri.invoke).mockResolvedValue(mockStats); @@ -245,7 +250,8 @@ describe('MonitoringService', () => { }); it('should handle failed invoke response in fallback (L118-125)', async () => { - vi.mocked(mockTauri.isTauri).mockReturnValue(false); + vi.mocked(mockTauri.isTauri).mockReturnValue(true); + vi.mocked(mockTauri.listen).mockRejectedValue(new Error('Listen fail')); vi.useFakeTimers(); vi.mocked(mockTauri.invoke).mockRejectedValue(new Error('Backend unavailable')); @@ -261,6 +267,32 @@ describe('MonitoringService', () => { vi.useRealTimers(); }); + it('should not notify subscribers from an in-flight fallback poll after stop', async () => { + vi.mocked(mockTauri.isTauri).mockReturnValue(true); + vi.mocked(mockTauri.listen).mockRejectedValue(new Error('Listen fail')); + vi.useFakeTimers(); + + let resolveStats: ((value: ISystemStats) => void) | undefined; + vi.mocked(mockTauri.invoke).mockImplementation( + () => + new Promise((resolve) => { + resolveStats = resolve; + }), + ); + + const subscriber = vi.fn(); + service.subscribe(subscriber); + await service.startMonitoring(); + await vi.advanceTimersByTimeAsync(2100); + + service.stopMonitoring(); + resolveStats?.(mockStats); + await Promise.resolve(); + + expect(subscriber).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + it('should dispose a late listener when monitoring is stopped before listen resolves', async () => { vi.mocked(mockTauri.isTauri).mockReturnValue(true); diff --git a/src/features/monitoring/services/MonitoringService.ts b/src/features/monitoring/services/MonitoringService.ts index 993d6fff..034ec425 100644 --- a/src/features/monitoring/services/MonitoringService.ts +++ b/src/features/monitoring/services/MonitoringService.ts @@ -4,7 +4,7 @@ import type { ISystemStats, StatsCallback } from '../types/monitoringTypes'; type MonitoringLogger = Pick; -const FALLBACK_MONITORING_POLL_INTERVAL_MS = 2000; +const MONITORING_POLL_INTERVAL_MS = 2000; export class MonitoringService { private isListening = false; @@ -19,7 +19,7 @@ export class MonitoringService { ) {} /** - * Starts listening to system stats or falls back to bridge polling. + * Starts listening to system stats. */ public async startMonitoring(): Promise { if (this.isListening) return; @@ -55,11 +55,11 @@ export class MonitoringService { return; } this._tracer.error('[MonitoringService] Failed to listen to events:', e); - this.startFallback(); + this.startPolling(lifecycleToken); } } else { - this._tracer.warn('[MonitoringService] Event transport unavailable, starting polling'); - this.startFallback(); + this._tracer.warn('[MonitoringService] Event transport unavailable outside Tauri'); + this.stopMonitoring(); } } @@ -110,30 +110,36 @@ export class MonitoringService { }); } - private startFallback(): void { + private startPolling(lifecycleToken: number): void { if (this.pollingTimeout !== null) { return; } const poll = (): void => { this.pollingTimeout = globalThis.setTimeout(() => { - void this._pollFallbackStats().finally(() => { + void this._pollStats(lifecycleToken).finally(() => { this.pollingTimeout = null; - if (this.isListening) { + if (this.isListening && lifecycleToken === this._lifecycleToken) { poll(); } }); - }, FALLBACK_MONITORING_POLL_INTERVAL_MS); + }, MONITORING_POLL_INTERVAL_MS); }; poll(); } - private async _pollFallbackStats(): Promise { + private async _pollStats(lifecycleToken: number): Promise { try { const stats = await this._tauri.invoke('get_system_stats'); + if (!this.isListening || lifecycleToken !== this._lifecycleToken) { + return; + } this.notifyListeners(stats); } catch (e) { + if (!this.isListening || lifecycleToken !== this._lifecycleToken) { + return; + } this._tracer.warn('[MonitoringService] Poll failed', e); } } diff --git a/src/features/monitoring/types/monitoringTypes.ts b/src/features/monitoring/types/monitoringTypes.ts index de30abfd..b4638baf 100644 --- a/src/features/monitoring/types/monitoringTypes.ts +++ b/src/features/monitoring/types/monitoringTypes.ts @@ -1,58 +1,4 @@ -export interface ICpuStats { - percent: number; - cores: number; - name: string; -} - -export interface IRamStats { - percent: number; - usedGb: number; - totalGb: number; - availableGb: number; -} - -export interface IGpuStats { - usage: number; - memoryUsed: number; - memoryTotal: number; - temp: number; - name: string; -} - -export interface IVramStats { - percent: number; - usedGb: number; - totalGb: number; -} - -export interface IDiskStats { - readRate: number; - writeRate: number; - utilization: number; - totalGb: number; - usedGb: number; - activityPercent: number; -} - -export interface INetworkStats { - downloadRate: number; - uploadRate: number; - totalReceived: number; - totalSent: number; - utilization: number; - activityPercent: number; -} - -export interface ISystemStats { - cpu: ICpuStats; - ram: IRamStats; - gpu: IGpuStats | null; - vram: IVramStats | null; - disk: IDiskStats; - network: INetworkStats; - pid: number; - appCpu: number; - appMemory: number; -} +import type { SystemStats } from '@/shared/types/bindings'; +export type ISystemStats = SystemStats; export type StatsCallback = (stats: ISystemStats) => void; diff --git a/src/features/settings/services/SettingsService.test.ts b/src/features/settings/services/SettingsService.test.ts index 8c18c621..7ce3177f 100644 --- a/src/features/settings/services/SettingsService.test.ts +++ b/src/features/settings/services/SettingsService.test.ts @@ -2,6 +2,29 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SettingsService } from './SettingsService'; import type { TauriProvider } from '@/infrastructure/tauri/TauriProvider'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; +import type * as Bindings from '@/shared/types/bindings'; + +const mocks = vi.hoisted(() => ({ + invokeSafe: vi.fn(), + commands: { + controlModule: vi.fn(), + }, +})); + +vi.mock('@/shared/api/invoke', (): { invokeSafe: (...args: unknown[]) => unknown } => ({ + invokeSafe: (...args: unknown[]): unknown => mocks.invokeSafe(...args) as unknown, +})); + +vi.mock('@/shared/types/bindings', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + commands: { + ...actual.commands, + controlModule: mocks.commands.controlModule, + }, + }; +}); function createMockTauri(): TauriProvider { return { @@ -9,6 +32,7 @@ function createMockTauri(): TauriProvider { isTauri: vi.fn(() => true), listen: vi.fn().mockResolvedValue(() => {}), saveSecureKey: vi.fn().mockResolvedValue(undefined), + removeSecureKey: vi.fn().mockResolvedValue(undefined), getSecureKey: vi.fn().mockResolvedValue(null), hasSecureKey: vi.fn().mockResolvedValue(false), getSecureKeyMeta: vi.fn().mockResolvedValue({ exists: false, length: 0 }), @@ -21,9 +45,17 @@ describe('SettingsService', () => { let tracer: Pick; beforeEach(() => { + vi.clearAllMocks(); tauri = createMockTauri(); tracer = { error: vi.fn() }; service = new SettingsService(tauri, tracer); + mocks.commands.controlModule.mockReturnValue( + Promise.resolve({ + status: 'ok', + data: { success: true, message: 'ok', status: 'running' }, + }), + ); + mocks.invokeSafe.mockImplementation((promise: Promise) => promise); }); describe('loadSettings', () => { @@ -106,20 +138,34 @@ describe('SettingsService', () => { describe('controlService', () => { it('should return true on success', async () => { - (tauri.invoke as ReturnType).mockResolvedValue(undefined); const result = await service.controlService('start', 'ollama'); expect(result).toBe(true); - expect(tauri.invoke).toHaveBeenCalledWith('control_service', { + expect(mocks.commands.controlModule).toHaveBeenCalledWith({ + module_id: 'ollama', action: 'start', - service: 'ollama', }); + expect(mocks.invokeSafe).toHaveBeenCalled(); }); it('should return false on error', async () => { - (tauri.invoke as ReturnType).mockRejectedValue(new Error('fail')); + mocks.invokeSafe.mockResolvedValueOnce({ + status: 'error', + error: { message: 'fail' }, + }); const result = await service.controlService('stop', 'ollama'); expect(result).toBe(false); }); + + it('should return false when backend reports unsuccessful control', async () => { + mocks.invokeSafe.mockResolvedValueOnce({ + status: 'ok', + data: { success: false, message: 'not implemented', status: null }, + }); + + const result = await service.controlService('restart', 'ollama'); + + expect(result).toBe(false); + }); }); describe('loadGpuInfo', () => { @@ -180,10 +226,18 @@ describe('SettingsService', () => { }); describe('saveSecureKey', () => { - it('should invoke save_secure_key with correct args', async () => { + it('should store cloud provider keys in the shared OpenRouter slot', async () => { await service.saveSecureKey('gemini', 'my-api-key'); expect(tauri.invoke).toHaveBeenCalledWith('save_secure_key', { - service: 'gemini_api_key', + service: 'openrouter_api_key', + key: 'my-api-key', + }); + }); + + it('should keep provider-specific slots for unknown providers', async () => { + await service.saveSecureKey('unknown-provider', 'my-api-key'); + expect(tauri.invoke).toHaveBeenCalledWith('save_secure_key', { + service: 'unknown-provider_api_key', key: 'my-api-key', }); }); @@ -194,6 +248,33 @@ describe('SettingsService', () => { }); }); + describe('removeSecureKey', () => { + it('should remove secure key through tauri provider helper', async () => { + await service.removeSecureKey('gemini'); + + expect(tauri.removeSecureKey).toHaveBeenCalledWith('openrouter_api_key'); + expect(tauri.invoke).not.toHaveBeenCalledWith('remove_secure_key', expect.anything()); + }); + + it('should fall back to invoke when helper is unavailable', async () => { + delete (tauri as unknown as { removeSecureKey?: unknown }).removeSecureKey; + + await service.removeSecureKey('gemini'); + + expect(tauri.invoke).toHaveBeenCalledWith('remove_secure_key', { + service: 'openrouter_api_key', + }); + }); + + it('should propagate remove errors', async () => { + (tauri.removeSecureKey as ReturnType).mockRejectedValue( + new Error('fail'), + ); + + await expect(service.removeSecureKey('gemini')).rejects.toThrow('fail'); + }); + }); + describe('validateApiKey', () => { it('should return true when valid', async () => { (tauri.invoke as ReturnType).mockResolvedValue(true); @@ -216,7 +297,7 @@ describe('SettingsService', () => { expect(result).toBe(true); expect(tauri.invoke).toHaveBeenCalledWith('has_secure_key', { - service: 'gemini_api_key', + service: 'openrouter_api_key', }); }); @@ -237,7 +318,7 @@ describe('SettingsService', () => { const result = await service.getSecureKeyMeta('gemini'); expect(result).toEqual(meta); - expect(tauri.getSecureKeyMeta).toHaveBeenCalledWith('gemini_api_key'); + expect(tauri.getSecureKeyMeta).toHaveBeenCalledWith('openrouter_api_key'); }); it('should return empty metadata on error', async () => { @@ -258,7 +339,7 @@ describe('SettingsService', () => { const result = await service.getSecureKey('gemini'); expect(result).toBe('secret'); - expect(tauri.getSecureKey).toHaveBeenCalledWith('gemini_api_key'); + expect(tauri.getSecureKey).toHaveBeenCalledWith('openrouter_api_key'); }); it('should return null on error', async () => { diff --git a/src/features/settings/services/SettingsService.ts b/src/features/settings/services/SettingsService.ts index d7de1edb..22467e54 100644 --- a/src/features/settings/services/SettingsService.ts +++ b/src/features/settings/services/SettingsService.ts @@ -2,18 +2,15 @@ import type { SecureKeyMeta, TauriProvider } from '@/infrastructure/tauri/TauriP import type { IApp } from '@/shared/types/coreTypes'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; -import type { AppSettings } from '@/shared/types/bindings'; +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; -export interface IGpuInfo { - detected: boolean; - name?: string; - cuda?: boolean; - backend?: string; - memory?: number; -} +export type IGpuInfo = Partial & Pick; export interface ICustomModel { id: string; @@ -85,8 +82,17 @@ export class SettingsService { service: string, ): Promise { try { - await this._tauri.invoke('control_service', { action, service }); - return true; + const result = await invokeSafe( + commands.controlModule({ + module_id: service, + action, + }), + ); + if (result.status === 'error') { + this._tracer.error('[SettingsService] Control service failed:', result.error); + return false; + } + return result.data.success === true; } catch (e) { this._tracer.error('[SettingsService] Control service failed:', e); return false; @@ -125,7 +131,7 @@ export class SettingsService { * Fallback to localStorage is PROHIBITED for security reasons. */ public async saveSecureKey(provider: string, key: string): Promise { - const storageKey = `${provider}_api_key`; + const storageKey = this._resolveSecureKeyService(provider); try { await this._tauri.invoke('save_secure_key', { service: storageKey, @@ -137,11 +143,31 @@ export class SettingsService { } } + /** + * Remove a securely stored API key. + */ + public async removeSecureKey(provider: string): Promise { + const storageKey = this._resolveSecureKeyService(provider); + try { + if (typeof this._tauri.removeSecureKey === 'function') { + await this._tauri.removeSecureKey(storageKey); + return; + } + + await this._tauri.invoke('remove_secure_key', { + service: storageKey, + }); + } catch (e) { + this._tracer.error('[SettingsService] Failed to remove secure key:', e); + throw e; + } + } + /** * Checks whether a secure API key exists without exposing the secret value. */ public async hasSecureKey(provider: string): Promise { - const storageKey = `${provider}_api_key`; + const storageKey = this._resolveSecureKeyService(provider); try { return await this._tauri.invoke('has_secure_key', { service: storageKey, @@ -156,7 +182,7 @@ export class SettingsService { * Returns non-sensitive metadata for a stored key. */ public async getSecureKeyMeta(provider: string): Promise { - const storageKey = `${provider}_api_key`; + const storageKey = this._resolveSecureKeyService(provider); try { return await this._tauri.getSecureKeyMeta(storageKey); } catch (e) { @@ -169,7 +195,7 @@ export class SettingsService { * Returns the decrypted secure key for explicit user reveal flows. */ public async getSecureKey(provider: string): Promise { - const storageKey = `${provider}_api_key`; + const storageKey = this._resolveSecureKeyService(provider); try { return await this._tauri.getSecureKey(storageKey); } catch (e) { @@ -247,4 +273,8 @@ export class SettingsService { throw e; } } + + private _resolveSecureKeyService(provider: string): string { + return resolveProviderSecretService(provider) ?? `${provider}_api_key`; + } } diff --git a/src/features/settings/ui/GeneralSettingsRenderer.test.ts b/src/features/settings/ui/GeneralSettingsRenderer.test.ts index 1ad05e20..b675a9eb 100644 --- a/src/features/settings/ui/GeneralSettingsRenderer.test.ts +++ b/src/features/settings/ui/GeneralSettingsRenderer.test.ts @@ -48,7 +48,6 @@ describe('GeneralSettingsRenderer', () => { -
@@ -123,6 +122,12 @@ describe('GeneralSettingsRenderer', () => { expect( document.querySelector('#sidebar .nav-btn[data-page="chat"]')?.getAttribute('tabindex'), ).toBe('-1'); + expect(chatButton.querySelector('.toggle-label')?.dataset['i18n']).toBe( + 'ui.launcher.web.chat', + ); + expect(chatButton.querySelector('.toggle-label')?.textContent).toBe( + 't:ui.launcher.web.chat:Chat', + ); const gpuMonitor = document.querySelector( '#monitor-toggles .monitor-toggle-btn[data-monitor-id="gpu"]', @@ -236,7 +241,7 @@ describe('GeneralSettingsRenderer', () => { t: (_key: string, fallback: string) => `ignored:${fallback}`, } as never); - expect(document.querySelectorAll('#taskbar-toggles .monitor-toggle-btn')).toHaveLength(6); + expect(document.querySelectorAll('#taskbar-toggles .monitor-toggle-btn')).toHaveLength(5); expect(ResizeObserverMock.instances).toHaveLength(2); const taskbar = document.getElementById('taskbar-toggles') as HTMLElement; diff --git a/src/features/settings/ui/GeneralSettingsRenderer.ts b/src/features/settings/ui/GeneralSettingsRenderer.ts index f95730d6..b1332b26 100644 --- a/src/features/settings/ui/GeneralSettingsRenderer.ts +++ b/src/features/settings/ui/GeneralSettingsRenderer.ts @@ -13,6 +13,7 @@ interface IToggleItem { id: string; label: string; icon: string; + labelKey?: string; } interface IToggleGroupConfig { @@ -118,6 +119,7 @@ export class GeneralSettingsRenderer { const navItems = APP_PAGES.filter((page) => page.inSettings === true).map((page) => ({ id: page.id, label: page.defaultLabel, + labelKey: page.i18nKey, icon: page.icon, })); @@ -128,7 +130,7 @@ export class GeneralSettingsRenderer { dataKey: 'pageId', hiddenItems, items: navItems, - getLabelKey: (item) => `ui.launcher.settings.toggle_${item.id}`, + getLabelKey: (item) => item.labelKey ?? `ui.launcher.settings.toggle_${item.id}`, onToggle: (pageId, enabled) => { this.toggleNavItem(pageId, enabled); }, diff --git a/src/features/settings/ui/ModuleSettingsBridgeController.test.ts b/src/features/settings/ui/ModuleSettingsBridgeController.test.ts deleted file mode 100644 index 02b7e95d..00000000 --- a/src/features/settings/ui/ModuleSettingsBridgeController.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { IApp } from '@/shared/types/coreTypes'; -import { ModuleSettingsBridgeController } from './ModuleSettingsBridgeController'; - -describe('ModuleSettingsBridgeController', () => { - beforeEach(() => { - delete (globalThis as unknown as Record)['openModuleSettings']; - }); - - it('installs and uninstalls global openModuleSettings bridge handler', async () => { - const controller = new ModuleSettingsBridgeController({ - error: vi.fn(), - }); - const openModuleSettings = vi - .fn<(...args: [IApp]) => Promise>() - .mockResolvedValue(undefined); - const app = { id: 'svc' } as IApp; - - controller.install(openModuleSettings); - (globalThis as unknown as { openModuleSettings: (app: IApp) => void }).openModuleSettings( - app, - ); - await Promise.resolve(); - - expect(openModuleSettings).toHaveBeenCalledWith(app); - - controller.uninstall(); - expect('openModuleSettings' in globalThis).toBe(false); - }); -}); diff --git a/src/features/settings/ui/ModuleSettingsBridgeController.ts b/src/features/settings/ui/ModuleSettingsBridgeController.ts deleted file mode 100644 index 71095441..00000000 --- a/src/features/settings/ui/ModuleSettingsBridgeController.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { IApp } from '@/shared/types/coreTypes'; -import type { LoggerService } from '@/infrastructure/logging/LoggerService'; - -type ModuleSettingsBridgeLogger = Pick; - -export class ModuleSettingsBridgeController { - private _previousOpenModuleSettings: unknown; - public constructor( - private readonly _tracer: ModuleSettingsBridgeLogger, - private readonly _bridgeTarget: Window & typeof globalThis = globalThis as Window & - typeof globalThis, - ) {} - - public install(openModuleSettings: (app: IApp) => Promise): void { - this._previousOpenModuleSettings = this._bridgeTarget.openModuleSettings; - - this._bridgeTarget.openModuleSettings = (app: IApp) => { - void openModuleSettings(app).catch((error: unknown) => { - this._tracer.error(String(error)); - }); - }; - } - - public uninstall(): void { - if (typeof this._previousOpenModuleSettings === 'function') { - this._bridgeTarget.openModuleSettings = this - ._previousOpenModuleSettings as typeof this._bridgeTarget.openModuleSettings; - } else { - delete (this._bridgeTarget as unknown as Record)['openModuleSettings']; - } - - this._previousOpenModuleSettings = undefined; - } -} diff --git a/src/features/settings/ui/ModuleSettingsControllerFactory.ts b/src/features/settings/ui/ModuleSettingsControllerFactory.ts index f05ca8ae..6de28c95 100644 --- a/src/features/settings/ui/ModuleSettingsControllerFactory.ts +++ b/src/features/settings/ui/ModuleSettingsControllerFactory.ts @@ -21,6 +21,7 @@ type ModuleSettingsControllerFactoryDeps = { debouncedSave: (key: string, value: SettingValue) => void; notifySettingsChanged: () => void; showSaveIndicator: () => void; + showSaveErrorIndicator: () => void; hideSaveIndicator: () => void; showDirtyIndicator: () => void; }; @@ -54,6 +55,9 @@ export class ModuleSettingsControllerFactory { showSaveIndicator: () => { this._deps.showSaveIndicator(); }, + showSaveErrorIndicator: () => { + this._deps.showSaveErrorIndicator(); + }, tracer: this._deps.tracer, }); } diff --git a/src/features/settings/ui/ModuleSettingsCustomUiController.test.ts b/src/features/settings/ui/ModuleSettingsCustomUiController.test.ts index 84edaf63..20d2d5e2 100644 --- a/src/features/settings/ui/ModuleSettingsCustomUiController.test.ts +++ b/src/features/settings/ui/ModuleSettingsCustomUiController.test.ts @@ -160,6 +160,31 @@ describe('ModuleSettingsCustomUiController', () => { expect(status?.textContent).toContain('Failed to load module settings UI.'); }); + it('ignores host messages from other windows', async () => { + const harness = createHarness(); + + await harness.controller.render(harness.container, { + id: 'sample-integration', + name: 'Sample Integration', + category: 'automation', + type: 'local', + settingsUi: 'settings-ui/index.html', + }); + + globalThis.dispatchEvent( + new MessageEvent('message', { + source: globalThis.window, + data: { + channel: 'axelate:module-settings-host', + type: 'host-ready', + }, + }), + ); + + const status = harness.container.querySelector('.module-settings-webui-status'); + expect(status?.classList.contains('hidden')).toBe(false); + }); + it('shows a failure state when the host reports an error', async () => { const harness = createHarness(); diff --git a/src/features/settings/ui/ModuleSettingsCustomUiController.ts b/src/features/settings/ui/ModuleSettingsCustomUiController.ts index 9e8147f9..a9d5d735 100644 --- a/src/features/settings/ui/ModuleSettingsCustomUiController.ts +++ b/src/features/settings/ui/ModuleSettingsCustomUiController.ts @@ -175,6 +175,10 @@ export class ModuleSettingsCustomUiController { }; const handleMessage = (event: MessageEvent) => { + if (event.source !== frame.contentWindow) { + return; + } + if (!this._isHostPayload(event.data)) { return; } diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.test.ts b/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.test.ts index fff9f7a4..6407cbc1 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.test.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.test.ts @@ -14,7 +14,6 @@ describe('ModuleSettingsEngineFieldCatalog', () => { }); expect(catalog.buildTextEngineFields(t).map((field) => field.key)).toEqual([ - 'compute_mode', 'context_size', 'llamacpp_system_prompt', ]); @@ -27,11 +26,44 @@ describe('ModuleSettingsEngineFieldCatalog', () => { expect(imageGroups.samplingFields.some((field) => field.key === 'sdcpp_sampler')).toBe( true, ); + expect( + imageGroups.samplingFields.find((field) => field.key === 'sdcpp_sampler')?.options, + ).toEqual([ + 'euler', + 'euler_a', + 'heun', + 'dpm2', + 'dpm++2s_a', + 'dpm++2m', + 'dpm++2mv2', + 'ipndm', + 'ipndm_v', + 'lcm', + 'ddim_trailing', + 'tcd', + 'res_multistep', + 'res_2s', + 'er_sde', + ]); + expect( + imageGroups.samplingFields.find((field) => field.key === 'sdcpp_scheduler')?.options, + ).toEqual([ + 'discrete', + 'karras', + 'exponential', + 'ays', + 'gits', + 'sgm_uniform', + 'simple', + 'smoothstep', + 'kl_optimal', + 'lcm', + 'bong_tangent', + ]); expect(catalog.buildImageExtraArgsField(t)).toMatchObject({ key: 'extra_args', showInfoButton: true, fullWidth: true, }); - expect(catalog.buildImageCompanionFields(t, 'sdcpp')).toEqual([]); }); }); diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.ts b/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.ts index b946bf10..b69323f9 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldCatalog.ts @@ -14,7 +14,7 @@ export type EngineFieldDefinition = { fullWidth?: boolean; showInfoButton?: boolean; isFile?: boolean; - fileKind?: 'model' | 'vae' | 'llm'; + fileKind?: 'model'; description?: string; }; @@ -26,6 +26,29 @@ export type ImageEngineFieldGroups = { }; export class ModuleSettingsEngineFieldCatalog { + public buildComputeModeField( + t: TranslateFn, + availableModes: Array<'gpu' | 'cpu'> = ['gpu', 'cpu'], + ): EngineFieldDefinition { + const options = availableModes.length > 0 ? availableModes : ['gpu', 'cpu']; + return { + label: t('ui.settings.engine.compute_mode', 'Compute Device'), + key: 'compute_mode', + type: 'select', + isEngineConfig: true, + options, + optionLabels: { + gpu: t('ui.settings.engine.compute_gpu', 'GPU'), + cpu: t('ui.settings.engine.compute_cpu', 'CPU'), + }, + defaultValue: 'gpu', + description: t( + 'ui.settings.engine.compute_mode_hint', + 'Choose whether this engine starts on the GPU or CPU.', + ), + }; + } + public buildCoreModelField( t: TranslateFn, modelPlaceholder: string, @@ -49,7 +72,7 @@ export class ModuleSettingsEngineFieldCatalog { ? { description: t( 'ui.settings.engine.image_model_path_hint', - 'Main image model. Use your SD model here, or a qwen-image*.gguf file for Qwen Image.', + 'Main diffusion model file.', ), } : {}), @@ -58,18 +81,6 @@ export class ModuleSettingsEngineFieldCatalog { public buildTextEngineFields(t: TranslateFn): EngineFieldDefinition[] { return [ - { - label: t('ui.settings.engine.compute_mode', 'Compute Device'), - key: 'compute_mode', - type: 'select', - isEngineConfig: true, - options: ['gpu', 'cpu'], - optionLabels: { - gpu: t('ui.settings.engine.compute_mode_gpu', 'GPU'), - cpu: t('ui.settings.engine.compute_mode_cpu', 'CPU'), - }, - defaultValue: 'gpu', - }, { label: t('ui.settings.engine.context_size', 'Context Window'), key: 'context_size', @@ -99,19 +110,25 @@ export class ModuleSettingsEngineFieldCatalog { return { promptFields: [ { - label: t('ui.settings.engine.sd_positive_prompt', 'Positive Prompt Prefix'), + label: t('ui.settings.engine.sd_positive_prompt', 'Positive Prompt'), key: `${appId}_positive_prompt`, type: 'textarea', isEngineConfig: false, - placeholder: 'e.g. score_9, score_8_up...', + placeholder: t( + 'ui.settings.engine.sd_positive_prompt_placeholder', + 'Describe the image style, subject, lighting, and details.', + ), defaultValue: '', }, { - label: t('ui.settings.engine.sd_negative_prompt', 'Negative Prompt Prefix'), + label: t('ui.settings.engine.sd_negative_prompt', 'Negative Prompt'), key: `${appId}_negative_prompt`, type: 'textarea', isEngineConfig: false, - placeholder: 'e.g. score_4, text, watermark...', + placeholder: t( + 'ui.settings.engine.sd_negative_prompt_placeholder', + 'Things to avoid: blurry, low quality, watermark, distortion.', + ), defaultValue: '', }, ], @@ -174,23 +191,40 @@ export class ModuleSettingsEngineFieldCatalog { type: 'select', isEngineConfig: false, options: [ - 'dpm++ 2m', - 'dpm++ 2m v2', - 'dpm++ 2s a', - 'euler a', + 'euler', + 'euler_a', 'heun', 'dpm2', - 'euler', + 'dpm++2s_a', + 'dpm++2m', + 'dpm++2mv2', 'ipndm', 'ipndm_v', - 'er sde', - 'ddim trailing', - 'res multistep', - 'res 2s', 'lcm', + 'ddim_trailing', 'tcd', + 'res_multistep', + 'res_2s', + 'er_sde', ], - defaultValue: 'euler a', + optionLabels: { + euler: 'Euler', + euler_a: 'Euler A', + heun: 'Heun', + dpm2: 'DPM2', + 'dpm++2s_a': 'DPM++ 2S A', + 'dpm++2m': 'DPM++ 2M', + 'dpm++2mv2': 'DPM++ 2M v2', + ipndm: 'IPNDM', + ipndm_v: 'IPNDM V', + lcm: 'LCM', + ddim_trailing: 'DDIM trailing', + tcd: 'TCD', + res_multistep: 'Res multistep', + res_2s: 'Res 2S', + er_sde: 'ER SDE', + }, + defaultValue: 'euler_a', }, { label: t('ui.settings.engine.sd_scheduler', 'Scheduler'), @@ -200,16 +234,29 @@ export class ModuleSettingsEngineFieldCatalog { options: [ 'discrete', 'karras', - 'sgm uniform', 'exponential', 'ays', 'gits', - 'smoothstep', - 'kl optimal', + 'sgm_uniform', 'simple', + 'smoothstep', + 'kl_optimal', 'lcm', - 'bong tangent', + 'bong_tangent', ], + optionLabels: { + discrete: 'Discrete', + karras: 'Karras', + exponential: 'Exponential', + ays: 'AYS', + gits: 'GITS', + sgm_uniform: 'SGM uniform', + simple: 'Simple', + smoothstep: 'Smoothstep', + kl_optimal: 'KL optimal', + lcm: 'LCM', + bong_tangent: 'Bong tangent', + }, defaultValue: 'discrete', }, ], @@ -247,10 +294,6 @@ export class ModuleSettingsEngineFieldCatalog { }; } - public buildImageCompanionFields(_t: TranslateFn, _appId: string): EngineFieldDefinition[] { - return []; - } - public buildImageExtraArgsField(t: TranslateFn): EngineFieldDefinition { return { label: t('ui.settings.engine.extra_args', 'Extra Arguments'), @@ -263,7 +306,7 @@ export class ModuleSettingsEngineFieldCatalog { showInfoButton: true, description: t( 'ui.settings.engine.extra_args_hint', - 'Advanced startup flags only. Qwen Image companion files are auto-detected next to the selected model or can be passed here.', + 'Advanced startup flags appended to sd.cpp.', ), }; } diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldController.ts b/src/features/settings/ui/ModuleSettingsEngineFieldController.ts index 672dd2ab..6a0a007d 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldController.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldController.ts @@ -13,14 +13,15 @@ type EngineFieldType = 'number' | 'text' | 'select' | 'password' | 'textarea'; type EngineInputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; type ModuleSettingsEngineFieldControllerDeps = { - getSettings: () => Record; - setConfig: (config: EngineConfig) => void; + getSettings: () => Record; + setConfig: (config: EngineConfig) => Promise; debouncedSave: (key: string, value: string | number | boolean | null) => void; showSaveIndicator: () => void; + showSaveErrorIndicator: () => void; translate: (key: string, fallback: string) => string; getModelFileName: (modelPath: string) => string; getModelFileFilters: ( - fileKind: 'model' | 'vae' | 'llm', + fileKind: 'model', isImage: boolean, ) => Array<{ name: string; extensions: string[] }>; tracer: Pick; @@ -78,7 +79,7 @@ export class ModuleSettingsEngineFieldController { input.parentElement?.classList.remove('focused'); }); - const handleSave = () => this.handleSave(input, options); + const handleSave = () => void this.handleSave(input, options); if ( options.type === 'text' || @@ -100,13 +101,18 @@ export class ModuleSettingsEngineFieldController { container: HTMLElement, input: HTMLInputElement, isImage: boolean, - fileKind: 'model' | 'vae' | 'llm', + fileKind: 'model', ): void { const browseBtn = document.createElement('button'); browseBtn.className = 'btn btn-secondary local-engine-browse-btn'; browseBtn.textContent = this._deps.translate('ui.settings.engine.browse', 'Browse'); browseBtn.onclick = async () => { + if (browseBtn.disabled) { + return; + } + + browseBtn.disabled = true; try { const selected = await open({ multiple: false, @@ -121,17 +127,21 @@ export class ModuleSettingsEngineFieldController { input.dataset['fullPath'] = selected; input.value = this._deps.getModelFileName(selected); input.title = selected; - input.dispatchEvent(new Event('change')); + window.setTimeout(() => { + input.dispatchEvent(new Event('change')); + }, 0); } } catch (error: unknown) { this._deps.tracer.error('[ModuleSettingsUI] Failed to open file dialog', error); + } finally { + browseBtn.disabled = false; } }; container.appendChild(browseBtn); } - public handleSave( + public async handleSave( input: EngineInputElement, options: { key: string; @@ -143,7 +153,7 @@ export class ModuleSettingsEngineFieldController { max?: number; defaultValue?: number | string; }, - ): void { + ): Promise { let rawValue = input.value.trim(); if (options.isFile === true && input instanceof HTMLInputElement) { rawValue = input.dataset['fullPath']?.trim() ?? rawValue; @@ -159,8 +169,13 @@ export class ModuleSettingsEngineFieldController { (options.config as unknown as Record)[ options.key ] = formatEngineFieldSaveValue(options.key, value); - this._deps.setConfig(options.config); - this._deps.showSaveIndicator(); + try { + await this._deps.setConfig(options.config); + this._deps.showSaveIndicator(); + } catch (error: unknown) { + this._deps.tracer.error('[ModuleSettingsUI] Failed to save engine setting', error); + this._deps.showSaveErrorIndicator(); + } } } } diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldRowRenderer.ts b/src/features/settings/ui/ModuleSettingsEngineFieldRowRenderer.ts index d1eefac7..f87d17b7 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldRowRenderer.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldRowRenderer.ts @@ -16,7 +16,7 @@ type EngineFieldRowOptions = { max?: number; isFile?: boolean; isImage?: boolean; - fileKind?: 'model' | 'vae' | 'llm'; + fileKind?: 'model'; description?: string; fullWidth?: boolean; showInfoButton?: boolean; @@ -45,7 +45,7 @@ type EngineFieldRowRendererDeps = { container: HTMLElement, input: HTMLInputElement, isImage: boolean, - fileKind: 'model' | 'vae' | 'llm', + fileKind: 'model', ) => void; getExtraArgsInfoText: () => string; toggleInfoPopover: ( @@ -146,10 +146,7 @@ export class ModuleSettingsEngineFieldRowRenderer { extraArgsControl.root.style.cursor = 'pointer'; extraArgsControl.root.addEventListener('click', (event) => { const target = event.target as Node; - if ( - target === extraArgsControl.root || - target === extraArgsControl.root.firstChild - ) { + if (target === extraArgsControl.root) { event.preventDefault(); event.stopPropagation(); this._deps.toggleInfoPopover(infoButton, options.appId, options.config); diff --git a/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts b/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts index 4c2c9eed..af55c3c9 100644 --- a/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts +++ b/src/features/settings/ui/ModuleSettingsEngineFieldSupport.ts @@ -2,8 +2,7 @@ type EngineFieldType = 'number' | 'text' | 'select' | 'password' | 'textarea'; type EngineInputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; type EngineFieldValue = string | number | string[] | null | undefined; type ExtraArgsTranslate = (key: string, fallback: string) => string; -type PerformanceTranslate = (key: string, fallback: string) => string; -export type EngineModelFileKind = 'model' | 'vae' | 'llm'; +export type EngineModelFileKind = 'model'; export type EngineModelFileFilter = { name: string; extensions: string[] }; type EngineFieldInitialOptions = { @@ -11,7 +10,7 @@ type EngineFieldInitialOptions = { isEngineConfig: boolean; defaultValue?: number | string; config: Record | null; - settings: Record; + settings: Record; }; type EngineFieldParseOptions = { @@ -42,7 +41,7 @@ export type EngineExtraArgsControl = { root: HTMLDivElement; syncTokens: () => void; getGroups: () => string[]; - setGroups: (groups: string[]) => void; + setGroups: (groups: string[], options?: { emit?: boolean }) => void; }; export type EngineExtraArgDoc = { @@ -56,9 +55,95 @@ export type EngineExtraArgDocs = { items: EngineExtraArgDoc[]; }; +type EngineExtraArgDocSource = { + flag: string; + fallback: string; +}; + +const SDCPP_EXTRA_ARG_DOCS: EngineExtraArgDocSource[] = [ + { flag: '--threads 8', fallback: 'Set worker thread count.' }, + { flag: '--vae path', fallback: 'Set VAE model path.' }, + { flag: '--taesd path', fallback: 'Set TAESD model path.' }, + { flag: '--control-net path', fallback: 'Set ControlNet model path.' }, + { flag: '--embd-dir path', fallback: 'Load textual inversion embeddings.' }, + { flag: '--stacked-id-embd-dir path', fallback: 'Load stacked ID embeddings.' }, + { flag: '--input-id-images-dir path', fallback: 'Load input ID images.' }, + { flag: '--lora-model-dir path', fallback: 'Directory containing LoRA models.' }, + { flag: '--vae-decode-only', fallback: 'Decode a latent image with VAE only.' }, + { flag: '--vae-encode-only', fallback: 'Encode an image into latent space.' }, + { flag: '--control-image path', fallback: 'Image used by ControlNet.' }, + { flag: '--output-video path', fallback: 'Set output video path.' }, + { flag: '--init-img path', fallback: 'Use an initial image for img2img.' }, + { flag: '--mask path', fallback: 'Use a mask image for inpainting.' }, + { flag: '--ref-image path', fallback: 'Use a reference image.' }, + { flag: '--clip_l path', fallback: 'Set CLIP-L model path.' }, + { flag: '--clip_g path', fallback: 'Set CLIP-G model path.' }, + { flag: '--clip_vision path', fallback: 'Set CLIP-Vision model path.' }, + { flag: '--t5xxl path', fallback: 'Set T5-XXL model path.' }, + { flag: '--llm path', fallback: 'Set LLM model path.' }, + { flag: '--diffusion-fa', fallback: 'Enable flash attention for diffusion.' }, + { flag: '--fa', fallback: 'Enable flash attention globally.' }, + { flag: '--no-fallback', fallback: 'Disable fallback execution paths.' }, + { flag: '--mmap', fallback: 'Memory-map model weights from disk.' }, + { flag: '--no-mmap', fallback: 'Disable memory mapping.' }, + { flag: '--offload-to-cpu', fallback: 'Offload model work to CPU.' }, + { flag: '--clip-on-cpu', fallback: 'Run CLIP on CPU.' }, + { flag: '--vae-on-cpu', fallback: 'Run VAE on CPU.' }, + { flag: '--vae-tiling', fallback: 'Use tiled VAE decoding to reduce VRAM usage.' }, + { flag: '--free-params-immediately', fallback: 'Free model params after load.' }, + { flag: '--keep-clip-on-cpu', fallback: 'Keep CLIP weights on CPU.' }, + { flag: '--keep-control-net-cpu', fallback: 'Keep ControlNet weights on CPU.' }, + { flag: '--keep-vae-on-cpu', fallback: 'Keep VAE weights on CPU.' }, + { flag: '--control-net-cpu', fallback: 'Run ControlNet on CPU when ControlNet is used.' }, + { flag: '--canny', fallback: 'Apply Canny preprocessing for ControlNet.' }, + { flag: '--color', fallback: 'Apply color preprocessing or colored output.' }, + { flag: '--cpu-params', fallback: 'Keep parameters in regular CPU memory.' }, + { flag: '--normalize-input', fallback: 'Normalize input image values.' }, + { flag: '--upscale-model path', fallback: 'Set ESRGAN upscale model path.' }, + { flag: '--upscale-repeats 2', fallback: 'Repeat upscaling passes.' }, + { flag: '--type q8_0', fallback: 'Set weight precision/type.' }, + { flag: '--rng cuda', fallback: 'Prefer CUDA RNG on NVIDIA systems.' }, + { flag: '--prediction v', fallback: 'Set prediction mode.' }, + { flag: '--guidance 3.5', fallback: 'Set guidance scale.' }, + { flag: '--eta 0', fallback: 'Set DDIM eta.' }, + { flag: '--pm-style-strength 20', fallback: 'Set PhotoMaker style strength.' }, + { flag: '--control-strength 0.9', fallback: 'Set ControlNet strength.' }, + { flag: '--video-frames 16', fallback: 'Set generated video frame count.' }, + { flag: '--fps 24', fallback: 'Set generated video FPS.' }, + { flag: '--motion-bucket-id 127', fallback: 'Set SVD motion bucket.' }, + { flag: '--augmentation-level 0', fallback: 'Set SVD augmentation level.' }, + { flag: '--sample-start 0', fallback: 'Set sample start value.' }, + { flag: '--sample-end 1', fallback: 'Set sample end value.' }, + { flag: '--slg-scale 0', fallback: 'Set skip-layer guidance scale.' }, + { flag: '--skip-layers 7,8,9', fallback: 'Set skip-layer guidance layers.' }, + { flag: '--skip-layer-start 0.01', fallback: 'Set skip-layer start ratio.' }, + { flag: '--skip-layer-end 0.2', fallback: 'Set skip-layer end ratio.' }, + { flag: '--vae-tile-size 32x32', fallback: 'Set VAE tile size.' }, + { flag: '--vae-tile-overlap 0.5', fallback: 'Set VAE tile overlap.' }, + { flag: '--vae-relative-tile-size 0.5x0.5', fallback: 'Set relative VAE tile size.' }, + { flag: '--verbose', fallback: 'Enable verbose logging.' }, + { flag: '--quiet', fallback: 'Reduce logging output.' }, + { flag: '--chroma-disable-ds', fallback: 'Disable Chroma downsampling.' }, + { flag: '--chroma-enable-t5-mask', fallback: 'Enable Chroma T5 mask.' }, + { flag: '--chroma-t5-mask-pad 1', fallback: 'Set Chroma T5 mask padding.' }, + { flag: '--flow-shift 3', fallback: 'Set flow shift value.' }, + { flag: '--timestep-shift 250', fallback: 'Set shifted timestep value.' }, + { flag: '--diffusion-cpu-params', fallback: 'Keep diffusion params on CPU.' }, + { flag: '--vae-cpu-params', fallback: 'Keep VAE params on CPU.' }, + { flag: '--clip-cpu-params', fallback: 'Keep CLIP params on CPU.' }, + { flag: '--control-net-cpu-params', fallback: 'Keep ControlNet params on CPU.' }, + { flag: '--rng std_default', fallback: 'Use standard RNG.' }, + { flag: '--sampler-rng cuda', fallback: 'Use CUDA RNG specifically for the sampler.' }, + { flag: '--load-id-weights path', fallback: 'Load ID weights file.' }, + { flag: '--photo-maker path', fallback: 'Set PhotoMaker model path.' }, + { flag: '--photo-maker-vae path', fallback: 'Set PhotoMaker VAE path.' }, + { flag: '--style-strength 20', fallback: 'Set PhotoMaker style strength.' }, + { flag: '--taesd-decode', fallback: 'Use TAESD decoder.' }, + { flag: '--taesd-encode', fallback: 'Use TAESD encoder.' }, +]; + export type EngineRecommendedExtraArgsContext = { config?: { - compute_mode?: 'gpu' | 'cpu'; extra_args?: string[]; } | null; currentGroups?: string[]; @@ -171,64 +256,6 @@ export function syncEnginePromptTextareaHeights( requestAnimationFrameFn(sync); } -export function renderEnginePerformanceModeField( - container: HTMLElement, - appId: string, - settings: Record, - translate: PerformanceTranslate, - debouncedSave: (key: string, value: string | number | boolean | null) => void, -): void { - const row = document.createElement('div'); - row.className = 'local-engine-field-row'; - - const labelRow = document.createElement('div'); - labelRow.className = 'local-engine-label-row'; - - const label = document.createElement('label'); - label.textContent = translate('ui.settings.engine.performance_mode', 'Performance Mode'); - label.className = 'local-engine-field-label'; - labelRow.appendChild(label); - - const inputWrapper = document.createElement('div'); - inputWrapper.className = 'local-engine-performance-toggle'; - - const statusLabel = document.createElement('span'); - statusLabel.className = 'local-engine-performance-toggle-status local-engine-perf-status'; - - const switchLabel = document.createElement('label'); - switchLabel.className = 'switch'; - switchLabel.style.pointerEvents = 'none'; - - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - - const slider = document.createElement('span'); - slider.className = 'slider'; - - switchLabel.append(checkbox, slider); - inputWrapper.append(statusLabel, switchLabel); - - let enabled = String(settings[`${appId}_performance_mode`] ?? 'false').toLowerCase() === 'true'; - - const sync = () => { - statusLabel.textContent = enabled - ? translate('ui.common.enabled', 'Enabled') - : translate('ui.common.disabled', 'Disabled'); - inputWrapper.classList.toggle('is-enabled', enabled); - checkbox.checked = enabled; - }; - sync(); - - inputWrapper.addEventListener('click', () => { - enabled = !enabled; - sync(); - debouncedSave(`${appId}_performance_mode`, enabled); - }); - - row.append(labelRow, inputWrapper); - container.appendChild(row); -} - export function getEngineModelFileName(modelPath: string, notSelectedLabel: string): string { if (modelPath.trim() === '') { return notSelectedLabel; @@ -239,17 +266,9 @@ export function getEngineModelFileName(modelPath: string, notSelectedLabel: stri } export function getEngineModelFileFilters( - fileKind: EngineModelFileKind, + _fileKind: EngineModelFileKind, isImage: boolean, ): EngineModelFileFilter[] { - if (fileKind === 'vae') { - return [{ name: 'SafeTensors', extensions: ['safetensors'] }]; - } - - if (fileKind === 'llm') { - return [{ name: 'GGUF Models', extensions: ['gguf'] }]; - } - if (isImage) { return [ { name: 'SD Models', extensions: ['gguf', 'safetensors'] }, @@ -265,18 +284,25 @@ export function createEngineExtraArgsField(translate: ExtraArgsTranslate): Engin const root = document.createElement('div'); root.className = 'local-engine-tags-editor'; - const hiddenInput = document.createElement('input'); - hiddenInput.type = 'hidden'; - hiddenInput.className = 'local-engine-tags-value'; + const input = document.createElement('input'); + input.type = 'hidden'; + input.className = 'local-engine-extra-args-input local-engine-extra-args-value'; const chips = document.createElement('div'); - chips.className = 'local-engine-tags-chips'; + chips.className = 'local-engine-extra-args-chips'; + + const draftInput = document.createElement('input'); + draftInput.type = 'text'; + draftInput.className = 'local-engine-extra-args-draft'; + draftInput.placeholder = translate( + 'ui.settings.engine.extra_args.placeholder', + 'Add startup flags', + ); + + let groups: string[] = []; const parseGroups = (raw: string): string[] => { - const tokens = raw - .split(/\s+/) - .map((token) => token.trim()) - .filter((token) => token !== ''); + const tokens = tokenizeEngineExtraArgs(raw); const groups: string[] = []; for (let index = 0; index < tokens.length; index += 1) { @@ -304,104 +330,132 @@ export function createEngineExtraArgsField(translate: ExtraArgsTranslate): Engin const flattenGroups = (groups: string[]): string => groups - .flatMap((group) => - group - .split(/\s+/) - .map((token) => token.trim()) - .filter((token) => token !== ''), - ) + .flatMap((group) => tokenizeEngineExtraArgs(group)) + .map(formatEngineExtraArgToken) .join(' '); - const getGroups = (): string[] => parseGroups(hiddenInput.value); + const commitHiddenValue = () => { + input.value = flattenGroups(groups); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + }; - const syncTokens = () => { - const groups = getGroups(); + const renderChips = () => { chips.replaceChildren( ...groups.map((group, index) => { - const chip = document.createElement('button'); - chip.type = 'button'; - chip.className = 'local-engine-tag-chip'; - chip.title = translate('ui.settings.engine.extra_args.remove', 'Remove'); - chip.dataset['groupIndex'] = String(index); - - const label = document.createElement('span'); - label.className = 'local-engine-tag-chip-label'; - label.textContent = group; - - const remove = document.createElement('span'); - remove.className = 'local-engine-tag-chip-remove'; - remove.textContent = 'x'; - - chip.append(label, remove); + const chip = document.createElement('span'); + chip.className = 'local-engine-extra-arg-chip'; + + const edit = document.createElement('button'); + edit.type = 'button'; + edit.className = 'local-engine-extra-arg-edit'; + edit.textContent = group; + edit.title = group; + edit.addEventListener('click', () => { + groups.splice(index, 1); + draftInput.value = group; + renderChips(); + commitHiddenValue(); + draftInput.focus(); + }); + + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'local-engine-extra-arg-remove'; + remove.textContent = '×'; + remove.setAttribute( + 'aria-label', + translate('ui.settings.engine.extra_args.remove', 'Remove'), + ); + remove.addEventListener('click', () => { + groups.splice(index, 1); + renderChips(); + commitHiddenValue(); + }); + + chip.append(edit, remove); return chip; }), ); }; - const setGroups = (groups: string[]) => { - hiddenInput.value = flattenGroups(groups); - syncTokens(); - hiddenInput.dispatchEvent(new Event('input', { bubbles: true })); - hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); - }; - - root.append(chips, hiddenInput); - chips.addEventListener('click', (event) => { - const target = event.target; - if (!(target instanceof HTMLElement)) { + const commitDraft = () => { + const nextGroups = parseGroups(draftInput.value); + if (nextGroups.length === 0) { return; } - const chip = target.closest('.local-engine-tag-chip'); - if (!(chip instanceof HTMLButtonElement)) { + const seen = new Set(groups); + nextGroups.forEach((group) => { + if (!seen.has(group)) { + seen.add(group); + groups.push(group); + } + }); + draftInput.value = ''; + renderChips(); + commitHiddenValue(); + }; + + const getGroups = (): string[] => [...groups, ...parseGroups(draftInput.value)]; + + const syncTokens = () => { + groups = parseGroups(input.value); + draftInput.value = ''; + renderChips(); + input.value = flattenGroups(groups); + }; + + const setGroups = (newGroups: string[], options: { emit?: boolean } = {}) => { + input.value = flattenGroups(newGroups); + syncTokens(); + if (options.emit === false) { return; } + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + }; - const groupIndex = Number(chip.dataset['groupIndex']); - if (Number.isNaN(groupIndex)) { - return; + draftInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ',') { + event.preventDefault(); + commitDraft(); } + if (event.key === 'Backspace' && draftInput.value === '' && groups.length > 0) { + draftInput.value = groups.pop() ?? ''; + renderChips(); + commitHiddenValue(); + } + }); + draftInput.addEventListener('blur', commitDraft); - const updated = getGroups().filter((_, index) => index !== groupIndex); - setGroups(updated); + root.addEventListener('click', (event) => { + if (event.target === root || event.target === chips) { + draftInput.focus(); + } }); - return { input: hiddenInput, root, syncTokens, getGroups, setGroups }; + root.append(input, chips, draftInput); + + return { input, root, syncTokens, getGroups, setGroups }; } -export function getEngineExtraArgDocs(appId: string): EngineExtraArgDocs { - if (appId === 'sdcpp' || appId === 'stable-diffusion') { +export function getEngineExtraArgDocs( + appId: string, + translate: ExtraArgsTranslate = (_key, fallback) => fallback, +): EngineExtraArgDocs { + if (appId === 'sdcpp') { return { - title: 'Manual sd.cpp flags', - subtitle: - 'These go into Extra Arguments as startup flags. Qwen Image companion files are auto-detected beside the selected model.', - items: [ - { flag: '--fa', description: 'Enable flash attention globally.' }, - { - flag: '--vae-tiling', - description: 'Use tiled VAE decoding to reduce VRAM usage.', - }, - { - flag: '--diffusion-fa', - description: 'Enable flash attention for the diffusion model only.', - }, - { flag: '--mmap', description: 'Memory-map model weights from disk.' }, - { - flag: '--offload-to-cpu', - description: 'Keep more weights in RAM to reduce VRAM pressure.', - }, - { flag: '--clip-on-cpu', description: 'Run CLIP on CPU for low-VRAM setups.' }, - { flag: '--vae-on-cpu', description: 'Run VAE on CPU for low-VRAM setups.' }, - { - flag: '--control-net-cpu', - description: 'Run ControlNet on CPU when ControlNet is used.', - }, - { flag: '--rng cuda', description: 'Prefer CUDA RNG on NVIDIA systems.' }, - { - flag: '--sampler-rng cuda', - description: 'Use CUDA RNG specifically for the sampler.', - }, - ], + title: translate('ui.settings.engine.sdcpp_flags.title', 'Manual sd.cpp flags'), + subtitle: translate( + 'ui.settings.engine.sdcpp_flags.subtitle', + 'Startup flags appended to sd-server.', + ), + items: buildEngineExtraArgDocs( + 'ui.settings.engine.sdcpp_flag', + SDCPP_EXTRA_ARG_DOCS, + translate, + ), }; } @@ -419,28 +473,36 @@ export function getEngineExtraArgDocs(appId: string): EngineExtraArgDocs { }; } +function buildEngineExtraArgDocs( + prefix: string, + docs: EngineExtraArgDocSource[], + translate: ExtraArgsTranslate, +): EngineExtraArgDoc[] { + return [...docs] + .sort((left, right) => left.flag.localeCompare(right.flag, 'en', { sensitivity: 'base' })) + .map((doc) => ({ + flag: doc.flag, + description: translate(`${prefix}.${getEngineExtraArgKey(doc.flag)}`, doc.fallback), + })); +} + +function getEngineExtraArgKey(flag: string): string { + return flag + .toLowerCase() + .replace(/^--/, '') + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); +} + export function getEngineRecommendedExtraArgs( appId: string, context: EngineRecommendedExtraArgsContext = {}, ): string[] { - if (appId !== 'sdcpp' && appId !== 'stable-diffusion') { + if (appId !== 'sdcpp') { return ['--flash-attn']; } - const existing = new Set([ - ...(context.config?.extra_args ?? []), - ...(context.currentGroups ?? []), - ]); - const cpuPreferenceFlags = ['--offload-to-cpu', '--clip-on-cpu', '--vae-on-cpu']; - const prefersCpuOrLowVram = - context.config?.compute_mode === 'cpu' || - cpuPreferenceFlags.some((flag) => existing.has(flag)); - - if (prefersCpuOrLowVram) { - return ['--mmap', '--vae-tiling', '--offload-to-cpu', '--clip-on-cpu', '--vae-on-cpu']; - } - - const recommended = ['--diffusion-fa', '--fa', '--mmap']; + const recommended = ['--diffusion-fa', '--mmap']; if (isHighResolutionImageGeneration(appId, context.settings)) { recommended.push('--vae-tiling'); } @@ -497,7 +559,7 @@ export function formatEngineFieldSaveValue( ): string | number | string[] | null { if (key === 'extra_args') { if (typeof value === 'string') { - return value.trim() ? value.trim().split(/\s+/) : []; + return tokenizeEngineExtraArgs(value); } return []; } @@ -505,6 +567,59 @@ export function formatEngineFieldSaveValue( return value; } +export function tokenizeEngineExtraArgs(raw: string): string[] { + const tokens: string[] = []; + let current = ''; + let quote: '"' | "'" | null = null; + + for (let index = 0; index < raw.length; index += 1) { + const char = raw[index] ?? ''; + if (quote !== null && char === '\\') { + const nextChar = raw[index + 1]; + if (nextChar === quote || nextChar === '\\') { + current += nextChar; + index += 1; + } else { + current += char; + } + continue; + } + + if ((char === '"' || char === "'") && (quote === null || quote === char)) { + quote = quote === null ? char : null; + continue; + } + + if (quote === null && /\s/.test(char)) { + if (current !== '') { + tokens.push(current); + current = ''; + } + continue; + } + + current += char; + } + + if (current !== '') { + tokens.push(current); + } + + return tokens; +} + +function formatEngineExtraArgToken(token: string): string { + if (token === '') { + return '""'; + } + + if (!/\s|["']/.test(token)) { + return token; + } + + return `"${token.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + function setInitialEngineConfigValue( input: EngineInputElement, key: string, @@ -532,10 +647,11 @@ function setInitialEngineSettingsValue( input: EngineInputElement, key: string, defaultValue: number | string | undefined, - settings: Record, + settings: Record, ): void { const value = settings[key]; - if (value !== undefined) { + // Some persisted settings stored the literal "null"; keep empty defaults empty. + if (value !== undefined && value !== null && !(value === 'null' && defaultValue === '')) { input.value = String(value); return; } diff --git a/src/features/settings/ui/ModuleSettingsEngineHtmlBuilder.ts b/src/features/settings/ui/ModuleSettingsEngineHtmlBuilder.ts index 53c16c76..6b88f079 100644 --- a/src/features/settings/ui/ModuleSettingsEngineHtmlBuilder.ts +++ b/src/features/settings/ui/ModuleSettingsEngineHtmlBuilder.ts @@ -21,9 +21,7 @@ export class ModuleSettingsEngineHtmlBuilder { config === null ? `

${this.escapeHtml(this._translate('ui.settings.engine.config_unavailable', 'Engine config unavailable (Tauri not connected)'))}

` : ''; - const generationSection = isImage - ? this._buildImageGenerationSection(app.id) - : this._buildTextRuntimeSections(app.id); + const generationSection = isImage ? this._buildImageGenerationSection(app.id) : ''; return `
@@ -31,7 +29,7 @@ export class ModuleSettingsEngineHtmlBuilder {
-

🧩 ${this.escapeHtml( +

${this.escapeHtml( this._translate( 'ui.settings.engine.core_config', 'Core Config', @@ -48,50 +46,15 @@ export class ModuleSettingsEngineHtmlBuilder { `; } - private _buildTextRuntimeSections(appId: string): string { - return ` -
-
-
-

${this.escapeHtml( - this._translate('ui.settings.engine.compute_mode', 'Compute Device'), - )}

-
-
-
-
-
-
-
-

${this.escapeHtml( - this._translate('ui.settings.engine.context_size', 'Context Window'), - )}

-
-
-
-
-
-
-
-

${this.escapeHtml( - this._translate('ui.settings.engine.system_prompt', 'System Prompt'), - )}

-
-
-
-
- `; - } - private _buildImageGenerationSection(appId: string): string { return `
-

🎛️ ${this.escapeHtml( +

${this.escapeHtml( this._translate( - 'ui.settings.engine.generation_presets', - 'Generation Presets', + 'ui.settings.engine.generation_settings', + 'Generation Settings', ), )}

diff --git a/src/features/settings/ui/ModuleSettingsEngineInfoPopover.ts b/src/features/settings/ui/ModuleSettingsEngineInfoPopover.ts index cb6c15c2..2dafcacc 100644 --- a/src/features/settings/ui/ModuleSettingsEngineInfoPopover.ts +++ b/src/features/settings/ui/ModuleSettingsEngineInfoPopover.ts @@ -38,7 +38,7 @@ export type EngineInfoPopoverHandle = { export function createEngineInfoPopover(deps: EngineInfoPopoverDeps): EngineInfoPopoverHandle { const { anchor, appId, runtime } = deps; - const docs = getEngineExtraArgDocs(appId); + const docs = getEngineExtraArgDocs(appId, deps.translate); const popover = document.createElement('div'); popover.className = 'local-engine-args-popover'; popover.dataset['appId'] = appId; @@ -64,13 +64,6 @@ export function createEngineInfoPopover(deps: EngineInfoPopoverDeps): EngineInfo ); actions.appendChild(recommendedBtn); - const addAllBtn = document.createElement('button'); - addAllBtn.type = 'button'; - addAllBtn.className = 'local-engine-args-copy-all'; - addAllBtn.dataset['action'] = 'add-all'; - addAllBtn.textContent = deps.translate('ui.settings.engine.extra_args.add_all', 'Add all'); - actions.appendChild(addAllBtn); - const list = document.createElement('div'); list.className = 'local-engine-args-list'; @@ -105,6 +98,7 @@ export function createEngineInfoPopover(deps: EngineInfoPopoverDeps): EngineInfo }); popover.append(title, subtitle, actions, list); + const appendFlags = (flags: string[]) => { const added = deps.appendExtraArgs(appId, flags); if (flags.length > 1) { @@ -146,12 +140,6 @@ export function createEngineInfoPopover(deps: EngineInfoPopoverDeps): EngineInfo return; } - const addAllAction = target.closest('[data-action="add-all"]'); - if (addAllAction instanceof HTMLButtonElement) { - appendFlags(docs.items.map((item) => item.flag)); - return; - } - const addRecommendedAction = target.closest( '[data-action="add-recommended"]', ); @@ -198,7 +186,7 @@ export function createEngineInfoPopover(deps: EngineInfoPopoverDeps): EngineInfo const availableWidth = modalRect.width; const edgeGap = Math.max(16, Math.min(32, Math.round(availableWidth * 0.015))); const gap = Math.max(14, Math.min(20, Math.round(availableWidth * 0.008))); - const panelWidth = Math.max(300, Math.min(344, Math.round(availableWidth * 0.18))); + const panelWidth = Math.max(380, Math.min(460, Math.round(availableWidth * 0.24))); modal?.style.setProperty('--app-modal-edge-gap', `${edgeGap}px`); modal?.style.setProperty('--app-modal-popover-width', `${panelWidth}px`); @@ -206,7 +194,6 @@ export function createEngineInfoPopover(deps: EngineInfoPopoverDeps): EngineInfo }; updatePosition(); modal?.classList.add('popover-open'); - popover.style.opacity = '0'; popover.style.transition = 'opacity 0.22s cubic-bezier(0.22, 1, 0.36, 1)'; runtime.requestAnimationFrame(() => { @@ -241,7 +228,7 @@ export function createEngineInfoPopover(deps: EngineInfoPopoverDeps): EngineInfo modal?.style.removeProperty('--app-modal-popover-spacing'); popover.remove(); deps.onClose(); - }, 280); + }, 190); }; const handleDocumentClick = (event: MouseEvent) => { diff --git a/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts b/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts index df45de57..4dc3f87e 100644 --- a/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts +++ b/src/features/settings/ui/ModuleSettingsEngineRenderFlow.ts @@ -12,7 +12,11 @@ type ModuleSettingsEngineRenderFlowDeps = { appId: string, config: EngineConfig | null, ) => void; - renderPerformanceModeFieldRow: (container: HTMLElement, appId: string) => void; + renderModelProfiles: ( + container: HTMLElement, + appId: string, + config: EngineConfig | null, + ) => void; renderFieldRow: ( container: HTMLElement, options: EngineFieldDefinition & { @@ -47,7 +51,10 @@ type ModuleSettingsEngineRenderOptions = { modelPlaceholder: string, isImage: boolean, ) => EngineFieldDefinition; - getImageCompanionFields: (translate: TranslateFn, appId: string) => EngineFieldDefinition[]; + getComputeModeField: ( + translate: TranslateFn, + availableModes?: Array<'gpu' | 'cpu'>, + ) => EngineFieldDefinition; getImageExtraArgsField: (translate: TranslateFn) => EngineFieldDefinition; }; @@ -76,8 +83,12 @@ export class ModuleSettingsEngineRenderFlow { modelPlaceholder, translate: options.translate, getCoreModelField: options.getCoreModelField, - getImageCompanionFields: options.getImageCompanionFields, + getComputeModeField: options.getComputeModeField, + ...(app.installedComputeModes !== undefined + ? { availableComputeModes: app.installedComputeModes } + : {}), getImageExtraArgsField: options.getImageExtraArgsField, + getTextFields: options.getTextFields, }); if (isImage) { @@ -91,13 +102,7 @@ export class ModuleSettingsEngineRenderFlow { return; } - this._renderTextFields({ - container, - appId: app.id, - config, - translate: options.translate, - getTextFields: options.getTextFields, - }); + this._deps.syncPromptTextareaHeights(corePrimary); } private _renderCoreFields(options: { @@ -107,42 +112,38 @@ export class ModuleSettingsEngineRenderFlow { isImage: boolean; modelPlaceholder: string; translate: TranslateFn; + availableComputeModes?: Array<'gpu' | 'cpu'>; getCoreModelField: ModuleSettingsEngineRenderOptions['getCoreModelField']; - getImageCompanionFields: ModuleSettingsEngineRenderOptions['getImageCompanionFields']; + getComputeModeField: ModuleSettingsEngineRenderOptions['getComputeModeField']; getImageExtraArgsField: ModuleSettingsEngineRenderOptions['getImageExtraArgsField']; + getTextFields: ModuleSettingsEngineRenderOptions['getTextFields']; }): void { const coreField = options.getCoreModelField( options.translate, options.modelPlaceholder, options.isImage, ); + const computeField = options.getComputeModeField( + options.translate, + options.availableComputeModes, + ); - if (options.isImage) { - const splitRow = document.createElement('div'); - splitRow.className = 'local-engine-split-row'; + this._deps.renderFieldRow(options.container, { + ...coreField, + isFile: true, + isImage: options.isImage, + appId: options.appId, + config: options.config, + }); - this._deps.renderFieldRow(splitRow, { - ...coreField, - isFile: true, - isImage: true, - appId: options.appId, - config: options.config, - }); - this._deps.renderPerformanceModeFieldRow(splitRow, options.appId); - options.container.appendChild(splitRow); - - const companionFields = options.getImageCompanionFields( - options.translate, - options.appId, - ); - companionFields.forEach((field) => { - this._deps.renderFieldRow(options.container, { - ...field, - isImage: field.fileKind === 'vae', - appId: options.appId, - config: options.config, - }); - }); + this._deps.renderFieldRow(options.container, { + ...computeField, + appId: options.appId, + config: options.config, + }); + + if (options.isImage) { + this._deps.renderModelProfiles(options.container, options.appId, options.config); this._deps.renderFieldRow(options.container, { ...options.getImageExtraArgsField(options.translate), @@ -152,12 +153,12 @@ export class ModuleSettingsEngineRenderFlow { return; } - this._deps.renderFieldRow(options.container, { - ...coreField, - isFile: true, - isImage: false, - appId: options.appId, - config: options.config, + options.getTextFields(options.translate).forEach((field) => { + this._deps.renderFieldRow(options.container, { + ...field, + appId: options.appId, + config: options.config, + }); }); } @@ -212,39 +213,4 @@ export class ModuleSettingsEngineRenderFlow { options.config, ); } - - private _renderTextFields(options: { - container: HTMLElement; - appId: string; - config: EngineConfig | null; - translate: TranslateFn; - getTextFields: ModuleSettingsEngineRenderOptions['getTextFields']; - }): void { - const fieldTargets: Record = { - compute_mode: `#local-engine-compute-${options.appId}`, - context_size: `#local-engine-context-${options.appId}`, - llamacpp_system_prompt: `#local-engine-system-prompt-${options.appId}`, - }; - - options.getTextFields(options.translate).forEach((field) => { - const targetSelector = fieldTargets[field.key]; - if (targetSelector === undefined) { - // eslint-disable-next-line no-console - console.warn( - `[ModuleSettingsEngineRenderFlow] Missing target for text field "${field.key}" in ${options.appId}`, - ); - return; - } - const target = options.container.querySelector(targetSelector); - if (!(target instanceof HTMLElement)) { - return; - } - - this._deps.renderFieldRow(target, { - ...field, - appId: options.appId, - config: options.config, - }); - }); - } } diff --git a/src/features/settings/ui/ModuleSettingsEngineRenderer.test.ts b/src/features/settings/ui/ModuleSettingsEngineRenderer.test.ts index 73c63a7c..67b5c42d 100644 --- a/src/features/settings/ui/ModuleSettingsEngineRenderer.test.ts +++ b/src/features/settings/ui/ModuleSettingsEngineRenderer.test.ts @@ -5,12 +5,12 @@ import { ModuleSettingsEngineRenderer } from './ModuleSettingsEngineRenderer'; import { ModuleSettingsEngineFieldController } from './ModuleSettingsEngineFieldController'; import { createEngineExtraArgsField, + getEngineExtraArgDocs, getEngineModelFileFilters, getEngineModelFileName, formatEngineFieldSaveValue, ModuleSettingsEngineInputFactory, parseEngineFieldValue, - renderEnginePerformanceModeField, setupInitialEngineFieldValue, } from './ModuleSettingsEngineFieldSupport'; import { ModuleSettingsEngineHtmlBuilder } from './ModuleSettingsEngineHtmlBuilder'; @@ -51,6 +51,7 @@ function createRendererHarness(options?: { const debouncedSave = vi.fn(); const notifySettingsChanged = vi.fn(); const showSaveIndicator = vi.fn(); + const showSaveErrorIndicator = vi.fn(); const setConfig = vi.fn().mockResolvedValue(undefined); let animationTime = 0; const runtime = { @@ -86,6 +87,7 @@ function createRendererHarness(options?: { debouncedSave, notifySettingsChanged, showSaveIndicator, + showSaveErrorIndicator, tracer: { error: vi.fn(), }, @@ -100,6 +102,7 @@ function createRendererHarness(options?: { debouncedSave, notifySettingsChanged, showSaveIndicator, + showSaveErrorIndicator, setConfig, runtime, }; @@ -109,12 +112,14 @@ function createFieldControllerHarness() { const setConfig = vi.fn(); const debouncedSave = vi.fn(); const showSaveIndicator = vi.fn(); + const showSaveErrorIndicator = vi.fn(); const error = vi.fn(); const fieldController = new ModuleSettingsEngineFieldController({ getSettings: () => ({}), setConfig, debouncedSave, showSaveIndicator, + showSaveErrorIndicator, translate: (key, fallback) => `t:${key}:${fallback}`, getModelFileName: (modelPath) => getEngineModelFileName( @@ -125,7 +130,14 @@ function createFieldControllerHarness() { tracer: { error }, }); - return { fieldController, setConfig, debouncedSave, showSaveIndicator, error }; + return { + fieldController, + setConfig, + debouncedSave, + showSaveIndicator, + showSaveErrorIndicator, + error, + }; } describe('ModuleSettingsEngineRenderer', () => { @@ -164,7 +176,7 @@ describe('ModuleSettingsEngineRenderer', () => { {} as never, ); - expect(imageHtml).toContain('t:ui.settings.engine.generation_presets:Generation Presets'); + expect(imageHtml).toContain('t:ui.settings.engine.generation_settings:Generation Settings'); expect(imageHtml).not.toContain('Auto download package'); expect(imageHtml).toContain( 't:ui.settings.engine.config_unavailable:Engine config unavailable (Tauri not connected)', @@ -210,7 +222,7 @@ describe('ModuleSettingsEngineRenderer', () => { type: 'select', isEngineConfig: false, options: ['Euler', 'DDIM'], - appId: 'stable-diffusion', + appId: 'sdcpp', config: null, }); @@ -223,55 +235,22 @@ describe('ModuleSettingsEngineRenderer', () => { expect(document.querySelectorAll('.local-engine-select-menu')).toHaveLength(0); }); - it('should render compute mode as a segmented control without a dropdown overlay', () => { - const { renderer } = createRendererHarness(); - const container = document.createElement('div'); - const config = { - engine_id: 'llamacpp', - compute_mode: 'gpu', - context_size: 4096, - model_path: null, - extra_args: [], - }; - - renderer._fieldRowRenderer.render(container, { - label: 'Compute Device', - key: 'compute_mode', - type: 'select', - isEngineConfig: true, - options: ['gpu', 'cpu'], - optionLabels: { gpu: 'GPU', cpu: 'CPU' }, - defaultValue: 'gpu', - appId: 'llamacpp', - config, - }); - - expect(container.querySelector('.local-engine-segmented-control')).toBeInstanceOf( - HTMLDivElement, - ); - expect(document.querySelector('.local-engine-select-menu')).toBeNull(); - - const cpuButton = container.querySelector( - '.local-engine-segmented-option[data-value="cpu"]', - ) as HTMLButtonElement; - cpuButton.click(); - - expect(config.compute_mode).toBe('cpu'); - expect(cpuButton.classList.contains('is-selected')).toBe(true); - }); - it('should localize extra args field labels and actions', () => { const control = createEngineExtraArgsField((key, fallback) => `t:${key}:${fallback}`); - const hiddenInput = control.root.querySelector('.local-engine-tags-value'); - expect(hiddenInput).toBeInstanceOf(HTMLInputElement); + const input = control.root.querySelector('.local-engine-extra-args-input'); + const draft = control.root.querySelector('.local-engine-extra-args-draft'); + expect(input).toBeInstanceOf(HTMLInputElement); + expect(draft).toBeInstanceOf(HTMLInputElement); + expect((draft as HTMLInputElement).placeholder).toBe( + 't:ui.settings.engine.extra_args.placeholder:Add startup flags', + ); control.setGroups(['--ctx-size 4096']); - const chip = control.root.querySelector('.local-engine-tag-chip'); - expect(chip).toBeInstanceOf(HTMLButtonElement); - expect((chip as HTMLButtonElement).title).toBe( - 't:ui.settings.engine.extra_args.remove:Remove', + expect((input as HTMLInputElement).value).toBe('--ctx-size 4096'); + expect(control.root.querySelector('.local-engine-extra-arg-chip')?.textContent).toContain( + '--ctx-size 4096', ); }); @@ -295,7 +274,7 @@ describe('ModuleSettingsEngineRenderer', () => { const popover = document.querySelector('.local-engine-args-popover') as HTMLElement; expect(popover.textContent).toContain('Manual llama.cpp flags'); - (popover.querySelector('.local-engine-args-copy-all') as HTMLButtonElement).click(); + (popover.querySelector('.local-engine-args-recommended') as HTMLButtonElement).click(); expect(showToast).toHaveBeenCalled(); const firstItem = popover.querySelector('.local-engine-args-item') as HTMLElement; @@ -336,7 +315,26 @@ describe('ModuleSettingsEngineRenderer', () => { const popover = document.querySelector('.local-engine-args-popover') as HTMLElement; (popover.querySelector('.local-engine-args-recommended') as HTMLButtonElement).click(); - expect(control.getGroups()).toEqual(['--diffusion-fa', '--fa', '--mmap', '--vae-tiling']); + expect(control.getGroups()).toEqual(['--diffusion-fa', '--mmap', '--vae-tiling']); + }); + + it('should expose official stable-diffusion.cpp startup flag names', () => { + const docs = getEngineExtraArgDocs('sdcpp'); + const flags = docs.items.map((item) => item.flag); + + expect(flags).toContain('--clip_l path'); + expect(flags).toContain('--clip_g path'); + expect(flags).toContain('--clip_vision path'); + expect(flags).toContain('--init-img path'); + expect(flags).toContain('--pm-style-strength 20'); + expect(flags).toContain('--vae-tile-size 32x32'); + expect(flags).toContain('--vae-tile-overlap 0.5'); + expect(flags).toContain('--vae-relative-tile-size 0.5x0.5'); + expect(flags).toContain('--timestep-shift 250'); + expect(flags).not.toContain('--clip-l path'); + expect(flags).not.toContain('--init-image path'); + expect(flags).not.toContain('--style-ratio 20'); + expect(flags).not.toContain('--schedule-shift 3'); }); it('should create text fields and parse values correctly', () => { @@ -381,6 +379,17 @@ describe('ModuleSettingsEngineRenderer', () => { '--threads', '8', ]); + expect( + formatEngineFieldSaveValue( + 'extra_args', + String.raw`--clip_l "C:\My Models\clip.safetensors" --vae C:\vae.sft`, + ), + ).toEqual([ + '--clip_l', + String.raw`C:\My Models\clip.safetensors`, + '--vae', + String.raw`C:\vae.sft`, + ]); }); it('should hydrate initial values from config aliases and defaults', () => { @@ -417,14 +426,16 @@ describe('ModuleSettingsEngineRenderer', () => { expect(input.value).toBe('512'); }); - it('should save engine field values', () => { + it('should save engine field values', async () => { const setConfig = vi.fn(); const showSaveIndicator = vi.fn(); + const showSaveErrorIndicator = vi.fn(); const fieldController = new ModuleSettingsEngineFieldController({ getSettings: () => ({}), setConfig, debouncedSave: vi.fn(), showSaveIndicator, + showSaveErrorIndicator, translate: (_key, fallback) => fallback, getModelFileName: (modelPath) => modelPath, getModelFileFilters: getEngineModelFileFilters, @@ -436,7 +447,7 @@ describe('ModuleSettingsEngineRenderer', () => { const engineInput = document.createElement('input'); engineInput.value = '--ctx 4096'; const config = { extra_args: [] as string[] }; - fieldController.handleSave(engineInput, { + await fieldController.handleSave(engineInput, { key: 'extra_args', type: 'text', isEngineConfig: true, @@ -483,32 +494,6 @@ describe('ModuleSettingsEngineRenderer', () => { ); }); - it('should localize performance mode title and state', () => { - const debouncedSave = vi.fn(); - const container = document.createElement('div'); - - renderEnginePerformanceModeField( - container, - 'sdcpp', - {}, - (key, fallback) => `t:${key}:${fallback}`, - debouncedSave, - ); - - const label = container.querySelector('.local-engine-field-label'); - const status = container.querySelector('.local-engine-perf-status'); - const checkbox = container.querySelector( - 'input[type="checkbox"]', - ) as HTMLInputElement | null; - - expect(label?.textContent).toBe('t:ui.settings.engine.performance_mode:Performance Mode'); - expect(status?.textContent).toBe('t:ui.common.disabled:Disabled'); - - checkbox?.click(); - expect(status?.textContent).toBe('t:ui.common.enabled:Enabled'); - expect(debouncedSave).toHaveBeenCalledWith('sdcpp_performance_mode', true); - }); - it('should allow both gguf and safetensors for image engines', async () => { vi.mocked(open).mockResolvedValue('C:\\Models\\sd.gguf'); const { fieldController } = createFieldControllerHarness(); diff --git a/src/features/settings/ui/ModuleSettingsEngineRenderer.ts b/src/features/settings/ui/ModuleSettingsEngineRenderer.ts index 3d8616ba..3c7b6595 100644 --- a/src/features/settings/ui/ModuleSettingsEngineRenderer.ts +++ b/src/features/settings/ui/ModuleSettingsEngineRenderer.ts @@ -25,7 +25,6 @@ import { getEngineModelFileFilters, getEngineModelFileName, ModuleSettingsEngineInputFactory, - renderEnginePerformanceModeField, syncEnginePromptTextareaHeights, type EngineExtraArgsControl, } from './ModuleSettingsEngineFieldSupport'; @@ -42,7 +41,7 @@ type EngineFieldControlOptions = { key: string; isEngineConfig: boolean; appId: string; - fileKind?: 'model' | 'vae' | 'llm'; + fileKind?: 'model'; placeholder?: string; defaultValue?: number | string; min?: number; @@ -57,6 +56,29 @@ type EngineFieldControlResult = { extraArgsControl: ExtraArgsControl | null; }; +type LocalEngineModelProfile = { + id: string; + name: string; + modelPath: string; + extraArgs: string[]; + generationSettings?: Record; +}; + +const IMAGE_GENERATION_PRESET_SETTING_SUFFIXES = [ + 'positive_prompt', + 'negative_prompt', + 'width', + 'height', + 'steps', + 'cfg_scale', + 'denoising_strength', + 'sampler', + 'scheduler', + 'seed', + 'clip_skip', + 'batch_size', +] as const; + const ENGINE_HTML_SANITIZE_OPTIONS: Parameters[1] = { ALLOW_DATA_ATTR: true, ALLOWED_TAGS: [ @@ -99,6 +121,7 @@ type ModuleSettingsEngineRendererDeps = { debouncedSave: (key: string, value: string | number | boolean | null) => void; notifySettingsChanged: () => void; showSaveIndicator: () => void; + showSaveErrorIndicator: () => void; tracer: Pick; }; @@ -120,6 +143,7 @@ function createEngineInfoPopoverRuntime(): EngineInfoPopoverRuntime { export class ModuleSettingsEngineRenderer { private readonly _extraArgsControls = new Map(); + private readonly _customSelectControls = new Map(); private readonly _fieldCatalog = new ModuleSettingsEngineFieldCatalog(); private readonly _fieldController: ModuleSettingsEngineFieldController; private readonly _fieldRowRenderer: ModuleSettingsEngineFieldRowRenderer; @@ -155,10 +179,21 @@ export class ModuleSettingsEngineRenderer { >[0] { return { getSettings: () => - this._deps.service.getSettings() as Record, - setConfig: (config) => { - void this._deps.engineConfigService.setConfig(config); - this._deps.notifySettingsChanged(); + this._deps.service.getSettings() as Record< + string, + string | number | null | undefined + >, + setConfig: async (config) => { + try { + await this._deps.engineConfigService.setConfig(config); + this._deps.notifySettingsChanged(); + } catch (error) { + this._deps.tracer.error( + '[ModuleSettingsEngineRenderer] setConfig failed:', + error, + ); + throw error; + } }, debouncedSave: (key, value) => { this._deps.debouncedSave(key, value); @@ -166,6 +201,9 @@ export class ModuleSettingsEngineRenderer { showSaveIndicator: () => { this._deps.showSaveIndicator(); }, + showSaveErrorIndicator: () => { + this._deps.showSaveErrorIndicator(); + }, translate: (key, fallback) => this._translate(key, fallback), getModelFileName: (modelPath) => this._getModelFileName(modelPath), getModelFileFilters: (fileKind, isImage) => @@ -205,8 +243,8 @@ export class ModuleSettingsEngineRenderer { renderFieldDefinitions: (container, definitions, appId, config) => { this._renderFieldDefinitions(container, definitions, appId, config); }, - renderPerformanceModeFieldRow: (container, appId) => { - this._renderPerformanceModeFieldRow(container, appId); + renderModelProfiles: (container, appId, config) => { + this._renderModelProfiles(container, appId, config); }, renderFieldRow: (container, options) => { this._fieldRowRenderer.render(container, options); @@ -224,6 +262,7 @@ export class ModuleSettingsEngineRenderer { public reset(): void { this._closeEngineInfoPopover(); this._extraArgsControls.clear(); + this._customSelectControls.clear(); } public async render(container: HTMLElement, app: IApp): Promise { @@ -243,8 +282,8 @@ export class ModuleSettingsEngineRenderer { getTextFields: (translate) => this._fieldCatalog.buildTextEngineFields(translate), getCoreModelField: (translate, modelPlaceholder, isImage) => this._fieldCatalog.buildCoreModelField(translate, modelPlaceholder, isImage), - getImageCompanionFields: (translate, appId) => - this._fieldCatalog.buildImageCompanionFields(translate, appId), + getComputeModeField: (translate, availableModes) => + this._fieldCatalog.buildComputeModeField(translate, availableModes), getImageExtraArgsField: (translate) => this._fieldCatalog.buildImageExtraArgsField(translate), }); @@ -289,10 +328,11 @@ export class ModuleSettingsEngineRenderer { private _createEngineFieldControl( options: EngineFieldControlOptions, ): EngineFieldControlResult { + if (options.type === 'select' && options.isEngineConfig && options.key === 'compute_mode') { + return this._createComputeModeControl(options); + } + if (options.type === 'select') { - if (options.key === 'compute_mode') { - return this._createComputeModeControl(options); - } return this._createSelectFieldControl(options); } @@ -313,6 +353,10 @@ export class ModuleSettingsEngineRenderer { options: EngineFieldControlOptions, ): EngineFieldControlResult { const customSelect = createEngineCustomSelectField(this._runtime, options); + this._customSelectControls.set( + this._getFieldControlKey(options.appId, options.key), + customSelect, + ); return { input: customSelect.root, engineInput: customSelect.input, @@ -325,35 +369,52 @@ export class ModuleSettingsEngineRenderer { options: EngineFieldControlOptions, ): EngineFieldControlResult { const root = document.createElement('div'); - root.className = 'local-engine-segmented-control'; + root.className = 'local-engine-compute-toggle'; const hiddenInput = document.createElement('input'); hiddenInput.type = 'hidden'; + hiddenInput.className = 'local-engine-compute-value'; - const buttons = (options.options ?? ['gpu', 'cpu']).map((value) => { + const buttons = new Map(); + const syncDisplay = () => { + let currentValue = + hiddenInput.value === '' + ? String(options.defaultValue ?? 'gpu') + : hiddenInput.value; + if (!buttons.has(currentValue)) { + currentValue = options.options?.[0] ?? String(options.defaultValue ?? 'gpu'); + hiddenInput.value = currentValue; + } + buttons.forEach((button, value) => { + const selected = value === currentValue; + button.classList.toggle('selected', selected); + button.setAttribute('aria-pressed', String(selected)); + }); + }; + + options.options?.forEach((option) => { const button = document.createElement('button'); button.type = 'button'; - button.className = 'local-engine-segmented-option'; - button.dataset['value'] = value; - button.textContent = options.optionLabels?.[value] ?? value.toUpperCase(); + button.className = 'thinking-option-card local-engine-compute-option'; + button.dataset['value'] = option; + button.setAttribute('aria-pressed', 'false'); + + const title = document.createElement('span'); + title.className = 'thinking-option-title'; + title.textContent = options.optionLabels?.[option] ?? option; + + button.append(title); button.addEventListener('click', () => { - hiddenInput.value = value; + hiddenInput.value = option; syncDisplay(); hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); }); - return button; - }); - const syncDisplay = () => { - const currentValue = hiddenInput.value || String(options.defaultValue ?? 'gpu'); - buttons.forEach((button) => { - const selected = button.dataset['value'] === currentValue; - button.classList.toggle('is-selected', selected); - button.setAttribute('aria-pressed', String(selected)); - }); - }; + buttons.set(option, button); + root.appendChild(button); + }); - root.append(hiddenInput, ...buttons); + root.prepend(hiddenInput); return { input: root, @@ -362,12 +423,18 @@ export class ModuleSettingsEngineRenderer { input: hiddenInput, root, syncDisplay, - destroy: () => {}, + destroy: () => { + return; + }, }, extraArgsControl: null, }; } + private _getFieldControlKey(appId: string, key: string): string { + return `${appId}:${key}`; + } + private _createExtraArgsFieldControl(appId: string): EngineFieldControlResult { const extraArgsControl = createEngineExtraArgsField((key, fallback) => this._translate(key, fallback), @@ -385,16 +452,324 @@ export class ModuleSettingsEngineRenderer { return { input, engineInput: input, customSelect: null, extraArgsControl: null }; } - private _renderPerformanceModeFieldRow(container: HTMLElement, appId: string): void { - renderEnginePerformanceModeField( - container, + private _renderModelProfiles( + container: HTMLElement, + appId: string, + config: EngineConfig | null, + ): void { + const section = document.createElement('div'); + section.className = 'local-engine-model-profiles'; + + const header = document.createElement('div'); + header.className = 'settings-card-header-center local-engine-section-header'; + const title = document.createElement('h3'); + title.textContent = this._translate( + 'ui.settings.engine.generation_presets', + 'Generation Presets', + ); + header.appendChild(title); + + const grid = document.createElement('div'); + grid.className = 'ai-models-grid local-engine-profile-grid'; + + const profiles = this._getModelProfiles(appId); + profiles.forEach((profile) => { + grid.appendChild(this._createModelProfileCard(appId, profile, config)); + }); + grid.appendChild(this._createSaveModelProfileCard(appId, config)); + + section.append(header, grid); + container.appendChild(section); + } + + private _createModelProfileCard( + appId: string, + profile: LocalEngineModelProfile, + config: EngineConfig | null, + ): HTMLDivElement { + const card = document.createElement('div'); + const selected = config?.model_path === profile.modelPath; + card.className = `ai-model-card ai-model-card--custom local-engine-profile-card${selected ? ' selected' : ''}`; + card.tabIndex = 0; + card.role = 'option'; + card.setAttribute('aria-selected', String(selected)); + + const copy = document.createElement('div'); + copy.className = 'ai-model-card-copy'; + + const name = document.createElement('div'); + name.className = 'model-name'; + name.textContent = this._getModelProfileDisplayName(profile); + name.title = profile.modelPath; + + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'ai-model-card-remove ai-model-card-action'; + remove.textContent = this._translate('ui.settings.custom_model_remove_button', 'Delete'); + remove.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + this._deleteModelProfile(appId, profile.id); + card.remove(); + }); + + const apply = () => this._applyModelProfile(appId, profile, config, card); + card.addEventListener('click', apply); + card.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + apply(); + } + }); + + copy.append(name); + card.append(copy, remove); + return card; + } + + private _getModelProfileDisplayName(profile: LocalEngineModelProfile): string { + return (profile.name.trim() || this._getModelFileName(profile.modelPath)).replace( + /\.(?:gguf|safetensors)$/iu, + '', + ); + } + + private _createSaveModelProfileCard( + appId: string, + config: EngineConfig | null, + ): HTMLDivElement { + const card = document.createElement('div'); + card.className = 'ai-model-card ai-model-card--composer local-engine-profile-card'; + card.role = 'button'; + card.tabIndex = 0; + + const name = document.createElement('div'); + name.className = 'model-name'; + name.textContent = this._translate('ui.settings.engine.profile_save', 'Save Current'); + + const desc = document.createElement('div'); + desc.className = 'model-desc'; + desc.textContent = this._translate( + 'ui.settings.engine.profile_save_desc', + 'Store model, generation settings, and startup flags.', + ); + + const body = document.createElement('div'); + body.className = 'model-pricing ai-custom-model-composer-body'; + + const saveButton = document.createElement('button'); + saveButton.type = 'button'; + saveButton.className = 'ai-check-btn ai-custom-model-save-btn'; + saveButton.textContent = this._translate('ui.settings.engine.profile_save_button', 'Save'); + + const save = () => this._saveCurrentModelProfile(appId, config, card); + saveButton.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + save(); + }); + card.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + save(); + } + }); + + body.append(saveButton); + card.append(name, desc, body); + return card; + } + + private _getModelProfiles(appId: string): LocalEngineModelProfile[] { + const raw = this._deps.service.getSettings()[`${appId}_model_profiles`]; + if (typeof raw !== 'string' || raw.trim() === '') { + return []; + } + + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .filter((item): item is LocalEngineModelProfile => { + if (typeof item !== 'object' || item === null) return false; + const candidate = item as Record; + return ( + typeof candidate['id'] === 'string' && + typeof candidate['name'] === 'string' && + typeof candidate['modelPath'] === 'string' && + Array.isArray(candidate['extraArgs']) && + candidate['extraArgs'].every((arg) => typeof arg === 'string') && + (candidate['generationSettings'] === undefined || + (typeof candidate['generationSettings'] === 'object' && + candidate['generationSettings'] !== null)) + ); + }) + .slice(0, 8); + } catch { + return []; + } + } + + private _saveModelProfiles(appId: string, profiles: LocalEngineModelProfile[]): void { + this._deps.debouncedSave(`${appId}_model_profiles`, JSON.stringify(profiles.slice(0, 8))); + this._deps.notifySettingsChanged(); + this._deps.showSaveIndicator(); + } + + private _saveCurrentModelProfile( + appId: string, + config: EngineConfig | null, + card: HTMLElement, + ): void { + const modelPath = config?.model_path?.trim() ?? ''; + if (modelPath === '') { + this._context.showToast( + this._translate( + 'ui.settings.engine.profile_select_model_first', + 'Select a model first', + ), + 'info', + ); + return; + } + + const profiles = this._getModelProfiles(appId).filter( + (profile) => profile.modelPath !== modelPath, + ); + const profile: LocalEngineModelProfile = { + id: `profile-${Date.now()}`, + name: this._getModelFileName(modelPath), + modelPath, + extraArgs: this._extraArgsControls.get(appId)?.getGroups() ?? config?.extra_args ?? [], + generationSettings: this._readGenerationPresetSettings( + appId, + card.closest('.local-engine-config') ?? undefined, + ), + }; + profiles.unshift(profile); + this._saveModelProfiles(appId, profiles); + const grid = card.closest('.local-engine-profile-grid'); + grid?.insertBefore(this._createModelProfileCard(appId, profile, config), card); + } + + private _deleteModelProfile(appId: string, profileId: string): void { + this._saveModelProfiles( appId, - this._deps.service.getSettings() as Record, - (key, fallback) => this._translate(key, fallback), - (key, value) => this._deps.debouncedSave(key, value), + this._getModelProfiles(appId).filter((profile) => profile.id !== profileId), ); } + private _applyModelProfile( + appId: string, + profile: LocalEngineModelProfile, + config: EngineConfig | null, + card: HTMLElement, + ): void { + if (config === null) { + return; + } + + config.model_path = profile.modelPath; + config.extra_args = [...profile.extraArgs]; + this._applyGenerationPresetSettings(appId, profile.generationSettings ?? {}); + void this._deps.engineConfigService + .setConfig(config) + .then(() => { + this._deps.notifySettingsChanged(); + this._deps.showSaveIndicator(); + }) + .catch((error: unknown) => { + this._deps.tracer.error('[ModuleSettingsUI] Failed to apply model profile', error); + this._deps.showSaveErrorIndicator(); + }); + + const root = card.closest('.local-engine-config'); + const modelInput = root?.querySelector( + '.local-engine-field-row--model-path input', + ); + if (modelInput !== null && modelInput !== undefined) { + modelInput.dataset['fullPath'] = profile.modelPath; + modelInput.value = this._getModelFileName(profile.modelPath); + modelInput.title = profile.modelPath; + } + this._extraArgsControls.get(appId)?.setGroups(profile.extraArgs, { emit: false }); + + root?.querySelectorAll('.local-engine-profile-card').forEach((node) => { + node.classList.toggle('selected', node === card); + node.setAttribute('aria-selected', String(node === card)); + }); + } + + private _readGenerationPresetSettings( + appId: string, + root: HTMLElement | Document = document, + ): Record { + const settings = this._deps.service.getSettings(); + return Object.fromEntries( + IMAGE_GENERATION_PRESET_SETTING_SUFFIXES.map((suffix) => { + const key = `${appId}_${suffix}`; + const value = this._readGenerationPresetInputValue(root, key) ?? settings[key]; + return [key, typeof value === 'string' || typeof value === 'number' ? value : null]; + }), + ); + } + + private _readGenerationPresetInputValue( + root: HTMLElement | Document, + key: string, + ): string | number | null | undefined { + const keyClass = key.replaceAll('_', '-'); + const input = root.querySelector< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + >( + `.local-engine-field-row--${keyClass} input, .local-engine-field-row--${keyClass} select, .local-engine-field-row--${keyClass} textarea`, + ); + if (input === null) { + return undefined; + } + + const value = input.value.trim(); + if (value === '') { + return null; + } + if (input instanceof HTMLInputElement && input.type === 'number') { + const numericValue = Number(value); + return Number.isFinite(numericValue) ? numericValue : value; + } + return value; + } + + private _applyGenerationPresetSettings( + appId: string, + generationSettings: Record, + ): void { + Object.entries(generationSettings).forEach(([key, value]) => { + this._deps.debouncedSave(key, value); + this._syncGenerationSettingInput(appId, key, value); + }); + } + + private _syncGenerationSettingInput( + appId: string, + key: string, + value: string | number | null, + ): void { + const root = document.querySelector('.local-engine-config'); + const keyClass = key.replaceAll('_', '-'); + const input = root?.querySelector( + `.local-engine-field-row--${keyClass} input, .local-engine-field-row--${keyClass} textarea`, + ); + if (input === null || input === undefined) { + return; + } + + input.value = value === null ? '' : String(value); + this._customSelectControls.get(this._getFieldControlKey(appId, key))?.syncDisplay(); + } + private _appendExtraArgs(appId: string, groups: string[]): number { const control = this._extraArgsControls.get(appId); if (control === undefined) return 0; diff --git a/src/features/settings/ui/ModuleSettingsHost.test.ts b/src/features/settings/ui/ModuleSettingsHost.test.ts index 2f8f5072..0f6a0824 100644 --- a/src/features/settings/ui/ModuleSettingsHost.test.ts +++ b/src/features/settings/ui/ModuleSettingsHost.test.ts @@ -30,9 +30,11 @@ function renderHostShell(): HTMLIFrameElement { Object.defineProperty(frame, 'contentWindow', { configurable: true, - value: { - postMessage: vi.fn(), - }, + value: window, + }); + Object.defineProperty(window, 'postMessage', { + configurable: true, + value: vi.fn(), }); return frame; @@ -84,6 +86,8 @@ describe('module settings host', () => { window.dispatchEvent( new MessageEvent('message', { + origin: window.location.origin, + source: frame.contentWindow, data: { channel: 'axelate:module-settings', type: 'module-ready', diff --git a/src/features/settings/ui/ModuleSettingsUI.ts b/src/features/settings/ui/ModuleSettingsUI.ts index d0847714..4d2c236d 100644 --- a/src/features/settings/ui/ModuleSettingsUI.ts +++ b/src/features/settings/ui/ModuleSettingsUI.ts @@ -24,7 +24,6 @@ import { type TauriProvider } from '@/infrastructure/tauri/TauriProvider'; import { type NavigationService } from '@/infrastructure/navigation/NavigationService'; import { EngineConfigService } from '@/features/ai/services/EngineConfigService'; import { ModuleSettingsModalController } from './ModuleSettingsModalController'; -import { ModuleSettingsBridgeController } from './ModuleSettingsBridgeController'; import type { ModuleSettingsAutosaveController } from './ModuleSettingsAutosaveController'; import type { ModuleSettingsCustomUiController } from './ModuleSettingsCustomUiController'; import type { ModuleSettingsEngineRenderer } from './ModuleSettingsEngineRenderer'; @@ -57,7 +56,6 @@ export class ModuleSettingsUI { private _engineRenderer: ModuleSettingsEngineRenderer | null = null; private _schemaRenderer: ModuleSettingsSchemaRenderer | null = null; private readonly _modalController: ModuleSettingsModalController; - private readonly _bridgeController: ModuleSettingsBridgeController; private readonly _viewHelper = new ModuleSettingsViewHelper(); private _autosaveController: ModuleSettingsAutosaveController | null = null; private _customUiController: ModuleSettingsCustomUiController | null = null; @@ -86,7 +84,6 @@ export class ModuleSettingsUI { private readonly _deps: ModuleSettingsUIDeps, ) { this._engineConfigService = new EngineConfigService(_tauri, this._deps.tracer); - this._bridgeController = new ModuleSettingsBridgeController(this._deps.tracer); this._modalController = new ModuleSettingsModalController(_navigation, { closeAppSelection: () => { this._deps.closeAppSelection(); @@ -110,6 +107,9 @@ export class ModuleSettingsUI { showSaveIndicator: () => { this._showSaveIndicator(); }, + showSaveErrorIndicator: () => { + this._showSaveErrorIndicator(); + }, hideSaveIndicator: () => { this._hideSaveIndicator(); }, @@ -143,9 +143,6 @@ export class ModuleSettingsUI { this._loadCardWidths(); this._initCardResizer(); - this._bridgeController.install( - async (app: IApp) => await this._openModuleSettingsHelper(app), - ); this._bindGlobalEvents(); } @@ -215,7 +212,6 @@ export class ModuleSettingsUI { this._unbindGlobalEvents(); this._destroyCardResizer(); aiSettingsRenderer.destroy(); - this._bridgeController.uninstall(); this._deps.tracer.info('[ModuleSettingsUI] Destroyed.'); } @@ -242,12 +238,12 @@ export class ModuleSettingsUI { } private _bindGlobalEvents(): void { - globalThis.addEventListener('lang:changed', this._boundLangChanged); + globalThis.addEventListener('language-changed', this._boundLangChanged); } private _unbindGlobalEvents(): void { this._unbindDropdownEvents(); - globalThis.removeEventListener('lang:changed', this._boundLangChanged); + globalThis.removeEventListener('language-changed', this._boundLangChanged); } private _initCardResizer(): void { @@ -471,6 +467,10 @@ export class ModuleSettingsUI { this._getAutosaveController().showPending(); } + private _showSaveErrorIndicator(): void { + this._getAutosaveController().showError(); + } + private _hideSaveIndicator(): void { this._getAutosaveController().hide(); } diff --git a/src/features/settings/ui/SettingsUI.test.ts b/src/features/settings/ui/SettingsUI.test.ts index 089f2a1f..148a4dca 100644 --- a/src/features/settings/ui/SettingsUI.test.ts +++ b/src/features/settings/ui/SettingsUI.test.ts @@ -76,6 +76,7 @@ describe('ModuleSettingsUI lifecycle', () => { } as unknown as I18nUI; const tauri = { isTauri: vi.fn().mockReturnValue(false), + invoke: vi.fn(), } as unknown as TauriProvider; const navigation = { removeBackAction: vi.fn(), @@ -127,7 +128,7 @@ describe('ModuleSettingsUI lifecycle', () => { return privateUI; } - it('should render compute mode, context size, and system prompt for llamacpp local settings', async () => { + it('should render context size and system prompt for llamacpp local settings', async () => { const ui = createSettingsUI(); const container = document.createElement('div'); ( @@ -152,6 +153,19 @@ describe('ModuleSettingsUI lifecycle', () => { expect(labels).toContain('t:ui.settings.engine.compute_mode:Compute Device'); expect(labels).toContain('t:ui.settings.engine.context_size:Context Window'); expect(labels).toContain('t:ui.settings.engine.system_prompt:System Prompt'); + const modelPathIndex = labels.indexOf( + 't:ui.settings.engine.model_path:Model Path (*.gguf, *.safetensors)', + ); + expect(modelPathIndex).toBe(0); + expect(modelPathIndex).toBeLessThan( + labels.indexOf('t:ui.settings.engine.compute_mode:Compute Device'), + ); + expect(modelPathIndex).toBeLessThan( + labels.indexOf('t:ui.settings.engine.context_size:Context Window'), + ); + expect(modelPathIndex).toBeLessThan( + labels.indexOf('t:ui.settings.engine.system_prompt:System Prompt'), + ); }); it('should stop active module lifecycle when module settings change', () => { @@ -186,6 +200,56 @@ describe('ModuleSettingsUI lifecycle', () => { expect(container.textContent).not.toContain('Auto download package'); }); + it('should save current visible image settings into sdcpp model presets', async () => { + vi.useFakeTimers(); + const ui = createSettingsUI(); + const container = document.createElement('div'); + ( + ui as unknown as { + _tauri: { isTauri: ReturnType; invoke: ReturnType }; + } + )._tauri.isTauri.mockReturnValue(true); + ( + ui as unknown as { + _tauri: { invoke: ReturnType }; + } + )._tauri.invoke.mockResolvedValue({ + config: { + engine_id: 'sdcpp', + compute_mode: 'gpu', + model_path: 'C:/models/current.safetensors', + extra_args: [], + }, + }); + + await ui._renderLocalEngineConfig(container, { id: 'sdcpp', capability: 'image' }); + const widthInput = container.querySelector( + '.local-engine-field-row--sdcpp-width input', + ); + expect(widthInput).not.toBeNull(); + if (widthInput === null) { + throw new Error('width input missing'); + } + widthInput.value = '768'; + + container.querySelector('.ai-custom-model-save-btn')?.click(); + await vi.runAllTimersAsync(); + + const service = ( + settingsUI as unknown as { + _service: { saveSetting: ReturnType }; + } + )._service; + const saveCall = service.saveSetting.mock.calls.find( + (call: unknown[]) => call[0] === 'sdcpp_model_profiles', + ); + expect(saveCall).toBeDefined(); + const profiles = JSON.parse(saveCall?.[1] as string) as Array<{ + generationSettings: Record; + }>; + expect(profiles[0]?.generationSettings['sdcpp_width']).toBe(768); + }); + it('should show save errors and apply stored card widths', async () => { vi.useFakeTimers(); const ui = createSettingsUI(); diff --git a/src/index.html b/src/index.html index dc422a0e..0191aa29 100644 --- a/src/index.html +++ b/src/index.html @@ -7,7 +7,7 @@ content="default-src 'self'; script-src 'self' data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https:; connect-src 'self' ipc: http://ipc.localhost https:; frame-src 'self' module-settings: http://127.0.0.1:* http://localhost:* http://module-settings.localhost https://module-settings.localhost; base-uri 'self';" /> - Axelate (Beta) + Axelate (Nightly)
-
diff --git a/src/infrastructure/i18n/I18nService.test.ts b/src/infrastructure/i18n/I18nService.test.ts index b86e7edb..a34f6b98 100644 --- a/src/infrastructure/i18n/I18nService.test.ts +++ b/src/infrastructure/i18n/I18nService.test.ts @@ -140,7 +140,7 @@ describe('I18nService', () => { }); describe('loadTranslations', () => { - it('should dispatch legacy DOM events and event bus notifications when translations load', async () => { + it('should dispatch DOM events and event bus notifications when translations load', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ greeting: 'Hello' }), @@ -148,7 +148,6 @@ describe('I18nService', () => { vi.stubGlobal('fetch', fetchMock); const languageChangedHandler = vi.fn(); - const legacyLangChangedHandler = vi.fn(); const languageBusHandler = vi.fn(); const translationsLoadedHandler = vi.fn(); @@ -156,7 +155,6 @@ describe('I18nService', () => { 'language-changed', languageChangedHandler as EventListener, ); - globalThis.addEventListener('lang:changed', legacyLangChangedHandler as EventListener); testEventBus.on('i18n:language:change', languageBusHandler); testEventBus.on('i18n:translations:loaded', translationsLoadedHandler); @@ -168,10 +166,6 @@ describe('I18nService', () => { expect( (languageChangedHandler.mock.calls[0]?.[0] as CustomEvent<{ lang: string }>).detail, ).toEqual({ lang: 'ru' }); - expect(legacyLangChangedHandler).toHaveBeenCalledTimes(1); - expect( - (legacyLangChangedHandler.mock.calls[0]?.[0] as CustomEvent).detail, - ).toBe('ru'); expect(languageBusHandler).toHaveBeenCalledWith({ lang: 'ru', previousLang: 'en' }); expect(translationsLoadedHandler).toHaveBeenCalledWith({ lang: 'ru' }); @@ -179,10 +173,6 @@ describe('I18nService', () => { 'language-changed', languageChangedHandler as EventListener, ); - globalThis.removeEventListener( - 'lang:changed', - legacyLangChangedHandler as EventListener, - ); }); it('should set currentLang after loading translations', async () => { @@ -287,7 +277,6 @@ describe('I18nService', () => { .mockResolvedValue({ ok: true, json: () => Promise.resolve({ hello: 'Hi' }) }), // mock translations ); // Force the method to reject to hit the `.catch` block - // eslint-disable-next-line @typescript-eslint/no-explicit-any const syncSpy = vi // eslint-disable-next-line @typescript-eslint/no-explicit-any .spyOn(i18n as any, '_syncToBackend') diff --git a/src/infrastructure/i18n/I18nService.ts b/src/infrastructure/i18n/I18nService.ts index f745971d..4f5a5ff0 100644 --- a/src/infrastructure/i18n/I18nService.ts +++ b/src/infrastructure/i18n/I18nService.ts @@ -172,7 +172,6 @@ export class I18nService { private _notifyLanguageChange(lang: string, previousLang: string): void { globalThis.dispatchEvent(new CustomEvent('language-changed', { detail: { lang } })); - globalThis.dispatchEvent(new CustomEvent('lang:changed', { detail: lang })); this._eventBus.emit('i18n:language:change', { lang, previousLang }); this._eventBus.emit('i18n:translations:loaded', { lang }); } diff --git a/src/infrastructure/i18n/I18nUI.ts b/src/infrastructure/i18n/I18nUI.ts index 559c45c0..3636ba3c 100644 --- a/src/infrastructure/i18n/I18nUI.ts +++ b/src/infrastructure/i18n/I18nUI.ts @@ -318,9 +318,6 @@ export class I18nUI { } } - /** - * Initializes emoji flags (legacy compatibility). - */ public initEmojiFlags(): void { this.updateSwitcherUI(); } diff --git a/src/infrastructure/logging/LoggerService.test.ts b/src/infrastructure/logging/LoggerService.test.ts index 23829eaf..82e9a92e 100644 --- a/src/infrastructure/logging/LoggerService.test.ts +++ b/src/infrastructure/logging/LoggerService.test.ts @@ -32,6 +32,7 @@ describe('LoggerService', () => { (tracer as unknown as { _transport: null })._transport = null; (tracer as unknown as { _fallbackTransport: null })._fallbackTransport = null; (tracer as unknown as { _buffer: unknown[] })._buffer = []; + (tracer as unknown as { _flushPromise: null })._flushPromise = null; (tracer as unknown as { _initialized: boolean })._initialized = false; // allow re-init for tests that test init }); @@ -175,6 +176,7 @@ describe('LoggerService', () => { } as PromiseRejectionEvent); } await Promise.resolve(); + await Promise.resolve(); const logs2 = mockTransport.mock.calls[0]?.[0] as Array>; expect(logs2[0]?.['message']).toBe('Unhandled Promise: String rejection'); }); @@ -373,7 +375,6 @@ describe('LoggerService', () => { }); it('should use [Unstringifiable Object] when fallbackStringify inner catch fires', () => { - // eslint-disable-next-line prefer-arrow-callback const fnObj = Object.assign(function noop() { /* poisoned getter test */ }, {}); @@ -451,6 +452,41 @@ describe('LoggerService', () => { expect(tracer.getLogs()).toHaveLength(0); }); + it('should not send the same buffered logs twice while a flush is pending', async () => { + let resolveTransport: (() => void) | undefined; + const pendingTransport = vi + .fn<(logs: { level: string; message: string }[]) => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveTransport = resolve; + }), + ) + .mockImplementation(() => Promise.resolve()); + tracer.setTransport(pendingTransport); + + for (let i = 0; i < 10; i++) { + tracer.info(`Msg ${i}`); + } + tracer.error('Error during pending flush'); + await Promise.resolve(); + + expect(pendingTransport).toHaveBeenCalledTimes(1); + expect(pendingTransport.mock.calls[0]?.[0] as unknown as unknown[]).toHaveLength(10); + + expect(resolveTransport).toBeDefined(); + resolveTransport?.(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(pendingTransport).toHaveBeenCalledTimes(2); + expect(pendingTransport.mock.calls[1]?.[0]).toEqual([ + { level: 'ERROR', message: 'Error during pending flush' }, + ]); + expect(tracer.getLogs()).toHaveLength(0); + }); + it('should use fallback transport if main transport is not set', async () => { const mockInvoke = vi.fn().mockResolvedValue(undefined); tracer.setFallbackTransport(async (logs): Promise => { @@ -621,7 +657,7 @@ describe('LoggerService', () => { }); it('should skip flush fallback if no transports are configured', async () => { - tracer.error('test no __TAURI__'); + tracer.error('test no Tauri internals'); // eslint-disable-next-line @typescript-eslint/no-explicit-any (tracer as any)._transport = null; (tracer as unknown as { _fallbackTransport: null })._fallbackTransport = null; diff --git a/src/infrastructure/logging/LoggerService.ts b/src/infrastructure/logging/LoggerService.ts index 75290e83..b1bb8cb5 100644 --- a/src/infrastructure/logging/LoggerService.ts +++ b/src/infrastructure/logging/LoggerService.ts @@ -17,6 +17,7 @@ import type { ILogEntry } from '@/shared/types/coreTypes'; export class LoggerService { private _buffer: ILogEntry[] = []; private _flushTimeout: ReturnType | null = null; + private _flushPromise: Promise | null = null; private readonly _FLUSH_INTERVAL = 500; private readonly _MAX_BUFFER = 10; private _originalConsoleError: (..._args: unknown[]) => void; @@ -27,7 +28,7 @@ export class LoggerService { // Flag to prevent recursive logging loops during interception private _isInternalLog = false; private _initialized = false; - /** Injected after TauriProvider is ready — avoids direct __TAURI__ access. */ + /** Injected after TauriProvider is ready. */ private _transport: ((logs: { level: string; message: string }[]) => Promise) | null = null; /** Optional early-boot transport used before the main transport is wired. */ @@ -46,7 +47,7 @@ export class LoggerService { /** * Injects the Tauri transport after TauriProvider is initialized. - * Decouples LoggerService from direct __TAURI__ access (§4.1). + * Decouples LoggerService from the concrete IPC implementation. */ public setTransport(fn: (logs: { level: string; message: string }[]) => Promise): void { this._transport = fn; @@ -221,12 +222,10 @@ export class LoggerService { }); if (level === 'ERROR' || this._buffer.length >= this._MAX_BUFFER) { + this._clearFlushTimeout(); void this._flush(); } else { - if (this._flushTimeout) clearTimeout(this._flushTimeout); - this._flushTimeout = setTimeout(() => { - void this._flush(); - }, this._FLUSH_INTERVAL); + this._scheduleFlush(); } } finally { this._isInternalLog = false; @@ -266,8 +265,33 @@ export class LoggerService { * Flushes the current log buffer to the backend. */ private async _flush(): Promise { + if (this._flushPromise !== null) { + return this._flushPromise; + } + if (this._buffer.length === 0) return; + this._clearFlushTimeout(); + this._flushPromise = (async () => { + const flushed = await this._flushOnce(); + this._flushPromise = null; + + if (this._buffer.length === 0) { + return; + } + + if (flushed) { + void this._flush(); + return; + } + + this._scheduleFlush(); + })(); + + return this._flushPromise; + } + + private async _flushOnce(): Promise { // Take snapshot const logs = [...this._buffer]; @@ -282,9 +306,26 @@ export class LoggerService { } // Clear only the logs we successfully sent this._buffer = this._buffer.slice(logs.length); + return true; } catch (e) { // Keep buffer intact so next flush might succeed this._originalConsoleError('Log batch sync failed:', e); + return false; + } + } + + private _scheduleFlush(): void { + this._clearFlushTimeout(); + this._flushTimeout = setTimeout(() => { + this._flushTimeout = null; + void this._flush(); + }, this._FLUSH_INTERVAL); + } + + private _clearFlushTimeout(): void { + if (this._flushTimeout !== null) { + clearTimeout(this._flushTimeout); + this._flushTimeout = null; } } @@ -317,6 +358,7 @@ export class LoggerService { */ public clear(): void { this._buffer = []; + this._clearFlushTimeout(); const overlay = document.getElementById('debug-overlay'); if (overlay !== null) { overlay.innerHTML = ''; diff --git a/src/infrastructure/navigation/NavigationService.ts b/src/infrastructure/navigation/NavigationService.ts index 87a7f42f..2477bce6 100644 --- a/src/infrastructure/navigation/NavigationService.ts +++ b/src/infrastructure/navigation/NavigationService.ts @@ -38,7 +38,7 @@ export class NavigationService { this._historyStack.push(lastPage); this._currentIndex = this._historyStack.length - 1; this._trimHistoryStack(); - this._tracer.info(`[NavigationService] Restored last page: ${lastPage}`); + this._tracer.debug(`[NavigationService] Restored last page: ${lastPage}`); } } @@ -148,7 +148,6 @@ export class NavigationService { public popBackAction(): boolean { if (this._actionStack.length > 0) { const actionInfo = this._actionStack.pop(); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (actionInfo) { this._tracer.debug(`[NavigationService] Executing back action: ${actionInfo.id}`); diff --git a/src/infrastructure/navigation/NavigationUI.ts b/src/infrastructure/navigation/NavigationUI.ts index 5391474d..f7674fd8 100644 --- a/src/infrastructure/navigation/NavigationUI.ts +++ b/src/infrastructure/navigation/NavigationUI.ts @@ -223,6 +223,11 @@ export class NavigationUI { return Promise.resolve(); } + public syncActiveNavigationButton(pageId: string): void { + const navBtns = document.querySelectorAll('.nav-btn'); + this._activateNavigationButton(navBtns, pageId, null); + } + private _bindWindowHandlers(): void { this._mouseDownHandler = (e: MouseEvent) => { this._handleMouseNavigation(e); diff --git a/src/infrastructure/tauri/TauriProvider.test.ts b/src/infrastructure/tauri/TauriProvider.test.ts index 1edf3f25..ec65388a 100644 --- a/src/infrastructure/tauri/TauriProvider.test.ts +++ b/src/infrastructure/tauri/TauriProvider.test.ts @@ -20,19 +20,6 @@ vi.mock('@tauri-apps/api/event', () => ({ import { invoke as mockedTauriInvoke } from '@tauri-apps/api/core'; import { listen as mockedTauriListen } from '@tauri-apps/api/event'; -// Full Tauri structure that TauriProvider expects (for globalThis fallback tests) -const tauriMock = { - core: { - invoke: mockedTauriInvoke, - }, - event: { - listen: mockedTauriListen, - }, -}; - -// Must set BEFORE import -(globalThis as unknown as Record)['__TAURI__'] = tauriMock; - function createTracer(): LoggerService { return { info: vi.fn(), @@ -54,17 +41,24 @@ function createListenWithPayload(payload: unknown) { function setupWebMode(): { win: Record; - origTauri: unknown; origInternals: unknown; provider: TauriProvider; + openExternal: ReturnType; } { const win = globalThis as unknown as Record; - const origTauri = win['__TAURI__']; const origInternals = win['__TAURI_INTERNALS__']; - delete win['__TAURI__']; delete win['__TAURI_INTERNALS__']; + const openExternal = vi.fn(); - return { win, origTauri, origInternals, provider: new TauriProvider(createTracer()) }; + return { + win, + origInternals, + openExternal, + provider: new TauriProvider(createTracer(), { + hasTauriGlobals: () => false, + openExternal, + }), + }; } import { TauriProvider } from '@/infrastructure/tauri/TauriProvider'; @@ -74,14 +68,12 @@ describe('TauriProvider', () => { beforeEach(() => { vi.clearAllMocks(); - // Ensure Tauri is set - (globalThis as unknown as Record)['__TAURI__'] = tauriMock; + (globalThis as unknown as Record)['__TAURI_INTERNALS__'] = {}; provider = new TauriProvider(createTracer()); }); afterEach(() => { - // Restore Tauri - (globalThis as unknown as Record)['__TAURI__'] = tauriMock; + (globalThis as unknown as Record)['__TAURI_INTERNALS__'] = {}; }); // ---------------------------------------------------------- constructor @@ -93,18 +85,17 @@ describe('TauriProvider', () => { // ---------------------------------------------------------- isTauri describe('isTauri', () => { - it('should return true when __TAURI__ is present', () => { + it('should return true when Tauri internals are present', () => { expect(provider.isTauri()).toBe(true); }); - it('should return false when __TAURI__ is missing', () => { + it('should return false when Tauri internals are missing', () => { const { provider: webProvider } = setupWebMode(); expect(webProvider.isTauri()).toBe(false); }); - it('should return true when __TAURI_INTERNALS__ is present but __TAURI__ is not', () => { + it('should return true when __TAURI_INTERNALS__ is present', () => { const win = globalThis as unknown as Record; - delete win['__TAURI__']; win['__TAURI_INTERNALS__'] = {}; const p = new TauriProvider(createTracer()); @@ -129,17 +120,26 @@ describe('TauriProvider', () => { // Wait for the async handshake promise to settle await new Promise((resolve) => setTimeout(resolve, 0)); - // Remove globals so the static check on line 35 would return false. - // If isTauri() still returns true, it MUST use the cached path (line 32). - const { win, origTauri } = setupWebMode(); + const { win, origInternals } = setupWebMode(); expect(provider.isTauri()).toBe(true); - win['__TAURI__'] = origTauri; + win['__TAURI_INTERNALS__'] = origInternals; }); - it('should return _isTauriDetected=false from line 31 after failed handshake', async () => { - const { win, origTauri } = setupWebMode(); + it('should keep Tauri mode after a failed handshake when runtime globals are present', async () => { + (mockedTauriInvoke as unknown as Mock).mockRejectedValueOnce( + new Error('Handshake fail'), + ); + provider.init(); + + await vi.waitFor(() => { + expect(provider.isTauri()).toBe(true); + }); + }); + + it('should return _isTauriDetected=false after failed handshake without runtime globals', async () => { + const { win, origInternals } = setupWebMode(); (mockedTauriInvoke as unknown as Mock).mockRejectedValueOnce( new Error('Handshake fail'), @@ -147,14 +147,11 @@ describe('TauriProvider', () => { const p = new TauriProvider(createTracer()); p.init(); - // Wait for handshake to fail → _isTauriDetected becomes false await vi.waitFor(() => { - // isTauri() now returns this._isTauriDetected (false), not the static check expect(p.isTauri()).toBe(false); }); - // Restore - win['__TAURI__'] = origTauri; + win['__TAURI_INTERNALS__'] = origInternals; }); }); @@ -191,11 +188,9 @@ describe('TauriProvider', () => { expect(mockedTauriInvoke).toHaveBeenCalledWith('simple_command', {}); }); - it('should fallback to mock when invoke fails for non-critical commands', async () => { - // Must be in non-test mode — but since we ARE in test mode, errors propagate + it('should propagate invoke failures', async () => { (mockedTauriInvoke as unknown as Mock).mockRejectedValueOnce(new Error('fail')); - // In test mode, error is propagated await expect(provider.invoke('get_settings')).rejects.toThrow('fail'); }); @@ -231,7 +226,7 @@ describe('TauriProvider', () => { await expect(provider.invoke('specta_err_string')).rejects.toThrow('rate limited'); await expect(provider.invoke('specta_err_message')).rejects.toThrow('boom'); - await expect(provider.invoke('specta_err_payload')).rejects.toThrow('[object Object]'); + await expect(provider.invoke('specta_err_payload')).rejects.toThrow('{"detail":"bad"}'); }); it('should normalize object rejections with message, code and stringify fallback', async () => { @@ -342,6 +337,26 @@ describe('TauriProvider', () => { }); }); + describe('removeSecureKey', () => { + it('should call remove_secure_key with correct args', async () => { + (mockedTauriInvoke as unknown as Mock).mockResolvedValueOnce(undefined); + + await provider.removeSecureKey('openai_api'); + + expect(mockedTauriInvoke).toHaveBeenCalledWith('remove_secure_key', { + service: 'openai_api', + }); + }); + + it('should rethrow remove errors', async () => { + (mockedTauriInvoke as unknown as Mock).mockRejectedValueOnce( + new Error('Storage failure'), + ); + + await expect(provider.removeSecureKey('openai_api')).rejects.toThrow('Storage failure'); + }); + }); + // ---------------------------------------------------------- hasSecureKey describe('hasSecureKey', () => { it('should return key presence when invoke succeeds', async () => { @@ -402,9 +417,17 @@ describe('TauriProvider', () => { }); }); - it('should log in web mode', async () => { + it('should reject when the Tauri clipboard plugin fails', async () => { + (mockedTauriInvoke as unknown as Mock).mockRejectedValueOnce(new Error('denied')); + + await expect(provider.writeToClipboard('copied text')).rejects.toThrow('denied'); + }); + + it('should use browser clipboard fallback in web mode', async () => { const { provider: webProvider } = setupWebMode(); - await webProvider.writeToClipboard('text'); // should not throw + + await expect(webProvider.writeToClipboard('text')).resolves.toBeUndefined(); + expect(mockedTauriInvoke).not.toHaveBeenCalled(); }); }); @@ -447,73 +470,29 @@ describe('TauriProvider', () => { }); }); - it('should open window in web mode', async () => { - const { provider: webProvider } = setupWebMode(); - - const openSpy = vi.fn(); - vi.stubGlobal('open', openSpy); - - await webProvider.openUrl('https://example.com'); + it('should use the runtime fallback outside Tauri', async () => { + const { provider: webProvider, openExternal } = setupWebMode(); - expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank'); + await expect(webProvider.openUrl('https://example.com')).resolves.toBeUndefined(); + expect(openExternal).toHaveBeenCalledWith('https://example.com'); }); }); - // ---------------------------------------------------------- mock mode - describe('mock mode', () => { - it('should work without Tauri and use mock invoke', async () => { + // ---------------------------------------------------------- web mode + describe('web mode', () => { + it('should reject command invocation without Tauri', async () => { const { provider: webProvider } = setupWebMode(); expect(webProvider.isTauri()).toBe(false); - const result = await webProvider.invoke('get_settings'); - expect(result).toEqual({ - language: 'en', - theme: 'dark', - use_gpu: true, - debug_mode: false, - }); - }); - - it('should return empty object for unknown commands', async () => { - const { provider: webProvider } = setupWebMode(); - const result = await webProvider.invoke('unknown_command'); - expect(result).toEqual({}); - }); - - it('should return mock modules for get_modules', async () => { - const { provider: webProvider } = setupWebMode(); - const result = await webProvider.invoke('get_modules'); - expect(result).toEqual([]); - }); - - it('should return mock system stats', async () => { - const { provider: webProvider } = setupWebMode(); - const result = await webProvider.invoke<{ cpu: { name: string } }>('get_system_stats'); - expect(result.cpu.name).toBe('Mock CPU'); - }); - - it('should return mock translations for get_translations', async () => { - const { provider: webProvider } = setupWebMode(); - const result = await webProvider.invoke('get_translations'); - expect(result).toEqual({}); - }); - - it('should return mock config for get_config', async () => { - const { provider: webProvider } = setupWebMode(); - const result = await webProvider.invoke<{ version: string }>('get_config'); - expect(result.version).toBe('1.0.0'); - }); - - it('should return true for validate_api_key', async () => { - const { provider: webProvider } = setupWebMode(); - const result = await webProvider.invoke('validate_api_key'); - expect(result).toBe(true); + await expect(webProvider.invoke('get_settings')).rejects.toThrow( + 'Tauri IPC unavailable for command: get_settings', + ); }); }); // ---------------------------------------------------------- handshake failure (lines 24-25) describe('init handshake failure', () => { - it('should set _isTauriDetected to false when handshake fails', () => { + it('should not disable Tauri mode when handshake fails but globals exist', async () => { // Make invoke throw so handshake fails (mockedTauriInvoke as unknown as Mock).mockRejectedValueOnce( new Error('Handshake failed'), @@ -522,43 +501,41 @@ describe('TauriProvider', () => { const provider2 = new TauriProvider(createTracer()); provider2.init(); - // After failed handshake, isTauri falls back to static detection (globalThis.__TAURI__ present) - // _isTauriDetected is now false, but isTauri() returns static check (true since __TAURI__ exists) - // The internal flag is false — verify by checking it doesn't use handshake-based detection - // We can verify indirectly: a new provider with no __TAURI__ and failed handshake returns false - const { win, origTauri, provider: provider3 } = setupWebMode(); + await vi.waitFor(() => { + expect(provider2.isTauri()).toBe(true); + }); + }); + + it('should set _isTauriDetected to false when handshake fails without globals', async () => { + const { win, origInternals, provider: provider3 } = setupWebMode(); (mockedTauriInvoke as unknown as Mock).mockRejectedValueOnce(new Error('Fail')); provider3.init(); - expect(provider3.isTauri()).toBe(false); + await vi.waitFor(() => { + expect(provider3.isTauri()).toBe(false); + }); - win['__TAURI__'] = origTauri; + win['__TAURI_INTERNALS__'] = origInternals; }); }); - // ---------------------------------------------------------- _performInvoke globalThis branch (lines 62-70) - describe('_performInvoke globalThis fallback', () => { - it('should use globalThis.__TAURI__.core.invoke when tauriInvoke is falsy', async () => { - // To hit the globalInvoke branch, we need tauriInvoke to NOT be a function. - // Since tauriInvoke is always a mock function in tests, we simulate by - // making it throw (which is what _handleInvokeError deals with). - // Instead we test the branch indirectly via isTauri() + successful invoke path. + describe('_performInvoke', () => { + it('should invoke through the imported Tauri API', async () => { (mockedTauriInvoke as unknown as Mock).mockResolvedValueOnce({ result: 'ok' }); const result = await provider.invoke<{ result: string }>('some_cmd'); expect(result.result).toBe('ok'); }); - it('should fallback to mock when __TAURI__ is removed', async () => { - const { provider: webProvider, win, origTauri } = setupWebMode(); + it('should reject when Tauri internals are missing', async () => { + const { provider: webProvider, win, origInternals } = setupWebMode(); - // Without __TAURI__, isTauri() is false → _mockInvoke is used - const result = await webProvider.invoke('any_cmd'); - expect(result).toEqual({}); + await expect(webProvider.invoke('any_cmd')).rejects.toThrow( + 'Tauri IPC unavailable for command: any_cmd', + ); - // Restore - win['__TAURI__'] = origTauri; + win['__TAURI_INTERNALS__'] = origInternals; }); }); }); diff --git a/src/infrastructure/tauri/TauriProvider.ts b/src/infrastructure/tauri/TauriProvider.ts index c3b16703..63d47cbf 100644 --- a/src/infrastructure/tauri/TauriProvider.ts +++ b/src/infrastructure/tauri/TauriProvider.ts @@ -1,6 +1,5 @@ import { listen } from '@tauri-apps/api/event'; import { invoke as tauriInvoke } from '@tauri-apps/api/core'; -import type * as Bindings from '@/shared/types/bindings'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { IBridge } from '@/shared/types/IBridge'; @@ -49,10 +48,14 @@ export class TauriProvider implements IBridge { // Priority Check: Try to call a safe, neutral command await this._performInvoke('get_health', {}); this._isTauriDetected = true; - this._tracer.info('[TauriProvider] IPC Handshake successful'); + this._tracer.debug('[TauriProvider] IPC Handshake successful'); } catch { - this._isTauriDetected = false; - this._tracer.warn('[TauriProvider] Handshake failed, operating in Mock mode'); + this._isTauriDetected = this._runtime.hasTauriGlobals(); + this._tracer.warn( + this._isTauriDetected + ? '[TauriProvider] Handshake failed, keeping Tauri IPC because runtime globals are present' + : '[TauriProvider] Handshake failed, Tauri runtime is unavailable', + ); } } @@ -65,12 +68,24 @@ export class TauriProvider implements IBridge { return this._runtime.hasTauriGlobals(); } + public hasCapability(capability: string): boolean { + if (!this.isTauri()) { + return false; + } + + if (capability === 'speechRecognition') { + return /\bWindows\b/i.test(globalThis.navigator.userAgent); + } + + return false; + } + public async invoke = Record>( cmd: string, args: A = {} as A, ): Promise { if (!this.isTauri()) { - return this._mockInvoke(cmd, args); + return Promise.reject(new Error(`Tauri IPC unavailable for command: ${cmd}`)); } try { @@ -98,7 +113,9 @@ export class TauriProvider implements IBridge { 'payload' in errorPayload ) { // Some AppErrors might have a payload field - message = String((errorPayload as { payload: unknown }).payload); + message = stringifyInvokePayload( + (errorPayload as { payload: unknown }).payload, + ); } const err = new Error(message); @@ -131,7 +148,7 @@ export class TauriProvider implements IBridge { } /** - * Internal execution of Tauri IPC with multiple fallback strategies. + * Internal execution of Tauri IPC. */ private async _performInvoke(cmd: string, args: unknown): Promise { return await tauriInvoke(cmd, args as Record); @@ -163,7 +180,6 @@ export class TauriProvider implements IBridge { return Promise.reject(new Error(String(e))); } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters public async listen(event: string, callback: (payload: T) => void): Promise<() => void> { if (this.isTauri()) { // Using imported listen for robust IPC @@ -172,7 +188,7 @@ export class TauriProvider implements IBridge { }); return unlisten; } else { - this._tracer.info(`[TauriProvider] Mock Listen: ${event}`); + this._tracer.info(`[TauriProvider] Listen skipped outside Tauri: ${event}`); return () => { /* no-op */ }; @@ -185,12 +201,8 @@ export class TauriProvider implements IBridge { await this.invoke('plugin:clipboard-manager|write_text', { text }); return; } catch (error) { - try { - if (await this._writeBrowserClipboard(text)) { - return; - } - } catch { - /* Preserve the original Tauri clipboard error. */ + if (await this._writeBrowserClipboard(text)) { + return; } throw error; } @@ -277,6 +289,15 @@ export class TauriProvider implements IBridge { } } + public async removeSecureKey(service: string): Promise { + try { + await this.invoke('remove_secure_key', { service }); + } catch (e) { + this._tracer.error(`[TauriProvider] Secure remove failed for ${service}: ${String(e)}`); + throw e; + } + } + /** * Check whether a non-empty key exists in secure storage. */ @@ -301,62 +322,16 @@ export class TauriProvider implements IBridge { return { exists: false, length: 0 }; } } +} + +function stringifyInvokePayload(payload: unknown): string { + if (typeof payload === 'string') { + return payload; + } - private _mockInvoke(cmd: string, args: unknown): Promise { - this._tracer.debug(`[Mock Invoke] ${cmd} ${JSON.stringify(args)}`); - - const saneDefaults: Record = { - get_settings: { - language: 'en', - theme: 'dark', - use_gpu: true, - debug_mode: false, - } as Bindings.AppSettings, - get_ui_state: {}, - get_module_settings: {}, - get_translations: {}, - get_system_language: 'en', - get_config: { - version: '1.0.0', - catalog: { ai: [], services: [], stars: [] }, - apiProviders: [], - } as Bindings.AppConfig, - get_modules: [] satisfies Bindings.Module[], - get_logs: [], - get_app_bootstrap_data: null, - get_system_stats: { - cpu: { percent: 0, cores: 0, name: 'Mock CPU' }, - ram: { percent: 0, usedGb: 0, totalGb: 16, availableGb: 16 }, - gpu: { usage: 0, memoryUsed: 0, memoryTotal: 0, temp: 0, name: 'Mock GPU' }, - vram: { percent: 0, usedGb: 0, totalGb: 8 }, - disk: { - readRate: 0, - writeRate: 0, - utilization: 0, - totalGb: 500, - usedGb: 0, - activityPercent: 0, - }, - network: { - downloadRate: 0, - uploadRate: 0, - totalReceived: 0, - totalSent: 0, - utilization: 0, - activityPercent: 0, - }, - pid: 1234, - appCpu: 0, - appMemory: 0, - } satisfies Bindings.SystemStats, - validate_api_key: true, - has_secure_key: false, - get_secure_key_meta: { exists: false, length: 0 } satisfies SecureKeyMeta, - clear_logs: null, - save_ui_state: null, - save_setting: true, - }; - - return Promise.resolve((saneDefaults[cmd] ?? {}) as unknown as T); + try { + return JSON.stringify(payload); + } catch { + return String(payload); } } diff --git a/src/package-lock.json b/src/package-lock.json index 825180ee..850935a2 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -22,25 +22,27 @@ "@commitlint/config-conventional": "^21.0.1", "@eslint/js": "^10.0.1", "@tauri-apps/cli": "~2.11.1", - "@types/node": "^25.7.0", + "@types/node": "^25.8.0", "@typescript-eslint/eslint-plugin": "^8.59.3", "@typescript-eslint/parser": "^8.59.3", "@typescript-eslint/utils": "^8.59.3", "@vitest/coverage-v8": "^4.1.6", - "@vitest/ui": "^4.1.5", + "@vitest/ui": "^4.1.6", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", + "fonteditor-core": "^2.6.3", "globals": "^17.6.0", "jsdom": "^29.1.1", "prettier": "^3.8.3", + "smol-toml": "^1.6.1", "terser": "^5.47.1", "typescript": "^6.0.3", "typescript-eslint": "^8.59.3", - "vite": "^8.0.12", - "vitest": "^4.1.5" + "vite": "^8.0.13", + "vitest": "^4.1.6" }, "engines": { - "node": ">=20.0.0" + "node": ">=26.1.0" } }, "node_modules/@asamuzakjp/css-color": { @@ -895,9 +897,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.129.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", - "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==", + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", "dev": true, "license": "MIT", "funding": { @@ -912,9 +914,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz", - "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", "cpu": [ "arm64" ], @@ -929,9 +931,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz", - "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", "cpu": [ "arm64" ], @@ -946,9 +948,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz", - "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", "cpu": [ "x64" ], @@ -963,9 +965,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz", - "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", "cpu": [ "x64" ], @@ -980,9 +982,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz", - "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", "cpu": [ "arm" ], @@ -997,9 +999,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz", - "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", "cpu": [ "arm64" ], @@ -1014,9 +1016,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz", - "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", "cpu": [ "arm64" ], @@ -1031,9 +1033,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz", - "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", "cpu": [ "ppc64" ], @@ -1048,9 +1050,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz", - "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", "cpu": [ "s390x" ], @@ -1065,9 +1067,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz", - "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", "cpu": [ "x64" ], @@ -1082,9 +1084,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz", - "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", "cpu": [ "x64" ], @@ -1099,9 +1101,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz", - "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", "cpu": [ "arm64" ], @@ -1116,9 +1118,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz", - "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", "cpu": [ "wasm32" ], @@ -1135,9 +1137,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz", - "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", "cpu": [ "arm64" ], @@ -1152,9 +1154,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz", - "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", "cpu": [ "x64" ], @@ -1169,9 +1171,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", - "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -1507,13 +1509,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.7.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", - "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.21.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/trusted-types": { @@ -1552,24 +1554,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", - "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, "node_modules/@typescript-eslint/parser": { "version": "8.59.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", @@ -1595,15 +1579,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "node_modules/@typescript-eslint/project-service": { "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", - "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3" + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1611,18 +1596,20 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/project-service": { + "node_modules/@typescript-eslint/scope-manager": { "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", - "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.3", - "@typescript-eslint/types": "^8.59.3", - "debug": "^4.4.3" + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1630,9 +1617,6 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/tsconfig-utils": { @@ -1743,24 +1727,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", - "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, "node_modules/@typescript-eslint/visitor-keys": { "version": "8.59.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", @@ -1958,6 +1924,16 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2395,9 +2371,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, @@ -2797,6 +2773,15 @@ "dev": true, "license": "ISC" }, + "node_modules/fonteditor-core": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/fonteditor-core/-/fonteditor-core-2.6.3.tgz", + "integrity": "sha512-YUryIKjkenjZ41E7JvM3V+02Ak4mTHDDTwBWgs9KBzypzHqLZHuua1UDRevZNTKawmnq1dbBAa70Jddl2+F4FQ==", + "dev": true, + "dependencies": { + "@xmldom/xmldom": "^0.8.3" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3890,14 +3875,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", - "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.129.0", - "@rolldown/pluginutils": "1.0.0" + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -3906,21 +3891,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0", - "@rolldown/binding-darwin-arm64": "1.0.0", - "@rolldown/binding-darwin-x64": "1.0.0", - "@rolldown/binding-freebsd-x64": "1.0.0", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0", - "@rolldown/binding-linux-arm64-gnu": "1.0.0", - "@rolldown/binding-linux-arm64-musl": "1.0.0", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0", - "@rolldown/binding-linux-s390x-gnu": "1.0.0", - "@rolldown/binding-linux-x64-gnu": "1.0.0", - "@rolldown/binding-linux-x64-musl": "1.0.0", - "@rolldown/binding-openharmony-arm64": "1.0.0", - "@rolldown/binding-wasm32-wasi": "1.0.0", - "@rolldown/binding-win32-arm64-msvc": "1.0.0", - "@rolldown/binding-win32-x64-msvc": "1.0.0" + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" } }, "node_modules/saxes": { @@ -3994,6 +3979,19 @@ "node": ">=18" } }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -4295,9 +4293,9 @@ } }, "node_modules/undici-types": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", - "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, @@ -4312,16 +4310,16 @@ } }, "node_modules/vite": { - "version": "8.0.12", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz", - "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==", + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", - "rolldown": "1.0.0", + "rolldown": "1.0.1", "tinyglobby": "^0.2.16" }, "bin": { diff --git a/src/package.json b/src/package.json index ca531771..3b9b5f3f 100644 --- a/src/package.json +++ b/src/package.json @@ -8,7 +8,8 @@ "bindings:sync": "cargo run --quiet --manifest-path ../src-tauri/Cargo.toml --bin exporter", "bindings:check": "cargo run --quiet --manifest-path ../src-tauri/Cargo.toml --bin exporter -- --check", "build": "npm run typecheck && npm run build:bundle", - "build:bundle": "vite build", + "build:bundle": "npm run fonts:subset && vite build", + "fonts:subset": "node scripts/subset-cubic11-zh.js", "preview": "vite preview", "clean": "node --input-type=module -e \"import { rmSync } from 'fs'; rmSync('dist', { recursive: true, force: true });\" && echo Cleaned", "check-size": "node scripts/check-size.js", @@ -27,22 +28,24 @@ "@commitlint/config-conventional": "^21.0.1", "@eslint/js": "^10.0.1", "@tauri-apps/cli": "~2.11.1", - "@types/node": "^25.7.0", + "@types/node": "^25.8.0", "@typescript-eslint/eslint-plugin": "^8.59.3", "@typescript-eslint/parser": "^8.59.3", "@typescript-eslint/utils": "^8.59.3", "@vitest/coverage-v8": "^4.1.6", - "@vitest/ui": "^4.1.5", + "@vitest/ui": "^4.1.6", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", + "fonteditor-core": "^2.6.3", "globals": "^17.6.0", "jsdom": "^29.1.1", "prettier": "^3.8.3", + "smol-toml": "^1.6.1", "terser": "^5.47.1", "typescript": "^6.0.3", "typescript-eslint": "^8.59.3", - "vite": "^8.0.12", - "vitest": "^4.1.5" + "vite": "^8.0.13", + "vitest": "^4.1.6" }, "dependencies": { "@tauri-apps/api": "~2.11.0", @@ -55,6 +58,6 @@ "wawoff2": "^2.0.1" }, "engines": { - "node": ">=20.0.0" + "node": ">=26.1.0" } } diff --git a/src/public/templates/pages/marketplace.html b/src/public/templates/pages/marketplace.html deleted file mode 100644 index f930a431..00000000 --- a/src/public/templates/pages/marketplace.html +++ /dev/null @@ -1,9 +0,0 @@ -
-
- Coming soon -
-
diff --git a/src/public/templates/pages/settings.html b/src/public/templates/pages/settings.html index 9fc5d037..753c57be 100644 --- a/src/public/templates/pages/settings.html +++ b/src/public/templates/pages/settings.html @@ -1,16 +1,22 @@
- -
Taskbar Management
-
Configure tab visibility
+
+ Sidebar Management +
+
+ Configure sidebar page visibility +
- -
-
Monitoring Management
-
System monitor visibility settings
+
+
+ Monitoring Management +
+
+ System monitor visibility settings +
diff --git a/src/scripts/check-size.js b/src/scripts/check-size.js index c6c187e1..8a548ef5 100644 --- a/src/scripts/check-size.js +++ b/src/scripts/check-size.js @@ -5,7 +5,7 @@ const DIST_DIR = path.resolve('dist'); const KB = 1024; const LIMITS = { - totalBytes: 1_200 * KB, + totalBytes: 1_236 * KB, mainJsBytes: 400 * KB, vendorJsBytes: 100 * KB, cssBytes: 200 * KB, @@ -45,42 +45,61 @@ const files = walkFiles(DIST_DIR).map((file) => ({ size: statSync(file).size, })); -const totalBytes = files.reduce((sum, file) => sum + file.size, 0); +const isFont = (file) => /\.(woff2|woff|ttf|otf)$/iu.test(file.relative); +const isEntryDocument = (file) => /\.html$/iu.test(file.relative); +const totalBytes = files + .filter((file) => !isFont(file) && !isEntryDocument(file)) + .reduce((sum, file) => sum + file.size, 0); const mainJs = files.find((file) => /^main-.*\.js$/u.test(file.relative)); const vendorJs = files.find((file) => /^chunks\/vendor-.*\.js$/u.test(file.relative)); const cssBundle = files.find((file) => /^assets\/main-.*\.css$/u.test(file.relative)); -const largestFont = files - .filter((file) => /\.(woff2|woff|ttf|otf)$/iu.test(file.relative)) - .sort((left, right) => right.size - left.size)[0]; +const largestFont = files.filter(isFont).sort((left, right) => right.size - left.size)[0]; +const largestFiles = files + .filter((file) => !isEntryDocument(file)) + .sort((left, right) => right.size - left.size) + .slice(0, 10); + +function printLargestFiles() { + console.log('[size] largest files:'); + for (const file of largestFiles) { + console.log(`[size] ${formatKb(file.size).padStart(10)} ${file.relative}`); + } +} if (totalBytes > LIMITS.totalBytes) { + printLargestFiles(); warn(`Total dist size ${formatKb(totalBytes)} exceeds ${formatKb(LIMITS.totalBytes)}`); } if (mainJs && mainJs.size > LIMITS.mainJsBytes) { + printLargestFiles(); warn( `Main bundle ${mainJs.relative} is ${formatKb(mainJs.size)} and exceeds ${formatKb(LIMITS.mainJsBytes)}`, ); } if (vendorJs && vendorJs.size > LIMITS.vendorJsBytes) { + printLargestFiles(); warn( `Vendor markdown chunk ${vendorJs.relative} is ${formatKb(vendorJs.size)} and exceeds ${formatKb(LIMITS.vendorJsBytes)}`, ); } if (cssBundle && cssBundle.size > LIMITS.cssBytes) { + printLargestFiles(); warn( `Main stylesheet ${cssBundle.relative} is ${formatKb(cssBundle.size)} and exceeds ${formatKb(LIMITS.cssBytes)}`, ); } if (largestFont && largestFont.size > LIMITS.fontBytes) { + printLargestFiles(); warn( `Largest font ${largestFont.relative} is ${formatKb(largestFont.size)} and exceeds ${formatKb(LIMITS.fontBytes)}`, ); } +printLargestFiles(); console.log( `[size] ok: total=${formatKb(totalBytes)}, main=${formatKb(mainJs?.size ?? 0)}, vendor=${formatKb(vendorJs?.size ?? 0)}, css=${formatKb(cssBundle?.size ?? 0)}, font=${formatKb(largestFont?.size ?? 0)}`, ); diff --git a/src/scripts/subset-cubic11-zh.js b/src/scripts/subset-cubic11-zh.js new file mode 100644 index 00000000..1e7cb848 --- /dev/null +++ b/src/scripts/subset-cubic11-zh.js @@ -0,0 +1,57 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { createFont, woff2 } from 'fonteditor-core'; + +const SOURCE_FONT = path.resolve('assets/fonts/Cubic_11.woff2'); +const OUTPUT_FONT = path.resolve('assets/fonts/Cubic_11.zh-subset.woff2'); +const ZH_LOCALE = path.resolve('../src-tauri/resources/locales/zh.json'); + +const EXTRA_TEXT = [ + 'Axelate', + '0123456789', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + 'abcdefghijklmnopqrstuvwxyz', + ' \n\t', + '.,:;!?()[]{}<>+-=*/\\|_#@$%^&`~\'"', + ',。:;!?()【】《》、“”‘’—…·¥', +].join(''); + +function collectCodePoints(value, codePoints) { + if (typeof value === 'string') { + for (const char of value) { + codePoints.add(char.codePointAt(0)); + } + return; + } + + if (Array.isArray(value)) { + value.forEach((item) => collectCodePoints(item, codePoints)); + return; + } + + if (value && typeof value === 'object') { + Object.values(value).forEach((item) => collectCodePoints(item, codePoints)); + } +} + +function formatKb(bytes) { + return `${(bytes / 1024).toFixed(2)} KB`; +} + +await woff2.init(); + +const codePoints = new Set(); +collectCodePoints(JSON.parse(readFileSync(ZH_LOCALE, 'utf8')), codePoints); +collectCodePoints(EXTRA_TEXT, codePoints); + +const source = readFileSync(SOURCE_FONT); +const font = createFont(source, { + type: 'woff2', + subset: [...codePoints].filter((codePoint) => codePoint !== undefined), +}); +const subset = Buffer.from(font.write({ type: 'woff2' })); +writeFileSync(OUTPUT_FONT, subset); + +console.log( + `[fonts] Cubic11 zh subset: ${codePoints.size} code points, ${formatKb(source.byteLength)} -> ${formatKb(subset.byteLength)}`, +); diff --git a/src/shared/api/invoke.test.ts b/src/shared/api/invoke.test.ts new file mode 100644 index 00000000..b21273fe --- /dev/null +++ b/src/shared/api/invoke.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})); + +import { invoke as tauriInvoke } from '@tauri-apps/api/core'; +import { invokeSafe } from './invoke'; + +const invokeMock = vi.mocked(tauriInvoke); + +describe('invokeSafe', () => { + it('returns ok data from string commands', async () => { + invokeMock.mockResolvedValueOnce({ version: '1.0.0' }); + + const result = await invokeSafe('get_version', { verbose: true }); + + expect(invokeMock).toHaveBeenCalledWith('get_version', { verbose: true }); + expect(result).toEqual({ status: 'ok', data: { version: '1.0.0' } }); + }); + + it('returns ok data from specta results', async () => { + const result = await invokeSafe(Promise.resolve({ status: 'ok', data: 42 })); + + expect(result).toEqual({ status: 'ok', data: 42 }); + }); + + it('uses Error.message for transport exceptions', async () => { + invokeMock.mockRejectedValueOnce(new Error('Download cancelled')); + + const result = await invokeSafe('download_module'); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error.message).toBe('Download cancelled'); + } + }); + + it('uses string transport exceptions as messages', async () => { + invokeMock.mockRejectedValueOnce('backend unavailable'); + + const result = await invokeSafe('get_status'); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error.code).toBe('INVOKE_EXCEPTION'); + expect(result.error.message).toBe('backend unavailable'); + expect(result.error.details).toBe('backend unavailable'); + } + }); + + it('uses structured transport error messages', async () => { + invokeMock.mockRejectedValueOnce({ message: 'invalid payload', code: 'BAD_PAYLOAD' }); + + const result = await invokeSafe('save_settings'); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error.code).toBe('INVOKE_EXCEPTION'); + expect(result.error.message).toBe('invalid payload'); + } + }); + + it('preserves specta error codes and messages', async () => { + const result = await invokeSafe( + Promise.resolve({ + status: 'error', + error: { code: 'RATE_LIMITED', message: 'Try later' }, + }), + ); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error.code).toBe('RATE_LIMITED'); + expect(result.error.message).toBe('Try later'); + expect(result.error.details).toEqual({ code: 'RATE_LIMITED', message: 'Try later' }); + } + }); + + it('stringifies structured payload errors from specta results', async () => { + const result = await invokeSafe( + Promise.resolve({ + status: 'error', + error: { payload: { reason: 'rate limited' } }, + }), + ); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error.message).toBe('{"reason":"rate limited"}'); + } + }); + + it('passes string payload errors through directly', async () => { + const result = await invokeSafe( + Promise.resolve({ + status: 'error', + error: { payload: 'plain payload error' }, + }), + ); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error.message).toBe('plain payload error'); + } + }); + + it('falls back to UNKNOWN and JSON for unstructured specta errors', async () => { + const result = await invokeSafe(Promise.resolve({ status: 'error', error: { ok: false } })); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error.code).toBe('UNKNOWN'); + expect(result.error.message).toBe('{"ok":false}'); + } + }); + + it('falls back to String when error JSON serialization fails', async () => { + const circular: Record = {}; + circular['self'] = circular; + + const result = await invokeSafe(Promise.resolve({ status: 'error', error: circular })); + + expect(result.status).toBe('error'); + if (result.status === 'error') { + expect(result.error.message).toBe('[object Object]'); + } + }); +}); diff --git a/src/shared/api/invoke.ts b/src/shared/api/invoke.ts index ee0ef650..89477627 100644 --- a/src/shared/api/invoke.ts +++ b/src/shared/api/invoke.ts @@ -20,15 +20,11 @@ export async function invokeSafe( if (result.status === 'ok') { return { status: 'ok', data: result.data }; } else { - const err = result.error as Record; return { status: 'error', error: { - code: typeof err['code'] === 'string' ? err['code'] : 'UNKNOWN', - message: - typeof err['message'] === 'string' - ? err['message'] - : String(result.error), + code: getInvokeErrorCode(result.error), + message: getInvokeErrorMessage(result.error), details: result.error, }, }; @@ -38,11 +34,60 @@ export async function invokeSafe( // Handle unexpected errors (e.g. transport) return { status: 'error', - error: { code: 'INVOKE_EXCEPTION', message: String(err), details: err }, + error: { + code: 'INVOKE_EXCEPTION', + message: getInvokeErrorMessage(err), + details: err, + }, }; } } +function getInvokeErrorCode(error: unknown): string { + if (typeof error === 'object' && error !== null) { + const code = (error as Record)['code']; + if (typeof code === 'string') { + return code; + } + } + + return 'UNKNOWN'; +} + +function getInvokeErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'string') { + return error; + } + + if (typeof error === 'object' && error !== null) { + const obj = error as Record; + if (typeof obj['message'] === 'string') { + return obj['message']; + } + if ('payload' in obj) { + return stringifyInvokeError(obj['payload']); + } + } + + return stringifyInvokeError(error); +} + +function stringifyInvokeError(error: unknown): string { + if (typeof error === 'string') { + return error; + } + + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + /** * Helper to use with tauri-specta generated commands if needed, * or as a pattern for our own service methods. diff --git a/src/shared/config/AppPages.ts b/src/shared/config/AppPages.ts index 2c44345d..d9a1b3c1 100644 --- a/src/shared/config/AppPages.ts +++ b/src/shared/config/AppPages.ts @@ -29,13 +29,6 @@ export const APP_PAGES: IAppPage[] = [ defaultLabel: 'Integrations', inSettings: true, }, - { - id: 'marketplace', - icon: '#icon-marketplace', - i18nKey: 'ui.launcher.web.marketplace', - defaultLabel: 'Market', - inSettings: true, - }, { id: 'settings', icon: '#icon-settings', diff --git a/src/shared/config/catalog_fallback.ts b/src/shared/config/catalog_fallback.ts deleted file mode 100644 index a3eb860b..00000000 --- a/src/shared/config/catalog_fallback.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { AppConfig } from '@/shared/types/bindings'; - -export const FALLBACK_CONFIG: AppConfig = { - version: '1.0.0', - apiProviders: [], - catalog: { - ai: [], - services: [], - stars: [], - }, -}; diff --git a/src/shared/services/CatalogLoadSnapshot.ts b/src/shared/services/CatalogLoadSnapshot.ts index 5e8f5817..934d3335 100644 --- a/src/shared/services/CatalogLoadSnapshot.ts +++ b/src/shared/services/CatalogLoadSnapshot.ts @@ -4,6 +4,7 @@ import type { IModule } from '@/shared/types/coreTypes'; export type EngineDefinition = { id: string; installed: boolean; + installed_compute_modes?: Array<'gpu' | 'cpu'>; }; export type CatalogLoadSnapshot = { diff --git a/src/shared/services/CatalogService.test.ts b/src/shared/services/CatalogService.test.ts index 8563e3c1..7fac3c3f 100644 --- a/src/shared/services/CatalogService.test.ts +++ b/src/shared/services/CatalogService.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { CatalogService } from './CatalogService'; import type { IModule } from '@/shared/types/coreTypes'; -import { FALLBACK_CONFIG } from '@/shared/config/catalog_fallback'; import { createCatalogHarness, createMockAppConfig, @@ -78,7 +77,7 @@ describe('CatalogService', () => { expect(app?.type).toBe('local'); }); - it('should fallback to FALLBACK_CONFIG if config is empty or invalid', async () => { + it('should keep an explicitly empty catalog empty', async () => { const invalidConfig = createMockAppConfig(); setupBridgeMocks(mockBridge, invalidConfig); @@ -87,9 +86,8 @@ describe('CatalogService', () => { const catalog = service.getCatalog(); - // Should be hydrated from fallback source - expect(catalog.ai.length).toBe(FALLBACK_CONFIG.catalog.ai.length); - expect(catalog.ai[0]?.id).toBe(FALLBACK_CONFIG.catalog.ai[0]?.id); + expect(catalog.ai).toHaveLength(0); + expect(catalog.services).toHaveLength(0); }); it('should inject apiProviderData for API modules', async () => { @@ -133,8 +131,7 @@ describe('CatalogService', () => { }); }); - // ---------------------------------------------------------- getCatalogCategory fallback (lines 39-40) - describe('getCatalogCategory fallback', () => { + describe('getCatalogCategory defaults', () => { it('should return empty array for unknown category', () => { // getCatalogCategory is now on GlobalBridge, not CatalogService // Test service-level method instead @@ -161,8 +158,7 @@ describe('CatalogService', () => { }); }); - // ---------------------------------------------------------- invoke fallback - describe('bridge fallback', () => { + 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: [] }, @@ -178,36 +174,57 @@ describe('CatalogService', () => { expect(mockBridge.invoke).toHaveBeenCalledWith('get_config'); }); - it('should fallback to FALLBACK_CONFIG when bridge returns null config', async () => { + it('should use an empty catalog when bridge returns null config', async () => { mockBridge.isTauri.mockReturnValue(false); setupBridgeMocks(mockBridge, null); await service.loadCatalog(); const catalog = service.getCatalog(); - expect(catalog.ai.length).toBe(FALLBACK_CONFIG.catalog.ai.length); + expect(catalog.ai).toHaveLength(0); + expect(catalog.services).toHaveLength(0); }); - it('should fallback when bridge throws', async () => { + it('should use an empty catalog when bridge throws', async () => { mockBridge.isTauri.mockReturnValue(false); mockBridge.invoke.mockRejectedValue(new Error('Bridge error')); await service.loadCatalog(); const catalog = service.getCatalog(); - expect(catalog.ai.length).toBe(FALLBACK_CONFIG.catalog.ai.length); + expect(catalog.ai).toHaveLength(0); + expect(catalog.services).toHaveLength(0); + }); + + it('should use an empty catalog when bridge returns malformed catalog shape', async () => { + setupBridgeMocks( + mockBridge, + createMockAppConfig({ + catalog: { ai: null, services: undefined }, + apiProviders: null, + }), + ); + + await service.loadCatalog(); + + const catalog = service.getCatalog(); + expect(catalog.ai).toHaveLength(0); + expect(catalog.services).toHaveLength(0); + expect(globalThis.dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'catalog-loaded' }), + ); }); }); - // ---------------------------------------------------------- _ensureValidConfig null config (lines 278-279) describe('_ensureValidConfig null config', () => { - it('should use FALLBACK_CONFIG when bridge invoke returns null', async () => { + it('should use an empty catalog when bridge invoke returns null', async () => { setupBridgeMocks(mockBridge, null); await service.loadCatalog(); const catalog = service.getCatalog(); - expect(catalog.ai.length).toBe(FALLBACK_CONFIG.catalog.ai.length); + expect(catalog.ai).toHaveLength(0); + expect(catalog.services).toHaveLength(0); }); }); @@ -232,8 +249,7 @@ describe('CatalogService', () => { }); }); - // ---------------------------------------------------------- _ensureFallbacks api-type (L193) - describe('_ensureFallbacks api-type branch (L193)', () => { + describe('catalog hydration', () => { it('should mark api-type apps as installed=true', async () => { const config = createMockAppConfig({ catalog: { @@ -319,6 +335,85 @@ describe('CatalogService', () => { true, ); }); + + 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); + }); + + it('should unsubscribe 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()); + mockBridge.isTauri.mockReturnValue(true); + mockBridge.listen.mockReturnValue( + new Promise((resolve) => { + resolveListen = resolve; + }), + ); + + const loadPromise = service.loadCatalog(); + service.destroy(); + resolveListen(unlisten); + await loadPromise; + await Promise.resolve(); + + expect(unlisten).toHaveBeenCalledTimes(1); + }); }); describe('_initGlobalExposures DEV branch (L29)', () => { diff --git a/src/shared/services/CatalogService.ts b/src/shared/services/CatalogService.ts index 5d6d6abc..54251f4a 100644 --- a/src/shared/services/CatalogService.ts +++ b/src/shared/services/CatalogService.ts @@ -7,13 +7,24 @@ 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 { LoggerService } from '@/infrastructure/logging/LoggerService'; -import { FALLBACK_CONFIG } from '@/shared/config/catalog_fallback'; import type { CatalogLoadSnapshot, EngineDefinition } from './CatalogLoadSnapshot'; -type CatalogLogger = Pick; +type CatalogLogger = Pick; +const EMPTY_CONFIG: AppConfig = { + version: '1.0.0', + apiProviders: [], + catalog: { + ai: [], + services: [], + stars: [], + }, +}; export class CatalogService { private readonly _appData: ICatalogData = { ai: [], services: [] }; + private _integrationWatcherUnlisten: (() => void) | null = null; + private _integrationWatcherBinding = false; + private _destroyed = false; constructor( private readonly _bridge: IBridge, @@ -24,6 +35,8 @@ export class CatalogService { * Asynchronously loads the application catalog from the Tauri backend. */ public async loadCatalog(): Promise { + this._destroyed = false; + this._bindIntegrationWatcher(); const snapshot = await this._loadSnapshot(); try { @@ -38,9 +51,6 @@ export class CatalogService { // Hydrate with schemas, providers & engine install status this._hydrateApps(snapshot.config, snapshot.installedModules, snapshot.engineDefs); - // Final check for fallbacks - this._ensureFallbacks(); - const event = new CustomEvent('catalog-loaded'); globalThis.dispatchEvent(event); } catch (e) { @@ -48,6 +58,43 @@ export class CatalogService { } } + public destroy(): void { + this._destroyed = true; + this._integrationWatcherUnlisten?.(); + this._integrationWatcherUnlisten = null; + this._integrationWatcherBinding = false; + } + + private _bindIntegrationWatcher(): void { + if ( + this._integrationWatcherBinding || + this._integrationWatcherUnlisten !== null || + !this._bridge.isTauri() + ) { + return; + } + + this._integrationWatcherBinding = true; + void this._bridge + .listen('integrations_changed', () => { + void this.loadCatalog(); + }) + .then((unlisten) => { + if (this._destroyed) { + unlisten(); + this._integrationWatcherBinding = false; + return; + } + this._integrationWatcherUnlisten = unlisten; + }) + .catch((error: unknown) => { + this._integrationWatcherBinding = false; + this._tracer.warn( + `[CatalogService] Failed to subscribe to integrations watcher: ${String(error)}`, + ); + }); + } + private async _loadSnapshot(): Promise { const [config, installedModules, engineDefs] = await Promise.all([ this._loadConfig(), @@ -62,17 +109,12 @@ export class CatalogService { }; } - /** - * Loads the configuration from backend or fallback. - */ - private async _loadConfig(): Promise { + private async _loadConfig(): Promise { try { return await this._bridge.invoke('get_config'); } catch (e) { - this._tracer.warn( - `[CatalogService] Backend config failed, using fallback: ${String(e)}`, - ); - return FALLBACK_CONFIG; + this._tracer.warn(`[CatalogService] Backend config failed: ${String(e)}`); + return null; } } @@ -148,6 +190,12 @@ export class CatalogService { 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 = @@ -168,6 +216,7 @@ export class CatalogService { } 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 @@ -213,7 +262,7 @@ export class CatalogService { if (discovered.length === 0) return; this._appData.services.push(...discovered); - this._tracer.info( + this._tracer.debug( `[CatalogService] Added ${String(discovered.length)} discovered integration(s).`, ); } @@ -240,34 +289,6 @@ export class CatalogService { }; } - /** - * Ensures each category has at least one app from fallbacks if empty. - */ - private _ensureFallbacks(): void { - const fallbackAi = FALLBACK_CONFIG.catalog.ai; - const fallbackServices = FALLBACK_CONFIG.catalog.services; - - if (this._appData.ai.length === 0) { - this._tracer.warn( - `[CatalogService] AI catalog still empty (fallback source has ${String(fallbackAi.length)} items), injecting fallbacks.`, - ); - this._appData.ai = this._mapModuleItems(fallbackAi, 'ai'); - this._tracer.info( - `[CatalogService] AI catalog now has ${String(this._appData.ai.length)} items.`, - ); - } - - if (this._appData.services.length === 0) { - this._tracer.warn( - `[CatalogService] Services catalog still empty (fallback source has ${String(fallbackServices.length)} items), injecting fallbacks.`, - ); - this._appData.services = this._mapModuleItems(fallbackServices, 'services'); - this._tracer.info( - `[CatalogService] Services catalog now has ${String(this._appData.services.length)} items.`, - ); - } - } - /** * Returns the current catalog data. */ @@ -285,25 +306,32 @@ export class CatalogService { ); } - /** - * Validates the configuration and returns a fallback if invalid. - */ private _ensureValidConfig(config: AppConfig | null): AppConfig { - const fallback = FALLBACK_CONFIG; - if (!config) { - this._tracer.warn('[CatalogService] Config is null. Using FALLBACK_CONFIG.'); - return fallback; + this._tracer.warn('[CatalogService] Config is unavailable. Using empty catalog.'); + return EMPTY_CONFIG; } - if (config.catalog.ai.length === 0 && config.catalog.services.length === 0) { - const aiLen = config.catalog.ai.length; - const srvLen = config.catalog.services.length; - this._tracer.warn( - `[CatalogService] Config invalid or empty (AI: ${String(aiLen)}, Services: ${String(srvLen)}). FORCING FALLBACK_CONFIG.`, - ); - return fallback; + if (!this._hasCatalogArrays(config)) { + this._tracer.warn('[CatalogService] Config shape is invalid. Using empty catalog.'); + return EMPTY_CONFIG; } return config; } + + private _hasCatalogArrays(config: AppConfig): boolean { + const candidate = config as unknown as { + catalog?: { + ai?: unknown; + services?: unknown; + }; + apiProviders?: unknown; + }; + + return ( + Array.isArray(candidate.catalog?.ai) && + Array.isArray(candidate.catalog.services) && + Array.isArray(candidate.apiProviders) + ); + } } diff --git a/src/shared/services/ErrorHandler.test.ts b/src/shared/services/ErrorHandler.test.ts index 1a67480f..ed3e56dd 100644 --- a/src/shared/services/ErrorHandler.test.ts +++ b/src/shared/services/ErrorHandler.test.ts @@ -6,7 +6,7 @@ import type { LoggerService } from '@/infrastructure/logging/LoggerService'; describe('ErrorHandler', () => { let errorHandler: ErrorHandler; let testEventBus: EventBus; - let tracer: Pick; + let tracer: Pick; const resetHandler = () => { errorHandler.destroy(); @@ -18,7 +18,7 @@ describe('ErrorHandler', () => { beforeEach(() => { testEventBus = new EventBus(); tracer = { - info: vi.fn(), + debug: vi.fn(), warn: vi.fn(), error: vi.fn(), }; @@ -115,7 +115,6 @@ describe('ErrorHandler', () => { }); it('should capture error and return undefined on failure', async () => { - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression const result = await errorHandler.wrapAsync(async () => { await Promise.resolve(); // Ensure async throw new Error('Async error'); @@ -166,7 +165,6 @@ describe('ErrorHandler', () => { describe('wrapAsync edge cases', () => { it('should handle non-Error throw with no context (L189)', async () => { - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression const result = await errorHandler.wrapAsync(async () => { await Promise.resolve(); throw 42; // eslint-disable-line no-throw-literal, @typescript-eslint/only-throw-error diff --git a/src/shared/services/ErrorHandler.ts b/src/shared/services/ErrorHandler.ts index 4d7b4510..a4e30fe8 100644 --- a/src/shared/services/ErrorHandler.ts +++ b/src/shared/services/ErrorHandler.ts @@ -23,7 +23,7 @@ type ErrorCallback = (_error: IErrorInfo) => void; type ErrorHandlerDeps = { eventBus: EventBus; - tracer: Pick; + tracer: Pick; }; type UnhandledRejectionHandler = (event: PromiseRejectionEvent) => unknown; @@ -73,7 +73,7 @@ export class ErrorHandler { }; this._initialized = true; - this._deps.tracer.info('[ErrorHandler] Initialized'); + this._deps.tracer.debug('[ErrorHandler] Initialized'); } public destroy(): void { diff --git a/src/shared/services/ModulePlatformService.test.ts b/src/shared/services/ModulePlatformService.test.ts index 25f49537..418b0e76 100644 --- a/src/shared/services/ModulePlatformService.test.ts +++ b/src/shared/services/ModulePlatformService.test.ts @@ -5,14 +5,39 @@ import type { IApp } from '../types/coreTypes'; import type { AIBridge } from '@/features/ai/services/AIBridge'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; +const mocks = vi.hoisted(() => ({ + deleteEngine: vi.fn(), + invokeSafe: vi.fn(), +})); + +vi.mock('@/shared/types/bindings', () => ({ + commands: { + deleteEngine: (...args: unknown[]): unknown => mocks.deleteEngine(...args), + }, +})); + +vi.mock('@/shared/api/invoke', () => ({ + invokeSafe: (...args: unknown[]): unknown => mocks.invokeSafe(...args), +})); + function createMockModuleService(): ModuleService { return { downloadModule: vi.fn().mockResolvedValue(undefined), + getReleaseDownloadOptions: vi.fn().mockResolvedValue({ + releases: [{ tag_name: 'v1.0.0', name: '1.0.0', assets: [] }], + }), deleteModule: vi.fn().mockResolvedValue(true), control: vi.fn().mockResolvedValue(true), pauseDownload: vi.fn().mockResolvedValue(true), resumeDownload: vi.fn().mockResolvedValue(true), cancelDownload: vi.fn().mockResolvedValue(true), + checkInstalled: vi.fn().mockResolvedValue(true), + getStatus: vi.fn().mockResolvedValue('running'), + getDownloadState: vi.fn().mockReturnValue({ status: 'downloading', progress: 50 }), + importIntegrationFolder: vi.fn().mockResolvedValue('folder-module'), + importIntegrationArchive: vi.fn().mockResolvedValue('archive-module'), + importIntegrationPath: vi.fn().mockResolvedValue('path-module'), + importIntegrationUrl: vi.fn().mockResolvedValue('url-module'), } as unknown as ModuleService; } @@ -29,15 +54,16 @@ function createApp(overrides: Partial = {}): IApp { describe('ModulePlatformService', () => { let moduleService: ModuleService; let service: ModulePlatformService; - let aiBridge: Pick; + let aiBridge: Pick; let tracer: Pick; beforeEach(() => { moduleService = createMockModuleService(); aiBridge = { stopProvider: vi.fn(), + stopEngineSlot: vi.fn(), getState: vi.fn(() => ({ activeProviderId: 'test-module', isRunning: true })), - }; + } as unknown as Pick; tracer = { info: vi.fn() }; service = new ModulePlatformService(() => moduleService, aiBridge as AIBridge, tracer); vi.clearAllMocks(); @@ -52,6 +78,26 @@ describe('ModulePlatformService', () => { 'https://repo.com/module.zip', 'abc123', undefined, + undefined, + ); + }); + + it('passes release selection to release downloads', async () => { + const app = createApp({ dlType: 'release' }); + await service.download(app, { + tag_name: 'v1.2.3', + compute_target: 'gpu', + }); + + expect(moduleService.downloadModule).toHaveBeenCalledWith( + 'test-module', + 'https://repo.com/module.zip', + undefined, + 'release', + { + tag_name: 'v1.2.3', + compute_target: 'gpu', + }, ); }); @@ -71,6 +117,31 @@ describe('ModulePlatformService', () => { }); }); + describe('getReleaseDownloadOptions', () => { + it('returns null when app has no release source', async () => { + await expect( + service.getReleaseDownloadOptions(createApp({ dlType: 'archive' })), + ).resolves.toBeNull(); + await expect( + service.getReleaseDownloadOptions(createApp({ repoUrl: '', dlType: 'release' })), + ).resolves.toBeNull(); + }); + + it('loads release options for release downloads', async () => { + const result = await service.getReleaseDownloadOptions( + createApp({ dlType: 'release' }), + ); + + expect(moduleService.getReleaseDownloadOptions).toHaveBeenCalledWith( + 'test-module', + 'https://repo.com/module.zip', + ); + expect(result).toEqual({ + releases: [{ tag_name: 'v1.0.0', name: '1.0.0', assets: [] }], + }); + }); + }); + describe('delete', () => { it('should delete a module successfully', async () => { const app = createApp(); @@ -83,6 +154,83 @@ describe('ModulePlatformService', () => { const app = createApp(); await expect(service.delete(app)).rejects.toThrow('ui.launcher.web.delete_model_error'); }); + + it('should delete AI engines through the engine command', async () => { + mocks.deleteEngine.mockReturnValue('delete-engine-promise'); + mocks.invokeSafe.mockResolvedValue({ status: 'ok', data: null }); + const app = createApp({ id: 'llamacpp', type: 'local' }); + + await service.delete(app, 'ai_text'); + + expect(mocks.deleteEngine).toHaveBeenCalledWith('llamacpp'); + expect(mocks.invokeSafe).toHaveBeenCalledWith('delete-engine-promise'); + expect(moduleService.deleteModule).not.toHaveBeenCalled(); + }); + + it('throws AI engine command errors', async () => { + mocks.deleteEngine.mockReturnValue('delete-engine-promise'); + mocks.invokeSafe.mockResolvedValue({ + status: 'error', + error: { message: 'engine busy' }, + }); + + await expect(service.delete(createApp(), 'ai_image')).rejects.toThrow('engine busy'); + }); + + it('should skip deleting externally managed AI engines', async () => { + const app = createApp({ + id: 'external-engine', + type: 'local', + managedExternally: true, + }); + + await service.delete(app, 'ai_text'); + + expect(mocks.deleteEngine).not.toHaveBeenCalled(); + expect(moduleService.deleteModule).not.toHaveBeenCalled(); + }); + }); + + describe('integration imports', () => { + it('delegates folder imports to module service', async () => { + await expect(service.importIntegrationFolder('C:\\Integrations\\Parser')).resolves.toBe( + 'folder-module', + ); + + expect(moduleService.importIntegrationFolder).toHaveBeenCalledWith( + 'C:\\Integrations\\Parser', + ); + }); + + it('delegates archive imports to module service', async () => { + await expect( + service.importIntegrationArchive('C:\\Downloads\\Parser.zip'), + ).resolves.toBe('archive-module'); + + expect(moduleService.importIntegrationArchive).toHaveBeenCalledWith( + 'C:\\Downloads\\Parser.zip', + ); + }); + + it('delegates auto-detected path imports to module service', async () => { + await expect(service.importIntegrationPath('C:\\Downloads\\Parser')).resolves.toBe( + 'path-module', + ); + + expect(moduleService.importIntegrationPath).toHaveBeenCalledWith( + 'C:\\Downloads\\Parser', + ); + }); + + it('delegates URL imports to module service', async () => { + await expect( + service.importIntegrationUrl('https://github.com/F0RLE/Axelate-telegram-parser'), + ).resolves.toBe('url-module'); + + expect(moduleService.importIntegrationUrl).toHaveBeenCalledWith( + 'https://github.com/F0RLE/Axelate-telegram-parser', + ); + }); }); describe('stop', () => { @@ -141,6 +289,39 @@ describe('ModulePlatformService', () => { expect(aiBridge.stopProvider).toHaveBeenCalled(); expect(moduleService.control).not.toHaveBeenCalled(); }); + + it('should skip stopping externally managed local modules', async () => { + const app = createApp({ + id: 'external', + type: 'local', + managedExternally: true, + }); + (aiBridge.getState as ReturnType).mockReturnValue({ + activeProviderId: undefined, + isRunning: false, + }); + + const result = await service.stop(app); + + expect(result).toBe(true); + expect(moduleService.control).not.toHaveBeenCalled(); + }); + + it('should stop AI text and image engine slots by category', async () => { + (aiBridge.getState as ReturnType).mockReturnValue({ + activeProviderId: undefined, + isRunning: false, + }); + + await expect(service.stop(createApp({ id: 'llamacpp' }), 'ai_text')).resolves.toBe( + true, + ); + await expect(service.stop(createApp({ id: 'sdcpp' }), 'ai_image')).resolves.toBe(true); + + expect(aiBridge.stopEngineSlot).toHaveBeenNthCalledWith(1, 'text'); + expect(aiBridge.stopEngineSlot).toHaveBeenNthCalledWith(2, 'image'); + expect(moduleService.control).not.toHaveBeenCalled(); + }); }); describe('cancelDownload', () => { @@ -167,6 +348,59 @@ describe('ModulePlatformService', () => { }); }); + describe('status and download state', () => { + it('checks local installation through module service', async () => { + await expect(service.checkInstalled('llamacpp')).resolves.toBe(true); + + expect(moduleService.checkInstalled).toHaveBeenCalledWith('llamacpp'); + }); + + it('reports active API provider as running', async () => { + await expect(service.getStatus(createApp({ type: 'api' }))).resolves.toBe('running'); + }); + + it('reports inactive API provider as stopped without backend calls', async () => { + (aiBridge.getState as ReturnType).mockReturnValue({ + activeProviderId: 'other', + isRunning: true, + }); + + await expect(service.getStatus(createApp({ type: 'api' }))).resolves.toBe('stopped'); + + expect(moduleService.getStatus).not.toHaveBeenCalled(); + }); + + it('uses app status for externally managed modules', async () => { + (aiBridge.getState as ReturnType).mockReturnValue({ + activeProviderId: undefined, + isRunning: false, + }); + + await expect( + service.getStatus(createApp({ managedExternally: true, status: 'running' })), + ).resolves.toBe('running'); + await expect( + service.getStatus(createApp({ managedExternally: true, status: 'stopped' })), + ).resolves.toBe('stopped'); + }); + + it('delegates local module status and download state to module service', async () => { + (aiBridge.getState as ReturnType).mockReturnValue({ + activeProviderId: undefined, + isRunning: false, + }); + + await expect(service.getStatus(createApp({ id: 'ollama' }))).resolves.toBe('running'); + expect(service.getDownloadState('ollama')).toEqual({ + status: 'downloading', + progress: 50, + }); + + expect(moduleService.getStatus).toHaveBeenCalledWith('ollama'); + expect(moduleService.getDownloadState).toHaveBeenCalledWith('ollama'); + }); + }); + describe('isApiModule', () => { it('should return true for type "api"', () => { expect(service.isApiModule(createApp({ type: 'api' }))).toBe(true); diff --git a/src/shared/services/ModulePlatformService.ts b/src/shared/services/ModulePlatformService.ts index 131987b6..7db4e865 100644 --- a/src/shared/services/ModulePlatformService.ts +++ b/src/shared/services/ModulePlatformService.ts @@ -1,8 +1,16 @@ -import type { IApp, IModuleDownloadState } from '../types/coreTypes'; +import type { + IApp, + IModuleDownloadState, + ReleaseDownloadOptions, + ReleaseDownloadSelection, +} from '../types/coreTypes'; import type { DownloadModuleOutcome, ModuleService } from './ModuleService'; import type { AIBridge } from '@/features/ai/services/AIBridge'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; +import { invokeSafe } from '@/shared/api/invoke'; +import { commands } from '@/shared/types/bindings'; import { isApiApp } from '@/shared/utils/moduleTypeUtils'; +import { isAiCategory } from '@/shared/utils/moduleCategoryPolicy'; type ModulePlatformLogger = Pick; export type ModuleRuntimeStatus = 'running' | 'stopped' | string; @@ -27,7 +35,10 @@ export class ModulePlatformService { * Downloads a module. * @param app The module to download. */ - public async download(app: IApp): Promise { + public async download( + app: IApp, + releaseSelection?: ReleaseDownloadSelection | null, + ): Promise { this._tracer.info(`[ModulePlatformService] Downloading: ${app.id}`); if (app.repoUrl === undefined || app.repoUrl === '') { @@ -35,15 +46,54 @@ export class ModulePlatformService { } const url: string = app.repoUrl; - return await this._moduleService.downloadModule(app.id, url, app.expectedHash, app.dlType); + return await this._moduleService.downloadModule( + app.id, + url, + app.expectedHash, + app.dlType, + releaseSelection, + ); + } + + public async getReleaseDownloadOptions(app: IApp): Promise { + if (app.repoUrl === undefined || app.repoUrl === '' || app.dlType !== 'release') { + return null; + } + + return await this._moduleService.getReleaseDownloadOptions(app.id, app.repoUrl); + } + + public async importIntegrationFolder(path: string): Promise { + this._tracer.info(`[ModulePlatformService] Importing integration folder: ${path}`); + return await this._moduleService.importIntegrationFolder(path); + } + + public async importIntegrationArchive(path: string): Promise { + this._tracer.info(`[ModulePlatformService] Importing integration archive: ${path}`); + return await this._moduleService.importIntegrationArchive(path); + } + + public async importIntegrationPath(path: string): Promise { + this._tracer.info(`[ModulePlatformService] Importing integration path: ${path}`); + return await this._moduleService.importIntegrationPath(path); + } + + public async importIntegrationUrl(sourceUrl: string): Promise { + this._tracer.info(`[ModulePlatformService] Importing integration URL: ${sourceUrl}`); + return await this._moduleService.importIntegrationUrl(sourceUrl); } /** * Deletes a module. * @param app The module to delete. */ - public async delete(app: IApp): Promise { + public async delete(app: IApp, category?: string): Promise { this._tracer.info(`[ModulePlatformService] Deleting: ${app.id}`); + if (category !== undefined && isAiCategory(category)) { + await this._deleteAiEngine(app); + return; + } + const success = await this._moduleService.deleteModule(app.id); if (!success) { throw new Error('ui.launcher.web.delete_model_error'); @@ -54,7 +104,7 @@ export class ModulePlatformService { * Stops a running module or provider. * @param app The module to stop. */ - public async stop(app: IApp): Promise { + public async stop(app: IApp, category?: string): Promise { const isApi = this._isApiModule(app); const { activeProviderId, isRunning } = this._aiBridge.getState(); @@ -77,6 +127,11 @@ export class ModulePlatformService { return true; } + if (category !== undefined && isAiCategory(category)) { + await this._stopAiEngineSlot(category); + return true; + } + this._tracer.info(`[ModulePlatformService] Requesting stop for local module: ${app.id}`); return await this._moduleService.control(app.id, 'stop'); } @@ -152,4 +207,24 @@ export class ModulePlatformService { private _isApiModule(app: IApp): boolean { return isApiApp(app); } + + private async _stopAiEngineSlot(category: string): Promise { + const capability = category === 'ai_image' ? 'image' : 'text'; + this._tracer.info(`[ModulePlatformService] Force stopping AI engine slot: ${capability}`); + await this._aiBridge.stopEngineSlot(capability); + } + + private async _deleteAiEngine(app: IApp): Promise { + if (app.managedExternally === true) { + this._tracer.info( + `[ModulePlatformService] Skip delete for externally managed AI engine: ${app.id}`, + ); + return; + } + + const result = await invokeSafe(commands.deleteEngine(app.id)); + if (result.status === 'error') { + throw new Error(result.error.message); + } + } } diff --git a/src/shared/services/ModuleService.test.ts b/src/shared/services/ModuleService.test.ts index 3c85f377..a317ab22 100644 --- a/src/shared/services/ModuleService.test.ts +++ b/src/shared/services/ModuleService.test.ts @@ -5,6 +5,7 @@ import type { LoggerService } from '@/infrastructure/logging/LoggerService'; // TYPES type ProgressHandler = ((_: Record) => void) | undefined; +type DownloadProgressEvent = CustomEvent>; // HOISTED MOCKS const mocks = vi.hoisted(() => { @@ -15,9 +16,14 @@ const mocks = vi.hoisted(() => { getModuleStatus: vi.fn(), downloadModule: vi.fn(), deleteModule: vi.fn(), + controlModule: vi.fn(), pauseDownload: vi.fn().mockResolvedValue(true), resumeDownload: vi.fn(), cancelDownload: vi.fn().mockResolvedValue(true), + importIntegrationFolder: vi.fn(), + importIntegrationArchive: vi.fn(), + importIntegrationPath: vi.fn(), + importIntegrationUrl: vi.fn(), }, tauriProvider: { isTauri: vi.fn().mockReturnValue(true), @@ -66,8 +72,11 @@ describe('ModuleService', () => { mocks.commands.getModuleStatus.mockResolvedValue({ status: 'ok', data: 'running' }); mocks.commands.downloadModule.mockResolvedValue({ status: 'ok', data: null }); mocks.commands.deleteModule.mockResolvedValue({ status: 'ok', data: null }); + mocks.commands.importIntegrationFolder.mockReturnValue('import-folder-promise'); + mocks.commands.importIntegrationArchive.mockReturnValue('import-archive-promise'); + mocks.commands.importIntegrationPath.mockReturnValue('import-path-promise'); + mocks.commands.importIntegrationUrl.mockReturnValue('import-url-promise'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument moduleService = new ModuleService(mocks.tauriProvider as any, mocks.tracer); }); @@ -95,6 +104,22 @@ describe('ModuleService', () => { expect(mocks.tauriProvider.listen).toHaveBeenCalledTimes(1); }); + + it('should allow retry when progress listener registration fails', async () => { + mocks.tauriProvider.listen + .mockRejectedValueOnce(new Error('listen failed')) + .mockResolvedValueOnce(() => { + /* no-op */ + }); + + await expect(moduleService.init()).rejects.toThrow('listen failed'); + await moduleService.init(); + + expect(mocks.tauriProvider.listen).toHaveBeenCalledTimes(2); + expect(mocks.tracer.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to subscribe to download progress'), + ); + }); }); describe('checkInstalled', () => { @@ -167,6 +192,7 @@ describe('ModuleService', () => { 'https://repo.com/module', null, null, + null, ); expect(mocks.invokeSafe).toHaveBeenCalled(); }); @@ -180,15 +206,33 @@ describe('ModuleService', () => { }); it('should update state on error', async () => { + const progressSpy = vi.fn<(event: DownloadProgressEvent) => void>(); + const progressListener: EventListener = (event) => { + progressSpy(event as DownloadProgressEvent); + }; + globalThis.addEventListener('download-progress-update', progressListener); mocks.invokeSafe.mockResolvedValueOnce({ status: 'error', error: { message: 'Download failed' }, }); - await expect(moduleService.downloadModule('test-module', 'url')).rejects.toThrow(); - - const state = moduleService.getDownloadState('test-module'); - expect(state?.status).toBe('error'); + try { + await expect(moduleService.downloadModule('test-module', 'url')).rejects.toThrow(); + + const state = moduleService.getDownloadState('test-module'); + expect(state?.status).toBe('error'); + expect(state?.error).toBe('Download failed'); + expect(progressSpy).toHaveBeenCalledOnce(); + const event = progressSpy.mock.calls[0]?.[0]; + expect(event?.detail).toMatchObject({ + module_id: 'test-module', + status: 'error', + message: 'Download failed', + error: 'Download failed', + }); + } finally { + globalThis.removeEventListener('download-progress-update', progressListener); + } }); it('should return paused outcome without marking it as error', async () => { @@ -201,17 +245,20 @@ describe('ModuleService', () => { expect(moduleService.getDownloadState('test-module')?.status).not.toBe('error'); }); - it('should treat legacy paused errors as interrupted downloads', async () => { + it('should surface unexpected paused errors as failed downloads', async () => { mocks.invokeSafe.mockResolvedValueOnce({ status: 'error', error: { message: 'Download paused' }, }); - const result = await moduleService.downloadModule('test-module', 'url'); + await expect(moduleService.downloadModule('test-module', 'url')).rejects.toThrow( + 'Download paused', + ); - expect(result).toBe('paused'); - expect(mocks.tracer.error).not.toHaveBeenCalled(); - expect(moduleService.getDownloadState('test-module')?.status).not.toBe('error'); + expect(mocks.tracer.error).toHaveBeenCalledWith( + '[ModuleService] Download error for test-module: Download paused', + ); + expect(moduleService.getDownloadState('test-module')?.status).toBe('error'); }); }); @@ -246,19 +293,109 @@ describe('ModuleService', () => { }); }); + describe('integration imports', () => { + it('should invoke integration folder import command', async () => { + mocks.invokeSafe.mockResolvedValueOnce({ status: 'ok', data: 'folder-module' }); + + await expect( + moduleService.importIntegrationFolder('C:\\Integrations\\Parser'), + ).resolves.toBe('folder-module'); + + expect(mocks.commands.importIntegrationFolder).toHaveBeenCalledWith( + 'C:\\Integrations\\Parser', + ); + expect(mocks.invokeSafe).toHaveBeenCalledWith('import-folder-promise'); + }); + + it('should invoke integration archive import command', async () => { + mocks.invokeSafe.mockResolvedValueOnce({ status: 'ok', data: 'archive-module' }); + + await expect( + moduleService.importIntegrationArchive('C:\\Downloads\\Parser.zip'), + ).resolves.toBe('archive-module'); + + expect(mocks.commands.importIntegrationArchive).toHaveBeenCalledWith( + 'C:\\Downloads\\Parser.zip', + ); + expect(mocks.invokeSafe).toHaveBeenCalledWith('import-archive-promise'); + }); + + it('should invoke auto-detected integration path import command', async () => { + mocks.invokeSafe.mockResolvedValueOnce({ status: 'ok', data: 'path-module' }); + + await expect( + moduleService.importIntegrationPath('C:\\Downloads\\Parser'), + ).resolves.toBe('path-module'); + + expect(mocks.commands.importIntegrationPath).toHaveBeenCalledWith( + 'C:\\Downloads\\Parser', + ); + expect(mocks.invokeSafe).toHaveBeenCalledWith('import-path-promise'); + }); + + it('should invoke integration URL import command', async () => { + mocks.invokeSafe.mockResolvedValueOnce({ status: 'ok', data: 'url-module' }); + + await expect( + moduleService.importIntegrationUrl( + 'https://github.com/F0RLE/Axelate-telegram-parser', + ), + ).resolves.toBe('url-module'); + + expect(mocks.commands.importIntegrationUrl).toHaveBeenCalledWith( + 'https://github.com/F0RLE/Axelate-telegram-parser', + ); + expect(mocks.invokeSafe).toHaveBeenCalledWith('import-url-promise'); + }); + + it('should throw integration import errors', async () => { + mocks.invokeSafe.mockResolvedValueOnce({ + status: 'error', + error: { message: 'bad integration' }, + }); + + await expect(moduleService.importIntegrationPath('C:\\Broken')).rejects.toThrow( + 'bad integration', + ); + }); + + it('should throw integration imports in web mode', async () => { + mocks.tauriProvider.isTauri.mockReturnValueOnce(false); + + await expect( + moduleService.importIntegrationUrl('https://example.com/mod.zip'), + ).rejects.toThrow('Import available only in desktop app'); + }); + + it('should ask backend after importing the same module id', async () => { + mocks.invokeSafe.mockResolvedValueOnce({ status: 'ok', data: null }); + await moduleService.deleteModule('restored-module'); + + mocks.invokeSafe.mockResolvedValueOnce({ status: 'ok', data: 'restored-module' }); + await moduleService.importIntegrationPath('C:\\Restored'); + + mocks.invokeSafe.mockResolvedValueOnce({ status: 'ok', data: true }); + await expect(moduleService.checkInstalled('restored-module')).resolves.toBe(true); + + expect(mocks.commands.checkModuleInstalled).toHaveBeenCalledWith('restored-module'); + }); + }); + describe('control', () => { it('should invoke control_module command', async () => { - mocks.tauriProvider.invoke.mockResolvedValueOnce(undefined); + mocks.invokeSafe.mockResolvedValueOnce({ + status: 'ok', + data: { success: true, message: 'started', status: 'running' }, + }); const result = await moduleService.control('test-service', 'start'); expect(result).toBe(true); - expect(mocks.tauriProvider.invoke).toHaveBeenCalledWith('control_module', { - request: { - module_id: 'test-service', - action: 'start', - }, + expect(mocks.commands.controlModule).toHaveBeenCalledWith({ + module_id: 'test-service', + action: 'start', }); + expect(mocks.invokeSafe).toHaveBeenCalled(); }); it('should return false when not in Tauri', async () => { @@ -270,12 +407,26 @@ describe('ModuleService', () => { }); it('should return false on error', async () => { - mocks.tauriProvider.invoke.mockRejectedValueOnce(new Error('Control failed')); + mocks.invokeSafe.mockResolvedValueOnce({ + status: 'error', + error: { message: 'Control failed' }, + }); const result = await moduleService.control('test-service', 'start'); expect(result).toBe(false); }); + + it('should return false when backend reports unsuccessful control response', async () => { + mocks.invokeSafe.mockResolvedValueOnce({ + status: 'ok', + data: { success: false, message: 'not implemented', status: null }, + }); + + const result = await moduleService.control('test-service', 'restart'); + + expect(result).toBe(false); + }); }); describe('getDownloadState', () => { @@ -317,27 +468,42 @@ describe('ModuleService', () => { }); it('should process progress payload correctly', async () => { + const progressSpy = vi.fn<(event: DownloadProgressEvent) => void>(); + const progressListener: EventListener = (event) => { + progressSpy(event as DownloadProgressEvent); + }; + globalThis.addEventListener('download-progress-update', progressListener); // Use ref pattern with module-level helper const handlerRef: { current: ProgressHandler } = { current: undefined }; mocks.tauriProvider.listen.mockImplementation(createListenCapture(handlerRef)); - await moduleService.init(); - - // Call handler with test data - handlerRef.current?.({ - module_id: 'test-module', - status: 'downloading', - progress: 0.5, - message: 'Downloading...', - downloaded: 50, - total: 100, - speed: 4096, - }); - - const state = moduleService.getDownloadState('test-module'); - expect(state?.status).toBe('downloading'); - expect(state?.progress).toBe(0.5); - expect(state?.speed).toBe(4096); + try { + await moduleService.init(); + + // Call handler with test data + handlerRef.current?.({ + module_id: 'test-module', + status: 'downloading', + progress: 0.5, + message: 'Downloading...', + downloaded: 50, + total: 100, + speed: 4096, + }); + + const state = moduleService.getDownloadState('test-module'); + expect(state?.status).toBe('downloading'); + expect(state?.progress).toBe(0.5); + expect(state?.speed).toBe(4096); + expect(progressSpy).toHaveBeenCalledOnce(); + const event = progressSpy.mock.calls[0]?.[0]; + expect(event?.detail).toMatchObject({ + module_id: 'test-module', + status: 'downloading', + }); + } finally { + globalThis.removeEventListener('download-progress-update', progressListener); + } }); it('should set progress to 1 on complete', async () => { @@ -435,6 +601,7 @@ describe('ModuleService', () => { 'https://repo.com', 'abc123', null, + null, ); }); @@ -446,6 +613,7 @@ describe('ModuleService', () => { 'https://repo.com', null, null, + null, ); }); }); @@ -460,13 +628,14 @@ describe('ModuleService', () => { expect(result).toBe(false); }); - it('should return false for deleted module', async () => { - // First delete the module + it('should ask backend after deleting a module so external restores are detected', async () => { mocks.invokeSafe.mockResolvedValueOnce({ status: 'ok' }); await moduleService.deleteModule('del-mod'); - // checkInstalled should short-circuit + + mocks.invokeSafe.mockResolvedValueOnce({ status: 'ok', data: true }); const result = await moduleService.checkInstalled('del-mod'); - expect(result).toBe(false); + expect(result).toBe(true); + expect(mocks.commands.checkModuleInstalled).toHaveBeenCalledWith('del-mod'); }); }); @@ -479,6 +648,7 @@ describe('ModuleService', () => { 'https://repo.com', null, null, + null, ); }); diff --git a/src/shared/services/ModuleService.ts b/src/shared/services/ModuleService.ts index 4bc0fd23..462a8dd7 100644 --- a/src/shared/services/ModuleService.ts +++ b/src/shared/services/ModuleService.ts @@ -5,7 +5,11 @@ import { type IBridge } from '@/shared/types/IBridge'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; -import type { IModuleDownloadState } from '../types/coreTypes'; +import type { + IModuleDownloadState, + ReleaseDownloadOptions, + ReleaseDownloadSelection, +} from '../types/coreTypes'; import { commands } from '../types/bindings'; import { invokeSafe } from '../api/invoke'; @@ -14,7 +18,6 @@ export type DownloadModuleOutcome = 'completed' | 'paused' | 'cancelled'; export class ModuleService { private readonly _downloadState: Record = {}; - private readonly _deletedModules = new Set(); private readonly _lastLoggedDownloadPhase = new Map(); private _downloadProgressUnlisten: (() => void) | null = null; private _initialized = false; @@ -29,46 +32,29 @@ export class ModuleService { */ public async init() { if (this._initialized) return; - this._initialized = true; if (!this._bridge.isTauri()) return; - this._downloadProgressUnlisten = await this._bridge.listen<{ - module_id: string; - status: string; - progress: number; - message: string; - downloaded: number; - total: number; - speed: number; - }>('download_progress', (payload) => { - this._logDownloadPhase(payload); - - this._downloadState[payload.module_id] = { - status: payload.status as - | 'init' - | 'pending' - | 'connecting' - | 'downloading' - | 'verifying' - | 'extracting' - | 'paused' - | 'complete' - | 'error' - | 'cancelled', - progress: payload.progress, - message: payload.message, - downloaded: payload.downloaded, - total: payload.total, - speed: payload.speed, - }; - - if (payload.status === 'complete') { - (this._downloadState[payload.module_id] as { progress: number }).progress = 1; - } - // Dispatch custom event for UI components that don't use this service directly - const event = new CustomEvent('download-progress-update', { detail: payload }); - globalThis.dispatchEvent(event); - }); + try { + this._downloadProgressUnlisten = await this._bridge.listen<{ + module_id: string; + status: string; + progress: number; + message: string; + downloaded: number; + total: number; + speed: number; + }>('download_progress', (payload) => { + this._logDownloadPhase(payload); + this._publishDownloadProgress(payload); + }); + this._initialized = true; + } catch (error) { + this._initialized = false; + this._tracer.error( + `[ModuleService] Failed to subscribe to download progress: ${String(error)}`, + ); + throw error; + } } /** @@ -86,7 +72,6 @@ export class ModuleService { */ public async checkInstalled(moduleId: string): Promise { if (!this._bridge.isTauri()) return false; - if (this._deletedModules.has(moduleId)) return false; try { // Updated to use new API layer @@ -132,6 +117,7 @@ export class ModuleService { repoUrl: string, expectedHash?: string, dlType?: string, + releaseSelection?: ReleaseDownloadSelection | null, ): Promise { this._tracer.info(`[ModuleService] Downloading module: ${moduleId} from ${repoUrl}`); if (expectedHash !== undefined && expectedHash !== '') { @@ -143,52 +129,116 @@ export class ModuleService { } try { - this._deletedModules.delete(moduleId); // Sanitize expectedHash: pass null if empty string or undefined to ensure rust gets None const hashToPass = expectedHash !== undefined && expectedHash.trim() !== '' ? expectedHash : null; // Updated to use new API layer const result = await invokeSafe( - commands.downloadModule(moduleId, repoUrl, hashToPass, dlType ?? null), + commands.downloadModule( + moduleId, + repoUrl, + hashToPass, + dlType ?? null, + releaseSelection ?? null, + ), ); if (result.status === 'error') { - const interrupted = this._downloadOutcomeFromError(result.error.message); - if (interrupted !== null) { - return interrupted; - } throw new Error(result.error.message); } return this._normalizeDownloadOutcome(result.data); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); - const interrupted = this._downloadOutcomeFromError(errorMessage); - if (interrupted !== null) { - return interrupted; - } this._tracer.error(`[ModuleService] Download error for ${moduleId}: ${errorMessage}`); - this._downloadState[moduleId] = { status: 'error', progress: 0, error: errorMessage }; + this._publishDownloadProgress({ + module_id: moduleId, + status: 'error', + progress: 0, + message: errorMessage, + downloaded: 0, + total: 0, + speed: 0, + error: errorMessage, + }); throw err; } } - private _normalizeDownloadOutcome(value: unknown): DownloadModuleOutcome { - if (value === 'paused' || value === 'cancelled' || value === 'completed') { - return value; + public async getReleaseDownloadOptions( + moduleId: string, + repoUrl: string, + ): Promise { + if (!this._bridge.isTauri()) return null; + + try { + const result = await invokeSafe(commands.getReleaseDownloadOptions(moduleId, repoUrl)); + if (result.status === 'ok') { + return result.data as ReleaseDownloadOptions; + } + this._tracer.warn( + `[ModuleService] Release options failed for ${moduleId}: ${result.error.message}`, + ); + throw new Error(result.error.message); + } catch (err) { + this._tracer.error(`[ModuleService] Release options error: ${String(err)}`); + throw err; } - return 'completed'; } - private _downloadOutcomeFromError(message: string): DownloadModuleOutcome | null { - const normalized = message.toLowerCase(); - if (normalized.includes('download paused')) { - return 'paused'; + public async importIntegrationFolder(path: string): Promise { + if (!this._bridge.isTauri()) { + throw new Error('Import available only in desktop app'); } - if (normalized.includes('download cancelled')) { - return 'cancelled'; + + const result = await invokeSafe(commands.importIntegrationFolder(path)); + if (result.status === 'error') { + throw new Error(result.error.message); + } + return result.data; + } + + public async importIntegrationArchive(path: string): Promise { + if (!this._bridge.isTauri()) { + throw new Error('Import available only in desktop app'); + } + + const result = await invokeSafe(commands.importIntegrationArchive(path)); + if (result.status === 'error') { + throw new Error(result.error.message); } - return null; + return result.data; + } + + public async importIntegrationPath(path: string): Promise { + if (!this._bridge.isTauri()) { + throw new Error('Import available only in desktop app'); + } + + const result = await invokeSafe(commands.importIntegrationPath(path)); + if (result.status === 'error') { + throw new Error(result.error.message); + } + return result.data; + } + + public async importIntegrationUrl(sourceUrl: string): Promise { + if (!this._bridge.isTauri()) { + throw new Error('Import available only in desktop app'); + } + + const result = await invokeSafe(commands.importIntegrationUrl(sourceUrl)); + if (result.status === 'error') { + throw new Error(result.error.message); + } + return result.data; + } + + private _normalizeDownloadOutcome(value: unknown): DownloadModuleOutcome { + if (value === 'paused' || value === 'cancelled' || value === 'completed') { + return value; + } + return 'completed'; } /** @@ -261,8 +311,6 @@ export class ModuleService { return false; } - this._deletedModules.add(moduleId); - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete this._downloadState[moduleId]; return true; } catch (e) { @@ -281,13 +329,17 @@ export class ModuleService { this._tracer.info(`[ModuleService] Control ${serviceName} -> ${action}`); if (this._bridge.isTauri()) { try { - await this._bridge.invoke('control_module', { - request: { + const result = await invokeSafe( + commands.controlModule({ module_id: serviceName, action: action.toLowerCase(), - }, - }); - return true; + }), + ); + if (result.status === 'error') { + this._tracer.error(`[ModuleService] Control failed: ${result.error.message}`); + return false; + } + return result.data.success === true; } catch (e) { this._tracer.error(`[ModuleService] Control failed: ${String(e)}`); return false; @@ -334,4 +386,29 @@ export class ModuleService { this._lastLoggedDownloadPhase.delete(payload.module_id); } } + + private _publishDownloadProgress(payload: { + module_id: string; + status: string; + progress: number; + message?: string; + downloaded?: number; + total?: number; + speed?: number; + error?: unknown; + }): void { + const state: IModuleDownloadState = { + status: payload.status as IModuleDownloadState['status'], + progress: payload.status === 'complete' ? 1 : payload.progress, + }; + if (payload.message !== undefined) state.message = payload.message; + if (payload.downloaded !== undefined) state.downloaded = payload.downloaded; + if (payload.total !== undefined) state.total = payload.total; + if (payload.speed !== undefined) state.speed = payload.speed; + if (payload.error !== undefined) state.error = payload.error; + + this._downloadState[payload.module_id] = state; + + globalThis.dispatchEvent(new CustomEvent('download-progress-update', { detail: payload })); + } } diff --git a/src/shared/services/StateManager.test.ts b/src/shared/services/StateManager.test.ts new file mode 100644 index 00000000..dd37bd88 --- /dev/null +++ b/src/shared/services/StateManager.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { StateManager, type StatePersistenceTarget } from './StateManager'; + +function createTarget(name = 'target'): StatePersistenceTarget { + return { + name, + saveAsync: vi.fn().mockResolvedValue(undefined), + saveImmediate: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('StateManager', () => { + const tracer = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + it('registers, unregisters, and skips empty saves', async () => { + const manager = new StateManager(tracer); + const target = createTarget('cache'); + + await manager.saveAllAsync(); + await manager.saveAllImmediate(); + manager.register(target); + manager.unregister('cache'); + await manager.saveAllAsync(); + await manager.saveAllImmediate(); + + expect(target.saveAsync).not.toHaveBeenCalled(); + expect(target.saveImmediate).not.toHaveBeenCalled(); + expect(tracer.debug).toHaveBeenCalledWith('[StateManager] Registered: cache'); + }); + + it('saves all async targets and logs failures', async () => { + const manager = new StateManager(tracer); + const ok = createTarget('ok'); + const failing = createTarget('failing'); + vi.mocked(failing.saveAsync).mockRejectedValueOnce(new Error('disk full')); + manager.register(ok); + manager.register(failing); + + await manager.saveAllAsync(); + + expect(ok.saveAsync).toHaveBeenCalledOnce(); + expect(failing.saveAsync).toHaveBeenCalledOnce(); + expect(tracer.warn).toHaveBeenCalledWith( + '[StateManager] Failed to save failing: Error: disk full', + ); + expect(tracer.info).toHaveBeenCalledWith('[StateManager] Save complete: 1 ok, 1 failed'); + }); + + it('should flush registered targets before destroy disables saving', async () => { + const manager = new StateManager(tracer); + const target = createTarget(); + manager.register(target); + + await manager.destroy(); + + expect(target.saveImmediate).toHaveBeenCalledTimes(1); + }); + + it('should await immediate saves', async () => { + const manager = new StateManager(tracer); + let settled = false; + let release!: () => void; + const target: StatePersistenceTarget = { + name: 'slow-target', + saveAsync: vi.fn().mockResolvedValue(undefined), + saveImmediate: vi.fn( + () => + new Promise((resolve) => { + release = () => { + settled = true; + resolve(); + }; + }), + ), + }; + manager.register(target); + + const save = manager.saveAllImmediate(); + await Promise.resolve(); + + expect(settled).toBe(false); + release(); + await save; + expect(settled).toBe(true); + }); + + it('logs immediate save failures without rejecting', async () => { + const manager = new StateManager(tracer); + const target = createTarget('window'); + vi.mocked(target.saveImmediate).mockRejectedValueOnce('locked'); + manager.register(target); + + await expect(manager.saveAllImmediate()).resolves.toBeUndefined(); + + expect(tracer.warn).toHaveBeenCalledWith( + '[StateManager] Immediate save failed for window: locked', + ); + }); + + it('saves on visibility hidden and beforeunload', async () => { + const manager = new StateManager(tracer); + const target = createTarget(); + manager.register(target); + manager.init(); + + Object.defineProperty(document, 'hidden', { + configurable: true, + value: true, + }); + document.dispatchEvent(new Event('visibilitychange')); + await Promise.resolve(); + await Promise.resolve(); + globalThis.dispatchEvent(new Event('beforeunload')); + await Promise.resolve(); + await Promise.resolve(); + + expect(target.saveAsync).toHaveBeenCalledOnce(); + expect(target.saveImmediate).toHaveBeenCalledOnce(); + + await manager.destroy(); + }); + + it('does not register duplicate global listeners on repeated init', async () => { + const manager = new StateManager(tracer); + const target = createTarget(); + manager.register(target); + manager.init(); + manager.init(); + + Object.defineProperty(document, 'hidden', { + configurable: true, + value: true, + }); + document.dispatchEvent(new Event('visibilitychange')); + await Promise.resolve(); + await Promise.resolve(); + + expect(target.saveAsync).toHaveBeenCalledOnce(); + expect(tracer.debug).toHaveBeenCalledWith('[StateManager] Global listeners registered'); + expect(tracer.debug).toHaveBeenCalledTimes(2); + + await manager.destroy(); + }); + + it('does not bind global listeners after destroy', async () => { + const manager = new StateManager(tracer); + const target = createTarget(); + manager.register(target); + await manager.destroy(); + + manager.init(); + globalThis.dispatchEvent(new Event('beforeunload')); + await Promise.resolve(); + + expect(target.saveImmediate).toHaveBeenCalledOnce(); + }); + + it('does not save on visibility visible and removes listeners on destroy', async () => { + const manager = new StateManager(tracer); + const target = createTarget(); + manager.register(target); + manager.init(); + + Object.defineProperty(document, 'hidden', { + configurable: true, + value: false, + }); + document.dispatchEvent(new Event('visibilitychange')); + await Promise.resolve(); + await manager.destroy(); + globalThis.dispatchEvent(new Event('beforeunload')); + document.dispatchEvent(new Event('visibilitychange')); + await Promise.resolve(); + + expect(target.saveAsync).not.toHaveBeenCalled(); + expect(target.saveImmediate).toHaveBeenCalledOnce(); + await expect(manager.destroy()).resolves.toBeUndefined(); + }); + + it('should not save targets registered after destroy', async () => { + const manager = new StateManager(tracer); + await manager.destroy(); + const target = createTarget(); + + manager.register(target); + await manager.saveAllAsync(); + await manager.saveAllImmediate(); + + expect(target.saveAsync).not.toHaveBeenCalled(); + expect(target.saveImmediate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/services/StateManager.ts b/src/shared/services/StateManager.ts index 4e3efb8f..2fdbaf79 100644 --- a/src/shared/services/StateManager.ts +++ b/src/shared/services/StateManager.ts @@ -14,19 +14,20 @@ import type { LoggerService } from '@/infrastructure/logging/LoggerService'; -type StateManagerLogger = Pick; +type StateManagerLogger = Pick; export interface StatePersistenceTarget { /** Human-readable name for logging */ name: string; /** Async save — used for debounced/visibility saves */ saveAsync: () => Promise; - /** Sync save — used for beforeunload (fire-and-forget ok) */ - saveImmediate: () => void; + /** Immediate save — used for explicit close/destroy and beforeunload best effort */ + saveImmediate: () => Promise | void; } export class StateManager { private readonly _targets = new Map(); + private _isInitialized = false; private _isDestroyed = false; constructor(private readonly _tracer: StateManagerLogger) {} @@ -38,7 +39,7 @@ export class StateManager { }; private readonly _boundBeforeUnload = () => { - this.saveAllImmediate(); + void this.saveAllImmediate(); }; /** @@ -47,7 +48,7 @@ export class StateManager { register(target: StatePersistenceTarget): void { if (this._isDestroyed) return; this._targets.set(target.name, target); - this._tracer.info(`[StateManager] Registered: ${target.name}`); + this._tracer.debug(`[StateManager] Registered: ${target.name}`); } /** @@ -95,10 +96,10 @@ export class StateManager { } /** - * Save ALL registered targets immediately (fire-and-forget). - * Called on beforeunload — no await, best effort. + * Save ALL registered targets immediately. + * Called on beforeunload as best effort and awaited by explicit shutdown paths. */ - saveAllImmediate(): void { + async saveAllImmediate(): Promise { if (this._isDestroyed) return; const targets = [...this._targets.values()]; @@ -106,16 +107,19 @@ export class StateManager { this._tracer.info(`[StateManager] Saving ${String(targets.length)} targets (immediate)...`); - // Fire all saves — no await, best effort before page unloads - for (const target of targets) { - try { - target.saveImmediate(); - } catch (e) { + const results = await Promise.allSettled( + targets.map(async (target) => { + await target.saveImmediate(); + }), + ); + + results.forEach((result, index) => { + if (result.status === 'rejected') { this._tracer.warn( - `[StateManager] Immediate save failed for ${target.name}: ${String(e)}`, + `[StateManager] Immediate save failed for ${targets[index]?.name}: ${String(result.reason)}`, ); } - } + }); } /** @@ -123,25 +127,31 @@ export class StateManager { * Call once during app bootstrap. */ init(): void { + if (this._isDestroyed || this._isInitialized) { + return; + } + + this._isInitialized = true; document.addEventListener('visibilitychange', this._boundVisibilityChange); globalThis.addEventListener('beforeunload', this._boundBeforeUnload); - this._tracer.info('[StateManager] Global listeners registered'); + this._tracer.debug('[StateManager] Global listeners registered'); } /** * Clean up all listeners and targets. */ - destroy(): void { + async destroy(): Promise { if (this._isDestroyed) return; - this._isDestroyed = true; // Final save before destroy - this.saveAllImmediate(); + await this.saveAllImmediate(); + this._isDestroyed = true; document.removeEventListener('visibilitychange', this._boundVisibilityChange); globalThis.removeEventListener('beforeunload', this._boundBeforeUnload); this._targets.clear(); + this._isInitialized = false; this._tracer.info('[StateManager] Destroyed'); } } diff --git a/src/shared/services/WindowNativeBridgeHelper.test.ts b/src/shared/services/WindowNativeBridgeHelper.test.ts index 05ba3180..39b294f6 100644 --- a/src/shared/services/WindowNativeBridgeHelper.test.ts +++ b/src/shared/services/WindowNativeBridgeHelper.test.ts @@ -4,9 +4,11 @@ import { WindowNativeBridgeHelper } from './WindowNativeBridgeHelper'; describe('WindowNativeBridgeHelper', () => { const invoke = vi.fn().mockResolvedValue(undefined); - const bridge = { invoke } as unknown as IBridge; + const isTauri = vi.fn().mockReturnValue(true); + const bridge = { invoke, isTauri } as unknown as IBridge; const runtime = { - getWindowApi: vi.fn(), + getCurrentWindow: vi.fn(), + createLogicalSize: vi.fn((width: number, height: number) => ({ width, height })), }; const helper = new WindowNativeBridgeHelper( bridge, @@ -15,6 +17,7 @@ describe('WindowNativeBridgeHelper', () => { beforeEach(() => { vi.clearAllMocks(); + isTauri.mockReturnValue(true); }); it('should set size, read maximize state and persist window state', async () => { @@ -23,21 +26,16 @@ describe('WindowNativeBridgeHelper', () => { const isMaximized = vi.fn().mockResolvedValue(false); const innerSize = vi.fn().mockResolvedValue({ width: 1280, height: 720 }); const outerPosition = vi.fn().mockResolvedValue({ x: 10, y: 20 }); - const LogicalSize = vi.fn(); - - runtime.getWindowApi.mockReturnValue({ - getCurrentWindow: () => ({ - setSize, - center, - isMaximized, - innerSize, - outerPosition, - }), - LogicalSize, + runtime.getCurrentWindow.mockReturnValue({ + setSize, + center, + isMaximized, + innerSize, + outerPosition, }); await helper.setSizeAndCenter(800, 600); - expect(setSize).toHaveBeenCalled(); + expect(setSize).toHaveBeenCalledWith({ width: 800, height: 600 }); expect(center).toHaveBeenCalled(); expect(await helper.isMaximized()).toBe(false); @@ -49,7 +47,7 @@ describe('WindowNativeBridgeHelper', () => { }); it('should gracefully no-op when runtime window api is unavailable', async () => { - runtime.getWindowApi.mockReturnValue(null); + isTauri.mockReturnValue(false); await expect(helper.setSizeAndCenter(800, 600)).resolves.toBeUndefined(); await expect(helper.isMaximized()).resolves.toBe(false); diff --git a/src/shared/services/WindowNativeBridgeHelper.ts b/src/shared/services/WindowNativeBridgeHelper.ts index 194ec9b5..e1266275 100644 --- a/src/shared/services/WindowNativeBridgeHelper.ts +++ b/src/shared/services/WindowNativeBridgeHelper.ts @@ -1,37 +1,20 @@ import type { IBridge } from '@/shared/types/IBridge'; +import { getCurrentWindow, LogicalSize } from '@tauri-apps/api/window'; -type WindowSize = { width: number; height: number }; -type WindowPosition = { x: number; y: number }; - -type TauriWindowHandle = { - setSize: (size: unknown) => Promise; - center: () => Promise; - isMaximized: () => Promise; - innerSize: () => Promise; - outerPosition: () => Promise; -}; - -type TauriWindowApi = { - getCurrentWindow: () => TauriWindowHandle; - LogicalSize: new (w: number, h: number) => unknown; -}; - -type TauriWindowGlobal = { - __TAURI__?: { - window?: TauriWindowApi; - }; -}; +type TauriWindowHandle = ReturnType; type WindowNativeRuntime = { - getWindowApi: () => TauriWindowApi | null; + getCurrentWindow: () => TauriWindowHandle; + createLogicalSize: ( + width: number, + height: number, + ) => Parameters[0]; }; function createDefaultWindowNativeRuntime(): WindowNativeRuntime { return { - getWindowApi: () => { - const globalWindow = globalThis as unknown as TauriWindowGlobal; - return globalWindow.__TAURI__?.window ?? null; - }, + getCurrentWindow, + createLogicalSize: (width, height) => new LogicalSize(width, height), }; } @@ -42,35 +25,34 @@ export class WindowNativeBridgeHelper { ) {} public isAvailable(): boolean { - return this._getWindowApi() !== null; + return this._bridge.isTauri(); } public async setSizeAndCenter(width: number, height: number): Promise { - const windowApi = this._getWindowApi(); - if (windowApi === null) { + if (!this.isAvailable()) { return; } - const appWindow = windowApi.getCurrentWindow(); - await appWindow.setSize(new windowApi.LogicalSize(width, height)); + const appWindow = this._runtime.getCurrentWindow(); + await appWindow.setSize(this._runtime.createLogicalSize(width, height)); await appWindow.center(); } public async isMaximized(): Promise { - const appWindow = this._getCurrentWindow(); - if (appWindow === null) { + if (!this.isAvailable()) { return false; } + const appWindow = this._runtime.getCurrentWindow(); return await appWindow.isMaximized(); } public async saveWindowState(): Promise { - const appWindow = this._getCurrentWindow(); - if (appWindow === null) { + if (!this.isAvailable()) { return; } + const appWindow = this._runtime.getCurrentWindow(); const maximized = await appWindow.isMaximized(); await this._bridge.invoke('save_maximized_state', { maximized }); @@ -90,17 +72,4 @@ export class WindowNativeBridgeHelper { y: position.y, }); } - - private _getCurrentWindow(): TauriWindowHandle | null { - const windowApi = this._getWindowApi(); - if (windowApi === null) { - return null; - } - - return windowApi.getCurrentWindow(); - } - - private _getWindowApi(): TauriWindowApi | null { - return this._runtime.getWindowApi(); - } } diff --git a/src/shared/services/WindowService.test.ts b/src/shared/services/WindowService.test.ts index 67c7ac75..4138dbe8 100644 --- a/src/shared/services/WindowService.test.ts +++ b/src/shared/services/WindowService.test.ts @@ -6,6 +6,23 @@ import { WindowService, type IWindowConfig } from './WindowService'; import type { IBridge } from '@/shared/types/IBridge'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => { + const windowApi = ( + globalThis as unknown as { + __AXELATE_TEST_WINDOW__?: { getCurrentWindow?: () => unknown }; + } + ).__AXELATE_TEST_WINDOW__; + return windowApi?.getCurrentWindow?.(); + }, + LogicalSize: class { + public constructor( + public readonly width: number, + public readonly height: number, + ) {} + }, +})); + describe('WindowService', () => { let mockBridge: { isTauri: ReturnType; @@ -18,12 +35,11 @@ describe('WindowService', () => { getResolutionZoom: ReturnType; setResolutionZoom: ReturnType; }; - let mockTracer: Pick; + let mockTracer: Pick; let service: WindowService; let mockRuntime: { addEventListener: ReturnType; removeEventListener: ReturnType; - close: ReturnType; getScreenSize: ReturnType; getInnerSize: ReturnType; setAppZoomCss: ReturnType; @@ -57,6 +73,7 @@ describe('WindowService', () => { }; mockTracer = { + debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), @@ -73,7 +90,6 @@ describe('WindowService', () => { nativeRemoveEventListener(...args); }, ), - close: vi.fn(), getScreenSize: vi.fn().mockReturnValue({ width: 1920, height: 1080 }), getInnerSize: vi.fn().mockReturnValue({ width: 1920, height: 1080 }), setAppZoomCss: vi.fn(), @@ -204,9 +220,12 @@ describe('WindowService', () => { expect(mockBridge.invoke).toHaveBeenCalledWith('minimize_window'); }); - it('should log in web mode for minimize', async () => { + it('should skip native minimize outside Tauri', async () => { mockBridge.isTauri.mockReturnValue(false); await service.minimize(); // should not throw + expect(mockTracer.info).toHaveBeenCalledWith( + '[WindowService] Native minimize unavailable outside Tauri', + ); }); it('should call maximize_window via bridge', async () => { @@ -214,9 +233,12 @@ describe('WindowService', () => { expect(mockBridge.invoke).toHaveBeenCalledWith('maximize_window'); }); - it('should log in web mode for toggleMaximize', async () => { + it('should skip native maximize outside Tauri', async () => { mockBridge.isTauri.mockReturnValue(false); await service.toggleMaximize(); + expect(mockTracer.info).toHaveBeenCalledWith( + '[WindowService] Native maximize unavailable outside Tauri', + ); }); it('should call close_window via bridge', async () => { @@ -240,10 +262,12 @@ describe('WindowService', () => { expect(calls).toEqual(['save', 'close']); }); - it('should call globalThis.close in web mode', async () => { + it('should skip native close outside Tauri', async () => { mockBridge.isTauri.mockReturnValue(false); await service.close(); - expect(mockRuntime.close).toHaveBeenCalled(); + expect(mockTracer.info).toHaveBeenCalledWith( + '[WindowService] Native close unavailable outside Tauri', + ); }); }); @@ -265,9 +289,12 @@ describe('WindowService', () => { expect(mockBridge.invoke).toHaveBeenCalledWith('minimize_window'); }); - it('should log in web mode', async () => { + it('should skip native hide-to-tray outside Tauri', async () => { mockBridge.isTauri.mockReturnValue(false); await service.hideToTray(); // should not throw + expect(mockTracer.info).toHaveBeenCalledWith( + '[WindowService] Native hide-to-tray unavailable outside Tauri', + ); }); }); @@ -475,6 +502,45 @@ describe('WindowService', () => { await service.setMonitoringPaused(true); expect(mockBridge.invoke).not.toHaveBeenCalled(); }); + + it('should skip duplicate monitoring pause states', async () => { + await service.setMonitoringPauseReason('window-inactive', true); + await service.setMonitoringPauseReason('window-inactive', true); + + expect(mockBridge.invoke).toHaveBeenCalledTimes(1); + expect(mockBridge.invoke).toHaveBeenCalledWith('set_monitoring_paused', { + paused: true, + }); + }); + + it('should aggregate monitoring pause reasons before resuming', async () => { + await service.setMonitoringPauseReason('window-inactive', true); + await service.setMonitoringPauseReason('monitor-hidden', true); + await service.setMonitoringPauseReason('window-inactive', false); + await service.setMonitoringPauseReason('monitor-hidden', false); + + expect(mockBridge.invoke).toHaveBeenCalledTimes(2); + expect(mockBridge.invoke).toHaveBeenNthCalledWith(1, 'set_monitoring_paused', { + paused: true, + }); + expect(mockBridge.invoke).toHaveBeenNthCalledWith(2, 'set_monitoring_paused', { + paused: false, + }); + }); + + it('should retry the same monitoring state after a failed apply', async () => { + mockBridge.invoke + .mockRejectedValueOnce(new Error('temporary failure')) + .mockResolvedValueOnce(undefined); + + await service.setMonitoringPauseReason('window-inactive', true); + await service.setMonitoringPauseReason('window-inactive', true); + + expect(mockBridge.invoke).toHaveBeenCalledTimes(2); + expect(mockTracer.error).toHaveBeenCalledWith( + '[WindowService] Failed to set monitoring state', + ); + }); }); // ---------------------------------------------------------- checkPolicy @@ -550,14 +616,12 @@ describe('WindowService', () => { const mockCenter = vi.fn().mockResolvedValue(undefined); const mockLogicalSize = vi.fn(); - vi.stubGlobal('__TAURI__', { - window: { - getCurrentWindow: () => ({ - setSize: mockSetSize, - center: mockCenter, - }), + vi.stubGlobal('__AXELATE_TEST_WINDOW__', { + getCurrentWindow: () => ({ + setSize: mockSetSize, + center: mockCenter, LogicalSize: mockLogicalSize, - }, + }), }); await service.setSize(800, 600); @@ -567,14 +631,12 @@ describe('WindowService', () => { }); it('should handle error gracefully', async () => { - vi.stubGlobal('__TAURI__', { - window: { - getCurrentWindow: () => ({ - setSize: vi.fn().mockRejectedValue(new Error('fail')), - center: vi.fn(), - }), + vi.stubGlobal('__AXELATE_TEST_WINDOW__', { + getCurrentWindow: () => ({ + setSize: vi.fn().mockRejectedValue(new Error('fail')), + center: vi.fn(), LogicalSize: vi.fn(), - }, + }), }); await service.setSize(800, 600); // should not throw @@ -585,8 +647,8 @@ describe('WindowService', () => { await service.setSize(800, 600); // no throw, no calls }); - it('should skip if __TAURI__.window is missing', async () => { - vi.stubGlobal('__TAURI__', {}); + it('should skip if the Tauri window handle is missing', async () => { + vi.stubGlobal('__AXELATE_TEST_WINDOW__', {}); await service.setSize(800, 600); // should not throw }); }); @@ -594,12 +656,10 @@ describe('WindowService', () => { // ---------------------------------------------------------- isMaximized describe('isMaximized', () => { it('should return true when Tauri window is maximized', async () => { - vi.stubGlobal('__TAURI__', { - window: { - getCurrentWindow: () => ({ - isMaximized: vi.fn().mockResolvedValue(true), - }), - }, + vi.stubGlobal('__AXELATE_TEST_WINDOW__', { + getCurrentWindow: () => ({ + isMaximized: vi.fn().mockResolvedValue(true), + }), }); const result = await service.isMaximized(); @@ -607,12 +667,10 @@ describe('WindowService', () => { }); it('should return false when Tauri window is not maximized', async () => { - vi.stubGlobal('__TAURI__', { - window: { - getCurrentWindow: () => ({ - isMaximized: vi.fn().mockResolvedValue(false), - }), - }, + vi.stubGlobal('__AXELATE_TEST_WINDOW__', { + getCurrentWindow: () => ({ + isMaximized: vi.fn().mockResolvedValue(false), + }), }); const result = await service.isMaximized(); @@ -620,20 +678,18 @@ describe('WindowService', () => { }); it('should return false on error', async () => { - vi.stubGlobal('__TAURI__', { - window: { - getCurrentWindow: () => ({ - isMaximized: vi.fn().mockRejectedValue(new Error('fail')), - }), - }, + vi.stubGlobal('__AXELATE_TEST_WINDOW__', { + getCurrentWindow: () => ({ + isMaximized: vi.fn().mockRejectedValue(new Error('fail')), + }), }); const result = await service.isMaximized(); expect(result).toBe(false); }); - it('should return false if __TAURI__.window is missing', async () => { - vi.stubGlobal('__TAURI__', {}); + it('should return false if the Tauri window handle is missing', async () => { + vi.stubGlobal('__AXELATE_TEST_WINDOW__', {}); const result = await service.isMaximized(); expect(result).toBe(false); }); @@ -666,14 +722,12 @@ describe('WindowService', () => { it('should save window state (maximized) when Tauri window resolves', async () => { const mockIsMaximized = vi.fn().mockResolvedValue(true); - vi.stubGlobal('__TAURI__', { - window: { - getCurrentWindow: () => ({ - isMaximized: mockIsMaximized, - innerSize: vi.fn().mockResolvedValue({ width: 1280, height: 720 }), - outerPosition: vi.fn().mockResolvedValue({ x: 0, y: 0 }), - }), - }, + vi.stubGlobal('__AXELATE_TEST_WINDOW__', { + getCurrentWindow: () => ({ + isMaximized: mockIsMaximized, + innerSize: vi.fn().mockResolvedValue({ width: 1280, height: 720 }), + outerPosition: vi.fn().mockResolvedValue({ x: 0, y: 0 }), + }), }); await service.init(mockWindowConfig, 1); @@ -692,14 +746,12 @@ describe('WindowService', () => { const mockInnerSize = vi.fn().mockResolvedValue({ width: 1000, height: 600 }); const mockOuterPos = vi.fn().mockResolvedValue({ x: 100, y: 50 }); - vi.stubGlobal('__TAURI__', { - window: { - getCurrentWindow: () => ({ - isMaximized: mockIsMaximized, - innerSize: mockInnerSize, - outerPosition: mockOuterPos, - }), - }, + vi.stubGlobal('__AXELATE_TEST_WINDOW__', { + getCurrentWindow: () => ({ + isMaximized: mockIsMaximized, + innerSize: mockInnerSize, + outerPosition: mockOuterPos, + }), }); await service.init(mockWindowConfig, 1); @@ -719,6 +771,86 @@ describe('WindowService', () => { y: 50, }); }); + + it('should await immediate window state save', async () => { + const mockIsMaximized = vi.fn().mockResolvedValue(true); + vi.stubGlobal('__AXELATE_TEST_WINDOW__', { + getCurrentWindow: () => ({ + isMaximized: mockIsMaximized, + innerSize: vi.fn().mockResolvedValue({ width: 1280, height: 720 }), + outerPosition: vi.fn().mockResolvedValue({ x: 0, y: 0 }), + }), + }); + + await service.saveImmediate(); + + expect(mockBridge.invoke).toHaveBeenCalledWith('save_maximized_state', { + maximized: true, + }); + }); + + it('should cancel pending debounced save when saving immediately', async () => { + const mockIsMaximized = vi.fn().mockResolvedValue(true); + vi.stubGlobal('__AXELATE_TEST_WINDOW__', { + getCurrentWindow: () => ({ + isMaximized: mockIsMaximized, + innerSize: vi.fn().mockResolvedValue({ width: 1280, height: 720 }), + outerPosition: vi.fn().mockResolvedValue({ x: 0, y: 0 }), + }), + }); + + service.scheduleSave(); + await service.saveImmediate(); + vi.advanceTimersByTime(1500); + await vi.runAllTimersAsync(); + + expect(mockBridge.invoke).toHaveBeenCalledTimes(1); + expect(mockBridge.invoke).toHaveBeenCalledWith('save_maximized_state', { + maximized: true, + }); + }); + + it('should serialize overlapping window state saves', async () => { + let releaseFirst!: () => void; + const mockIsMaximized = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirst = () => { + resolve(false); + }; + }), + ) + .mockResolvedValueOnce(true); + const mockInnerSize = vi.fn().mockResolvedValue({ width: 1000, height: 600 }); + const mockOuterPos = vi.fn().mockResolvedValue({ x: 100, y: 50 }); + + vi.stubGlobal('__AXELATE_TEST_WINDOW__', { + getCurrentWindow: () => ({ + isMaximized: mockIsMaximized, + innerSize: mockInnerSize, + outerPosition: mockOuterPos, + }), + }); + + const first = service.saveImmediate(); + const second = service.saveImmediate(); + await Promise.resolve(); + + expect(mockIsMaximized).toHaveBeenCalledTimes(1); + releaseFirst(); + await first; + await second; + + expect(mockIsMaximized).toHaveBeenCalledTimes(2); + expect(mockBridge.invoke).toHaveBeenNthCalledWith(1, 'save_maximized_state', { + maximized: false, + }); + expect(mockBridge.invoke).toHaveBeenLastCalledWith('save_maximized_state', { + maximized: true, + }); + }); }); // ---------------------------------------------------------- Resolution change zoom handling @@ -807,6 +939,17 @@ describe('WindowService', () => { expect(unlisten).toHaveBeenCalledTimes(1); }); + + it('should log tauri move listener registration failures', async () => { + mockBridge.listen.mockRejectedValue(new Error('listen failed')); + + await service.init(mockWindowConfig, 1); + await Promise.resolve(); + + expect(mockTracer.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to subscribe to window move events'), + ); + }); }); // ---------------------------------------------------------- _getInitialZoomWithFallback valid zoom (lines 468-471) diff --git a/src/shared/services/WindowService.ts b/src/shared/services/WindowService.ts index 007ab78a..92ff8b8c 100644 --- a/src/shared/services/WindowService.ts +++ b/src/shared/services/WindowService.ts @@ -15,7 +15,7 @@ import { type WindowZoomSettingsStore, } from './WindowServiceZoom'; -type WindowServiceLogger = Pick; +type WindowServiceLogger = Pick; export interface IWindowBreakpoints { compact: number; @@ -49,7 +49,6 @@ const PAGE_ZOOM_PROFILES: Record = { home: { minWidth: 800, minHeight: 600 }, chat: { minWidth: 920, minHeight: 640 }, modules: { minWidth: 1024, minHeight: 650 }, - marketplace: { minWidth: 1024, minHeight: 650 }, downloads: { minWidth: 900, minHeight: 600 }, console: { minWidth: 1080, minHeight: 650 }, settings: { minWidth: 980, minHeight: 680 }, @@ -58,19 +57,17 @@ const PAGE_ZOOM_PROFILES: Record = { type WindowRuntime = { addEventListener: typeof globalThis.addEventListener; removeEventListener: typeof globalThis.removeEventListener; - close: () => void; getScreenSize: () => { width: number; height: number }; getInnerSize: () => { width: number; height: number }; setAppZoomCss: (zoom: string) => void; }; +type MonitoringPauseReason = 'window-inactive' | 'monitor-hidden' | 'manual'; + function createDefaultWindowRuntime(): WindowRuntime { return { addEventListener: globalThis.addEventListener.bind(globalThis), removeEventListener: globalThis.removeEventListener.bind(globalThis), - close: () => { - globalThis.close(); - }, getScreenSize: () => ({ width: globalThis.screen.width, height: globalThis.screen.height, @@ -115,6 +112,8 @@ export class WindowService { private readonly _policyService: WindowServicePolicy; private readonly _zoomService: WindowServiceZoom; private _activePageId = 'home'; + private readonly _monitoringPauseReasons = new Map(); + private _lastAppliedMonitoringPaused: boolean | null = null; private readonly _boundWindowResize = () => { this._persistence.scheduleSave(); }; @@ -127,7 +126,6 @@ export class WindowService { this._nativeHelper = new WindowNativeBridgeHelper(_bridge); this._actions = new WindowServiceActions({ bridge: _bridge, - runtime: _runtime, tracer: this._tracer, beforeClose: () => this._beforeClose, }); @@ -177,7 +175,9 @@ export class WindowService { (await this._bridge.invoke('get_window_config')); // Update breakpoints from backend (placeholder/not used in UI yet) - this._tracer.info(`[WindowService] Loaded config: ${JSON.stringify(this._config)}`); + this._tracer.debug( + `[WindowService] Loaded config: ${JSON.stringify(this._config)}`, + ); // Use pre-loaded initialZoom or determine it const zoom = @@ -194,7 +194,7 @@ export class WindowService { // Initialize persistence listeners this._persistence.initWindowListeners(); } else { - // Web Fallback: Load from localStorage or default to 1 + // Non-native test/runtime path: apply the injected/default zoom without persistence. this._currentZoom = fallbackZoom; this._runtime.setAppZoomCss(this._currentZoom.toFixed(3)); } @@ -222,7 +222,7 @@ export class WindowService { } /** - * Closes the application window or browser tab. + * Closes the native application window. */ public async close(): Promise { await this._actions.close(); @@ -336,7 +336,27 @@ export class WindowService { * Notifies the backend of a change in system monitoring state. */ public async setMonitoringPaused(paused: boolean): Promise { - await this._actions.setMonitoringPaused(paused); + await this.setMonitoringPauseReason('manual', paused); + } + + public async setMonitoringPauseReason( + reason: MonitoringPauseReason, + paused: boolean, + ): Promise { + const previousReasonPaused = this._monitoringPauseReasons.get(reason); + if (previousReasonPaused !== paused) { + this._monitoringPauseReasons.set(reason, paused); + } + + const aggregatePaused = Array.from(this._monitoringPauseReasons.values()).some(Boolean); + if (this._lastAppliedMonitoringPaused === aggregatePaused) { + return; + } + + const applied = await this._actions.setMonitoringPaused(aggregatePaused); + if (applied) { + this._lastAppliedMonitoringPaused = aggregatePaused; + } } // --- Small Screen Helpers --- @@ -413,10 +433,10 @@ export class WindowService { } /** - * Immediate window state save (fire-and-forget). + * Immediate window state save. * Exposed for StateManager registration. */ - public saveImmediate(): void { - void this._persistence.saveWindowState(); + public async saveImmediate(): Promise { + await this._persistence.saveWindowState(); } } diff --git a/src/shared/services/WindowServiceActions.ts b/src/shared/services/WindowServiceActions.ts index 08d6294c..17e612f3 100644 --- a/src/shared/services/WindowServiceActions.ts +++ b/src/shared/services/WindowServiceActions.ts @@ -6,13 +6,8 @@ type WindowActionsLogger = { error: (message: string) => void; }; -type WindowActionsRuntime = { - close: () => void; -}; - type WindowServiceActionsDeps = { bridge: IBridge; - runtime: WindowActionsRuntime; tracer: WindowActionsLogger; beforeClose: () => (() => Promise) | null; }; @@ -24,7 +19,7 @@ export class WindowServiceActions { if (this._deps.bridge.isTauri()) { await this._deps.bridge.invoke('minimize_window'); } else { - this._deps.tracer.info('[WindowService] minimize (mock)'); + this._deps.tracer.info('[WindowService] Native minimize unavailable outside Tauri'); } } @@ -32,7 +27,7 @@ export class WindowServiceActions { if (this._deps.bridge.isTauri()) { await this._deps.bridge.invoke('maximize_window'); } else { - this._deps.tracer.info('[WindowService] toggleMaximize (mock)'); + this._deps.tracer.info('[WindowService] Native maximize unavailable outside Tauri'); } } @@ -42,7 +37,7 @@ export class WindowServiceActions { await beforeClose?.(); await this._deps.bridge.invoke('close_window'); } else { - this._deps.runtime.close(); + this._deps.tracer.info('[WindowService] Native close unavailable outside Tauri'); } } @@ -54,7 +49,7 @@ export class WindowServiceActions { await minimizeFallback(); } } else { - this._deps.tracer.info('[WindowService] hideToTray (mock)'); + this._deps.tracer.info('[WindowService] Native hide-to-tray unavailable outside Tauri'); } } @@ -84,15 +79,17 @@ export class WindowServiceActions { ); } - public async setMonitoringPaused(paused: boolean): Promise { + public async setMonitoringPaused(paused: boolean): Promise { if (!this._deps.bridge.isTauri()) { - return; + return true; } try { await this._deps.bridge.invoke('set_monitoring_paused', { paused }); + return true; } catch { this._deps.tracer.error('[WindowService] Failed to set monitoring state'); + return false; } } } diff --git a/src/shared/services/WindowServicePersistence.ts b/src/shared/services/WindowServicePersistence.ts index 5ca11229..3590bff5 100644 --- a/src/shared/services/WindowServicePersistence.ts +++ b/src/shared/services/WindowServicePersistence.ts @@ -24,6 +24,7 @@ export class WindowServicePersistence { private _saveWindowTimer: ReturnType | null = null; private _moveUnlisten: (() => void) | null = null; private _windowListenersInitialized = false; + private _saveChain: Promise = Promise.resolve(); constructor(private readonly _deps: WindowServicePersistenceDeps) {} @@ -33,13 +34,20 @@ export class WindowServicePersistence { this._deps.runtime.addEventListener('resize', this._deps.onResize); - void this._deps.bridge.listen('tauri://move', this._deps.onResize).then((unlisten) => { - if (this._deps.isDestroyed()) { - unlisten(); - return; - } - this._moveUnlisten = unlisten; - }); + void this._deps.bridge + .listen('tauri://move', this._deps.onResize) + .then((unlisten) => { + if (this._deps.isDestroyed()) { + unlisten(); + return; + } + this._moveUnlisten = unlisten; + }) + .catch((error: unknown) => { + this._deps.tracer.warn( + `[WindowService] Failed to subscribe to window move events: ${String(error)}`, + ); + }); } public destroy(): void { @@ -62,17 +70,34 @@ export class WindowServicePersistence { clearTimeout(this._saveWindowTimer); } this._saveWindowTimer = setTimeout(() => { - void this.saveWindowState(); + this._saveWindowTimer = null; + void this.saveWindowState().catch(() => { + // saveWindowState already logs the concrete backend/native error. + }); }, 1000); } public async saveWindowState(): Promise { + if (this._saveWindowTimer !== null) { + clearTimeout(this._saveWindowTimer); + this._saveWindowTimer = null; + } + if (!this._deps.bridge.isTauri()) return; - try { + const save = this._saveChain.then(async () => { + if (this._deps.isDestroyed()) return; await this._deps.nativeHelper.saveWindowState(); + }); + this._saveChain = save.catch(() => { + // Keep the chain usable after a failed save. + }); + + try { + await save; } catch (error) { this._deps.tracer.warn(`[WindowService] Failed to save window state: ${String(error)}`); + throw error; } } } diff --git a/src/shared/services/WindowServicePolicy.ts b/src/shared/services/WindowServicePolicy.ts index 832b6e9c..a81ef79a 100644 --- a/src/shared/services/WindowServicePolicy.ts +++ b/src/shared/services/WindowServicePolicy.ts @@ -5,6 +5,7 @@ import type { IWindowPolicy } from './WindowService'; import type { WindowServiceZoom } from './WindowServiceZoom'; type WindowPolicyLogger = { + debug: (message: string) => void; info: (message: string) => void; error: (message: string) => void; }; @@ -59,7 +60,7 @@ export class WindowServicePolicy { const previousResolutionKey = this._lastResolutionKey; this._lastResolutionKey = currentResolutionKey; - this._deps.tracer.info( + this._deps.tracer.debug( `[WindowService] Resolution changed: ${previousResolutionKey} -> ${currentResolutionKey}`, ); void this.handleResolutionChange(); diff --git a/src/shared/services/ai/AISettingsService.test.ts b/src/shared/services/ai/AISettingsService.test.ts index 67f89f1c..c5a507fa 100644 --- a/src/shared/services/ai/AISettingsService.test.ts +++ b/src/shared/services/ai/AISettingsService.test.ts @@ -22,8 +22,8 @@ describe('AISettingsService', () => { expect(service.getThinkingLevel('gemini')).toBe('off'); }); - it('should default llamacpp thinking level to low', () => { - expect(service.getThinkingLevel('llamacpp')).toBe('low'); + it('should not infer thinking defaults from provider ids', () => { + expect(service.getThinkingLevel('llamacpp')).toBe('off'); }); it('should set thinking level', () => { @@ -56,16 +56,4 @@ describe('AISettingsService', () => { service.setLocalMaxOutputTokens('llamacpp', 999999); expect(service.getLocalMaxOutputTokens('llamacpp')).toBe(32768); }); - - it('should get and set AI session ID', () => { - expect(service.getAiSessionId()).toBeNull(); - service.setAiSessionId('sess-123'); - expect(service.getAiSessionId()).toBe('sess-123'); - }); - - it('should set AI session ID to null', () => { - service.setAiSessionId('sess-123'); - service.setAiSessionId(null); - expect(service.getAiSessionId()).toBeNull(); - }); }); diff --git a/src/shared/services/ai/AISettingsService.ts b/src/shared/services/ai/AISettingsService.ts index a80c5102..ab912ccc 100644 --- a/src/shared/services/ai/AISettingsService.ts +++ b/src/shared/services/ai/AISettingsService.ts @@ -1,6 +1,5 @@ import type { UiStateStore, ThinkingLevel } from '../state/UiStateStore'; -const LOCAL_LOW_THINKING_DEFAULTS = new Set(['llamacpp']); const DEFAULT_LOCAL_MAX_OUTPUT_TOKENS = 384; export class AISettingsService { @@ -20,7 +19,7 @@ export class AISettingsService { return savedLevel; } - return LOCAL_LOW_THINKING_DEFAULTS.has(appId) ? 'low' : 'off'; + return 'off'; } public setThinkingLevel(appId: string, level: ThinkingLevel): void { @@ -53,12 +52,4 @@ export class AISettingsService { const normalized = Math.max(1, Math.min(Math.trunc(tokens), 32768)); this._store.updateNestedState('local_max_output_tokens', appId, normalized); } - - public getAiSessionId(): string | null { - return this._store.getState().ai_session_id; - } - - public setAiSessionId(sessionId: string | null): void { - this._store.updateState({ ai_session_id: sessionId }); - } } diff --git a/src/shared/services/modules/ModuleSettingsService.test.ts b/src/shared/services/modules/ModuleSettingsService.test.ts index d6182461..91d2dbbe 100644 --- a/src/shared/services/modules/ModuleSettingsService.test.ts +++ b/src/shared/services/modules/ModuleSettingsService.test.ts @@ -20,7 +20,7 @@ function createMockStore(): UiStateStore { ai_thinking_level: {}, ai_web_search_enabled: {}, local_max_output_tokens: {}, - ai_session_id: null, + integration_import_last_directory: null, pending_chat_reveal: false, }; diff --git a/src/shared/services/state/UiStateStore.test.ts b/src/shared/services/state/UiStateStore.test.ts index 53f641b4..6a21b32d 100644 --- a/src/shared/services/state/UiStateStore.test.ts +++ b/src/shared/services/state/UiStateStore.test.ts @@ -15,8 +15,6 @@ describe('UiStateStore', () => { let bridge: IBridge; let store: UiStateStore; let tracer: Pick; - let storage: Pick; - let storageState: Record; beforeEach(() => { vi.useFakeTimers(); @@ -26,14 +24,7 @@ describe('UiStateStore', () => { warn: vi.fn(), error: vi.fn(), }; - storageState = {}; - storage = { - getItem: vi.fn((key: string) => storageState[key] ?? null), - setItem: vi.fn((key: string, value: string) => { - storageState[key] = value; - }), - }; - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); }); afterEach(() => { @@ -48,7 +39,6 @@ describe('UiStateStore', () => { expect(state.sidebar_width).toBe(280); expect(state.zoom_level).toBe(1); expect(state.sound_enabled).toBe(true); - expect(state.ai_session_id).toBeNull(); }); it('should merge state via setState', () => { @@ -68,6 +58,23 @@ describe('UiStateStore', () => { expect(store.getState().zoom_level).toBe(2); }); + it('should normalize direct updates before future nested writes', () => { + store.updateState( + { + zoom_level: Number.NaN, + resolution_zoom: null as unknown as Record, + }, + false, + ); + + expect(store.getState().zoom_level).toBe(1); + expect(store.getState().resolution_zoom).toEqual({}); + + store.updateNestedState('resolution_zoom', '1920x1080', 1.4, false); + + expect(store.getState().resolution_zoom).toEqual({ '1920x1080': 1.4 }); + }); + it('should update nested state', () => { store.updateNestedState('card_widths', 'card-1', '300px'); expect(store.getState().card_widths['card-1']).toBe('300px'); @@ -97,40 +104,74 @@ describe('UiStateStore', () => { (bridge.invoke as ReturnType).mockResolvedValue({ sidebar_width: 500, }); - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); const result = await store.loadState(); expect(bridge.invoke).toHaveBeenCalledWith('get_ui_state'); expect(result.sidebar_width).toBe(500); }); - it('should load from localStorage in non-Tauri environment', async () => { - storageState['axelate_ui_state'] = JSON.stringify({ sidebar_width: 350 }); - store = new UiStateStore(bridge, tracer, storage); - + it('should keep defaults in non-Tauri environment', async () => { const result = await store.loadState(); - expect(result.sidebar_width).toBe(350); + expect(result.sidebar_width).toBe(280); + expect(bridge.invoke).not.toHaveBeenCalled(); }); - it('should clamp legacy zoom values when loading state', async () => { - storageState['axelate_ui_state'] = JSON.stringify({ + it('should clamp out-of-range zoom values when setting state', () => { + store.setState({ zoom_level: 3, resolution_zoom: { '1920x1080': 3.2, '2560x1440': 2.4 }, }); - store = new UiStateStore(bridge, tracer, storage); - const result = await store.loadState(); + const result = store.getState(); expect(result.zoom_level).toBe(2.6); expect(result.resolution_zoom['1920x1080']).toBe(2.6); expect(result.resolution_zoom['2560x1440']).toBe(2.4); }); - it('should use defaults when localStorage is null (L67)', async () => { - delete storageState['axelate_ui_state']; - store = new UiStateStore(bridge, tracer, storage); + it('should keep partial state instead of dropping it when map fields are missing', () => { + store.setState({ + sidebar_width: 360, + zoom_level: 1.25, + }); - const result = await store.loadState(); - expect(result.sidebar_width).toBe(280); // default + const result = store.getState(); + + expect(result.sidebar_width).toBe(360); + expect(result.zoom_level).toBe(1.25); + expect(result.resolution_zoom).toEqual({}); + expect(result.local_max_output_tokens).toEqual({}); + expect(tracer.warn).not.toHaveBeenCalled(); + }); + + it('should normalize malformed map fields while preserving valid entries', () => { + store.setState({ + resolution_zoom: null as unknown as Record, + selected_ai_models: { gpt: 'gpt-5.5', bad: 42 } as unknown as Record< + string, + string + >, + ai_thinking_level: { gpt: 'high', bad: 'fast' } as unknown as Record< + string, + 'off' | 'low' | 'medium' | 'high' + >, + ai_web_search_enabled: { gpt: true, bad: 'yes' } as unknown as Record< + string, + boolean + >, + local_max_output_tokens: { llamacpp: 8192, bad: 'many' } as unknown as Record< + string, + number + >, + }); + + const result = store.getState(); + + expect(result.resolution_zoom).toEqual({}); + expect(result.selected_ai_models).toEqual({ gpt: 'gpt-5.5' }); + expect(result.ai_thinking_level).toEqual({ gpt: 'high' }); + expect(result.ai_web_search_enabled).toEqual({ gpt: true }); + expect(result.local_max_output_tokens).toEqual({ llamacpp: 8192 }); }); it('should handle load errors and return defaults', async () => { @@ -138,7 +179,7 @@ describe('UiStateStore', () => { (bridge.invoke as ReturnType).mockRejectedValue( new Error('Backend fail'), ); - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); const result = await store.loadState(); // Should fall back to defaults without throwing @@ -149,7 +190,7 @@ describe('UiStateStore', () => { describe('saveAsync', () => { it('should save to backend in Tauri environment when dirty', async () => { bridge = createMockBridge(true); - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); store.updateState({ sidebar_width: 999 }); await store.saveAsync(); @@ -158,15 +199,11 @@ describe('UiStateStore', () => { }); }); - it('should save to localStorage in non-Tauri environment when dirty', async () => { + it('should not persist in non-Tauri environment', async () => { store.updateState({ sidebar_width: 450 }); await store.saveAsync(); - const stored = JSON.parse(storageState['axelate_ui_state'] ?? '{}') as Record< - string, - unknown - >; - expect(stored['sidebar_width']).toBe(450); + expect(bridge.invoke).not.toHaveBeenCalled(); }); it('should not save when not dirty', async () => { @@ -177,46 +214,85 @@ describe('UiStateStore', () => { it('should handle save errors gracefully', async () => { bridge = createMockBridge(true); (bridge.invoke as ReturnType).mockRejectedValue(new Error('Save fail')); - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); store.updateState({ sidebar_width: 123 }); // Should not throw await expect(store.saveAsync()).resolves.toBeUndefined(); }); + + it('should keep dirty state when state changes during an in-flight save', async () => { + bridge = createMockBridge(true); + let resolveFirstSave: () => void = () => {}; + (bridge.invoke as ReturnType) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstSave = resolve; + }), + ) + .mockResolvedValue(undefined); + store = new UiStateStore(bridge, tracer); + store.updateState({ sidebar_width: 111 }); + + const firstSave = store.saveAsync(); + store.updateState({ sidebar_width: 222 }); + resolveFirstSave(); + await firstSave; + await store.saveAsync(); + + expect(bridge.invoke).toHaveBeenCalledTimes(2); + expect(bridge.invoke).toHaveBeenLastCalledWith('save_ui_state', { + state: expect.objectContaining({ sidebar_width: 222 }) as unknown, + }); + }); }); describe('saveImmediate', () => { - it('should save synchronously to localStorage when dirty', () => { + it('should not persist synchronously in non-Tauri environment', async () => { store.updateState({ zoom_level: 1.5 }); - store.saveImmediate(); + await store.saveImmediate(); - const stored = JSON.parse(storageState['axelate_ui_state'] ?? '{}') as Record< - string, - unknown - >; - expect(stored['zoom_level']).toBe(1.5); + expect(bridge.invoke).not.toHaveBeenCalled(); }); - it('should not save when not dirty', () => { - store.saveImmediate(); - expect(storageState['axelate_ui_state']).toBeUndefined(); + it('should not save when not dirty', async () => { + await store.saveImmediate(); + expect(bridge.invoke).not.toHaveBeenCalled(); }); - it('should save to backend in Tauri environment', () => { + it('should save to backend in Tauri environment', async () => { bridge = createMockBridge(true); - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); store.updateState({ sidebar_width: 777 }); - store.saveImmediate(); + await store.saveImmediate(); expect(bridge.invoke).toHaveBeenCalledWith('save_ui_state', { state: expect.objectContaining({ sidebar_width: 777 }) as unknown, }); }); + + it('should keep dirty state when immediate backend save rejects', async () => { + bridge = createMockBridge(true); + (bridge.invoke as ReturnType) + .mockRejectedValueOnce(new Error('Save failed')) + .mockResolvedValue(undefined); + store = new UiStateStore(bridge, tracer); + store.updateState({ sidebar_width: 888 }); + + await expect(store.saveImmediate()).rejects.toThrow('Save failed'); + await store.saveAsync(); + + expect(bridge.invoke).toHaveBeenCalledTimes(2); + expect(bridge.invoke).toHaveBeenLastCalledWith('save_ui_state', { + state: expect.objectContaining({ sidebar_width: 888 }) as unknown, + }); + }); }); describe('Debounced auto-save', () => { it('should debounce saves on updateState', async () => { bridge = createMockBridge(true); - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); store.updateState({ sidebar_width: 100 }); store.updateState({ sidebar_width: 200 }); @@ -237,23 +313,22 @@ describe('UiStateStore', () => { }); describe('saveImmediate error handling', () => { - it('should catch errors during saveImmediate gracefully', () => { + it('should report errors during saveImmediate', async () => { bridge = createMockBridge(true); (bridge.invoke as ReturnType).mockImplementation(() => { throw new Error('Invoke crashed'); }); - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); store.updateState({ sidebar_width: 123 }); - // Should not throw - expect(() => store.saveImmediate()).not.toThrow(); + await expect(store.saveImmediate()).rejects.toThrow('Invoke crashed'); }); }); describe('Auto-save events', () => { it('should save on visibilitychange when hidden', () => { bridge = createMockBridge(true); - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); store.updateState({ sidebar_width: 400 }); // Simulate document becoming hidden @@ -270,7 +345,7 @@ describe('UiStateStore', () => { it('should not save on visibilitychange when NOT hidden (L160)', () => { bridge = createMockBridge(true); - store = new UiStateStore(bridge, tracer, storage); + store = new UiStateStore(bridge, tracer); store.updateState({ sidebar_width: 400 }); // Simulate document becoming visible (not hidden) @@ -285,36 +360,32 @@ describe('UiStateStore', () => { // bridge.invoke should not have been called for save }); - it('should saveImmediate synchronously', () => { - bridge = createMockBridge(); - store = new UiStateStore(bridge, tracer, storage); + it('should saveImmediate synchronously', async () => { + bridge = createMockBridge(true); + store = new UiStateStore(bridge, tracer); store.updateState({ sidebar_width: 500 }); // beforeunload is now handled by StateManager; test saveImmediate directly - store.saveImmediate(); + await store.saveImmediate(); - const stored = JSON.parse(storageState['axelate_ui_state'] ?? '{}') as Record< - string, - unknown - >; - expect(stored['sidebar_width']).toBe(500); + expect(bridge.invoke).toHaveBeenCalledWith('save_ui_state', { + state: expect.objectContaining({ sidebar_width: 500 }) as unknown, + }); }); - it('should remove auto-save timer on destroy', () => { - bridge = createMockBridge(); - store = new UiStateStore(bridge, tracer, storage); + it('should remove auto-save timer on destroy', async () => { + bridge = createMockBridge(true); + store = new UiStateStore(bridge, tracer); store.updateState({ sidebar_width: 640 }); store.destroy(); // saveImmediate should still work (no event listeners to remove) - store.saveImmediate(); + await store.saveImmediate(); - const stored = JSON.parse(storageState['axelate_ui_state'] ?? '{}') as Record< - string, - unknown - >; - expect(stored['sidebar_width']).toBe(640); + expect(bridge.invoke).toHaveBeenCalledWith('save_ui_state', { + state: expect.objectContaining({ sidebar_width: 640 }) as unknown, + }); }); }); }); diff --git a/src/shared/services/state/UiStateStore.ts b/src/shared/services/state/UiStateStore.ts index 1a24a0c7..044a87be 100644 --- a/src/shared/services/state/UiStateStore.ts +++ b/src/shared/services/state/UiStateStore.ts @@ -31,18 +31,16 @@ export interface IUIState { ai_thinking_level: Record; ai_web_search_enabled: Record; local_max_output_tokens: Record; - ai_session_id: string | null; + integration_import_last_directory: string | null; preferred_language?: string | null; pending_chat_reveal: boolean; } -type UiStateStorage = Pick; - const DEFAULT_UI_STATE: IUIState = { sidebar_collapsed: false, sidebar_manual_override: false, sidebar_width: 280, - hidden_nav_items: ['marketplace'], + hidden_nav_items: [], hidden_monitors: [], card_widths: {}, download_limit_enabled: false, @@ -55,7 +53,7 @@ const DEFAULT_UI_STATE: IUIState = { ai_thinking_level: {}, ai_web_search_enabled: {}, local_max_output_tokens: {}, - ai_session_id: null, + integration_import_last_directory: null, preferred_language: null, pending_chat_reveal: false, }; @@ -66,14 +64,13 @@ const MAX_UI_ZOOM = 2.6; export class UiStateStore { private _state: IUIState = { ...DEFAULT_UI_STATE }; private _isDirty = false; + private _revision = 0; private _autoSaveTimer: ReturnType | null = null; - private readonly _STORAGE_KEY = 'axelate_ui_state'; private _isDestroyed = false; constructor( private readonly _bridge: IBridge, private readonly _tracer: UiStateStoreLogger, - private readonly _storage: UiStateStorage | null = globalThis.localStorage, ) {} public async loadState(): Promise { @@ -82,12 +79,6 @@ export class UiStateStore { const loaded = await this._bridge.invoke('get_ui_state'); this.setState(loaded); this._tracer.info('[UiStateStore] Loaded from backend'); - } else { - const stored = this._storage?.getItem(this._STORAGE_KEY) ?? null; - if (stored !== null) { - this.setState(JSON.parse(stored) as Partial); - this._tracer.info('[UiStateStore] Loaded from browser storage'); - } } } catch (e) { this._tracer.warn(`[UiStateStore] Failed to load, using defaults: ${String(e)}`); @@ -104,9 +95,10 @@ export class UiStateStore { } public updateState(updates: Partial, markDirty = true): void { - this._state = { ...this._state, ...updates }; + this._state = this._normalizeState({ ...this._state, ...updates }); if (markDirty) { this._isDirty = true; + this._revision += 1; this._debouncedSave(); } } @@ -117,10 +109,11 @@ export class UiStateStore { value: unknown, markDirty = true, ): void { - const target = this._state[key] as Record; + const target = this._getRecordTarget(key); target[nestedKey] = value; if (markDirty) { this._isDirty = true; + this._revision += 1; this._debouncedSave(); } } @@ -130,10 +123,11 @@ export class UiStateStore { nestedKey: string, markDirty = true, ): void { - const target = this._state[key] as Record; + const target = this._getRecordTarget(key); delete target[nestedKey]; if (markDirty) { this._isDirty = true; + this._revision += 1; this._debouncedSave(); } } @@ -156,6 +150,15 @@ export class UiStateStore { this.removeNestedState('selected_modules', category); } + public getIntegrationImportLastDirectory(): string | null { + return this._state.integration_import_last_directory; + } + + public setIntegrationImportLastDirectory(path: string | null): void { + const normalized = typeof path === 'string' ? path.trim() : ''; + this.updateState({ integration_import_last_directory: normalized || null }); + } + private _debouncedSave(): void { if (this._autoSaveTimer !== null) { globalThis.clearTimeout(this._autoSaveTimer); @@ -167,29 +170,34 @@ export class UiStateStore { public async saveAsync(): Promise { if (!this._isDirty) return; + const revision = this._revision; + const state = this._snapshotState(); try { if (this._bridge.isTauri()) { - await this._bridge.invoke('save_ui_state', { state: this._state }); - } else { - this._storage?.setItem(this._STORAGE_KEY, JSON.stringify(this._state)); + await this._bridge.invoke('save_ui_state', { state }); + } + if (this._revision === revision) { + this._isDirty = false; } - this._isDirty = false; } catch (e) { this._tracer.error(`[UiStateStore] Failed to save state: ${String(e)}`); } } - public saveImmediate(): void { + public async saveImmediate(): Promise { if (!this._isDirty) return; + const revision = this._revision; + const state = this._snapshotState(); try { if (this._bridge.isTauri()) { - void this._bridge.invoke('save_ui_state', { state: this._state }); - } else { - this._storage?.setItem(this._STORAGE_KEY, JSON.stringify(this._state)); + await this._bridge.invoke('save_ui_state', { state }); + if (this._revision === revision) { + this._isDirty = false; + } } - this._isDirty = false; } catch (e) { this._tracer.error(`[UiStateStore] Save immediate failed: ${String(e)}`); + throw e; } } @@ -204,18 +212,143 @@ export class UiStateStore { } private _normalizeState(state: IUIState): IUIState { + const resolutionZoom = this._normalizeNumberRecord( + state.resolution_zoom, + DEFAULT_UI_STATE.resolution_zoom, + ); + return { ...state, - zoom_level: this._clampZoom(state.zoom_level), + hidden_nav_items: this._normalizeStringArray( + state.hidden_nav_items, + DEFAULT_UI_STATE.hidden_nav_items, + ), + hidden_monitors: this._normalizeStringArray( + state.hidden_monitors, + DEFAULT_UI_STATE.hidden_monitors, + ), + card_widths: this._normalizeStringRecord(state.card_widths), + selected_modules: this._normalizeObjectRecord(state.selected_modules), + selected_ai_models: this._normalizeStringRecord(state.selected_ai_models), resolution_zoom: Object.fromEntries( - Object.entries(state.resolution_zoom).map(([key, zoom]) => [ - key, - this._clampZoom(zoom), - ]), + Object.entries(resolutionZoom).map(([key, zoom]) => [key, this._clampZoom(zoom)]), + ), + ai_thinking_level: this._normalizeThinkingLevelRecord(state.ai_thinking_level), + ai_web_search_enabled: this._normalizeBooleanRecord(state.ai_web_search_enabled), + local_max_output_tokens: this._normalizeNumberRecord( + state.local_max_output_tokens, + DEFAULT_UI_STATE.local_max_output_tokens, + ), + integration_import_last_directory: this._normalizeNullableString( + state.integration_import_last_directory, ), + zoom_level: this._clampZoom(state.zoom_level), }; } + private _normalizeStringArray(value: unknown, fallback: string[]): string[] { + if (!Array.isArray(value)) { + return [...fallback]; + } + + return value.filter((item): item is string => typeof item === 'string'); + } + + private _normalizeObjectRecord(value: unknown): Record> { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + return value as Record>; + } + + private _normalizeStringRecord(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + return Object.fromEntries( + Object.entries(value as Record).filter( + (entry): entry is [string, string] => { + const [, item] = entry; + return typeof item === 'string'; + }, + ), + ); + } + + private _normalizeNullableString(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim(); + return trimmed === '' ? null : trimmed; + } + + private _normalizeBooleanRecord(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + return Object.fromEntries( + Object.entries(value as Record).filter( + (entry): entry is [string, boolean] => { + const [, item] = entry; + return typeof item === 'boolean'; + }, + ), + ); + } + + private _normalizeThinkingLevelRecord(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + return Object.fromEntries( + Object.entries(value as Record).filter( + (entry): entry is [string, ThinkingLevel] => { + const [, item] = entry; + return item === 'off' || item === 'low' || item === 'medium' || item === 'high'; + }, + ), + ); + } + + private _normalizeNumberRecord( + value: unknown, + fallback: Record = {}, + ): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return { ...fallback }; + } + + return Object.fromEntries( + Object.entries(value as Record).filter( + (entry): entry is [string, number] => { + const [, item] = entry; + return typeof item === 'number' && Number.isFinite(item); + }, + ), + ); + } + + private _snapshotState(): IUIState { + return structuredClone(this._state); + } + + private _getRecordTarget(key: K): Record { + const target = this._state[key]; + if (target !== null && typeof target === 'object' && !Array.isArray(target)) { + return target as Record; + } + + const replacement: Record = {}; + (this._state as Record)[key] = replacement; + return replacement; + } + private _clampZoom(zoom: number): number { if (!Number.isFinite(zoom)) { return DEFAULT_UI_STATE.zoom_level; diff --git a/src/shared/shell/AppUI.test.ts b/src/shared/shell/AppUI.test.ts index 27240136..4ceee4b4 100644 --- a/src/shared/shell/AppUI.test.ts +++ b/src/shared/shell/AppUI.test.ts @@ -6,6 +6,7 @@ import type { NavigationService } from '@/infrastructure/navigation/NavigationSe import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { IApp } from '../types/coreTypes'; import { CUSTOM_IMAGE_PROVIDER_ID, CUSTOM_TEXT_PROVIDER_ID } from '../utils/customProviderSupport'; +import { openIntegrationUrlDialog } from './ui/IntegrationImportDialog'; describe('AppUI lifecycle', () => { let appUI: AppUI | null = null; @@ -17,6 +18,8 @@ describe('AppUI lifecycle', () => { let launchAppMock: ReturnType; let openModuleSettingsMock: ReturnType; let stopAiProviderMock: ReturnType; + let reloadCatalogMock: ReturnType Promise>>; + let openExternalUrlMock: ReturnType Promise>>; let getCatalogCategoryMock: ReturnType; let tracerMock: LoggerService; let platformServiceMock: { @@ -41,6 +44,8 @@ describe('AppUI lifecycle', () => { launchAppMock = vi.fn().mockResolvedValue(undefined); openModuleSettingsMock = vi.fn(); stopAiProviderMock = vi.fn(); + reloadCatalogMock = vi.fn<() => Promise>().mockResolvedValue(undefined); + openExternalUrlMock = vi.fn<(_url: string) => Promise>().mockResolvedValue(undefined); getCatalogCategoryMock = vi.fn().mockReturnValue([]); tracerMock = { info: vi.fn(), @@ -99,6 +104,8 @@ describe('AppUI lifecycle', () => { ) => void )(category, moduleData); }, + getIntegrationImportLastDirectory: () => null, + setIntegrationImportLastDirectory: vi.fn(), }, launchApp: async (category: string, app: IApp) => { await ( @@ -114,6 +121,10 @@ describe('AppUI lifecycle', () => { stopAiProvider: () => { (stopAiProviderMock as () => void)(); }, + reloadCatalog: async () => { + await reloadCatalogMock(); + }, + openExternalUrl: openExternalUrlMock, }, ); } @@ -159,6 +170,43 @@ describe('AppUI lifecycle', () => { expect(testEventBus.listenerCount('page:change')).toBe(initialCount); }); + it('opens the first URL when an error toast is clicked', () => { + appUI = createAppUI(); + + appUI.showToast( + 'Error 402: Payment Required. Please check your balance at https://openrouter.ai/settings/credits.', + 'error', + ); + + const toast = document.querySelector('.toast'); + if (!(toast instanceof HTMLElement)) { + throw new Error('Toast was not created'); + } + + expect(toast.classList.contains('toast--actionable')).toBe(true); + + toast.click(); + + expect(openExternalUrlMock).toHaveBeenCalledWith('https://openrouter.ai/settings/credits'); + }); + + it('keeps explicitly provided toast actions ahead of URL auto-actions', () => { + appUI = createAppUI(); + const onClick = vi.fn(); + + appUI.showToast('Open https://example.com', 'info', 3000, null, null, onClick); + + const toast = document.querySelector('.toast'); + if (!(toast instanceof HTMLElement)) { + throw new Error('Toast was not created'); + } + + toast.click(); + + expect(onClick).toHaveBeenCalledOnce(); + expect(openExternalUrlMock).not.toHaveBeenCalled(); + }); + it('should remove language-changed listener on destroy', () => { appUI = createAppUI(); const refreshSpy = vi.spyOn( @@ -176,6 +224,20 @@ describe('AppUI lifecycle', () => { expect(refreshSpy).toHaveBeenCalledTimes(1); }); + it('should close transient integration dialogs on page change', async () => { + appUI = createAppUI(); + const result = openIntegrationUrlDialog({ + translate: (_key, fallback) => fallback, + }); + + expect(document.querySelector('.integration-import-dialog-view')).not.toBeNull(); + + testEventBus.emit('page:change', { pageId: 'settings' }); + + await expect(result).resolves.toBeNull(); + expect(document.querySelector('.integration-import-dialog-view')).toBeNull(); + }); + it('should cancel pending AI card wheel switch on destroy', () => { vi.useFakeTimers(); appUI = createAppUI(); @@ -350,8 +412,8 @@ describe('AppUI lifecycle', () => { appUI.clearModuleCard('ai_image'); expect(card.classList.contains('empty')).toBe(true); expect(stopAiProviderMock).toHaveBeenCalled(); - expect(platformServiceMock.stop).toHaveBeenCalledWith(textApp); - expect(platformServiceMock.stop).toHaveBeenCalledWith(imageApp); + expect(platformServiceMock.stop).toHaveBeenCalledWith(textApp, 'ai_text'); + expect(platformServiceMock.stop).toHaveBeenCalledWith(imageApp, 'ai_image'); }); it('should stop the current services module when clearing its card', () => { @@ -374,10 +436,10 @@ describe('AppUI lifecycle', () => { appUI.clearModuleCard('services'); - expect(platformServiceMock.stop).toHaveBeenCalledWith(serviceApp); + expect(platformServiceMock.stop).toHaveBeenCalledWith(serviceApp, 'services'); }); - it('should stop the previous services module when switching cards without action button running state', () => { + it('should not stop the previous services module when only switching selected cards', () => { appUI = createAppUI(); document.body.innerHTML = `
@@ -399,10 +461,10 @@ describe('AppUI lifecycle', () => { appUI.updateModuleCard('services', newApp); - expect(platformServiceMock.stop).toHaveBeenCalledWith(oldApp); + expect(platformServiceMock.stop).not.toHaveBeenCalled(); }); - it('should swallow stop errors when switching away from a previous module', async () => { + it('should not touch runtime stop path when switching selected cards', async () => { appUI = createAppUI(); platformServiceMock.stop.mockRejectedValueOnce(new Error('stop failed')); document.body.innerHTML = ` @@ -427,6 +489,7 @@ describe('AppUI lifecycle', () => { }).not.toThrow(); await Promise.resolve(); + expect(platformServiceMock.stop).not.toHaveBeenCalled(); }); it('should reset services card instead of showing an AI module when clearing services', () => { @@ -539,10 +602,7 @@ describe('AppUI lifecycle', () => { appUI.updateModuleCard('ai_image', sharedApp); expect(card.dataset['currentCapability']).toBe('ai_image'); - const resolvedCategory = ( - appUI as unknown as { _resolveCategoryFromCard: (card: HTMLElement) => string } - )._resolveCategoryFromCard(card); - expect(resolvedCategory).toBe('ai_image'); + expect(appUI.getPreferredAiCategory()).toBe('ai_image'); }); it('should retarget AI card settings and close actions after wheel switching slots', () => { @@ -582,7 +642,7 @@ describe('AppUI lifecycle', () => { closeBadge.click(); expect(uiStateMocks.removeSelectedModule).toHaveBeenCalledWith('ai_image'); - expect(platformServiceMock.stop).toHaveBeenCalledWith(imageApp); + expect(platformServiceMock.stop).toHaveBeenCalledWith(imageApp, 'ai_image'); expect(card.dataset['currentModule']).toBe('text-model'); }); @@ -639,7 +699,7 @@ describe('AppUI lifecycle', () => { expect(updateSelectionSpy).toHaveBeenLastCalledWith(null); }); - it('should mark selected service cards as running after backend status confirms launch', async () => { + it('should show selected service runtime status after launch', async () => { appUI = createAppUI(); document.body.innerHTML = `
@@ -669,39 +729,6 @@ describe('AppUI lifecycle', () => { expect(card.dataset['runtimeStatus']).toBe('running'); }); - it('should handle modal download success and error', () => { - appUI = createAppUI(); - const privateAppUI = appUI as unknown as { - _onModalDownloadSuccess: (btn: HTMLElement | null, app: IApp, category: string) => void; - _onModalDownloadError: (btn: HTMLElement | null, err: unknown) => void; - _modalManager: { - refreshCurrentSelection: ReturnType; - isViewingCategory: ReturnType; - }; - }; - vi.spyOn(privateAppUI._modalManager, 'isViewingCategory').mockReturnValue(true); - const refreshSpy = vi.spyOn(privateAppUI._modalManager, 'refreshCurrentSelection'); - - const card = document.createElement('div'); - card.className = 'app-card'; - card.innerHTML = ` -
-
-
- `; - const btn = document.createElement('button'); - btn.className = 'download-btn downloading indeterminate'; - card.appendChild(btn); - - const app = { id: 'local-app', name: 'Local App', installed: false } as IApp; - privateAppUI._onModalDownloadSuccess(btn, app, 'services'); - expect(app.installed).toBe(true); - expect(btn.classList.contains('downloading')).toBe(false); - expect(refreshSpy).toHaveBeenCalled(); - - privateAppUI._onModalDownloadError(btn, new Error('broken')); - }); - it('should show a placeholder toast instead of selecting or downloading coming-soon modules', async () => { appUI = createAppUI(); const toastSpy = vi.spyOn(appUI, 'showToast'); @@ -729,7 +756,7 @@ describe('AppUI lifecycle', () => { expect(launchAppMock).not.toHaveBeenCalled(); }); - it('should stop stale launched module after quick reselection', async () => { + it('should stop stale service launch during quick reselection', async () => { appUI = createAppUI(); let releaseFirstLaunch!: () => void; @@ -776,9 +803,10 @@ describe('AppUI lifecycle', () => { releaseFirstLaunch(); await Promise.resolve(); await Promise.resolve(); + await new Promise((resolve) => globalThis.setTimeout(resolve, 0)); - expect(launchApp).toHaveBeenNthCalledWith(1, 'services', firstApp); - expect(launchApp).toHaveBeenNthCalledWith(2, 'services', secondApp); + expect(launchApp).toHaveBeenCalledWith('services', firstApp); + expect(launchApp).toHaveBeenCalledWith('services', secondApp); expect(platformServiceMock.stop).toHaveBeenCalledWith(firstApp); }); @@ -804,16 +832,19 @@ describe('AppUI lifecycle', () => { expect(reopenSpy).not.toHaveBeenCalled(); }); - it('should reopen modal after delete when app selection is still open', async () => { + it('should refresh modal after delete when app selection is still open', async () => { appUI = createAppUI(); const privateAppUI = appUI as unknown as { _handleDeleteModule: (app: IApp, category: string) => Promise; - _modalManager: { isAppSelectionOpen: () => boolean }; + _modalManager: { + isAppSelectionOpen: () => boolean; + refreshCurrentSelection: (apps?: IApp[], selectedId?: string | null) => void; + }; }; vi.spyOn(privateAppUI._modalManager, 'isAppSelectionOpen').mockReturnValue(true); - const reopenSpy = vi.spyOn(appUI, 'openAppSelection'); + const refreshSpy = vi.spyOn(privateAppUI._modalManager, 'refreshCurrentSelection'); platformServiceMock.delete.mockResolvedValue(undefined); const refreshedApps = [{ id: 'svc', name: 'Service', installed: false }] as IApp[]; @@ -824,31 +855,80 @@ describe('AppUI lifecycle', () => { 'services', ); - expect(reopenSpy).toHaveBeenCalledWith('services', refreshedApps); + expect(refreshSpy).toHaveBeenCalledWith(refreshedApps, null); }); - it('should not refresh modal after download success when viewing another category', () => { + it('should refresh an open integrations modal after catalog reload events', () => { appUI = createAppUI(); + const refreshedApps: IApp[] = []; + getCatalogCategoryMock.mockReturnValue(refreshedApps); const privateAppUI = appUI as unknown as { - _onModalDownloadSuccess: (btn: HTMLElement | null, app: IApp, category: string) => void; _modalManager: { isViewingCategory: (category: string) => boolean; - refreshCurrentSelection: () => void; + refreshCurrentSelection: (apps?: IApp[], selectedId?: string | null) => void; }; }; - - vi.spyOn(privateAppUI._modalManager, 'isViewingCategory').mockReturnValue(false); + vi.spyOn(privateAppUI._modalManager, 'isViewingCategory').mockReturnValue(true); const refreshSpy = vi.spyOn(privateAppUI._modalManager, 'refreshCurrentSelection'); - const btn = document.createElement('button'); - btn.className = 'download-btn downloading indeterminate'; - const app = { id: 'local-app', name: 'Local App', installed: false } as IApp; + globalThis.dispatchEvent(new Event('catalog-loaded')); + + expect(refreshSpy).toHaveBeenCalledWith(refreshedApps, null); + }); + + it('should clear selected integration without stopping it when it disappears from catalog', () => { + appUI = createAppUI(); + document.body.innerHTML = ` +
+
+
+
+
+ `; + const missingApp = { + id: 'axelate-telegram-parser', + name: 'Parser', + installed: true, + type: 'local', + } as IApp; + appUI.updateModuleCard('services', missingApp); + getCatalogCategoryMock.mockReturnValue([]); + + globalThis.dispatchEvent(new Event('catalog-loaded')); + + expect(uiStateMocks.removeSelectedModule).toHaveBeenCalledWith('services'); + expect(platformServiceMock.stop).not.toHaveBeenCalledWith(missingApp, 'services'); + expect(document.getElementById('services-module-card')?.classList.contains('empty')).toBe( + true, + ); + }); + + it('should clear selected AI slots when their module disappears from catalog', () => { + appUI = createAppUI(); + document.body.innerHTML = ` +
+
+
+
+
+ `; + const missingApp = { + id: 'local-text-engine', + name: 'Local Text Engine', + installed: true, + type: 'local', + capability: 'text', + } as IApp; + appUI.updateModuleCard('ai_text', missingApp); + getCatalogCategoryMock.mockImplementation((category: string) => + category === 'ai' ? [] : [], + ); - privateAppUI._onModalDownloadSuccess(btn, app, 'services'); + globalThis.dispatchEvent(new Event('catalog-loaded')); - expect(app.installed).toBe(true); - expect(refreshSpy).not.toHaveBeenCalled(); + expect(uiStateMocks.removeSelectedModule).toHaveBeenCalledWith('ai_text'); + expect(document.getElementById('ai-module-card')?.classList.contains('empty')).toBe(true); }); it('should resolve app by id from injected catalog resolver', () => { diff --git a/src/shared/shell/AppUI.ts b/src/shared/shell/AppUI.ts index 1106cd93..aacfa615 100644 --- a/src/shared/shell/AppUI.ts +++ b/src/shared/shell/AppUI.ts @@ -14,6 +14,7 @@ import { AppUiLifecycleBindings } from './ui/AppUiLifecycleBindings'; import { AppUiModuleFlow } from './ui/AppUiModuleFlow'; import { AppUiModuleLifecycle } from './ui/AppUiModuleLifecycle'; import { AppUiSelectionFlow } from './ui/AppUiSelectionFlow'; +import { closeIntegrationImportDialogs } from './ui/IntegrationImportDialog'; import { ModalManager } from './ui/ModalManager'; import { ModuleCardRenderer } from './ui/ModuleCardRenderer'; import { SkeletonManager } from './ui/SkeletonManager'; @@ -24,6 +25,8 @@ import { type NavigationService } from '@/infrastructure/navigation/NavigationSe type AppUIStateDeps = { removeSelectedModule: (category: string) => void; setSelectedModule: (category: string, moduleData: Partial) => void; + getIntegrationImportLastDirectory: () => string | null; + setIntegrationImportLastDirectory: (path: string | null) => void; }; type AppUIDeps = { @@ -32,6 +35,8 @@ type AppUIDeps = { launchApp: (category: string, app: IApp) => Promise; openModuleSettings: (app: IApp) => void; stopAiProvider: () => void; + reloadCatalog: () => Promise; + openExternalUrl: (url: string) => Promise; }; /** @@ -42,6 +47,8 @@ type AppUIDeps = { // Note: Window interface extensions are defined in core.ts export class AppUI { + private static readonly _urlPattern = /https?:\/\/[^\s<>"')\]]+/iu; + private readonly _chrome: AppUiChrome; private readonly _toastManager: ToastManager; private readonly _modalManager: ModalManager; @@ -74,8 +81,6 @@ export class AppUI { this._chrome = new AppUiChrome(this._translate, this._deps.tracer); this._toastManager = new ToastManager(); this._cardRenderer = new ModuleCardRenderer({ - checkInstalled: async (moduleId) => - await this._platformService.checkInstalled(moduleId), translate: this._translate, tracer: this._deps.tracer, openModuleSettings: (app) => { @@ -96,6 +101,7 @@ export class AppUI { removeSelectedModule: (category) => { this._deps.uiState.removeSelectedModule(category); }, + stopSelectedApp: (app, category) => this._platformService.stop(app, category), openAppSelection: (category) => this.openAppSelection(category), updateMultiSlotBadge: () => this._updateMultiSlotBadge(), activateAiSlot: (category, app) => { @@ -108,7 +114,8 @@ export class AppUI { this._cardRenderer, async (e, app, category) => await this._handleAppCardClick(e, app, category), (capability) => this._selectionState.get(`ai_${capability}`)?.id ?? null, - async (app) => await this._platformService.download(app), + async (app, category, btn) => + await this._moduleFlow.handleDownloadModule(app, category, btn), async (app) => { await this._platformService.cancelDownload(app.id); }, @@ -121,6 +128,9 @@ export class AppUI { async (app) => { await this._platformService.resumeDownload(app.id); }, + (action) => { + void this._moduleFlow.handleIntegrationImport(action); + }, ); this._moduleFlow = new AppUiModuleFlow({ platformService: this._platformService, @@ -128,12 +138,21 @@ export class AppUI { modalManager: this._modalManager, getCatalogApps: (category) => this._getCatalogApps(category), getSelectedAppId: (category) => this._selectionState.get(category)?.id ?? null, + getIntegrationImportLastDirectory: () => + this._deps.uiState.getIntegrationImportLastDirectory(), + setIntegrationImportLastDirectory: (path) => + this._deps.uiState.setIntegrationImportLastDirectory(path), clearModuleCard: (category) => this.clearModuleCard(category), - openAppSelection: (category, apps) => this.openAppSelection(category, apps), markSlotCardAsInstalled: (card, app) => this._dashboardSupport.markSlotCardAsInstalled(card, app), showToast: (message, type = 'info') => this.showToast(message, type), translate: this._translate, + reloadCatalog: async () => { + await this._deps.reloadCatalog(); + }, + openExternalUrl: async (url) => { + await this._deps.openExternalUrl(url); + }, }); this._cardActionFlow = new AppUiCardActionFlow({ platformService: this._platformService, @@ -173,7 +192,7 @@ export class AppUI { updateModalSelection: (appId) => this._modalManager.updateSelection(appId), bumpLaunchSelectionVersion: (category) => this._moduleLifecycle.bumpLaunchSelectionVersion(category), - stopSelectedApp: (app) => this._platformService.stop(app), + stopSelectedApp: (app, category) => this._platformService.stop(app, category), launchSelectedApp: (category, app, launchSelectionVersion, launchApp) => this._moduleLifecycle.launchSelectedApp( category, @@ -191,11 +210,16 @@ export class AppUI { }); this._lifecycleBindings = new AppUiLifecycleBindings({ eventBus: this._eventBus, + onCatalogLoaded: () => { + this._reconcileSelectionsWithCatalog(); + this._refreshOpenServicesSelection(); + }, onLanguageChanged: () => { this._modalManager.refreshCurrentSelection(); }, onPageChange: ({ pageId }) => { if (pageId !== 'modules' && pageId !== 'page-modules') { + closeIntegrationImportDialogs(); this.closeAppSelection(); } }, @@ -242,7 +266,32 @@ export class AppUI { id: string | null = null, onClick: (() => void) | null = null, ): void { - this._toastManager.show(message, type, duration, title, id, onClick); + this._toastManager.show( + message, + type, + duration, + title, + id, + onClick ?? this._createLinkToastAction(message), + ); + } + + private _createLinkToastAction(message: string): (() => void) | null { + const url = AppUI._extractFirstUrl(message); + if (url === null) { + return null; + } + + return () => { + void this._deps.openExternalUrl(url).catch((error: unknown) => { + this._deps.tracer.warn(`[AppUI] Failed to open toast link: ${String(error)}`); + }); + }; + } + + private static _extractFirstUrl(message: string): string | null { + const match = AppUI._urlPattern.exec(message); + return match?.[0].replace(/[.,!?;:]+$/u, '') ?? null; } // --- Action Feedback --- @@ -308,7 +357,6 @@ export class AppUI { } this._dashboardSupport.cancelPendingSwitch(); - this._moduleLifecycle.stopPreviousModule(card, app, category); this._dashboardSupport.applySelectedCardState(card, app, category); this._selectionState.set(category, app); this._updateMultiSlotBadge(); @@ -356,7 +404,7 @@ export class AppUI { return; } - void this._platformService.stop(app).catch((err: unknown) => { + void this._platformService.stop(app, category).catch((err: unknown) => { this._deps.tracer.warn( `[AppUI] Failed to stop removed module ${app.id}: ${String(err)}`, ); @@ -404,14 +452,6 @@ export class AppUI { await this._moduleFlow.handleDeleteModule(app, category); } - public _onModalDownloadSuccess(btn: HTMLElement | null, app: IApp, category: string): void { - this._moduleFlow.onModalDownloadSuccess(btn, app, category); - } - - public _onModalDownloadError(btn: HTMLElement | null, err: unknown): void { - this._moduleFlow.onModalDownloadError(btn, err); - } - private _resolveAppById(appId: string): IApp | undefined { for (const selectedApp of this._selectionState.values()) { if (selectedApp.id === appId) { @@ -431,13 +471,51 @@ export class AppUI { return this._getCatalogApps(this._dashboardSupport.resolveCatalogCategory(category)); } - public _resolveCategoryFromCard(card: HTMLElement): string { - const currentCapability = card.dataset['currentCapability']; - if (typeof currentCapability === 'string' && currentCapability !== '') { - return currentCapability; + private _refreshOpenServicesSelection(): void { + if (!this._modalManager.isViewingCategory('services')) { + return; } - return card.id === 'ai-module-card' ? CategoryKey.AI_TEXT : CategoryKey.SERVICES; + this._modalManager.refreshCurrentSelection( + this._getCatalogApps('services'), + this._selectionState.get('services')?.id ?? null, + ); + } + + private _reconcileSelectionsWithCatalog(): void { + this._clearMissingSelection(CategoryKey.AI_TEXT); + this._clearMissingSelection(CategoryKey.AI_IMAGE); + this._clearMissingSelection(CategoryKey.SERVICES); + } + + private _clearMissingSelection(category: string): void { + const selectedApp = this._selectionState.get(category); + if (selectedApp === undefined) { + return; + } + + const catalogCategory = this._dashboardSupport.resolveCatalogCategory(category); + const stillExists = this._getCatalogApps(catalogCategory).some((app) => { + return app.id === selectedApp.id; + }); + if (stillExists) { + return; + } + + this._deps.tracer.warn( + `[AppUI] Selected module disappeared from catalog, clearing ${category}: ${selectedApp.id}`, + ); + const card = this._dashboardSupport.getDashboardCard(category); + this._dashboardSupport.cancelPendingSwitch(); + this._moduleLifecycle.bumpLaunchSelectionVersion(category); + this._selectionState.delete(category); + if (card instanceof HTMLElement) { + this._dashboardSupport.resetCardToEmpty(card); + } + this._deps.uiState.removeSelectedModule(category); + if (this._modalManager.isViewingCategory(catalogCategory)) { + this._modalManager.updateSelection(null); + } } public getPreferredAiCategory(): 'ai_text' | 'ai_image' { diff --git a/src/shared/shell/GlobalTextContextMenu.test.ts b/src/shared/shell/GlobalTextContextMenu.test.ts new file mode 100644 index 00000000..a0ad6a44 --- /dev/null +++ b/src/shared/shell/GlobalTextContextMenu.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { GlobalTextContextMenu } from './GlobalTextContextMenu'; + +function createMenu(options?: { clipboardText?: string | null }) { + const copyText = vi.fn().mockResolvedValue(undefined); + const readClipboardText = vi.fn().mockResolvedValue(options?.clipboardText ?? ' pasted'); + const warn = vi.fn(); + const menu = new GlobalTextContextMenu({ + translate: (_key, fallback) => fallback ?? _key, + copyText, + readClipboardText, + tracer: { warn }, + }); + + menu.init(); + return { menu, copyText, readClipboardText, warn }; +} + +function openContextMenu(target: HTMLElement): void { + target.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 30, + }), + ); +} + +function getButton(action: string): HTMLButtonElement { + const button = document.querySelector(`[data-action="${action}"]`); + expect(button).not.toBeNull(); + return button as HTMLButtonElement; +} + +describe('GlobalTextContextMenu', () => { + let activeMenu: GlobalTextContextMenu | null = null; + + beforeEach(() => { + document.body.innerHTML = ''; + }); + + afterEach(() => { + activeMenu?.destroy(); + activeMenu = null; + }); + + it('replaces the browser menu for normal inputs', async () => { + const input = document.createElement('input'); + input.value = 'hello'; + input.setSelectionRange(0, 5); + document.body.appendChild(input); + + activeMenu = createMenu().menu; + openContextMenu(input); + await vi.waitFor(() => { + expect(document.querySelector('.chat-input-context-menu')).not.toBeNull(); + }); + + expect(getButton('copy').disabled).toBe(false); + expect(getButton('paste').disabled).toBe(false); + }); + + it('copies, cuts, pastes, and selects text controls', async () => { + const input = document.createElement('input'); + input.value = 'hello world'; + input.setSelectionRange(0, 5); + document.body.appendChild(input); + const created = createMenu({ clipboardText: 'Axelate' }); + activeMenu = created.menu; + const { copyText } = created; + + openContextMenu(input); + await vi.waitFor(() => expect(getButton('copy')).not.toBeNull()); + getButton('copy').click(); + await vi.waitFor(() => expect(copyText).toHaveBeenCalledWith('hello')); + + input.setSelectionRange(6, 11); + openContextMenu(input); + await vi.waitFor(() => expect(getButton('cut')).not.toBeNull()); + getButton('cut').click(); + await vi.waitFor(() => expect(input.value).toBe('hello ')); + + openContextMenu(input); + await vi.waitFor(() => expect(getButton('paste')).not.toBeNull()); + getButton('paste').click(); + await vi.waitFor(() => expect(input.value).toBe('hello Axelate')); + + openContextMenu(input); + await vi.waitFor(() => expect(getButton('selectAll')).not.toBeNull()); + getButton('selectAll').click(); + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe(input.value.length); + }); + + it('does not open on non-editable launcher chrome', async () => { + const button = document.createElement('button'); + button.textContent = 'Home'; + document.body.appendChild(button); + activeMenu = createMenu().menu; + + openContextMenu(button); + await Promise.resolve(); + + expect(document.querySelector('.chat-input-context-menu')).toBeNull(); + }); +}); diff --git a/src/shared/shell/GlobalTextContextMenu.ts b/src/shared/shell/GlobalTextContextMenu.ts new file mode 100644 index 00000000..516ca67f --- /dev/null +++ b/src/shared/shell/GlobalTextContextMenu.ts @@ -0,0 +1,384 @@ +type GlobalTextContextMenuTranslate = (key: string, fallback?: string) => string; + +type GlobalTextContextMenuDeps = { + translate: GlobalTextContextMenuTranslate; + copyText: (text: string) => Promise; + readClipboardText: () => Promise; + tracer: { + warn: (message: string, ...args: unknown[]) => void; + }; +}; + +type TextContextAction = 'cut' | 'copy' | 'paste' | 'selectAll'; + +type TextContextItem = { + action: TextContextAction; + labelKey: string; + fallback: string; + shortcut?: string; + dividerBefore?: boolean; +}; + +type EditableTarget = HTMLInputElement | HTMLTextAreaElement | HTMLElement; + +const TEXT_CONTEXT_ITEMS: TextContextItem[] = [ + { + action: 'cut', + labelKey: 'ui.chat.input_menu.cut', + fallback: 'Cut', + shortcut: 'Ctrl+X', + }, + { + action: 'copy', + labelKey: 'ui.chat.input_menu.copy', + fallback: 'Copy', + shortcut: 'Ctrl+C', + }, + { + action: 'paste', + labelKey: 'ui.chat.input_menu.paste', + fallback: 'Paste', + shortcut: 'Ctrl+V', + }, + { + action: 'selectAll', + labelKey: 'ui.chat.input_menu.select_all', + fallback: 'Select all', + shortcut: 'Ctrl+A', + dividerBefore: true, + }, +]; + +export class GlobalTextContextMenu { + private _menu: HTMLDivElement | null = null; + private _target: EditableTarget | null = null; + private _clipboardText: string | null = null; + private _openToken = 0; + private _abort: AbortController | null = null; + + private readonly _boundContextMenu = (event: MouseEvent) => { + this._handleContextMenu(event); + }; + private readonly _boundDocumentPointerDown = (event: PointerEvent) => { + const target = event.target; + if (target instanceof Node && this._menu?.contains(target) === true) { + return; + } + this.close(); + }; + private readonly _boundKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + this.close(); + } + }; + private readonly _boundClose = () => { + this.close(); + }; + + public constructor(private readonly _deps: GlobalTextContextMenuDeps) {} + + public init(): void { + if (this._abort !== null) return; + + this._abort = new AbortController(); + document.addEventListener('contextmenu', this._boundContextMenu, { + signal: this._abort.signal, + }); + } + + public destroy(): void { + this._abort?.abort(); + this._abort = null; + this.close(); + } + + public close(): void { + this._openToken += 1; + this._menu?.remove(); + this._menu = null; + this._target = null; + this._clipboardText = null; + document.removeEventListener('pointerdown', this._boundDocumentPointerDown, { + capture: true, + }); + document.removeEventListener('keydown', this._boundKeyDown, { capture: true }); + window.removeEventListener('blur', this._boundClose); + window.removeEventListener('resize', this._boundClose); + document.removeEventListener('scroll', this._boundClose, { capture: true }); + } + + private _handleContextMenu(event: MouseEvent): void { + const target = this._resolveEditableTarget(event.target); + if (target === null) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + target.focus({ preventScroll: true }); + this._open(target, event.clientX, event.clientY); + } + + private _resolveEditableTarget(target: EventTarget | null): EditableTarget | null { + if (!(target instanceof Element)) { + return null; + } + + const editable = target.closest( + 'input, textarea, [contenteditable="true"], [role="textbox"]', + ); + if (editable === null) { + return null; + } + + if (editable instanceof HTMLInputElement) { + const type = editable.type.toLowerCase(); + if ( + [ + 'button', + 'checkbox', + 'color', + 'file', + 'hidden', + 'image', + 'radio', + 'range', + 'reset', + 'submit', + ].includes(type) + ) { + return null; + } + return editable; + } + + return editable; + } + + private _open(target: EditableTarget, clientX: number, clientY: number): void { + this.close(); + const openToken = this._openToken; + this._target = target; + void this._openWithClipboardState(target, clientX, clientY, openToken); + } + + private async _openWithClipboardState( + target: EditableTarget, + clientX: number, + clientY: number, + openToken: number, + ): Promise { + const clipboardText = await this._readClipboardForMenu(); + if (this._openToken !== openToken || this._target !== target) { + return; + } + + this._clipboardText = clipboardText; + + const menu = document.createElement('div'); + menu.className = 'chat-input-context-menu'; + menu.setAttribute('role', 'menu'); + menu.tabIndex = -1; + + const state = this._getState(target); + for (const item of TEXT_CONTEXT_ITEMS) { + if (item.dividerBefore === true) { + const divider = document.createElement('div'); + divider.className = 'chat-input-context-menu-divider'; + divider.setAttribute('role', 'separator'); + menu.appendChild(divider); + } + menu.appendChild(this._createButton(target, item, state)); + } + + if (this._openToken !== openToken || this._target !== target) { + return; + } + + document.body.appendChild(menu); + this._menu = menu; + this._positionMenu(menu, clientX, clientY); + menu.focus({ preventScroll: true }); + + document.addEventListener('pointerdown', this._boundDocumentPointerDown, { + capture: true, + }); + document.addEventListener('keydown', this._boundKeyDown, { capture: true }); + window.addEventListener('blur', this._boundClose); + window.addEventListener('resize', this._boundClose); + document.addEventListener('scroll', this._boundClose, { capture: true }); + } + + private async _readClipboardForMenu(): Promise { + try { + return await this._deps.readClipboardText(); + } catch (error) { + this._deps.tracer.warn('[GlobalTextContextMenu] Clipboard read failed:', error); + return null; + } + } + + private _createButton( + target: EditableTarget, + item: TextContextItem, + state: { hasSelection: boolean; hasText: boolean; readOnly: boolean }, + ): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'chat-input-context-menu-item'; + button.dataset['action'] = item.action; + button.setAttribute('role', 'menuitem'); + button.disabled = this._isDisabled(item.action, state); + + const label = document.createElement('span'); + label.className = 'chat-input-context-menu-label'; + label.textContent = this._deps.translate(item.labelKey, item.fallback); + button.appendChild(label); + + if (item.shortcut !== undefined) { + const shortcut = document.createElement('span'); + shortcut.className = 'chat-input-context-menu-shortcut'; + shortcut.textContent = item.shortcut; + button.appendChild(shortcut); + } + + button.addEventListener('click', () => { + void this._runAction(target, item.action); + }); + + return button; + } + + private _getState(target: EditableTarget): { + hasSelection: boolean; + hasText: boolean; + readOnly: boolean; + } { + if (this._isTextControl(target)) { + return { + hasSelection: target.selectionStart !== target.selectionEnd, + hasText: target.value.length > 0, + readOnly: target.readOnly || target.disabled, + }; + } + + const text = target.textContent; + return { + hasSelection: window.getSelection()?.toString() !== '', + hasText: text.length > 0, + readOnly: target.getAttribute('contenteditable') !== 'true', + }; + } + + private _isDisabled( + action: TextContextAction, + state: { hasSelection: boolean; hasText: boolean; readOnly: boolean }, + ): boolean { + if (action === 'cut') { + return state.readOnly || !state.hasSelection; + } + if (action === 'copy') { + return !state.hasSelection; + } + if (action === 'paste') { + return state.readOnly || this._clipboardText === null || this._clipboardText === ''; + } + return !state.hasText; + } + + private async _runAction(target: EditableTarget, action: TextContextAction): Promise { + try { + if (action === 'selectAll') { + this._selectAll(target); + this.close(); + return; + } + + if (action === 'copy' || action === 'cut') { + const selectedText = this._selectedText(target); + if (selectedText === '') { + this.close(); + return; + } + + await this._deps.copyText(selectedText); + if (action === 'cut') { + this._replaceSelection(target, ''); + } + this.close(); + return; + } + + const text = this._clipboardText; + if (text !== null && text !== '') { + this._replaceSelection(target, text); + } + this.close(); + } catch (error) { + this._deps.tracer.warn('[GlobalTextContextMenu] Action failed:', error); + this.close(); + } + } + + private _selectedText(target: EditableTarget): string { + if (this._isTextControl(target)) { + return target.value.slice(target.selectionStart ?? 0, target.selectionEnd ?? 0); + } + + return window.getSelection()?.toString() ?? ''; + } + + private _replaceSelection(target: EditableTarget, text: string): void { + target.focus({ preventScroll: true }); + if (this._isTextControl(target)) { + target.setRangeText(text, target.selectionStart ?? 0, target.selectionEnd ?? 0, 'end'); + this._dispatchInputChange(target); + return; + } + + document.execCommand('insertText', false, text); + this._dispatchInputChange(target); + } + + private _selectAll(target: EditableTarget): void { + target.focus({ preventScroll: true }); + if (this._isTextControl(target)) { + target.select(); + return; + } + + const range = document.createRange(); + range.selectNodeContents(target); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } + + private _dispatchInputChange(target: EditableTarget): void { + target.dispatchEvent(new Event('input', { bubbles: true })); + target.dispatchEvent(new Event('change', { bubbles: true })); + } + + private _isTextControl( + target: EditableTarget, + ): target is HTMLInputElement | HTMLTextAreaElement { + return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement; + } + + private _positionMenu(menu: HTMLElement, clientX: number, clientY: number): void { + const viewportPadding = 8; + const rect = menu.getBoundingClientRect(); + const left = Math.min( + Math.max(clientX, viewportPadding), + window.innerWidth - rect.width - viewportPadding, + ); + const top = Math.min( + Math.max(clientY, viewportPadding), + window.innerHeight - rect.height - viewportPadding, + ); + + menu.style.left = `${Math.round(left)}px`; + menu.style.top = `${Math.round(top)}px`; + } +} diff --git a/src/shared/shell/Particles.test.ts b/src/shared/shell/Particles.test.ts index f461ee78..cd79c798 100644 --- a/src/shared/shell/Particles.test.ts +++ b/src/shared/shell/Particles.test.ts @@ -151,4 +151,21 @@ describe('Particles', () => { particles.destroy(); }); + + it('reuses the reduced-motion media query across focus restores', () => { + const particles = new Particles(); + const matchMediaSpy = globalThis.matchMedia as unknown as ReturnType; + + expect(matchMediaSpy).toHaveBeenCalledTimes(1); + + Object.defineProperty(document, 'hidden', { configurable: true, value: true }); + document.dispatchEvent(new Event('visibilitychange')); + Object.defineProperty(document, 'hidden', { configurable: true, value: false }); + document.dispatchEvent(new Event('visibilitychange')); + globalThis.dispatchEvent(new Event('focus')); + globalThis.dispatchEvent(new Event('focus')); + + expect(matchMediaSpy).toHaveBeenCalledTimes(1); + particles.destroy(); + }); }); diff --git a/src/shared/shell/Particles.ts b/src/shared/shell/Particles.ts index cc6dcf8b..7aed97a4 100644 --- a/src/shared/shell/Particles.ts +++ b/src/shared/shell/Particles.ts @@ -81,6 +81,7 @@ export class Particles { private _animationFrameId: number | null = null; private _animationTimerId: ReturnType | null = null; private _resizeFrameId: number | null = null; + private _motionQuery: MediaQueryList | null = null; private readonly _cleanupAbort: AbortController = new AbortController(); constructor(private readonly _runtime: ParticlesRuntime = createDefaultParticlesRuntime()) { @@ -195,6 +196,7 @@ export class Particles { this._canvas.remove(); this._particles = []; this._particlesByColor = {}; + this._motionQuery = null; } private _init(): void { @@ -268,7 +270,7 @@ export class Particles { { signal }, ); - const motionQuery = this._runtime.matchMedia('(prefers-reduced-motion: reduce)'); + const motionQuery = this._getMotionQuery(); const handleMotion = (): void => { if (motionQuery.matches) this.stop(); else this.start(); @@ -287,15 +289,17 @@ export class Particles { } private _checkReducedMotionAndStart(): void { - const motionQuery = this._runtime.matchMedia('(prefers-reduced-motion: reduce)'); + const motionQuery = this._getMotionQuery(); if (!motionQuery.matches) { this.start(); } } public start(): void { + if (!this._isTauriRuntime) return; + if (!this._isRunning) { - const motionQuery = this._runtime.matchMedia('(prefers-reduced-motion: reduce)'); + const motionQuery = this._getMotionQuery(); if (motionQuery.matches) return; this._isRunning = true; @@ -304,6 +308,11 @@ export class Particles { } } + private _getMotionQuery(): MediaQueryList { + this._motionQuery ??= this._runtime.matchMedia('(prefers-reduced-motion: reduce)'); + return this._motionQuery; + } + public stop(): void { this._isRunning = false; this._cancelAnimationFrame(); diff --git a/src/shared/shell/SidebarNavigationRenderer.test.ts b/src/shared/shell/SidebarNavigationRenderer.test.ts index 2424395f..bddd1a3f 100644 --- a/src/shared/shell/SidebarNavigationRenderer.test.ts +++ b/src/shared/shell/SidebarNavigationRenderer.test.ts @@ -21,7 +21,7 @@ describe('SidebarNavigationRenderer', () => { renderer.render(sidebar); - expect(document.querySelectorAll('.main-menu .nav-btn')).toHaveLength(6); + expect(document.querySelectorAll('.main-menu .nav-btn')).toHaveLength(5); expect(document.querySelectorAll('.bottom-menu .nav-btn')).toHaveLength(1); expect(document.querySelector('.console-trigger')?.getAttribute('data-page')).toBe( 'console', diff --git a/src/shared/shell/SidebarUI.test.ts b/src/shared/shell/SidebarUI.test.ts index 15e285ed..29a83f46 100644 --- a/src/shared/shell/SidebarUI.test.ts +++ b/src/shared/shell/SidebarUI.test.ts @@ -37,6 +37,7 @@ describe('SidebarUI', () => { getZoom: vi.fn(() => 1), getMaxSafeZoom: vi.fn(() => Number.POSITIVE_INFINITY), setMonitoringPaused: vi.fn().mockResolvedValue(undefined), + setMonitoringPauseReason: vi.fn().mockResolvedValue(undefined), }; const tracer = { info: vi.fn(), @@ -88,6 +89,7 @@ describe('SidebarUI', () => { windowService.getZoom.mockReturnValue(1); windowService.getMaxSafeZoom.mockReturnValue(Number.POSITIVE_INFINITY); windowService.setMonitoringPaused.mockResolvedValue(undefined); + windowService.setMonitoringPauseReason.mockResolvedValue(undefined); vi.clearAllMocks(); }); @@ -108,7 +110,7 @@ describe('SidebarUI', () => { await sidebarUi.init(); - expect(document.querySelectorAll('.main-menu .nav-btn')).toHaveLength(6); + expect(document.querySelectorAll('.main-menu .nav-btn')).toHaveLength(5); expect(document.querySelectorAll('.bottom-menu .nav-btn')).toHaveLength(1); expect(document.querySelector('.console-trigger')?.getAttribute('data-page')).toBe( 'console', @@ -183,7 +185,7 @@ describe('SidebarUI', () => { document.getElementById('system-monitor')?.classList.contains('adaptive-hidden'), ).toBe(true); expect(sidebar.classList.contains('monitor-hidden')).toBe(true); - expect(windowService.setMonitoringPaused).toHaveBeenCalledWith(true); + expect(windowService.setMonitoringPauseReason).toHaveBeenCalledWith('monitor-hidden', true); }); it('resumes monitoring when adaptive monitor is visible', async () => { @@ -199,7 +201,10 @@ describe('SidebarUI', () => { expect( document.getElementById('system-monitor')?.classList.contains('adaptive-hidden'), ).toBe(false); - expect(windowService.setMonitoringPaused).toHaveBeenCalledWith(false); + expect(windowService.setMonitoringPauseReason).toHaveBeenCalledWith( + 'monitor-hidden', + false, + ); }); it('enables auto compact when zoom threshold is reached', async () => { @@ -338,7 +343,7 @@ describe('SidebarUI', () => { const monitor = document.getElementById('system-monitor') as HTMLElement; expect(monitor.classList.contains('adaptive-hidden')).toBe(true); - expect(windowService.setMonitoringPaused).toHaveBeenCalledWith(true); + expect(windowService.setMonitoringPauseReason).toHaveBeenCalledWith('monitor-hidden', true); expect(sidebar.classList.contains('auto-compact')).toBe(false); expect(sidebar.style.width).toBe('280px'); diff --git a/src/shared/shell/SidebarUI.ts b/src/shared/shell/SidebarUI.ts index 6fae0f71..8787b9e8 100644 --- a/src/shared/shell/SidebarUI.ts +++ b/src/shared/shell/SidebarUI.ts @@ -175,7 +175,6 @@ export class SidebarUI extends BaseComponent { this._isCollapsed = !this._isCollapsed; this._hasManualSidebarOverride = false; } - this._startSnappingAnimation(); this._updateAutoCompactState(); this._applySidebarWidth(); this._persistSidebarPreferenceState(); @@ -311,7 +310,7 @@ export class SidebarUI extends BaseComponent { return; } const isMonitorVisible = this._monitorVisibilityController.update(elements); - void this._windowService?.setMonitoringPaused(!isMonitorVisible); + void this._windowService?.setMonitoringPauseReason('monitor-hidden', !isMonitorVisible); } private async _findSidebar(): Promise { diff --git a/src/shared/shell/WindowUI.test.ts b/src/shared/shell/WindowUI.test.ts index e3ba665f..ad70805f 100644 --- a/src/shared/shell/WindowUI.test.ts +++ b/src/shared/shell/WindowUI.test.ts @@ -55,6 +55,7 @@ describe('WindowUI lifecycle', () => { const service = { checkPolicy: vi.fn().mockResolvedValue({ isSmallScreen: false, showWarning: false }), setMonitoringPaused: vi.fn().mockResolvedValue(undefined), + setMonitoringPauseReason: vi.fn().mockResolvedValue(undefined), checkResolutionChange: vi.fn(), isMaximized: vi.fn().mockResolvedValue(false), toggleMaximize: vi.fn().mockResolvedValue(undefined), @@ -292,6 +293,14 @@ describe('WindowUI lifecycle', () => { document.dispatchEvent(blockedShortcut); expect(blockedShortcut.defaultPrevented).toBe(true); + const tabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(tabEvent); + expect(tabEvent.defaultPrevented).toBe(true); + const plainContext = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, @@ -330,7 +339,7 @@ describe('WindowUI lifecycle', () => { expect(dblClick.defaultPrevented).toBe(true); }); - it('should block window-level shortcuts while a dialog is open', () => { + it('should block window-level shortcuts while a dialog is open but still allow reload', () => { document.body.innerHTML = ` @@ -364,7 +373,7 @@ describe('WindowUI lifecycle', () => { }); document.dispatchEvent(refreshEvent); expect(refreshEvent.defaultPrevented).toBe(true); - expect(reloadSpy).not.toHaveBeenCalled(); + expect(reloadSpy).toHaveBeenCalled(); }); it('should manage monitoring, wheel zoom, tooltip suppression and maximize icon rebuild', async () => { @@ -380,7 +389,7 @@ describe('WindowUI lifecycle', () => { ui = createWindowUI(); const service = (ui as unknown as { _service: WindowService })._service as unknown as { - setMonitoringPaused: ReturnType; + setMonitoringPauseReason: ReturnType; changeZoom: ReturnType; persistZoom: ReturnType; }; @@ -407,12 +416,12 @@ describe('WindowUI lifecycle', () => { const focusSpy = vi.spyOn(document, 'hasFocus').mockReturnValue(false); document.dispatchEvent(new Event('visibilitychange')); globalThis.dispatchEvent(new Event('blur')); - expect(service.setMonitoringPaused).toHaveBeenCalledWith(true); + expect(service.setMonitoringPauseReason).toHaveBeenCalledWith('window-inactive', true); focusSpy.mockReturnValue(true); Object.defineProperty(document, 'hidden', { configurable: true, value: false }); globalThis.dispatchEvent(new Event('focus')); - expect(service.setMonitoringPaused).toHaveBeenCalledWith(false); + expect(service.setMonitoringPauseReason).toHaveBeenCalledWith('window-inactive', false); document.dispatchEvent( new WheelEvent('wheel', { diff --git a/src/shared/shell/WindowUI.ts b/src/shared/shell/WindowUI.ts index 92fc3703..fd22b2aa 100644 --- a/src/shared/shell/WindowUI.ts +++ b/src/shared/shell/WindowUI.ts @@ -77,7 +77,7 @@ export class WindowUI { await this._service.persistZoom(); }, setMonitoringPaused: async (paused) => { - await this._service.setMonitoringPaused(paused); + await this._service.setMonitoringPauseReason('window-inactive', paused); }, hasOpenDialog: () => this._hasOpenDialog(), isInGracePeriod: () => this._isInGracePeriod, diff --git a/src/shared/shell/WindowUiInteractionController.ts b/src/shared/shell/WindowUiInteractionController.ts index 7fa9552c..66c7a8df 100644 --- a/src/shared/shell/WindowUiInteractionController.ts +++ b/src/shared/shell/WindowUiInteractionController.ts @@ -155,6 +155,16 @@ export class WindowUiInteractionController { } private _handleKeydown(e: KeyboardEvent): void { + if (e.key === 'Tab') { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + return; + } + if ( e.key === 'F12' || (e.ctrlKey && @@ -166,6 +176,12 @@ export class WindowUiInteractionController { return; } + if (this._isReloadShortcut(e)) { + e.preventDefault(); + this._deps.runtime.reload(); + return; + } + if (this._deps.hasOpenDialog() && this._isWindowShortcut(e)) { e.preventDefault(); e.stopPropagation(); @@ -180,12 +196,6 @@ export class WindowUiInteractionController { return; } - if (this._isReloadShortcut(e)) { - e.preventDefault(); - this._deps.runtime.reload(); - return; - } - if (e.ctrlKey && BLOCKED_CTRL_KEYS.includes(e.key.toLowerCase() as never)) { e.preventDefault(); e.stopPropagation(); diff --git a/src/shared/shell/ui/AppUiCardActionFlow.test.ts b/src/shared/shell/ui/AppUiCardActionFlow.test.ts index a86625dc..acc0166f 100644 --- a/src/shared/shell/ui/AppUiCardActionFlow.test.ts +++ b/src/shared/shell/ui/AppUiCardActionFlow.test.ts @@ -78,14 +78,13 @@ describe('AppUiCardActionFlow', () => { const event = { stopPropagation: vi.fn(), currentTarget: card, - target: card, + target: btn, clientX: 20, } as unknown as MouseEvent; const app = { id: 'svc', installed: false, repoUrl: 'https://repo' } as IApp; platformService.delete.mockResolvedValue(undefined); await flow.tryDownloadAction(event, app, 'services'); - await Promise.resolve(); expect(deps.pauseDownload).toHaveBeenCalledWith('svc'); expect(btn.dataset['downloadStatus']).toBe('paused'); @@ -114,13 +113,12 @@ describe('AppUiCardActionFlow', () => { const event = { stopPropagation: vi.fn(), currentTarget: card, - target: card, + target: btn, clientX: 20, } as unknown as MouseEvent; const app = { id: 'svc', installed: false, repoUrl: 'https://repo' } as IApp; await flow.tryDownloadAction(event, app, 'services'); - await Promise.resolve(); expect(deps.resumeDownload).toHaveBeenCalledWith('svc'); expect(btn.dataset['downloadStatus']).toBe('downloading'); @@ -145,17 +143,139 @@ describe('AppUiCardActionFlow', () => { const event = { stopPropagation: vi.fn(), currentTarget: card, - target: card, + target: btn, clientX: 75, } as unknown as MouseEvent; const app = { id: 'svc', installed: false, repoUrl: 'https://repo' } as IApp; await flow.tryDownloadAction(event, app, 'services'); - await Promise.resolve(); expect(deps.cancelDownload).toHaveBeenCalledWith('svc'); expect(platformService.delete).not.toHaveBeenCalled(); expect(deps.resetDownloadButton).toHaveBeenCalledWith(btn); expect(deps.restoreDownloadButtonLabel).toHaveBeenCalledWith(btn); }); + + it('keeps active download button state when pause is rejected by backend', async () => { + deps.pauseDownload.mockResolvedValue(false); + const card = document.createElement('div'); + card.className = 'app-card'; + const btn = document.createElement('button'); + btn.className = 'download-btn downloading'; + btn.dataset['resumeLabel'] = 'Resume'; + btn.innerHTML = 'Pause'; + btn.getBoundingClientRect = vi.fn( + () => + ({ + left: 0, + width: 100, + }) as DOMRect, + ); + card.appendChild(btn); + const event = { + stopPropagation: vi.fn(), + currentTarget: card, + target: btn, + clientX: 20, + } as unknown as MouseEvent; + const app = { id: 'svc', installed: false, repoUrl: 'https://repo' } as IApp; + + await flow.tryDownloadAction(event, app, 'services'); + + expect(btn.dataset['downloadStatus']).toBeUndefined(); + expect(btn.querySelector('.download-hover-action-pause')?.textContent).toBe('Pause'); + expect(deps.resetDownloadButton).not.toHaveBeenCalled(); + expect(deps.showToast).toHaveBeenCalledWith('Download control failed', 'warning'); + }); + + it('keeps active download button state when cancel is rejected by backend', async () => { + deps.cancelDownload.mockResolvedValue(false); + const card = document.createElement('div'); + card.className = 'app-card'; + const btn = document.createElement('button'); + btn.className = 'download-btn downloading'; + btn.getBoundingClientRect = vi.fn( + () => + ({ + left: 0, + width: 100, + }) as DOMRect, + ); + card.appendChild(btn); + const event = { + stopPropagation: vi.fn(), + currentTarget: card, + target: btn, + clientX: 75, + } as unknown as MouseEvent; + const app = { id: 'svc', installed: false, repoUrl: 'https://repo' } as IApp; + + await flow.tryDownloadAction(event, app, 'services'); + + expect(deps.cancelDownload).toHaveBeenCalledWith('svc'); + expect(deps.resetDownloadButton).not.toHaveBeenCalled(); + expect(deps.restoreDownloadButtonLabel).not.toHaveBeenCalled(); + expect(deps.showToast).toHaveBeenCalledWith('Download control failed', 'warning'); + }); + + it('starts a download from a plain uninstalled card click', async () => { + const card = document.createElement('div'); + card.className = 'app-card'; + const btn = document.createElement('button'); + btn.className = 'download-btn'; + card.appendChild(btn); + + const event = { + stopPropagation: vi.fn(), + currentTarget: card, + target: card, + clientX: 20, + } as unknown as MouseEvent; + const app = { id: 'llamacpp', installed: false, repoUrl: 'https://repo' } as IApp; + + await flow.handleAppCardClick(event, app, 'ai_text'); + + expect(event.stopPropagation).toHaveBeenCalled(); + expect(deps.handleDownloadModule).toHaveBeenCalledWith(app, 'ai_text', btn); + expect(deps.performSelectionAction).not.toHaveBeenCalled(); + }); + + it('ignores plain card clicks while download is already active', async () => { + const card = document.createElement('div'); + card.className = 'app-card'; + const btn = document.createElement('button'); + btn.className = 'download-btn downloading'; + card.appendChild(btn); + + const event = { + stopPropagation: vi.fn(), + currentTarget: card, + target: card, + clientX: 20, + } as unknown as MouseEvent; + const app = { id: 'llamacpp', installed: false, repoUrl: 'https://repo' } as IApp; + + await flow.handleAppCardClick(event, app, 'ai_text'); + + expect(deps.handleDownloadModule).not.toHaveBeenCalled(); + expect(deps.pauseDownload).not.toHaveBeenCalled(); + expect(deps.cancelDownload).not.toHaveBeenCalled(); + expect(deps.performSelectionAction).not.toHaveBeenCalled(); + }); + + it('selects installed local cards from a plain card click', async () => { + const card = document.createElement('div'); + card.className = 'app-card'; + const event = { + stopPropagation: vi.fn(), + currentTarget: card, + target: card, + clientX: 20, + } as unknown as MouseEvent; + const app = { id: 'llamacpp', installed: true, repoUrl: 'https://repo' } as IApp; + + await flow.handleAppCardClick(event, app, 'ai_text'); + + expect(deps.performSelectionAction).toHaveBeenCalledWith('ai_text', app); + }); }); diff --git a/src/shared/shell/ui/AppUiCardActionFlow.ts b/src/shared/shell/ui/AppUiCardActionFlow.ts index 5c07e45a..36763f21 100644 --- a/src/shared/shell/ui/AppUiCardActionFlow.ts +++ b/src/shared/shell/ui/AppUiCardActionFlow.ts @@ -34,6 +34,9 @@ export class AppUiCardActionFlow { } if (await this.tryDeleteAction(event, app, category)) return; if (await this.tryDownloadAction(event, app, category)) return; + if (!this._deps.platformService.isApiModule(app) && app.installed !== true) { + return; + } this._deps.performSelectionAction(category, app); } @@ -58,6 +61,14 @@ export class AppUiCardActionFlow { return true; } + const btn = this._resolveDownloadButton(event); + const clickedDownloadButton = (event.target as HTMLElement | null)?.closest( + '.download-btn', + ); + if (clickedDownloadButton === null && btn?.classList.contains('downloading') === true) { + return false; + } + if (this._deps.platformService.isApiModule(app) || app.installed === true) { return false; } @@ -75,9 +86,8 @@ export class AppUiCardActionFlow { } event.stopPropagation(); - const btn = this._resolveDownloadButton(event); - if (btn?.classList.contains('downloading') === true) { - this._handleActiveDownloadAction(event, app, btn); + if (clickedDownloadButton !== null && btn?.classList.contains('downloading') === true) { + await this._handleActiveDownloadAction(event, app, btn); return true; } @@ -91,35 +101,58 @@ export class AppUiCardActionFlow { return card?.querySelector('.download-btn') ?? null; } - private _handleActiveDownloadAction(event: MouseEvent, app: IApp, btn: HTMLElement): void { + private async _handleActiveDownloadAction( + event: MouseEvent, + app: IApp, + btn: HTMLElement, + ): Promise { const action = resolveDownloadButtonAction(btn as HTMLButtonElement, event); if (action === 'pause') { this._deps.tracer.info(`[AppUI] Pausing download for: ${app.id}`); - markModuleCardDownloadPaused(btn); - void this._deps.pauseDownload(app.id); + if (await this._deps.pauseDownload(app.id)) { + markModuleCardDownloadPaused(btn); + } else { + this._showDownloadControlFailed(app.id, 'pause'); + } return; } if (action === 'resume') { this._deps.tracer.info(`[AppUI] Resuming download for: ${app.id}`); - markModuleCardDownloadResuming(btn); - void this._deps.resumeDownload(app.id); + if (await this._deps.resumeDownload(app.id)) { + markModuleCardDownloadResuming(btn); + } else { + this._showDownloadControlFailed(app.id, 'resume'); + } return; } - this._cancelDownload(app, btn); + await this._cancelDownload(app, btn); } - private _cancelDownload(app: IApp, btn: HTMLElement | null): void { + private async _cancelDownload(app: IApp, btn: HTMLElement | null): Promise { this._deps.tracer.info(`[AppUI] Cancelling download for: ${app.id}`); - void (async () => { - try { - await this._deps.cancelDownload(app.id); + try { + if (await this._deps.cancelDownload(app.id)) { this._deps.resetDownloadButton(btn); this._deps.restoreDownloadButtonLabel(btn); - } catch (err) { - this._deps.tracer.error(`[AppUI] Cancel failed for ${app.id}:`, err); + } else { + this._showDownloadControlFailed(app.id, 'cancel'); } - })(); + } catch (err) { + this._deps.tracer.error(`[AppUI] Cancel failed for ${app.id}:`, err); + this._showDownloadControlFailed(app.id, 'cancel'); + } + } + + private _showDownloadControlFailed(moduleId: string, action: string): void { + this._deps.tracer.warn(`[AppUI] Download ${action} failed for ${moduleId}`); + this._deps.showToast( + this._deps.translate( + 'ui.launcher.web.download_control_error', + 'Download control failed', + ), + 'warning', + ); } } diff --git a/src/shared/shell/ui/AppUiDashboardSupport.ts b/src/shared/shell/ui/AppUiDashboardSupport.ts index fecd271f..5426fd86 100644 --- a/src/shared/shell/ui/AppUiDashboardSupport.ts +++ b/src/shared/shell/ui/AppUiDashboardSupport.ts @@ -24,6 +24,7 @@ type AppUiDashboardControllerDeps = { openModuleSettings: (app: IApp) => void; clearModuleCard: (category: string) => void; removeSelectedModule: (category: string) => void; + stopSelectedApp: (app: IApp, category: string) => Promise; openAppSelection: (category: string) => void; updateMultiSlotBadge: () => void; activateAiSlot: (category: 'ai_text' | 'ai_image', app: IApp) => void; @@ -193,9 +194,13 @@ export class AppUiDashboardSupport { mouseEvent.preventDefault(); mouseEvent.stopPropagation(); const category = this._deps.selectionState.resolveCategoryFromCard(card); + const app = this._deps.selectionState.get(category); this._deps.tracer.info(`[AppUI] Middle-click close for ${category}`); this._deps.clearModuleCard(category); this._deps.removeSelectedModule(category); + if (app !== undefined) { + void this._deps.stopSelectedApp(app, category); + } } else if (mouseEvent.button === 2) { mouseEvent.stopPropagation(); mouseEvent.stopImmediatePropagation(); @@ -290,6 +295,7 @@ export class AppUiDashboardSupport { const closeButton = this._deps.chrome.createCloseBadge(category, (resolvedCategory) => { this._deps.clearModuleCard(resolvedCategory); this._deps.removeSelectedModule(resolvedCategory); + void this._deps.stopSelectedApp(app, category); }); card.appendChild(closeButton); } diff --git a/src/shared/shell/ui/AppUiLifecycleBindings.ts b/src/shared/shell/ui/AppUiLifecycleBindings.ts index e9954e2b..bbb1af9a 100644 --- a/src/shared/shell/ui/AppUiLifecycleBindings.ts +++ b/src/shared/shell/ui/AppUiLifecycleBindings.ts @@ -2,6 +2,7 @@ import type { EventBus } from '@/shared/services/EventBus'; type AppUiLifecycleBindingsDeps = { eventBus: EventBus; + onCatalogLoaded: () => void; onLanguageChanged: () => void; onPageChange: (payload: { pageId: string }) => void; }; @@ -10,11 +11,15 @@ export class AppUiLifecycleBindings { private readonly _boundLanguageChanged = () => { this._deps.onLanguageChanged(); }; + private readonly _boundCatalogLoaded = () => { + this._deps.onCatalogLoaded(); + }; private readonly _pageChangeUnsub: () => void; public constructor(private readonly _deps: AppUiLifecycleBindingsDeps) { globalThis.addEventListener('language-changed', this._boundLanguageChanged); + globalThis.addEventListener('catalog-loaded', this._boundCatalogLoaded); this._pageChangeUnsub = this._deps.eventBus.on('page:change', (payload) => { this._deps.onPageChange(payload); }); @@ -22,6 +27,7 @@ export class AppUiLifecycleBindings { public destroy(): void { globalThis.removeEventListener('language-changed', this._boundLanguageChanged); + globalThis.removeEventListener('catalog-loaded', this._boundCatalogLoaded); this._pageChangeUnsub(); } } diff --git a/src/shared/shell/ui/AppUiModuleFlow.test.ts b/src/shared/shell/ui/AppUiModuleFlow.test.ts index 6e068cd1..71adff71 100644 --- a/src/shared/shell/ui/AppUiModuleFlow.test.ts +++ b/src/shared/shell/ui/AppUiModuleFlow.test.ts @@ -2,34 +2,64 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { IApp } from '../../types/coreTypes'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import { AppUiModuleFlow } from './AppUiModuleFlow'; +import { open } from '@tauri-apps/plugin-dialog'; +import { downloadDir } from '@tauri-apps/api/path'; + +const integrationDialogMocks = vi.hoisted(() => ({ + openIntegrationUrlDialog: vi.fn(), +})); + +vi.mock('./IntegrationImportDialog', () => ({ + openIntegrationUrlDialog: integrationDialogMocks.openIntegrationUrlDialog, +})); + +vi.mock('@tauri-apps/plugin-dialog', () => ({ + open: vi.fn(), +})); + +vi.mock('@tauri-apps/api/path', () => ({ + downloadDir: vi.fn(), +})); describe('AppUiModuleFlow', () => { const platformService = { delete: vi.fn(), download: vi.fn(), + importIntegrationPath: vi.fn(), + importIntegrationUrl: vi.fn(), } as unknown as { delete: ReturnType; download: ReturnType; + importIntegrationPath: ReturnType; + importIntegrationUrl: ReturnType; }; const modalManager = { isAppSelectionOpen: vi.fn(), isViewingCategory: vi.fn(), + closeAppSelection: vi.fn(), + suspendAppSelection: vi.fn(), + resumeAppSelection: vi.fn(), + openAppSelection: vi.fn(), refreshCurrentSelection: vi.fn(), }; const getCatalogApps = vi.fn(); const getSelectedAppId = vi.fn(); const clearModuleCard = vi.fn(); - const openAppSelection = vi.fn(); const markSlotCardAsInstalled = vi.fn(); const showToast = vi.fn(); const translate = vi.fn((_key: string, fallback: string) => fallback); + const reloadCatalog = vi.fn().mockResolvedValue(undefined); + const openExternalUrl = vi.fn().mockResolvedValue(undefined); + const getIntegrationImportLastDirectory = vi.fn<() => string | null>(); + const setIntegrationImportLastDirectory = vi.fn(); let flow: AppUiModuleFlow; beforeEach(() => { vi.clearAllMocks(); + getIntegrationImportLastDirectory.mockReturnValue(null); document.body.innerHTML = ''; flow = new AppUiModuleFlow({ platformService: platformService as never, @@ -42,15 +72,18 @@ describe('AppUiModuleFlow', () => { modalManager, getCatalogApps, getSelectedAppId, + getIntegrationImportLastDirectory, + setIntegrationImportLastDirectory, clearModuleCard, - openAppSelection, markSlotCardAsInstalled, showToast, translate, + reloadCatalog, + openExternalUrl, }); }); - it('reopens modal after delete when selection stays open', async () => { + it('refreshes modal after deleting the selected module', async () => { const app = { id: 'svc', name: 'Service', installed: true } as IApp; platformService.delete.mockResolvedValue(undefined); modalManager.isAppSelectionOpen.mockReturnValue(true); @@ -59,14 +92,34 @@ describe('AppUiModuleFlow', () => { await flow.handleDeleteModule(app, 'services'); + expect(platformService.delete).toHaveBeenCalledWith(app, 'services'); + expect(reloadCatalog).toHaveBeenCalledOnce(); expect(app.installed).toBe(false); expect(clearModuleCard).toHaveBeenCalledWith('services'); - expect(openAppSelection).toHaveBeenCalledWith('services', [ - { id: 'svc', installed: false }, - ]); + expect(modalManager.refreshCurrentSelection).toHaveBeenCalledWith( + [{ id: 'svc', installed: false }], + null, + ); + }); + + it('refreshes modal after deleting an unselected module without selecting it', async () => { + const app = { id: 'svc', name: 'Service', installed: true } as IApp; + platformService.delete.mockResolvedValue(undefined); + modalManager.isAppSelectionOpen.mockReturnValue(true); + getCatalogApps.mockReturnValue([{ id: 'svc', installed: false }]); + getSelectedAppId.mockReturnValue('other'); + + await flow.handleDeleteModule(app, 'services'); + + expect(reloadCatalog).toHaveBeenCalledOnce(); + expect(clearModuleCard).not.toHaveBeenCalled(); + expect(modalManager.refreshCurrentSelection).toHaveBeenCalledWith( + [{ id: 'svc', installed: false }], + 'other', + ); }); - it('refreshes modal selection after successful download', () => { + it('reloads catalog and refreshes modal selection after successful download', async () => { const app = { id: 'svc', name: 'Service', installed: false } as IApp; const card = document.createElement('div'); card.className = 'app-card'; @@ -77,14 +130,73 @@ describe('AppUiModuleFlow', () => { getSelectedAppId.mockReturnValue('svc'); modalManager.isViewingCategory.mockReturnValue(true); - flow.onModalDownloadSuccess(btn, app, 'services'); + await flow.onModalDownloadSuccess(btn, app, 'services'); expect(app.installed).toBe(true); expect(btn.classList.contains('downloading')).toBe(false); expect(markSlotCardAsInstalled).toHaveBeenCalledWith(card, app); + expect(reloadCatalog).toHaveBeenCalledOnce(); expect(modalManager.refreshCurrentSelection).toHaveBeenCalledWith([app], 'svc'); }); + it('does not refresh modal selection after download success in another category', async () => { + const app = { id: 'svc', name: 'Service', installed: false } as IApp; + const btn = document.createElement('button'); + btn.className = 'download-btn downloading indeterminate'; + modalManager.isViewingCategory.mockReturnValue(false); + + await flow.onModalDownloadSuccess(btn, app, 'services'); + + expect(app.installed).toBe(true); + expect(btn.classList.contains('downloading')).toBe(false); + expect(reloadCatalog).toHaveBeenCalledOnce(); + expect(modalManager.refreshCurrentSelection).not.toHaveBeenCalled(); + }); + + it('keeps successful download UI when catalog refresh fails', async () => { + const app = { id: 'svc', name: 'Service', installed: false } as IApp; + const card = document.createElement('div'); + card.className = 'app-card'; + const btn = document.createElement('button'); + btn.className = 'download-btn downloading indeterminate'; + card.appendChild(btn); + reloadCatalog.mockRejectedValueOnce(new Error('catalog unavailable')); + modalManager.isViewingCategory.mockReturnValue(true); + + await flow.onModalDownloadSuccess(btn, app, 'services'); + + expect(app.installed).toBe(true); + expect(markSlotCardAsInstalled).toHaveBeenCalledWith(card, app); + expect(showToast).not.toHaveBeenCalled(); + expect(modalManager.refreshCurrentSelection).toHaveBeenCalled(); + }); + + it('resets download button and shows a toast after download errors', () => { + const btn = document.createElement('button'); + btn.className = 'download-btn downloading indeterminate'; + btn.innerHTML = ` + + + `; + + flow.onModalDownloadError(btn, new Error('broken')); + + expect(btn.classList.contains('downloading')).toBe(false); + expect(btn.querySelector('.download-pct')?.style.display).toBe('none'); + expect(btn.querySelector('.download-label')?.textContent).toBe('Download'); + expect(showToast).toHaveBeenCalledWith('Download failed', 'error'); + }); + + it('falls back to default download error text for non-error rejections', () => { + const btn = document.createElement('button'); + btn.className = 'download-btn downloading indeterminate'; + + flow.onModalDownloadError(btn, 'network down'); + + expect(btn.classList.contains('downloading')).toBe(false); + expect(showToast).toHaveBeenCalledWith('Download failed', 'error'); + }); + it('keeps paused download button state for resume', async () => { const app = { id: 'svc', name: 'Service', installed: false } as IApp; const btn = document.createElement('button'); @@ -120,4 +232,158 @@ describe('AppUiModuleFlow', () => { expect(btn.querySelector('.download-pct')?.style.display).toBe('none'); expect(btn.querySelector('.download-label')?.textContent).toBe('Download'); }); + + it('opens integration folder picker in downloads by default and remembers selected folder', async () => { + vi.mocked(downloadDir).mockResolvedValue('C:\\Users\\FORLE\\Downloads'); + vi.mocked(open).mockResolvedValue('C:\\Users\\FORLE\\Downloads\\Parser'); + platformService.importIntegrationPath.mockResolvedValue('telegram-parser'); + modalManager.isViewingCategory.mockReturnValue(false); + + await flow.handleIntegrationImport('local'); + + expect(open).toHaveBeenCalledWith( + expect.objectContaining({ + directory: true, + defaultPath: 'C:\\Users\\FORLE\\Downloads', + }), + ); + expect(platformService.importIntegrationPath).toHaveBeenCalledWith( + 'C:\\Users\\FORLE\\Downloads\\Parser', + ); + expect(setIntegrationImportLastDirectory).toHaveBeenCalledWith( + 'C:\\Users\\FORLE\\Downloads\\Parser', + ); + + getIntegrationImportLastDirectory.mockReturnValue('C:\\Users\\FORLE\\Downloads\\Parser'); + vi.mocked(open).mockResolvedValue(null); + await flow.handleIntegrationImport('local'); + + expect(open).toHaveBeenLastCalledWith( + expect.objectContaining({ + defaultPath: 'C:\\Users\\FORLE\\Downloads\\Parser', + }), + ); + }); + + it('does not import when local integration picking is cancelled', async () => { + vi.mocked(downloadDir).mockResolvedValue('C:\\Users\\FORLE\\Downloads'); + vi.mocked(open).mockResolvedValue(null); + + await flow.handleIntegrationImport('local'); + + expect(platformService.importIntegrationPath).not.toHaveBeenCalled(); + expect(reloadCatalog).not.toHaveBeenCalled(); + expect(showToast).not.toHaveBeenCalled(); + }); + + it('imports integration archives with archive filters and remembers the archive folder', async () => { + vi.mocked(downloadDir).mockResolvedValue('C:\\Users\\FORLE\\Downloads'); + vi.mocked(open).mockResolvedValue('C:\\Users\\FORLE\\Downloads\\Parser.zip'); + platformService.importIntegrationPath.mockResolvedValue('telegram-parser'); + + await flow.handleIntegrationImport('archive'); + + expect(open).toHaveBeenCalledWith( + expect.objectContaining({ + directory: false, + defaultPath: 'C:\\Users\\FORLE\\Downloads', + filters: [ + { + name: 'Archive', + extensions: ['zip', 'tar', 'gz', 'tgz', 'xz', 'txz', '7z'], + }, + ], + }), + ); + expect(platformService.importIntegrationPath).toHaveBeenCalledWith( + 'C:\\Users\\FORLE\\Downloads\\Parser.zip', + ); + expect(setIntegrationImportLastDirectory).toHaveBeenCalledWith( + 'C:\\Users\\FORLE\\Downloads', + ); + }); + + it('refreshes the open integrations modal after local integration import', async () => { + vi.mocked(downloadDir).mockResolvedValue('C:\\Users\\FORLE\\Downloads'); + vi.mocked(open).mockResolvedValue('C:\\Users\\FORLE\\Downloads\\Parser'); + platformService.importIntegrationPath.mockResolvedValue('telegram-parser'); + modalManager.isViewingCategory.mockReturnValue(true); + getCatalogApps.mockReturnValue([{ id: 'telegram-parser', installed: true }]); + getSelectedAppId.mockReturnValue('telegram-parser'); + + await flow.handleIntegrationImport('local'); + + expect(reloadCatalog).toHaveBeenCalledOnce(); + expect(modalManager.refreshCurrentSelection).toHaveBeenCalledWith( + [{ id: 'telegram-parser', installed: true }], + 'telegram-parser', + ); + expect(showToast).toHaveBeenCalledWith('Integration added', 'success'); + }); + + it('opens the custom integration guide without importing anything', async () => { + await flow.handleIntegrationImport('guide'); + + expect(openExternalUrl).toHaveBeenCalledWith( + 'https://github.com/F0RLE/Axelate/blob/nightly/docs/localization/en/CUSTOM_INTEGRATIONS.md', + ); + expect(platformService.importIntegrationPath).not.toHaveBeenCalled(); + expect(platformService.importIntegrationUrl).not.toHaveBeenCalled(); + expect(reloadCatalog).not.toHaveBeenCalled(); + }); + + it('imports integration URLs while suspending and resuming the selection modal', async () => { + modalManager.isAppSelectionOpen.mockReturnValue(true); + modalManager.suspendAppSelection.mockReturnValue(true); + modalManager.isViewingCategory.mockReturnValue(true); + integrationDialogMocks.openIntegrationUrlDialog.mockResolvedValue( + 'https://github.com/F0RLE/Axelate-telegram-parser', + ); + platformService.importIntegrationUrl.mockResolvedValue('axelate-telegram-parser'); + getCatalogApps.mockReturnValue([{ id: 'axelate-telegram-parser', installed: true }]); + getSelectedAppId.mockReturnValue(null); + + await flow.handleIntegrationImport('url'); + + expect(modalManager.suspendAppSelection).toHaveBeenCalledOnce(); + expect(integrationDialogMocks.openIntegrationUrlDialog).toHaveBeenCalledWith({ + translate, + }); + expect(platformService.importIntegrationUrl).toHaveBeenCalledWith( + 'https://github.com/F0RLE/Axelate-telegram-parser', + ); + expect(modalManager.resumeAppSelection).toHaveBeenCalledOnce(); + expect(reloadCatalog).toHaveBeenCalledOnce(); + expect(modalManager.refreshCurrentSelection).toHaveBeenCalledWith( + [{ id: 'axelate-telegram-parser', installed: true }], + null, + ); + expect(showToast).toHaveBeenCalledWith('Integration added', 'success'); + }); + + it('does not import URLs when the URL dialog is cancelled', async () => { + modalManager.isAppSelectionOpen.mockReturnValue(true); + modalManager.suspendAppSelection.mockReturnValue(true); + integrationDialogMocks.openIntegrationUrlDialog.mockResolvedValue(null); + + await flow.handleIntegrationImport('url'); + + expect(platformService.importIntegrationUrl).not.toHaveBeenCalled(); + expect(reloadCatalog).not.toHaveBeenCalled(); + expect(showToast).not.toHaveBeenCalled(); + expect(modalManager.resumeAppSelection).toHaveBeenCalledOnce(); + }); + + it('shows localized import errors without refreshing catalog', async () => { + vi.mocked(downloadDir).mockResolvedValue('C:\\Users\\FORLE\\Downloads'); + vi.mocked(open).mockResolvedValue('C:\\Users\\FORLE\\Downloads\\Broken'); + platformService.importIntegrationPath.mockRejectedValue( + new Error('ui.launcher.integrations.import.error'), + ); + + await flow.handleIntegrationImport('local'); + + expect(reloadCatalog).not.toHaveBeenCalled(); + expect(showToast).toHaveBeenCalledWith('Integration import failed', 'error'); + }); }); diff --git a/src/shared/shell/ui/AppUiModuleFlow.ts b/src/shared/shell/ui/AppUiModuleFlow.ts index 1ebe5fcb..7092f820 100644 --- a/src/shared/shell/ui/AppUiModuleFlow.ts +++ b/src/shared/shell/ui/AppUiModuleFlow.ts @@ -1,11 +1,23 @@ -import type { IApp } from '../../types/coreTypes'; +import type { IApp, ReleaseDownloadSelection } from '../../types/coreTypes'; import { resolveCatalogCategory } from '../../utils/moduleCategoryPolicy'; import type { ModulePlatformService } from '../../services/ModulePlatformService'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; +import { openDownloadSelectionDialog } from './DownloadSelectionDialog'; +import { openIntegrationUrlDialog } from './IntegrationImportDialog'; +import type { IntegrationImportAction } from './ModalManagerSupport'; +import { open } from '@tauri-apps/plugin-dialog'; +import { downloadDir } from '@tauri-apps/api/path'; + +const CUSTOM_INTEGRATION_GUIDE_URL = + 'https://github.com/F0RLE/Axelate/blob/nightly/docs/localization/en/CUSTOM_INTEGRATIONS.md'; type ModalBridge = { isAppSelectionOpen(): boolean; isViewingCategory(category: string): boolean; + closeAppSelection(): void; + suspendAppSelection(): boolean; + resumeAppSelection(): void; + openAppSelection(category: string, apps: IApp[], selectedId?: string): void; refreshCurrentSelection(apps?: IApp[], selectedId?: string | null): void; }; @@ -15,11 +27,14 @@ type AppUiModuleFlowDeps = { modalManager: ModalBridge; getCatalogApps: (category: string) => IApp[]; getSelectedAppId: (category: string) => string | null; + getIntegrationImportLastDirectory: () => string | null; + setIntegrationImportLastDirectory: (path: string | null) => void; clearModuleCard: (category: string) => void; - openAppSelection: (category: string, apps?: IApp[]) => void; markSlotCardAsInstalled: (card: HTMLElement, app: IApp) => void; showToast: (message: string, type?: string) => void; translate: (key: string, fallback: string) => string; + reloadCatalog: () => Promise; + openExternalUrl: (url: string) => Promise; }; export class AppUiModuleFlow { @@ -28,17 +43,19 @@ export class AppUiModuleFlow { public async handleDeleteModule(app: IApp, category: string): Promise { this._deps.tracer.info('[AppUI] Remove module clicked:', app.id); try { - await this._deps.platformService.delete(app); + await this._deps.platformService.delete(app, category); + await this._deps.reloadCatalog(); app.installed = false; - if (this._deps.getSelectedAppId(category) === app.id) { + const wasSelected = this._deps.getSelectedAppId(category) === app.id; + if (wasSelected) { this._deps.clearModuleCard(category); } if (this._deps.modalManager.isAppSelectionOpen()) { - this._deps.openAppSelection( - category, + this._deps.modalManager.refreshCurrentSelection( this._deps.getCatalogApps(this._toRawCategory(category)), + wasSelected ? null : this._deps.getSelectedAppId(category), ); } } catch (err: unknown) { @@ -56,17 +73,55 @@ export class AppUiModuleFlow { btn: HTMLElement | null, ): Promise { this._deps.tracer.info('[AppUI] Download module clicked:', app.id); - this.prepareDownloadButton(btn); + let activeButton = btn; try { - const outcome = await this._deps.platformService.download(app); + const releaseSelection = await this._resolveReleaseDownloadSelection(app, category); + if (releaseSelection === null && app.dlType === 'release') { + return; + } + + activeButton = + app.dlType === 'release' ? (this._findModalDownloadButton(app) ?? btn) : btn; + + this.prepareDownloadButton(activeButton); + + const outcome = await this._deps.platformService.download(app, releaseSelection); if (outcome !== 'completed') { - this.onModalDownloadInterrupted(btn, outcome); + this.onModalDownloadInterrupted(activeButton, outcome); return; } - this.onModalDownloadSuccess(btn, app, category); + await this.onModalDownloadSuccess(activeButton, app, category); } catch (err: unknown) { - this.onModalDownloadError(btn, err); + this.onModalDownloadError(activeButton, err); + } + } + + private async _resolveReleaseDownloadSelection( + app: IApp, + category: string, + ): Promise { + if (app.dlType !== 'release') { + return undefined; + } + + const shouldRestoreSelection = this._deps.modalManager.isAppSelectionOpen(); + const suspendedSelection = shouldRestoreSelection + ? this._deps.modalManager.suspendAppSelection() + : false; + + try { + return await openDownloadSelectionDialog({ + app, + loadOptions: () => this._deps.platformService.getReleaseDownloadOptions(app), + translate: this._deps.translate, + }); + } finally { + if (suspendedSelection) { + this._deps.modalManager.resumeAppSelection(); + } else if (shouldRestoreSelection) { + this._restoreAppSelection(category); + } } } @@ -111,7 +166,11 @@ export class AppUiModuleFlow { } } - public onModalDownloadSuccess(btn: HTMLElement | null, app: IApp, category: string): void { + public async onModalDownloadSuccess( + btn: HTMLElement | null, + app: IApp, + category: string, + ): Promise { app.installed = true; this.resetDownloadButton(btn); @@ -122,6 +181,12 @@ export class AppUiModuleFlow { this._deps.markSlotCardAsInstalled(card, app); } + try { + await this._deps.reloadCatalog(); + } catch (error) { + this._deps.tracer.warn('[AppUI] Catalog refresh after download failed:', error); + } + if (this._deps.modalManager.isViewingCategory(category)) { this._deps.modalManager.refreshCurrentSelection( this._deps.getCatalogApps(this._toRawCategory(category)), @@ -145,15 +210,153 @@ export class AppUiModuleFlow { public onModalDownloadError(btn: HTMLElement | null, err: unknown): void { this._deps.tracer.error('[AppUI] Download error:', err); this.resetDownloadButton(btn); + this.restoreDownloadButtonLabel(btn); this._deps.showToast( this._getLocalizedError(err, 'ui.launcher.web.download_error', 'Download failed'), 'error', ); } + public async handleIntegrationImport(action: IntegrationImportAction): Promise { + if (action === 'guide') { + await this._deps.openExternalUrl(CUSTOM_INTEGRATION_GUIDE_URL); + return; + } + + try { + const moduleId = await this._runIntegrationImportAction(action); + if (moduleId === null) { + return; + } + + await this._deps.reloadCatalog(); + if (this._deps.modalManager.isViewingCategory('services')) { + this._deps.modalManager.refreshCurrentSelection( + this._deps.getCatalogApps('services'), + this._deps.getSelectedAppId('services'), + ); + } + + this._deps.showToast( + this._deps.translate( + 'ui.launcher.integrations.import.success', + 'Integration added', + ), + 'success', + ); + } catch (error) { + this._deps.tracer.error('[AppUI] Integration import error:', error); + this._deps.showToast( + this._getLocalizedError( + error, + 'ui.launcher.integrations.import.error', + 'Integration import failed', + ), + 'error', + ); + } + } + + private async _runIntegrationImportAction( + action: Exclude, + ): Promise { + if (action === 'local') { + const path = await this._openLocalIntegrationSource(); + return path === null + ? null + : await this._deps.platformService.importIntegrationPath(path); + } + + if (action === 'archive') { + const path = await this._openArchiveIntegrationSource(); + return path === null + ? null + : await this._deps.platformService.importIntegrationPath(path); + } + + const url = await this._openIntegrationUrlSource(); + return url === null ? null : await this._deps.platformService.importIntegrationUrl(url); + } + + private async _openIntegrationUrlSource(): Promise { + const shouldRestoreSelection = this._deps.modalManager.isAppSelectionOpen(); + const suspendedSelection = shouldRestoreSelection + ? this._deps.modalManager.suspendAppSelection() + : false; + + try { + return await openIntegrationUrlDialog({ translate: this._deps.translate }); + } finally { + if (suspendedSelection) { + this._deps.modalManager.resumeAppSelection(); + } + } + } + + private async _openLocalIntegrationSource(): Promise { + const selectedPath = normalizeDialogPath( + await open({ + directory: true, + defaultPath: await this._getIntegrationImportDefaultPath(), + multiple: false, + recursive: true, + title: this._deps.translate( + 'ui.launcher.integrations.import.folder_title', + 'Choose integration folder', + ), + }), + ); + if (selectedPath !== null) { + this._deps.setIntegrationImportLastDirectory(selectedPath); + } + return selectedPath; + } + + private async _openArchiveIntegrationSource(): Promise { + const selectedPath = normalizeDialogPath( + await open({ + directory: false, + defaultPath: await this._getIntegrationImportDefaultPath(), + multiple: false, + title: this._deps.translate( + 'ui.launcher.integrations.import.archive_title', + 'Choose integration archive', + ), + filters: [ + { + name: this._deps.translate( + 'ui.launcher.integrations.import.archive', + 'Archive', + ), + extensions: ['zip', 'tar', 'gz', 'tgz', 'xz', 'txz', '7z'], + }, + ], + }), + ); + if (selectedPath !== null) { + this._deps.setIntegrationImportLastDirectory( + getParentDirectory(selectedPath) ?? selectedPath, + ); + } + return selectedPath; + } + + private async _getIntegrationImportDefaultPath(): Promise { + const savedPath = this._deps.getIntegrationImportLastDirectory(); + if (savedPath !== null) { + return savedPath; + } + + try { + return await downloadDir(); + } catch { + return undefined; + } + } + private _getLocalizedError(err: unknown, fallbackKey: string, fallbackText: string): string { - const error = err as Error; - const msg = error.message.startsWith('ui.') ? error.message : fallbackKey; + const message = err instanceof Error ? err.message : typeof err === 'string' ? err : ''; + const msg = message.startsWith('ui.') ? message : fallbackKey; const fallback = msg === fallbackKey ? fallbackText : msg; return this._deps.translate(msg, fallback); } @@ -161,4 +364,43 @@ export class AppUiModuleFlow { private _toRawCategory(category: string): string { return resolveCatalogCategory(category); } + + private _restoreAppSelection(category: string): void { + this._deps.modalManager.openAppSelection( + category, + this._deps.getCatalogApps(this._toRawCategory(category)), + this._deps.getSelectedAppId(category) ?? undefined, + ); + } + + private _findModalDownloadButton(app: IApp): HTMLElement | null { + const cards = document.querySelectorAll('#app-modal-list .app-card'); + for (const card of cards) { + if (card.dataset['appId'] === app.id) { + return card.querySelector('.download-btn'); + } + } + return null; + } +} + +function normalizeDialogPath(value: string | string[] | null): string | null { + if (typeof value === 'string') { + return value; + } + if (Array.isArray(value)) { + return value[0] ?? null; + } + return null; +} + +function getParentDirectory(path: string): string | null { + const normalized = path.replace(/\\/gu, '/'); + const lastSeparator = normalized.lastIndexOf('/'); + if (lastSeparator <= 0) { + return null; + } + + const parent = path.slice(0, lastSeparator); + return parent.trim().length === 0 ? null : parent; } diff --git a/src/shared/shell/ui/AppUiModuleLifecycle.test.ts b/src/shared/shell/ui/AppUiModuleLifecycle.test.ts index 883e4ec9..020dfc4f 100644 --- a/src/shared/shell/ui/AppUiModuleLifecycle.test.ts +++ b/src/shared/shell/ui/AppUiModuleLifecycle.test.ts @@ -49,7 +49,7 @@ describe('AppUiModuleLifecycle', () => { const app = { id: 'svc-a', name: 'Service A' } as IApp; const version = lifecycle.bumpLaunchSelectionVersion('services'); - platformService.stop.mockResolvedValue(undefined); + platformService.stop.mockResolvedValue(true); getSelectedApp.mockReturnValue({ id: 'svc-b' }); const pending = lifecycle.launchSelectedApp('services', app, version, launchApp); @@ -79,7 +79,7 @@ describe('AppUiModuleLifecycle', () => { const nextApp = { id: 'svc-new', name: 'New Service' } as IApp; const previousApp = { id: 'svc-old', name: 'Old Service' } as IApp; resolveAppById.mockReturnValue(previousApp); - platformService.stop.mockResolvedValue(undefined); + platformService.stop.mockResolvedValue(true); lifecycle.stopPreviousModule(card, nextApp, 'services'); await Promise.resolve(); @@ -87,4 +87,20 @@ describe('AppUiModuleLifecycle', () => { expect(platformService.stop).toHaveBeenCalledWith(previousApp); expect(showToast).toHaveBeenCalledWith('Old Service stopped', 'info'); }); + + it('does not show stopped toast when previous module stop reports failure', async () => { + const card = document.createElement('div'); + card.dataset['currentModule'] = 'svc-old'; + card.dataset['currentModuleName'] = 'Old Service'; + const nextApp = { id: 'svc-new', name: 'New Service' } as IApp; + const previousApp = { id: 'svc-old', name: 'Old Service' } as IApp; + resolveAppById.mockReturnValue(previousApp); + platformService.stop.mockResolvedValue(false); + + lifecycle.stopPreviousModule(card, nextApp, 'services'); + await Promise.resolve(); + + expect(platformService.stop).toHaveBeenCalledWith(previousApp); + expect(showToast).not.toHaveBeenCalled(); + }); }); diff --git a/src/shared/shell/ui/AppUiModuleLifecycle.ts b/src/shared/shell/ui/AppUiModuleLifecycle.ts index 3edd9f19..58effcb7 100644 --- a/src/shared/shell/ui/AppUiModuleLifecycle.ts +++ b/src/shared/shell/ui/AppUiModuleLifecycle.ts @@ -85,7 +85,13 @@ export class AppUiModuleLifecycle { void this._deps.platformService .stop(previousApp) - .then(() => { + .then((stopped) => { + if (!stopped) { + this._deps.tracer.warn( + `[AppUI] Previous module ${previousApp.id} did not report a successful stop`, + ); + return; + } const prevName = previousApp.name ?? card.dataset['currentModuleName'] ?? previousModuleId; if (!this._deps.platformService.isApiModule(previousApp)) { diff --git a/src/shared/shell/ui/AppUiSelectionFlow.test.ts b/src/shared/shell/ui/AppUiSelectionFlow.test.ts index 542c34b3..2c20585d 100644 --- a/src/shared/shell/ui/AppUiSelectionFlow.test.ts +++ b/src/shared/shell/ui/AppUiSelectionFlow.test.ts @@ -32,7 +32,7 @@ describe('AppUiSelectionFlow', () => { }); }); - it('selects integration module, persists it and launches it', () => { + it('selects integration module, persists it, and launches it', () => { const app = { id: 'svc', name: 'Service', type: 'local', icon: 'S', desc: 'Desc' } as IApp; getSelectedApp.mockReturnValue(undefined); @@ -41,10 +41,10 @@ describe('AppUiSelectionFlow', () => { expect(updateModuleCard).toHaveBeenCalledWith('services', app); expect(updateModalSelection).toHaveBeenCalledWith('svc'); expect(setSelectedModule).toHaveBeenCalled(); - expect(launchSelectedApp).toHaveBeenCalled(); + expect(launchSelectedApp).toHaveBeenCalledWith('services', app, 1, launchApp); }); - it('selects AI module without launching it immediately', () => { + it('selects AI module and launches it through lifecycle guard', () => { const app = { id: 'text-model', name: 'Text Model', type: 'api', icon: 'T' } as IApp; getSelectedApp.mockReturnValue(undefined); @@ -53,14 +53,13 @@ describe('AppUiSelectionFlow', () => { expect(updateModuleCard).toHaveBeenCalledWith('ai_text', app); expect(updateModalSelection).toHaveBeenCalledWith('text-model'); expect(setSelectedModule).toHaveBeenCalled(); - expect(launchSelectedApp).not.toHaveBeenCalled(); - expect(launchApp).not.toHaveBeenCalled(); + expect(launchSelectedApp).toHaveBeenCalledWith('ai_text', app, 1, launchApp); }); - it('does not launch AI when switching the visible shared AI slot', () => { - const app = { id: 'image-model', name: 'Image Model', type: 'local' } as IApp; + it('does not launch an existing selection when switching visible state', () => { + const app = { id: 'svc', name: 'Service', type: 'local' } as IApp; - flow.activateExistingSelection('ai_image', app); + flow.activateExistingSelection('services', app); expect(launchApp).not.toHaveBeenCalled(); }); @@ -74,6 +73,6 @@ describe('AppUiSelectionFlow', () => { expect(clearModuleCard).toHaveBeenCalledWith('services'); expect(updateModalSelection).toHaveBeenCalledWith(null); expect(removeSelectedModule).toHaveBeenCalledWith('services'); - expect(stopSelectedApp).toHaveBeenCalledWith(app); + expect(stopSelectedApp).toHaveBeenCalledWith(app, 'services'); }); }); diff --git a/src/shared/shell/ui/AppUiSelectionFlow.ts b/src/shared/shell/ui/AppUiSelectionFlow.ts index 56265e45..ab3909c0 100644 --- a/src/shared/shell/ui/AppUiSelectionFlow.ts +++ b/src/shared/shell/ui/AppUiSelectionFlow.ts @@ -1,6 +1,4 @@ import type { IApp } from '../../types/coreTypes'; -import { isAiCategory, shouldLaunchOnSelection } from '../../utils/moduleCategoryPolicy'; - type LaunchAppFn = (category: string, app: IApp) => Promise; type AppUiSelectionFlowDeps = { @@ -9,7 +7,7 @@ type AppUiSelectionFlowDeps = { updateModuleCard: (category: string, app: IApp) => void; updateModalSelection: (appId: string | null) => void; bumpLaunchSelectionVersion: (category: string) => number; - stopSelectedApp: (app: IApp) => Promise; + stopSelectedApp: (app: IApp, category: string) => Promise; launchSelectedApp: ( category: string, app: IApp, @@ -31,7 +29,7 @@ export class AppUiSelectionFlow { this._deps.clearModuleCard(category); this._deps.removeSelectedModule(category); this._deps.updateModalSelection(null); - void this._deps.stopSelectedApp(app); + void this._deps.stopSelectedApp(app, category); return; } @@ -40,7 +38,7 @@ export class AppUiSelectionFlow { this._deps.updateModalSelection(app.id); this._persistSelectedModule(category, app); - if (shouldLaunchOnSelection(category) && typeof this._deps.launchApp === 'function') { + if (this._deps.launchApp !== undefined) { void this._deps.launchSelectedApp( category, app, @@ -63,10 +61,7 @@ export class AppUiSelectionFlow { } public activateExistingSelection(category: string, app: IApp): void { - if (isAiCategory(category) || typeof this._deps.launchApp !== 'function') { - return; - } - - void this._deps.launchApp(category, app); + void category; + void app; } } diff --git a/src/shared/shell/ui/DownloadSelectionDialog.test.ts b/src/shared/shell/ui/DownloadSelectionDialog.test.ts new file mode 100644 index 00000000..b05ff4f4 --- /dev/null +++ b/src/shared/shell/ui/DownloadSelectionDialog.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { openDownloadSelectionDialog } from './DownloadSelectionDialog'; +import type { ReleaseDownloadOptions } from '@/shared/types/coreTypes'; + +describe('openDownloadSelectionDialog', () => { + it('allows selecting both CPU and GPU packages', async () => { + vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => { + callback(0); + return 0; + }); + Element.prototype.scrollIntoView = vi.fn(); + + const options: ReleaseDownloadOptions = { + module_id: 'llamacpp', + versions: [ + { + tag_name: 'v1.0.0', + published_at: '2026-05-06T00:00:00Z', + recommended: 'gpu', + gpu: { + compute_target: 'gpu', + assets: ['gpu.zip'], + total_size: 1024, + }, + cpu: { + compute_target: 'cpu', + assets: ['cpu.zip'], + total_size: 2048, + }, + }, + ], + }; + + const resultPromise = openDownloadSelectionDialog({ + app: { id: 'llamacpp', name: 'llama.cpp', icon: 'L' }, + loadOptions: () => Promise.resolve(options), + translate: (_key, fallback) => fallback, + }); + + await vi.waitFor(() => { + expect(document.querySelectorAll('[data-download-target]')).toHaveLength(3); + }); + + const bothButton = document.querySelector( + '[data-download-target="both"]', + ); + expect(bothButton).not.toBeNull(); + if (bothButton === null) { + throw new Error('Both package button not found'); + } + bothButton.click(); + + const confirmButton = document.querySelector( + '.download-selection-confirm', + ); + expect(confirmButton).not.toBeNull(); + if (confirmButton === null) { + throw new Error('Download confirm button not found'); + } + confirmButton.click(); + + await expect(resultPromise).resolves.toEqual({ + tag_name: 'v1.0.0', + compute_target: 'both', + }); + }); +}); diff --git a/src/shared/shell/ui/DownloadSelectionDialog.ts b/src/shared/shell/ui/DownloadSelectionDialog.ts new file mode 100644 index 00000000..ab217f1b --- /dev/null +++ b/src/shared/shell/ui/DownloadSelectionDialog.ts @@ -0,0 +1,386 @@ +import type { + IApp, + ReleaseComputeTarget, + ReleaseDownloadOptions, + ReleaseDownloadSelection, + ReleaseDownloadVariant, + ReleaseDownloadVersion, +} from '@/shared/types/coreTypes'; + +type TranslateFn = (key: string, fallback: string) => string; + +type DownloadSelectionDialogOptions = { + app: IApp; + loadOptions: () => Promise; + translate: TranslateFn; +}; + +const TARGETS: Array> = ['gpu', 'cpu', 'both']; + +export function openDownloadSelectionDialog({ + app, + loadOptions, + translate, +}: DownloadSelectionDialogOptions): Promise { + return new Promise((resolve) => { + const host = document.body; + const selectionView = document.createElement('div'); + selectionView.className = 'download-selection-view'; + selectionView.setAttribute('role', 'dialog'); + selectionView.setAttribute('aria-modal', 'false'); + selectionView.setAttribute( + 'aria-label', + translate('ui.download.select_package', 'Select package'), + ); + selectionView.tabIndex = -1; + + let options: ReleaseDownloadOptions | null = null; + let selectedVersion: ReleaseDownloadVersion | null = null; + let selectedTarget: Exclude = 'gpu'; + let loading = true; + let errorMessage: string | null = null; + let resolved = false; + + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + event.preventDefault(); + close(null); + } + }; + + const close = (value: ReleaseDownloadSelection | null): void => { + if (resolved) return; + resolved = true; + document.removeEventListener('keydown', onKeyDown); + document.body.classList.remove('download-selection-open'); + selectionView.remove(); + resolve(value); + }; + + const renderVariantSummary = (): string => { + if (selectedVersion === null) return ''; + const variant = getVariant(selectedVersion, selectedTarget); + if (variant === null) return ''; + const assetList = variant.assets + .map((asset) => `
  • ${escapeHtml(asset)}
  • `) + .join(''); + return ` +
    +
    + ${escapeHtml(formatBytes(variant.total_size))} + ${escapeHtml(formatDate(selectedVersion.published_at, translate))} +
    +
      ${assetList}
    +
    + `; + }; + + const renderBody = (): string => { + if (loading) { + return ` +
    + + ${escapeHtml(translate('ui.download.loading_versions', 'Loading versions...'))} +
    + `; + } + + if (errorMessage !== null || options === null || selectedVersion === null) { + return ` +
    + ${escapeHtml(errorMessage ?? translate('ui.download.no_release_options', 'No compatible release packages found'))} +
    + `; + } + + const activeVersion = selectedVersion; + return ` +
    + ${TARGETS.map((target) => renderTargetButton(target, selectedTarget, activeVersion, translate)).join('')} +
    +
    +
    + ${escapeHtml(translate('ui.download.version', 'Version'))} + ${escapeHtml(formatDate(activeVersion.published_at, translate))} +
    +
    + ${options.versions + .map((version, index) => + renderVersionButton(version, activeVersion, index, translate), + ) + .join('')} +
    +
    + ${renderVariantSummary()} + `; + }; + + const render = (): void => { + selectionView.innerHTML = ` +
    +
    +
    ${escapeHtml(app.icon ?? '')}
    +
    +

    ${escapeHtml(app.name ?? app.id)}

    +

    ${escapeHtml(translate('ui.download.package_subtitle', 'Choose package and version'))}

    +
    +
    + ${renderBody()} +
    + + +
    +
    + `; + + bindEvents(); + keepSelectedVersionVisible(); + }; + + const bindEvents = (): void => { + selectionView.onclick = (event) => { + if (event.target === selectionView) { + close(null); + } + }; + + selectionView + .querySelector('.download-selection-cancel') + ?.addEventListener('click', () => close(null)); + + selectionView + .querySelectorAll('[data-download-version]') + .forEach((button) => { + button.addEventListener('click', () => { + const tag = button.dataset['downloadVersion']; + if (tag === undefined || options === null) return; + selectedVersion = + options.versions.find((version) => version.tag_name === tag) ?? + selectedVersion; + if (selectedVersion === null) return; + selectedTarget = normalizeTarget(selectedTarget, selectedVersion); + render(); + }); + }); + + selectionView + .querySelectorAll('[data-download-target]') + .forEach((button) => { + button.addEventListener('click', () => { + const target = button.dataset['downloadTarget']; + if (selectedVersion === null) return; + if (target !== 'gpu' && target !== 'cpu' && target !== 'both') return; + if (getVariant(selectedVersion, target) === null) return; + selectedTarget = target; + render(); + }); + }); + + selectionView.querySelector('form')?.addEventListener('submit', (event) => { + event.preventDefault(); + if (selectedVersion === null || loading) return; + close({ + tag_name: selectedVersion.tag_name, + compute_target: selectedTarget, + }); + }); + }; + + render(); + host.appendChild(selectionView); + document.body.classList.add('download-selection-open'); + document.addEventListener('keydown', onKeyDown); + selectionView.focus({ preventScroll: true }); + + void loadOptions() + .then((loadedOptions) => { + if (resolved) return; + options = loadedOptions; + const firstVersion = options?.versions[0]; + if (options === null || firstVersion === undefined) { + errorMessage = translate( + 'ui.download.no_release_options', + 'No compatible release packages found', + ); + return; + } + selectedVersion = firstVersion; + selectedTarget = normalizeTarget(selectedVersion.recommended, selectedVersion); + }) + .catch((err: unknown) => { + errorMessage = + err instanceof Error && err.message.trim() !== '' + ? err.message + : translate( + 'ui.download.load_versions_error', + 'Failed to load release versions', + ); + }) + .finally(() => { + if (resolved) return; + loading = false; + render(); + }); + }); +} + +function keepSelectedVersionVisible(): void { + requestAnimationFrame(() => { + document + .querySelector('.download-selection-version-item.selected') + ?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + }); +} + +function renderTargetButton( + target: Exclude, + selectedTarget: ReleaseComputeTarget, + selectedVersion: ReleaseDownloadVersion, + translate: TranslateFn, +): string { + const variant = getVariant(selectedVersion, target); + const selected = selectedTarget === target; + const disabled = variant === null; + const label = + target === 'gpu' + ? translate('ui.download.gpu_package', 'GPU') + : target === 'cpu' + ? translate('ui.download.cpu_package', 'CPU') + : translate('ui.download.both_packages', 'CPU + GPU'); + const meta = + variant === null + ? translate('ui.download.unavailable', 'Unavailable') + : formatBytes(variant.total_size); + + return ` + + `; +} + +function renderVersionButton( + version: ReleaseDownloadVersion, + selectedVersion: ReleaseDownloadVersion, + index: number, + translate: TranslateFn, +): string { + const latest = index === 0 ? ` ${translate('ui.download.latest_suffix', '(latest)')}` : ''; + const date = formatDate(version.published_at, translate); + const selected = version.tag_name === selectedVersion.tag_name; + return ` + + `; +} + +function normalizeTarget( + target: ReleaseComputeTarget, + version: ReleaseDownloadVersion, +): Exclude { + if ( + target === 'both' && + version.gpu !== null && + version.gpu !== undefined && + version.cpu !== null && + version.cpu !== undefined + ) { + return 'both'; + } + if ( + (target === 'gpu' || target === 'auto') && + version.gpu !== null && + version.gpu !== undefined + ) { + return 'gpu'; + } + if (target === 'cpu' && version.cpu !== null && version.cpu !== undefined) { + return 'cpu'; + } + return version.cpu !== null && version.cpu !== undefined ? 'cpu' : 'gpu'; +} + +function getVariant( + version: ReleaseDownloadVersion, + target: ReleaseComputeTarget, +): ReleaseDownloadVariant | null { + if (target === 'cpu') return version.cpu ?? null; + if (target === 'gpu') return version.gpu ?? null; + if (target === 'both') return getCombinedVariant(version); + return version.gpu ?? version.cpu ?? null; +} + +function getCombinedVariant(version: ReleaseDownloadVersion): ReleaseDownloadVariant | null { + if (version.cpu === null || version.cpu === undefined) return null; + if (version.gpu === null || version.gpu === undefined) return null; + + const assets = [...version.gpu.assets]; + version.cpu.assets.forEach((asset) => { + if (!assets.includes(asset)) { + assets.push(asset); + } + }); + + return { + compute_target: 'both', + assets, + total_size: version.cpu.total_size + version.gpu.total_size, + }; +} + +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 MB'; + const units = ['B', 'KB', 'MB', 'GB']; + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`; +} + +function formatDate(value: string | null | undefined, translate: TranslateFn): string { + if (value === null || value === undefined || value === '') { + return translate('ui.download.date_unknown', 'date unknown'); + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: '2-digit', + }).format(date); +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/src/shared/shell/ui/IntegrationImportDialog.test.ts b/src/shared/shell/ui/IntegrationImportDialog.test.ts new file mode 100644 index 00000000..5340172f --- /dev/null +++ b/src/shared/shell/ui/IntegrationImportDialog.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { I18nUI } from '@/infrastructure/i18n/I18nUI'; +import type { I18nService } from '@/infrastructure/i18n/I18nService'; +import { closeIntegrationImportDialogs, openIntegrationUrlDialog } from './IntegrationImportDialog'; + +describe('IntegrationImportDialog', () => { + afterEach(() => { + closeIntegrationImportDialogs(); + document.body.innerHTML = ''; + }); + + it('closes when transient dialogs are dismissed globally', async () => { + const result = openIntegrationUrlDialog({ + translate: (_key, fallback) => fallback, + }); + + expect(document.querySelector('.integration-import-dialog-view')).not.toBeNull(); + + closeIntegrationImportDialogs(); + + await expect(result).resolves.toBeNull(); + expect(document.querySelector('.integration-import-dialog-view')).toBeNull(); + expect(document.body.classList.contains('integration-import-open')).toBe(false); + }); + + it('returns the trimmed URL on confirm', async () => { + const result = openIntegrationUrlDialog({ + translate: (_key, fallback) => fallback, + }); + const input = document.querySelector('.integration-import-url-input'); + const confirm = document.querySelector( + '.integration-import-dialog-confirm', + ); + + if (input === null || confirm === null) { + throw new Error('dialog controls missing'); + } + + input.value = ' https://github.com/F0RLE/Axelate-telegram-parser '; + confirm.click(); + + await expect(result).resolves.toBe('https://github.com/F0RLE/Axelate-telegram-parser'); + expect(document.querySelector('.integration-import-dialog-view')).toBeNull(); + }); + + it('keeps the dialog open when confirm is clicked with an empty URL', async () => { + const result = openIntegrationUrlDialog({ + translate: (_key, fallback) => fallback, + }); + const input = document.querySelector('.integration-import-url-input'); + const confirm = document.querySelector( + '.integration-import-dialog-confirm', + ); + + if (input === null || confirm === null) { + throw new Error('dialog controls missing'); + } + + confirm.click(); + + expect(document.querySelector('.integration-import-dialog-view')).not.toBeNull(); + expect(document.activeElement).toBe(input); + + closeIntegrationImportDialogs(); + await result; + }); + + it('submits with Enter and cancels with Escape', async () => { + const submitted = openIntegrationUrlDialog({ + translate: (_key, fallback) => fallback, + }); + const input = document.querySelector('.integration-import-url-input'); + if (input === null) { + throw new Error('dialog input missing'); + } + + input.value = 'https://example.com/integration.zip'; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + + await expect(submitted).resolves.toBe('https://example.com/integration.zip'); + + const cancelled = openIntegrationUrlDialog({ + translate: (_key, fallback) => fallback, + }); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + await expect(cancelled).resolves.toBeNull(); + }); + + it('cancels from the cancel button and backdrop', async () => { + const fromButton = openIntegrationUrlDialog({ + translate: (_key, fallback) => fallback, + }); + document.querySelector('.integration-import-dialog-cancel')?.click(); + + await expect(fromButton).resolves.toBeNull(); + + const fromBackdrop = openIntegrationUrlDialog({ + translate: (_key, fallback) => fallback, + }); + const overlay = document.querySelector('.integration-import-dialog-view'); + if (overlay === null) { + throw new Error('dialog overlay missing'); + } + overlay.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + await expect(fromBackdrop).resolves.toBeNull(); + }); + + it('updates visible copy when global translations are reapplied', async () => { + const result = openIntegrationUrlDialog({ + translate: (_key, fallback) => fallback, + }); + const i18nUI = new I18nUI({ + t: (key: string) => `translated:${key}`, + getCurrentLang: () => 'ru', + } as I18nService); + + i18nUI.applyTranslations(); + + expect(document.querySelector('h3')?.textContent).toBe( + 'translated:ui.launcher.integrations.import.url_title', + ); + expect(document.querySelector('p')?.textContent).toBe( + 'translated:ui.launcher.integrations.import.url_desc', + ); + expect(document.querySelector('input')?.placeholder).toBe( + 'translated:ui.launcher.integrations.import.url_placeholder', + ); + expect(document.querySelector('.integration-import-dialog-cancel')?.textContent).toBe( + 'translated:ui.launcher.button.cancel', + ); + expect(document.querySelector('.integration-import-dialog-confirm')?.textContent).toBe( + 'translated:ui.launcher.integrations.import.add', + ); + + i18nUI.destroy(); + closeIntegrationImportDialogs(); + await result; + }); +}); diff --git a/src/shared/shell/ui/IntegrationImportDialog.ts b/src/shared/shell/ui/IntegrationImportDialog.ts new file mode 100644 index 00000000..7b665d11 --- /dev/null +++ b/src/shared/shell/ui/IntegrationImportDialog.ts @@ -0,0 +1,136 @@ +type TranslateFunc = (key: string, fallback: string) => string; + +type DialogElements = { + overlay: HTMLDivElement; + input: HTMLInputElement; + confirm: HTMLButtonElement; + cancel: HTMLButtonElement; +}; + +const CLOSE_INTEGRATION_IMPORT_DIALOG_EVENT = 'integration-import-dialog:close'; + +export function closeIntegrationImportDialogs(): void { + globalThis.dispatchEvent(new Event(CLOSE_INTEGRATION_IMPORT_DIALOG_EVENT)); +} + +export async function openIntegrationUrlDialog(options: { + translate: TranslateFunc; +}): Promise { + return await new Promise((resolve) => { + const elements = createDialogElements(options.translate); + let settled = false; + + const close = (value: string | null) => { + if (settled) return; + settled = true; + document.removeEventListener('keydown', onKeyDown); + globalThis.removeEventListener(CLOSE_INTEGRATION_IMPORT_DIALOG_EVENT, onExternalClose); + document.body.classList.remove('integration-import-open'); + elements.overlay.remove(); + resolve(value); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return; + } + event.preventDefault(); + close(null); + }; + + const onExternalClose = () => { + close(null); + }; + + elements.cancel.addEventListener('click', () => { + close(null); + }); + elements.overlay.addEventListener('click', (event) => { + if (event.target === elements.overlay) { + close(null); + } + }); + elements.confirm.addEventListener('click', () => { + const value = elements.input.value.trim(); + if (value === '') { + elements.input.focus(); + return; + } + close(value); + }); + elements.input.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + elements.confirm.click(); + } + }); + + document.body.appendChild(elements.overlay); + document.body.classList.add('integration-import-open'); + document.addEventListener('keydown', onKeyDown); + globalThis.addEventListener(CLOSE_INTEGRATION_IMPORT_DIALOG_EVENT, onExternalClose); + requestAnimationFrame(() => { + elements.input.focus(); + }); + }); +} + +function createDialogElements(translate: TranslateFunc): DialogElements { + const overlay = document.createElement('div'); + overlay.className = 'integration-import-dialog-view'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + + const panel = document.createElement('div'); + panel.className = 'integration-import-dialog-panel'; + + const title = document.createElement('h3'); + title.dataset['i18n'] = 'ui.launcher.integrations.import.url_title'; + title.textContent = translate( + 'ui.launcher.integrations.import.url_title', + 'Add integration URL', + ); + + const description = document.createElement('p'); + description.dataset['i18n'] = 'ui.launcher.integrations.import.url_desc'; + description.textContent = translate( + 'ui.launcher.integrations.import.url_desc', + 'Paste a GitHub repository or direct archive URL.', + ); + + const input = document.createElement('input'); + input.type = 'url'; + input.className = 'integration-import-url-input'; + input.dataset['i18nPlaceholder'] = 'ui.launcher.integrations.import.url_placeholder'; + input.placeholder = translate( + 'ui.launcher.integrations.import.url_placeholder', + 'Repository or archive URL', + ); + input.value = ''; + input.autocomplete = 'new-password'; + input.setAttribute('autocapitalize', 'none'); + input.setAttribute('data-lpignore', 'true'); + input.setAttribute('data-form-type', 'other'); + input.spellcheck = false; + + const actions = document.createElement('div'); + actions.className = 'integration-import-dialog-actions'; + + const cancel = document.createElement('button'); + cancel.type = 'button'; + cancel.className = 'integration-import-dialog-cancel'; + cancel.dataset['i18n'] = 'ui.launcher.button.cancel'; + cancel.textContent = translate('ui.launcher.button.cancel', 'Cancel'); + + const confirm = document.createElement('button'); + confirm.type = 'button'; + confirm.className = 'integration-import-dialog-confirm'; + confirm.dataset['i18n'] = 'ui.launcher.integrations.import.add'; + confirm.textContent = translate('ui.launcher.integrations.import.add', 'Add'); + + actions.append(cancel, confirm); + panel.append(title, description, input, actions); + overlay.appendChild(panel); + + return { overlay, input, confirm, cancel }; +} diff --git a/src/shared/shell/ui/ModalFocusTrapHelper.test.ts b/src/shared/shell/ui/ModalFocusTrapHelper.test.ts index eb27d19a..72508ee3 100644 --- a/src/shared/shell/ui/ModalFocusTrapHelper.test.ts +++ b/src/shared/shell/ui/ModalFocusTrapHelper.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { ModalFocusTrapHelper } from './ModalFocusTrapHelper'; describe('ModalFocusTrapHelper', () => { - it('should keep focus inside modal and close on overlay click', () => { + it('should disable tab focus movement and close on overlay click', () => { const onClose = vi.fn(); const helper = new ModalFocusTrapHelper(onClose); const modal = document.createElement('dialog'); @@ -18,17 +18,17 @@ describe('ModalFocusTrapHelper', () => { helper.attach(modal); helper.focusFirstElement(modal); - expect(document.activeElement).toBe(first); + expect(document.activeElement).toBe(document.body); last.focus(); - helper.handleModalKeydown( - new KeyboardEvent('keydown', { - key: 'Tab', - bubbles: true, - cancelable: true, - }), - ); - expect(document.activeElement).toBe(first); + const tabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }); + helper.handleModalKeydown(tabEvent); + expect(tabEvent.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(document.body); outside.focus(); const focusInEvent = new FocusEvent('focusin', { bubbles: true }); @@ -37,7 +37,7 @@ describe('ModalFocusTrapHelper', () => { value: outside, }); helper.handleFocusIn(focusInEvent); - expect(document.activeElement).toBe(first); + expect(document.activeElement).toBe(document.body); helper.attachOverlayOnly(modal); modal.dispatchEvent(new MouseEvent('click', { bubbles: true })); @@ -47,7 +47,7 @@ describe('ModalFocusTrapHelper', () => { document.body.innerHTML = ''; }); - it('should prefer content controls over close buttons for initial modal focus', () => { + it('should not focus modal controls on open', () => { const helper = new ModalFocusTrapHelper(vi.fn()); const modal = document.createElement('dialog'); const closeButton = document.createElement('button'); @@ -61,7 +61,7 @@ describe('ModalFocusTrapHelper', () => { helper.focusFirstElement(modal); - expect(document.activeElement).toBe(actionButton); + expect(document.activeElement).toBe(document.body); helper.detach(); document.body.innerHTML = ''; diff --git a/src/shared/shell/ui/ModalFocusTrapHelper.ts b/src/shared/shell/ui/ModalFocusTrapHelper.ts index 7125236d..9cef491d 100644 --- a/src/shared/shell/ui/ModalFocusTrapHelper.ts +++ b/src/shared/shell/ui/ModalFocusTrapHelper.ts @@ -16,37 +16,13 @@ export class ModalFocusTrapHelper { }; public readonly handleModalKeydown = (event: KeyboardEvent): void => { - if (event.key !== 'Tab' || this._modal === null) { + if (event.key !== 'Tab') { return; } - const focusable = this.getFocusableElements(this._modal); - if (focusable.length === 0) { - event.preventDefault(); - this._modal.focus(); - return; - } - - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - if (first === undefined || last === undefined) { - event.preventDefault(); - this._modal.focus(); - return; - } - - const active = document.activeElement; - if (event.shiftKey) { - if (active === first || active === this._modal) { - event.preventDefault(); - last.focus(); - } - return; - } - - if (active === last) { - event.preventDefault(); - first.focus(); + event.preventDefault(); + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); } }; @@ -60,7 +36,9 @@ export class ModalFocusTrapHelper { return; } - this.focusFirstElement(this._modal); + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } }; public attach(modal: HTMLDialogElement): void { @@ -88,15 +66,11 @@ export class ModalFocusTrapHelper { } public focusFirstElement(modal: HTMLDialogElement): void { - const focusable = this.getFocusableElements(modal); - const preferred = focusable.find((element) => !element.classList.contains('app-close-btn')); - const target = preferred ?? focusable[0]; - if (target !== undefined) { - target.focus(); - return; + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); } - modal.focus(); + modal.blur(); } public getFocusableElements(root: HTMLElement): HTMLElement[] { diff --git a/src/shared/shell/ui/ModalManager.test.ts b/src/shared/shell/ui/ModalManager.test.ts index a6bdbbab..87966b12 100644 --- a/src/shared/shell/ui/ModalManager.test.ts +++ b/src/shared/shell/ui/ModalManager.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ModalManager } from './ModalManager'; import { ModuleCardRenderer } from './ModuleCardRenderer'; import { ModalSelectionPolicy } from './ModalSelectionPolicy'; +import type { IntegrationImportAction } from './ModalManagerSupport'; import type { LoggerService } from '@/infrastructure/logging/LoggerService'; import type { NavigationService } from '@/infrastructure/navigation/NavigationService'; import type { IApp } from '../../types/coreTypes'; @@ -84,7 +85,10 @@ describe('ModalManager lifecycle', () => { vi.useRealTimers(); }); - function createManager(onFilterChange?: (capability: 'text' | 'image') => string | null) { + function createManager( + onFilterChange?: (capability: 'text' | 'image') => string | null, + onIntegrationImport?: (action: IntegrationImportAction) => void, + ) { return new ModalManager( new ModuleCardRenderer({ translate: (_key, fallback) => fallback, tracer }), interactionSpy as unknown as (e: MouseEvent, app: IApp, category: string) => void, @@ -94,6 +98,9 @@ describe('ModalManager lifecycle', () => { (_key, fallback) => fallback, tracer, navigation, + undefined, + undefined, + onIntegrationImport, ); } @@ -104,6 +111,8 @@ describe('ModalManager lifecycle', () => { modalManager.closeAppSelection(); modalManager.openAppSelection('services', []); + expect(document.body.classList.contains('app-selection-open')).toBe(true); + const closeSpy = vi.spyOn(modalManager, 'closeAppSelection'); const modal = document.getElementById('app-selection-modal') as HTMLDialogElement; modal.dispatchEvent(new MouseEvent('click', { bubbles: true })); @@ -111,6 +120,56 @@ describe('ModalManager lifecycle', () => { expect(closeSpy).toHaveBeenCalledTimes(1); }); + it('should render integration import actions for empty services lists', () => { + const importSpy = vi.fn(); + modalManager = createManager(undefined, importSpy); + + modalManager.openAppSelection('services', []); + + const importCard = document.querySelector('.integration-import-card'); + expect(importCard).not.toBeNull(); + if (importCard === null) throw new Error('integration import card missing'); + expect(document.querySelector('.app-modal-empty-state')).toBeNull(); + + const actionButtons = document.querySelectorAll( + '.integration-import-action-btn', + ); + expect(actionButtons).toHaveLength(2); + const [openButton, linkButton] = Array.from(actionButtons); + if (openButton === undefined || linkButton === undefined) { + throw new Error('integration import buttons missing'); + } + + openButton.click(); + linkButton.click(); + const helpBadge = document.querySelector('.integration-help-badge'); + expect(helpBadge).not.toBeNull(); + if (helpBadge === null) throw new Error('integration help badge missing'); + expect(helpBadge.querySelector('.badge-icon')?.textContent).toBe('?'); + helpBadge.click(); + importCard.click(); + + expect(importSpy).toHaveBeenNthCalledWith(1, 'local'); + expect(importSpy).toHaveBeenNthCalledWith(2, 'url'); + expect(importSpy).toHaveBeenNthCalledWith(3, 'guide'); + expect(importSpy).toHaveBeenNthCalledWith(4, 'archive'); + }); + + it('should rerender services selection when refresh receives an empty app list', () => { + modalManager = createManager(); + + modalManager.openAppSelection('services', [ + { id: 'parser', name: 'Parser', installed: true } as IApp, + ]); + expect(document.querySelector('[data-app-id="parser"]')).not.toBeNull(); + + modalManager.refreshCurrentSelection([], null); + + expect(document.querySelector('[data-app-id="parser"]')).toBeNull(); + expect(document.querySelector('.integration-import-card')).not.toBeNull(); + expect(document.querySelector('.app-modal-empty-state')).toBeNull(); + }); + it('should switch filters without leaving transient list styles', () => { vi.useFakeTimers(); @@ -192,11 +251,15 @@ describe('ModalManager lifecycle', () => { 'svc-b', ); - expect(document.querySelectorAll('#app-modal-list .app-card')).toHaveLength(1); expect( - (document.querySelector('#app-modal-list .app-card') as HTMLElement | null)?.dataset[ - 'appId' - ], + document.querySelectorAll('#app-modal-list .app-card:not(.integration-import-card)'), + ).toHaveLength(1); + expect( + ( + document.querySelector( + '#app-modal-list .app-card:not(.integration-import-card)', + ) as HTMLElement | null + )?.dataset['appId'], ).toBe('svc-b'); expect(document.querySelector('#app-modal-list .modal-btn')?.textContent).toBe('Remove'); }); @@ -280,7 +343,58 @@ describe('ModalManager lifecycle', () => { expect(navigation.pushBackAction).toHaveBeenCalledTimes(1); }); - it('keeps keyboard focus inside the app selection modal', () => { + it('should suspend and resume app selection without exposing the dashboard', () => { + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 0; + }), + ); + modalManager = createManager(); + const modal = document.getElementById('app-selection-modal') as HTMLDialogElement; + const container = document.querySelector('.models-container') as HTMLElement; + + modalManager.openAppSelection( + 'services', + [{ id: 'svc-a', name: 'Service A', installed: true } as IApp], + 'svc-a', + ); + + expect(modalManager.suspendAppSelection()).toBe(true); + expect(modal.open).toBe(true); + expect(modal.classList.contains('hidden')).toBe(false); + expect(modal.style.visibility).toBe('hidden'); + expect(document.body.classList.contains('app-selection-open')).toBe(true); + expect(container.classList.contains('content-hidden')).toBe(true); + + modalManager.resumeAppSelection(); + + expect(modal.style.visibility).toBe(''); + expect(document.body.classList.contains('app-selection-open')).toBe(true); + expect(container.classList.contains('content-hidden')).toBe(true); + expect(navigation.removeBackAction).not.toHaveBeenCalledWith('app-selection-modal'); + }); + + it('should clear page-hidden state after closing app selection', () => { + modalManager = createManager(); + + modalManager.openAppSelection( + 'services', + [{ id: 'svc-a', name: 'Service A', installed: true } as IApp], + 'svc-a', + ); + expect(document.body.classList.contains('app-selection-open')).toBe(true); + + modalManager.closeAppSelection(); + + expect(document.body.classList.contains('app-selection-open')).toBe(false); + expect( + document.querySelector('.models-container')?.classList.contains('content-hidden'), + ).toBe(false); + }); + + it('disables tab focus movement inside the app selection modal', () => { modalManager = createManager(); const outsideButton = document.createElement('button'); outsideButton.textContent = 'Outside'; @@ -298,16 +412,17 @@ describe('ModalManager lifecycle', () => { '#app-modal-list .module-selection-card-actions button', ) as HTMLButtonElement; - expect(document.activeElement).toBe(modalAction); + expect(document.activeElement).toBe(document.body); modalAction.focus(); - modal.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Tab', - bubbles: true, - }), - ); - expect(document.activeElement).toBe(closeButton); + const tabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }); + modal.dispatchEvent(tabEvent); + expect(tabEvent.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(document.body); outsideButton.focus(); const focusInEvent = new FocusEvent('focusin', { @@ -318,7 +433,8 @@ describe('ModalManager lifecycle', () => { value: outsideButton, }); document.dispatchEvent(focusInEvent); - expect(document.activeElement).toBe(modalAction); + expect(document.activeElement).toBe(document.body); + expect(closeButton).toBeInstanceOf(HTMLButtonElement); }); it('should preserve current selection when replaying modal back action', () => { @@ -487,6 +603,30 @@ describe('ModalManager lifecycle', () => { ).toBe(true); }); + it('should route modal progress updates for app ids that need selector escaping', () => { + modalManager = createManager(); + const list = document.getElementById('app-modal-list') as HTMLElement; + const appId = 'svc"quoted\\id'; + const card = document.createElement('div'); + card.className = 'app-card'; + card.dataset['appId'] = appId; + const button = document.createElement('button'); + button.className = 'download-btn'; + button.innerHTML = + 'Download'; + card.appendChild(button); + list.appendChild(card); + + globalThis.dispatchEvent( + new CustomEvent('download-progress-update', { + detail: { module_id: appId, status: 'downloading', progress: 0.42 }, + }), + ); + + expect(button.classList.contains('downloading')).toBe(true); + expect(button.querySelector('.download-pct')?.textContent).toBe('42%'); + }); + it('should cancel and start downloads through injected callbacks', async () => { const onDownloadRequest = vi.fn().mockResolvedValue(undefined); const onCancelDownloadRequest = vi.fn().mockResolvedValue(undefined); @@ -527,6 +667,7 @@ describe('ModalManager lifecycle', () => { ); expect(document.querySelector('.download-label')?.textContent).toBe('Download'); + modalManager.openAppSelection('services', []); list.innerHTML = `
    `; handleDownload.call(modalManager, { id: 'svc', @@ -543,6 +684,48 @@ describe('ModalManager lifecycle', () => { expectedHash: 'abc', dlType: 'github', }), + 'services', + expect.any(HTMLButtonElement), + ); + }); + + it('should start modal downloads for app ids that need selector escaping', () => { + const onDownloadRequest = vi.fn().mockResolvedValue(undefined); + modalManager = new ModalManager( + new ModuleCardRenderer({ translate: (_key, fallback) => fallback, tracer }), + interactionSpy as unknown as (e: MouseEvent, app: IApp, category: string) => void, + () => null, + onDownloadRequest, + vi.fn().mockResolvedValue(undefined), + (_key, fallback) => fallback, + tracer, + navigation, + ); + + modalManager.openAppSelection('services', []); + const list = document.getElementById('app-modal-list') as HTMLElement; + const appId = 'svc"quoted\\id'; + const card = document.createElement('div'); + card.className = 'app-card'; + card.dataset['appId'] = appId; + const button = document.createElement('button'); + button.className = 'download-btn'; + card.appendChild(button); + list.appendChild(card); + + const handleDownload = (modalManager as unknown as { _handleDownload: (app: IApp) => void }) + ._handleDownload; + handleDownload.call(modalManager, { + id: appId, + name: 'Service', + installed: false, + repoUrl: 'https://example.com/service.zip', + } as IApp); + + expect(onDownloadRequest).toHaveBeenCalledWith( + expect.objectContaining({ id: appId }), + 'services', + button, ); }); diff --git a/src/shared/shell/ui/ModalManager.ts b/src/shared/shell/ui/ModalManager.ts index adb9373a..b80ddaba 100644 --- a/src/shared/shell/ui/ModalManager.ts +++ b/src/shared/shell/ui/ModalManager.ts @@ -8,6 +8,7 @@ import { ModalFilterTransitionController } from './ModalFilterTransitionControll import { cancelModalDownload, createModalDownloadProgressHandler, + type IntegrationImportAction, populateModalAppList, transitionSelectionButton, updateModalSidebarWidth, @@ -18,7 +19,12 @@ import { } from './ModuleCardDownloadProgress'; import { ModalSelectionPolicy } from './ModalSelectionPolicy'; import { ModalFocusTrapHelper } from './ModalFocusTrapHelper'; -import { resolveModalSidebarCategory } from '../../utils/moduleCategoryPolicy'; +import { + getAiSlotForCapability, + isAiCategory, + resolveModalSidebarCategory, +} from '../../utils/moduleCategoryPolicy'; +import { escapeCssSelectorValue } from '../../utils/cssSelectors'; /** * @class ModalManager @@ -69,10 +75,15 @@ export class ModalManager { private readonly _onAppInteraction: (e: MouseEvent, app: IApp, category: string) => void; // Called when user switches filter tab — returns the selected app ID for that capability private readonly _onFilterChange: (capability: 'text' | 'image') => string | null; - private readonly _onDownloadRequest: (app: IApp) => Promise; + private readonly _onDownloadRequest: ( + app: IApp, + category: string, + btn: HTMLElement | null, + ) => Promise; private readonly _onCancelDownloadRequest: (app: IApp) => Promise; private readonly _onPauseDownloadRequest: (app: IApp) => Promise; private readonly _onResumeDownloadRequest: (app: IApp) => Promise; + private readonly _onIntegrationImport: (action: IntegrationImportAction) => void; private readonly _translate: (key: string, fallback: string) => string; private readonly _tracer: LoggerService; @@ -80,13 +91,18 @@ export class ModalManager { cardRenderer: ModuleCardRenderer, onAppInteraction: (e: MouseEvent, app: IApp, category: string) => void, onFilterChange: (capability: 'text' | 'image') => string | null, - onDownloadRequest: (app: IApp) => Promise, + onDownloadRequest: ( + app: IApp, + category: string, + btn: HTMLElement | null, + ) => Promise, onCancelDownloadRequest: (app: IApp) => Promise, translate: (key: string, fallback: string) => string, tracer: LoggerService, private readonly _navigation: NavigationService, onPauseDownloadRequest?: (app: IApp) => Promise, onResumeDownloadRequest?: (app: IApp) => Promise, + onIntegrationImport?: (action: IntegrationImportAction) => void, ) { this._cardRenderer = cardRenderer; this._onAppInteraction = onAppInteraction; @@ -95,6 +111,7 @@ export class ModalManager { this._onCancelDownloadRequest = onCancelDownloadRequest; this._onPauseDownloadRequest = onPauseDownloadRequest ?? (() => Promise.resolve()); this._onResumeDownloadRequest = onResumeDownloadRequest ?? (() => Promise.resolve()); + this._onIntegrationImport = onIntegrationImport ?? (() => {}); this._translate = translate; this._tracer = tracer; @@ -129,6 +146,10 @@ export class ModalManager { this._currentApps = apps; this._currentSelectedAppId = selectedAppId ?? null; + document.body.classList.add('app-selection-open'); + const container = document.querySelector('.models-container'); + if (container !== null) container.classList.add('content-hidden'); + // Derive filter from compound category — do NOT blindly reset to 'text' // so that reopening after removing an image-slot app stays on the image tab. if (category === CategoryKey.AI_IMAGE) { @@ -166,10 +187,6 @@ export class ModalManager { }, 0); }); - // Add smooth hiding for main content - const container = document.querySelector('.models-container'); - if (container !== null) container.classList.add('content-hidden'); - // Register back action for mouse/keyboard global navigation this._navigation.pushBackAction( 'app-selection-modal', @@ -204,10 +221,45 @@ export class ModalManager { } // Restore main content visibility + document.body.classList.remove('app-selection-open'); const container = document.querySelector('.models-container'); if (container !== null) container.classList.remove('content-hidden'); } + public suspendAppSelection(): boolean { + if (!this.isAppSelectionOpen()) { + return false; + } + + const modal = document.getElementById('app-selection-modal') as HTMLDialogElement | null; + if (modal === null) { + return false; + } + + this._filterTransitionController.cancelPending(); + this._detachOverlayCloseHandler(); + modal.classList.add('app-selection-suspended'); + modal.style.visibility = 'hidden'; + return true; + } + + public resumeAppSelection(): void { + const modal = document.getElementById('app-selection-modal') as HTMLDialogElement | null; + if (modal === null || !modal.open || modal.classList.contains('hidden')) { + return; + } + + modal.classList.remove('app-selection-suspended'); + modal.style.removeProperty('visibility'); + this._overlayClickModal = modal; + this._focusTrap.attach(modal); + requestAnimationFrame(() => { + if (this._overlayClickModal === modal && this.isAppSelectionOpen()) { + this._focusTrap.focusFirstElement(modal); + } + }); + } + public isAppSelectionOpen(): boolean { const modal = document.getElementById('app-selection-modal') as HTMLDialogElement | null; return modal !== null && modal.open && !modal.classList.contains('hidden'); @@ -221,7 +273,7 @@ export class ModalManager { this._currentSelectedAppId = selectedAppId; } - if (this._currentCategory === null || this._currentApps.length === 0) { + if (this._currentCategory === null) { return; } @@ -235,7 +287,11 @@ export class ModalManager { } public isViewingCategory(category: string): boolean { - return this.isAppSelectionOpen() && this._currentCategory === category; + return ( + this.isAppSelectionOpen() && + resolveModalSidebarCategory(this._currentCategory ?? '') === + resolveModalSidebarCategory(category) + ); } // --- Helpers --- @@ -315,6 +371,9 @@ export class ModalManager { cardRenderer: this._cardRenderer, onAppInteraction: this._onAppInteraction, onDownload: (app, action) => this._handleDownload(app, action), + onIntegrationImport: (action) => { + this._onIntegrationImport(action); + }, translate: this._translate, }); } @@ -329,7 +388,8 @@ export class ModalManager { } const list = document.getElementById('app-modal-list'); - const card = list?.querySelector(`.app-card[data-app-id="${app.id}"]`); + const escapedAppId = escapeCssSelectorValue(app.id); + const card = list?.querySelector(`.app-card[data-app-id="${escapedAppId}"]`); const btn = card?.querySelector('.download-btn'); if (btn?.classList.contains('downloading') === true) { @@ -338,7 +398,10 @@ export class ModalManager { } this._tracer.info(`[ModalManager] Starting download: ${app.id}`); - void this._onDownloadRequest(app); + const interactionCategory = isAiCategory(this._currentCategory ?? '') + ? getAiSlotForCapability(this._currentFilter) + : (this._currentCategory ?? ''); + void this._onDownloadRequest(app, interactionCategory, btn ?? null); } private _handleActiveDownloadAction( diff --git a/src/shared/shell/ui/ModalManagerSupport.ts b/src/shared/shell/ui/ModalManagerSupport.ts index 3b649c3e..163161cb 100644 --- a/src/shared/shell/ui/ModalManagerSupport.ts +++ b/src/shared/shell/ui/ModalManagerSupport.ts @@ -3,6 +3,7 @@ import { ModuleCardRenderer } from './ModuleCardRenderer'; import type { ModuleCardDownloadAction } from './ModuleCardActions'; import type { ModalSelectionPolicy } from './ModalSelectionPolicy'; import { getAiSlotForCapability, isAiCategory } from '../../utils/moduleCategoryPolicy'; +import { escapeCssSelectorValue } from '../../utils/cssSelectors'; type DownloadProgressPayload = { module_id: string; @@ -14,6 +15,7 @@ type AppInteractionHandler = (event: MouseEvent, app: IApp, category: string) => type DownloadHandler = (app: IApp, action: ModuleCardDownloadAction) => void; type ProgressEventHandler = (event: Event) => void; type TranslateFunc = (key: string, fallback: string) => string; +export type IntegrationImportAction = 'local' | 'archive' | 'url' | 'guide'; export async function cancelModalDownload(options: { app: IApp; @@ -60,9 +62,8 @@ export function createModalDownloadProgressHandler(): ProgressEventHandler { return; } - const card = list.querySelector( - `.app-card[data-app-id="${payload.module_id}"]`, - ); + const escapedModuleId = escapeCssSelectorValue(payload.module_id); + const card = list.querySelector(`.app-card[data-app-id="${escapedModuleId}"]`); if (card === null) { return; } @@ -91,6 +92,7 @@ export function populateModalAppList(options: { cardRenderer: ModuleCardRenderer; onAppInteraction: AppInteractionHandler; onDownload: DownloadHandler; + onIntegrationImport?: (action: IntegrationImportAction) => void; translate: TranslateFunc; }): void { const visibleApps = options.selectionPolicy.getVisibleApps( @@ -99,9 +101,14 @@ export function populateModalAppList(options: { options.currentFilter, ); + const shouldShowIntegrationImport = options.category === 'services'; + options.listElement.innerHTML = ''; - options.listElement.classList.toggle('app-grid-empty', visibleApps.length === 0); - if (visibleApps.length === 0) { + options.listElement.classList.toggle( + 'app-grid-empty', + visibleApps.length === 0 && !shouldShowIntegrationImport, + ); + if (visibleApps.length === 0 && !shouldShowIntegrationImport) { renderModalEmptyState(options.listElement, options.translate); return; } @@ -121,6 +128,12 @@ export function populateModalAppList(options: { ); options.listElement.appendChild(card); }); + + if (shouldShowIntegrationImport) { + options.listElement.appendChild( + createIntegrationImportCard(options.translate, options.onIntegrationImport), + ); + } } export function transitionSelectionButton(options: { @@ -222,3 +235,104 @@ function resetModalDownloadButton(button: HTMLButtonElement, translate: Translat label.textContent = translate('ui.launcher.module.download', 'Download'); } } + +function createIntegrationImportCard( + translate: TranslateFunc, + onIntegrationImport?: (action: IntegrationImportAction) => void, +): HTMLElement { + const card = document.createElement('div'); + card.className = 'app-card module-selection-card integration-import-card'; + card.tabIndex = 0; + card.setAttribute( + 'aria-label', + translate('ui.launcher.integrations.import.card_title', 'Add integration'), + ); + card.addEventListener('click', () => { + onIntegrationImport?.('archive'); + }); + card.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + + event.preventDefault(); + onIntegrationImport?.('archive'); + }); + + const help = createIntegrationHelpBadge(translate, onIntegrationImport); + const icon = document.createElement('div'); + icon.className = 'module-selection-card-icon integration-import-icon'; + icon.innerHTML = ''; + + const title = document.createElement('div'); + title.className = 'module-selection-card-title'; + title.textContent = translate('ui.launcher.integrations.import.card_title', 'Add integration'); + + const desc = document.createElement('div'); + desc.className = 'module-selection-card-description'; + desc.textContent = translate( + 'ui.launcher.integrations.import.card_desc', + 'Import a custom integration from a folder, archive, or repository link.', + ); + + const actions = document.createElement('div'); + actions.className = 'module-selection-card-actions integration-import-actions'; + actions.append( + createIntegrationImportButton( + 'local', + translate('ui.launcher.integrations.import.folder', 'Folder'), + onIntegrationImport, + ), + createIntegrationImportButton( + 'url', + translate('ui.launcher.integrations.import.url', 'Link'), + onIntegrationImport, + ), + ); + + card.append(help, icon, title, desc, actions); + return card; +} + +function createIntegrationHelpBadge( + translate: TranslateFunc, + onIntegrationImport?: (action: IntegrationImportAction) => void, +): HTMLButtonElement { + const badge = document.createElement('button'); + badge.type = 'button'; + badge.className = 'module-action-badge right integration-help-badge'; + badge.title = translate('ui.launcher.integrations.import.guide_title', 'Integration guide'); + badge.setAttribute('aria-label', badge.title); + const icon = document.createElement('span'); + icon.className = 'badge-icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = '?'; + badge.append(icon); + badge.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + onIntegrationImport?.('guide'); + }); + + return badge; +} + +function createIntegrationImportButton( + action: Exclude, + label: string, + onIntegrationImport?: (action: IntegrationImportAction) => void, +): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'modal-btn modal-btn-primary integration-import-action-btn'; + button.title = label; + button.setAttribute('aria-label', label); + button.textContent = label; + button.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + onIntegrationImport?.(action); + }); + + return button; +} diff --git a/src/shared/shell/ui/ModalSelectionPolicy.test.ts b/src/shared/shell/ui/ModalSelectionPolicy.test.ts index 203c0971..fd9bcc40 100644 --- a/src/shared/shell/ui/ModalSelectionPolicy.test.ts +++ b/src/shared/shell/ui/ModalSelectionPolicy.test.ts @@ -34,7 +34,7 @@ describe('ModalSelectionPolicy', () => { expect(policy.getButtonState(card, false)).toEqual({ className: 'modal-btn modal-btn-primary', key: 'ui.launcher.modules.modal.btn_select', - defaultLabel: 'Select', + defaultLabel: 'Launch', }); card.classList.add('engine-starting'); diff --git a/src/shared/shell/ui/ModalSelectionPolicy.ts b/src/shared/shell/ui/ModalSelectionPolicy.ts index 0a2857bb..529953e3 100644 --- a/src/shared/shell/ui/ModalSelectionPolicy.ts +++ b/src/shared/shell/ui/ModalSelectionPolicy.ts @@ -45,7 +45,7 @@ export class ModalSelectionPolicy { return { className: 'modal-btn modal-btn-primary', key: 'ui.launcher.modules.modal.btn_select', - defaultLabel: 'Select', + defaultLabel: 'Launch', }; } diff --git a/src/shared/shell/ui/ModuleCardActions.ts b/src/shared/shell/ui/ModuleCardActions.ts index fd337e57..a4f1f54b 100644 --- a/src/shared/shell/ui/ModuleCardActions.ts +++ b/src/shared/shell/ui/ModuleCardActions.ts @@ -105,7 +105,7 @@ export function buildModuleCardActionButton( actionBtn.className = 'modal-btn modal-btn-primary'; const i18nKey = 'ui.launcher.modules.modal.btn_select'; actionBtn.dataset['i18n'] = i18nKey; - actionBtn.textContent = translate(i18nKey, 'Select'); + actionBtn.textContent = translate(i18nKey, 'Launch'); } actionBtn.onclick = (event) => { diff --git a/src/shared/shell/ui/ModuleCardDownloadProgress.ts b/src/shared/shell/ui/ModuleCardDownloadProgress.ts index 3a5d0614..e19bf0d4 100644 --- a/src/shared/shell/ui/ModuleCardDownloadProgress.ts +++ b/src/shared/shell/ui/ModuleCardDownloadProgress.ts @@ -28,8 +28,7 @@ export function setModuleCardDownloadProgress( const label = btn.querySelector('.download-label'); if (label) { - const cardEl = label.closest('.app-card'); - const extractingLabel = (cardEl?.dataset['translateExtracting'] ?? 'Extracting').replace( + const extractingLabel = (btn.dataset['translateExtracting'] ?? 'Extracting').replace( /\.+$/, '', ); diff --git a/src/shared/shell/ui/ModuleCardRenderer.test.ts b/src/shared/shell/ui/ModuleCardRenderer.test.ts index 0843c97c..5d981c2a 100644 --- a/src/shared/shell/ui/ModuleCardRenderer.test.ts +++ b/src/shared/shell/ui/ModuleCardRenderer.test.ts @@ -11,12 +11,10 @@ import { ModuleCardRenderer } from './ModuleCardRenderer'; describe('ModuleCardRenderer', () => { let renderer: ModuleCardRenderer; - let checkInstalled: (moduleId: string) => Promise; let openModuleSettingsSpy: ReturnType; let tracer: LoggerService; beforeEach(() => { - checkInstalled = vi.fn<(_: string) => Promise>(() => Promise.resolve(false)); openModuleSettingsSpy = vi.fn(); tracer = { info: vi.fn(), @@ -25,7 +23,6 @@ describe('ModuleCardRenderer', () => { debug: vi.fn(), } as unknown as LoggerService; renderer = new ModuleCardRenderer({ - checkInstalled, translate: (key, fallback) => `${key}:${fallback}`, tracer, openModuleSettings: (app) => { @@ -86,7 +83,7 @@ describe('ModuleCardRenderer', () => { ModuleCardRenderer.setDownloadProgress(card, 73, 'extracting'); expect((card.querySelector('.download-label') as HTMLElement).textContent).toContain( - 'Extracting', + 'ui.launcher.module.extracting:Extracting', ); expect((card.querySelector('.download-pct') as HTMLElement).textContent).toBe('73%'); expect(card.querySelector('.download-btn')?.classList.contains('indeterminate')).toBe( @@ -104,7 +101,6 @@ describe('ModuleCardRenderer', () => { it('restores active download state when a card is recreated', () => { renderer = new ModuleCardRenderer({ - checkInstalled, translate: (key, fallback) => `${key}:${fallback}`, tracer, getDownloadState: (moduleId) => @@ -161,7 +157,7 @@ describe('ModuleCardRenderer', () => { false, onClick, ); - expect(selectCard.textContent).toContain('Select'); + expect(selectCard.textContent).toContain('Launch'); (selectCard.querySelector('.modal-btn-primary') as HTMLButtonElement).click(); expect(onClick).toHaveBeenCalled(); @@ -192,6 +188,51 @@ describe('ModuleCardRenderer', () => { expect(card.querySelector('.download-btn')).toBeNull(); }); + it('renders uninstalled AI engine cards as downloadable', () => { + const onClick = vi.fn(); + const onDownload = vi.fn(); + + const card = renderer.createSelectionCard( + { + id: 'llamacpp', + name: 'llama.cpp', + desc: 'Local engine', + installed: false, + type: 'local', + capability: 'text', + } as never, + 'ai_text', + false, + onClick, + onDownload, + ); + + expect(card.querySelector('.download-btn')?.textContent).toContain('Download'); + }); + + it('renders installed AI engine cards as selectable', () => { + const onClick = vi.fn(); + const onDownload = vi.fn(); + + const card = renderer.createSelectionCard( + { + id: 'llamacpp', + name: 'llama.cpp', + desc: 'Local engine', + installed: true, + type: 'local', + capability: 'text', + } as never, + 'ai_text', + false, + onClick, + onDownload, + ); + + expect(card.querySelector('.download-btn')).toBeNull(); + expect(card.querySelector('.modal-btn-primary')?.textContent).toContain('Launch'); + }); + it('renders delete badge emoji for installed local modules', () => { const onClick = vi.fn(); const card = renderer.createSelectionCard( @@ -205,7 +246,7 @@ describe('ModuleCardRenderer', () => { expect(deleteIcon?.textContent).toContain('🗑'); }); - it('opens module settings on right click for installed cards and ignores uninstalled ones', async () => { + it('opens module settings on right click for installed cards and ignores uninstalled ones', () => { const onClick = vi.fn(); const installedCard = renderer.createSelectionCard( @@ -219,21 +260,6 @@ describe('ModuleCardRenderer', () => { ); expect(openModuleSettingsSpy).toHaveBeenCalled(); - (checkInstalled as ReturnType).mockResolvedValue(true); - const asyncCard = renderer.createSelectionCard( - { id: 'late-install', name: 'Later', desc: 'Desc', installed: false } as never, - 'services', - false, - onClick, - ); - document.body.appendChild(asyncCard); - await Promise.resolve(); - await Promise.resolve(); - - expect(asyncCard.classList.contains('is-installed')).toBe(true); - asyncCard.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); - expect(openModuleSettingsSpy).toHaveBeenCalledTimes(2); - const uninstalledCard = renderer.createSelectionCard( { id: 'not-installed', name: 'Missing', desc: 'Desc', installed: false } as never, 'services', @@ -243,7 +269,7 @@ describe('ModuleCardRenderer', () => { uninstalledCard.dispatchEvent( new MouseEvent('contextmenu', { bubbles: true, cancelable: true }), ); - expect(openModuleSettingsSpy).toHaveBeenCalledTimes(2); + expect(openModuleSettingsSpy).toHaveBeenCalledTimes(1); }); it('should not open settings for modules with settings disabled', () => { @@ -262,24 +288,6 @@ describe('ModuleCardRenderer', () => { expect(openModuleSettingsSpy).not.toHaveBeenCalled(); }); - it('should ignore late async install resolution for detached cards', async () => { - const onClick = vi.fn(); - (checkInstalled as ReturnType).mockResolvedValue(true); - - const card = renderer.createSelectionCard( - { id: 'late-install-detached', name: 'Later', desc: 'Desc', installed: false } as never, - 'services', - false, - onClick, - ); - - card.remove(); - await Promise.resolve(); - await Promise.resolve(); - - expect(card.classList.contains('is-installed')).toBe(false); - }); - it('updates dashboard card content and marks cards as installed', () => { const card = document.createElement('div'); card.innerHTML = ` diff --git a/src/shared/shell/ui/ModuleCardRenderer.ts b/src/shared/shell/ui/ModuleCardRenderer.ts index 4d56b183..f487dd3a 100644 --- a/src/shared/shell/ui/ModuleCardRenderer.ts +++ b/src/shared/shell/ui/ModuleCardRenderer.ts @@ -15,7 +15,6 @@ import { import { ModuleCardPresentationHelper } from './ModuleCardPresentationHelper'; type ModuleCardRendererDeps = { - checkInstalled?: (moduleId: string) => Promise; translate?: (key: string, fallback: string) => string; openModuleSettings?: (app: IApp) => void; getDownloadState?: (moduleId: string) => IModuleDownloadState | undefined; @@ -101,7 +100,7 @@ export class ModuleCardRenderer { } card.dataset['appId'] = app.id; - const state = this._resolveCardState(app); + const state = this._resolveCardState(app, _category); this._applyCardState(card, state); const template = document.getElementById('tpl-module-card') as HTMLTemplateElement | null; @@ -120,12 +119,11 @@ export class ModuleCardRenderer { this._applyExistingDownloadState(card, app); this._attachEventHandlers(card, app, state.isApi, onClick); - this._startAsyncInstallCheck(card, app, state, onClick); return card; } - private _resolveCardState(app: IApp): CardState { + private _resolveCardState(app: IApp, _category: string): CardState { const isApi = this._isApiModule(app); const isComingSoon = app.comingSoon === true; const isInstalled = isApi || (!isComingSoon && app.installed === true); @@ -304,61 +302,6 @@ export class ModuleCardRenderer { ); } - private _startAsyncInstallCheck( - card: HTMLElement, - app: IApp, - state: CardState, - onClick: (e: MouseEvent, app: IApp) => void, - ): void { - if (state.isInstalled || state.isApi || state.isComingSoon) { - return; - } - - const checkInstalled = this._deps.checkInstalled; - if (checkInstalled === undefined) { - return; - } - - void this._runAsyncInstallCheck(card, app, state.isApi, onClick, checkInstalled); - } - - private async _runAsyncInstallCheck( - card: HTMLElement, - app: IApp, - isApi: boolean, - onClick: (e: MouseEvent, app: IApp) => void, - checkInstalled: (moduleId: string) => Promise, - ): Promise { - try { - if (await checkInstalled(app.id)) { - this._handleAsyncInstallSuccess(card, app, isApi, onClick); - } - } catch (err) { - this._tracer?.debug( - `[ModuleCardRenderer] Failed to check installation status for ${app.id}: ${String(err)}`, - ); - } - } - - private _handleAsyncInstallSuccess( - card: HTMLElement, - app: IApp, - isApi: boolean, - onClick: (e: MouseEvent, app: IApp) => void, - ): void { - if (!card.isConnected) return; - if (card.dataset['appId'] !== app.id) return; - - app.installed = true; - - this._applyInstalledCardAppearance(card); - this._replaceCardActions( - card, - buildModuleCardActionButton(app, false, this._translate, onClick), - ); - this._ensureDeleteBadge(card, isApi); - } - public updateSlotCardAttributes(card: HTMLElement, app: IApp, capability?: string): void { card.dataset['currentModule'] = app.id; card.dataset['currentModuleName'] = app.name ?? app.id; @@ -448,16 +391,6 @@ export class ModuleCardRenderer { } } - private _replaceCardActions(card: HTMLElement, actionButton: HTMLElement): void { - const actionsContainer = card.querySelector('.module-selection-card-actions'); - if (actionsContainer === null) { - return; - } - - actionsContainer.innerHTML = ''; - actionsContainer.appendChild(actionButton); - } - private _ensureDeleteBadge(card: HTMLElement, isApi: boolean): void { if (card.querySelector('.app-delete-badge') !== null) { return; diff --git a/src/shared/shell/ui/ToastManager.test.ts b/src/shared/shell/ui/ToastManager.test.ts index e6af1438..e779afda 100644 --- a/src/shared/shell/ui/ToastManager.test.ts +++ b/src/shared/shell/ui/ToastManager.test.ts @@ -131,4 +131,22 @@ describe('ToastManager', () => { expect(document.getElementById('toast-container')).toBeNull(); expect(document.querySelector('.toast')).toBeNull(); }); + + it('should run an action when an actionable toast is clicked', () => { + const onClick = vi.fn(); + + manager.show('Open settings', 'warning', 1000, null, 'settings', onClick); + + const toast = document.getElementById('toast-settings'); + if (!(toast instanceof HTMLElement)) { + throw new Error('Toast was not created'); + } + + expect(toast.classList.contains('toast--actionable')).toBe(true); + expect(toast.getAttribute('role')).toBe('button'); + + toast.click(); + + expect(onClick).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/shared/types/bindings.ts b/src/shared/types/bindings.ts index fd42ce02..024321da 100644 --- a/src/shared/types/bindings.ts +++ b/src/shared/types/bindings.ts @@ -9,1316 +9,1359 @@ import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core"; /** Commands */ export const commands = { - // Checks backend health status + /** Checks backend health status */ getHealth: () => typedError(__TAURI_INVOKE("get_health")), - // Loads application configuration with module installation status - getConfig: () => typedError(__TAURI_INVOKE("get_config")), - // Retrieves application settings (theme, language, GPU, debug) + /** 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)), + /** Retrieves application settings (theme, language, GPU, debug) */ getSettings: () => typedError(__TAURI_INVOKE("get_settings")), - // Saves application settings + /** Saves application settings */ saveSettings: (settings: AppSettings) => typedError(__TAURI_INVOKE("save_settings", { settings })), - // Saves a single setting by key-value pair + /** Saves a single setting by key-value pair */ saveSetting: (key: string, value: string) => typedError(__TAURI_INVOKE("save_setting", { key, value })), - // Retrieves persisted settings for a specific module. - getModuleSettings: (moduleId: string) => typedError<{ [key in string]: "Null" | ({ Bool: boolean }) & { Array?: never; Number?: never; Object?: never; String?: never } | ({ Number: ({ f64: number }) & { i64?: never; u64?: never } | ({ i64: number }) & { f64?: never; u64?: never } | ({ u64: number }) & { f64?: never; i64?: never } }) & { Array?: never; Bool?: never; Object?: never; String?: never } | ({ String: string }) & { Array?: never; Bool?: never; Number?: never; Object?: never } | ({ Array: Value[] }) & { Bool?: never; Number?: never; Object?: never; String?: never } | ({ Object: { [key in string]: Value } }) & { Array?: never; Bool?: never; Number?: never; String?: never } }, AppError>(__TAURI_INVOKE("get_module_settings", { moduleId })), - // Saves persisted settings for a specific module. - saveModuleSettings: (moduleId: string, settings: { [key in string]: "Null" | ({ Bool: boolean }) & { Array?: never; Number?: never; Object?: never; String?: never } | ({ Number: ({ f64: number }) & { i64?: never; u64?: never } | ({ i64: number }) & { f64?: never; u64?: never } | ({ u64: number }) & { f64?: never; i64?: never } }) & { Array?: never; Bool?: never; Object?: never; String?: never } | ({ String: string }) & { Array?: never; Bool?: never; Number?: never; Object?: never } | ({ Array: Value[] }) & { Bool?: never; Number?: never; Object?: never; String?: never } | ({ Object: { [key in string]: Value } }) & { Array?: never; Bool?: never; Number?: never; String?: never } }) => typedError(__TAURI_INVOKE("save_module_settings", { moduleId, settings })), - // Detects and returns the current system language code + /** Retrieves persisted settings for a specific module. */ + getModuleSettings: (moduleId: string) => typedError<{ [key in string]: unknown }, AppError>(__TAURI_INVOKE("get_module_settings", { moduleId })).then((v) => ((v.status === "ok" ? { ...v, data: Object.fromEntries(Object.entries(v.data).map(([k,v])=>[k,v])) } : v) as typeof v)), + /** Saves persisted settings for a specific module. */ + saveModuleSettings: (moduleId: string, settings: { [key in string]: unknown }) => typedError(__TAURI_INVOKE("save_module_settings", { moduleId, settings: Object.fromEntries(Object.entries(settings).map(([k,v])=>[k,v])) })), + /** Detects and returns the current system language code */ getSystemLanguage: () => typedError(__TAURI_INVOKE("get_system_language")), - // Retrieves log entries since a given timestamp - getLogs: (since: number) => typedError(__TAURI_INVOKE("get_logs", { since })), - // Returns aggregated console metadata for views and runtime statuses. + /** Retrieves log entries since a given timestamp */ + getLogs: (since: number) => typedError(__TAURI_INVOKE("get_logs", { since })).then((v) => ((v.status === "ok" ? { ...v, data: v.data.map(i=>i) } : v) as typeof v)), + /** Retrieves log entries for a single console view since a given timestamp. */ + getConsoleLogs: (viewId: string, since: number) => typedError(__TAURI_INVOKE("get_console_logs", { viewId, since })).then((v) => ((v.status === "ok" ? { ...v, data: v.data.map(i=>i) } : v) as typeof v)), + /** Returns aggregated console metadata for views and runtime statuses. */ getConsoleOverview: () => typedError(__TAURI_INVOKE("get_console_overview")), - // Clears all stored log entries + /** Clears all stored log entries */ clearLogs: () => typedError(__TAURI_INVOKE("clear_logs")), - // Clears log entries and files for a single console view. + /** Clears log entries and files for a single console view. */ clearConsoleLogs: (viewId: string) => typedError(__TAURI_INVOKE("clear_console_logs", { viewId })), - // Returns the root folder where launcher logs are stored. + /** Returns the root folder where launcher logs are stored. */ getLogDir: () => typedError(__TAURI_INVOKE("get_log_dir")), - // Opens the root folder where launcher logs are stored. + /** Opens the root folder where launcher logs are stored. */ openLogDir: () => typedError(__TAURI_INVOKE("open_log_dir")), - // Opens the log folder for a single console view. + /** Opens the log folder for a single console view. */ openConsoleLogTarget: (viewId: string) => typedError(__TAURI_INVOKE("open_console_log_target", { viewId })), - // Adds a single log entry to the log store + /** Adds a single log entry to the log store */ addLog: (msg: string, source: string, level: string) => typedError(__TAURI_INVOKE("add_log", { msg, source, level })), - // Adds multiple log entries in batch from frontend + /** Adds multiple log entries in batch from frontend */ logBatch: (logs: BatchLogEntry[]) => typedError(__TAURI_INVOKE("log_batch", { logs })), - // Downloads and verifies a module from a Git repository - downloadModule: (moduleId: string, repoUrl: string, expectedHash: string | null, dlType: string | null) => typedError(__TAURI_INVOKE("download_module", { moduleId, repoUrl, expectedHash, dlType })), - // Resumes a paused module download using backend-owned request metadata. + /** Downloads and verifies a module from a Git repository */ + downloadModule: (moduleId: string, repoUrl: string, expectedHash: string | null, dlType: string | null, releaseSelection: { + /** GitHub release tag to download. `None` means the newest compatible release. */ + tag_name: string | null, + /** Compute target selected by the user. */ + compute_target?: ReleaseComputeTarget, +} | null) => typedError(__TAURI_INVOKE("download_module", { moduleId, repoUrl, expectedHash, dlType, releaseSelection })), + /** Lists compatible release versions and CPU/GPU package choices for a module. */ + getReleaseDownloadOptions: (moduleId: string, repoUrl: string) => typedError(__TAURI_INVOKE("get_release_download_options", { moduleId, repoUrl })), + /** Imports an integration from a local folder containing `axelate-module.toml`. */ + importIntegrationFolder: (path: string) => typedError(__TAURI_INVOKE("import_integration_folder", { path })), + /** Imports an integration from a local `.zip`, `.tar.gz`, `.tgz`, or `.7z` archive. */ + importIntegrationArchive: (path: string) => typedError(__TAURI_INVOKE("import_integration_archive", { path })), + /** Imports an integration from a local folder or archive, auto-detected by path type. */ + importIntegrationPath: (path: string) => typedError(__TAURI_INVOKE("import_integration_path", { path })), + /** Downloads and imports an integration from a repository or archive URL. */ + importIntegrationUrl: (sourceUrl: string) => typedError(__TAURI_INVOKE("import_integration_url", { sourceUrl })), + /** Resumes a paused module download using backend-owned request metadata. */ resumeDownload: (moduleId: string) => typedError(__TAURI_INVOKE("resume_download", { moduleId })), - // Checks if a module is already installed locally + /** Checks if a module is already installed locally */ checkModuleInstalled: (moduleId: string) => typedError(__TAURI_INVOKE("check_module_installed", { moduleId })), - // Retrieves the filesystem path to a module's directory + /** Retrieves the filesystem path to a module's directory */ getModulePath: (moduleId: string) => typedError(__TAURI_INVOKE("get_module_path", { moduleId })), - // Deletes a module from local storage + /** Deletes a module from local storage */ deleteModule: (moduleId: string) => typedError(__TAURI_INVOKE("delete_module", { moduleId })), - // Lists all files in a module's directory + /** Lists all files in a module's directory */ listModuleFiles: (moduleId: string) => typedError(__TAURI_INVOKE("list_module_files", { moduleId })), - // Configures download bandwidth limits + /** Configures download bandwidth limits */ setDownloadSettings: (enabled: boolean, maxSpeed: number) => __TAURI_INVOKE("set_download_settings", { enabled, maxSpeed }), - // Cancels an in-progress module download + /** Cancels an in-progress module download */ cancelDownload: (moduleId: string) => __TAURI_INVOKE("cancel_download", { moduleId }), - // Pauses an in-progress module download while preserving partial files for resume + /** Pauses an in-progress module download while preserving partial files for resume */ pauseDownload: (moduleId: string) => __TAURI_INVOKE("pause_download", { moduleId }), - // Retrieves real-time system statistics (CPU, RAM, GPU, disk, network) - getSystemStats: () => typedError(__TAURI_INVOKE("get_system_stats")), - // Retrieves GPU information and preferred runtime backend hint + /** Retrieves real-time system statistics (CPU, RAM, GPU, disk, network) */ + getSystemStats: () => typedError(__TAURI_INVOKE("get_system_stats")).then((v) => ((v.status === "ok" ? { ...v, data: ({...v.data,gpu:v.data.gpu==null?v.data.gpu:v.data.gpu,vram:v.data.vram==null?v.data.vram:v.data.vram}) } : v) as typeof v)), + /** Retrieves GPU information and preferred runtime backend hint */ getGpuInfo: () => typedError(__TAURI_INVOKE("get_gpu_info")), - // Pauses or resumes system monitoring + /** Pauses or resumes system monitoring */ 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")), - // Controls a module (start, stop, restart) + /** 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) */ controlModule: (request: ControlRequest) => typedError(__TAURI_INVOKE("control_module", { request })), - // Retrieves runtime status of a specific module + /** Retrieves runtime status of a specific module */ getModuleStatus: (moduleId: string) => typedError(__TAURI_INVOKE("get_module_status", { moduleId })), - // Creates a scoped settings-session token for a module-owned custom settings UI. + /** Creates a scoped settings-session token for a module-owned custom settings UI. */ createModuleSettingsSession: (moduleId: string) => typedError(__TAURI_INVOKE("create_module_settings_session", { moduleId })), - // Minimizes the application window + /** Minimizes the application window */ minimizeWindow: () => typedError(__TAURI_INVOKE("minimize_window")), - // Maximizes or unmaximizes the window + /** Maximizes or unmaximizes the window */ maximizeWindow: () => typedError(__TAURI_INVOKE("maximize_window")), - // Closes the window gracefully (app remains in tray) + /** Closes the window gracefully (app remains in tray) */ closeWindow: () => typedError(__TAURI_INVOKE("close_window")), - // Shows and focuses the window + /** Shows and focuses the window */ showWindow: () => typedError(__TAURI_INVOKE("show_window")), - // Hides the window + /** Hides the window */ hideWindow: () => typedError(__TAURI_INVOKE("hide_window")), - // Retrieves translation strings for the specified language - getTranslations: (lang: string) => typedError<"Null" | ({ Bool: boolean }) & { Array?: never; Number?: never; Object?: never; String?: never } | ({ Number: ({ f64: number }) & { i64?: never; u64?: never } | ({ i64: number }) & { f64?: never; u64?: never } | ({ u64: number }) & { f64?: never; i64?: never } }) & { Array?: never; Bool?: never; Object?: never; String?: never } | ({ String: string }) & { Array?: never; Bool?: never; Number?: never; Object?: never } | ({ Array: Value[] }) & { Bool?: never; Number?: never; Object?: never; String?: never } | ({ Object: { [key in string]: Value } }) & { Array?: never; Bool?: never; Number?: never; String?: never }, AppError>(__TAURI_INVOKE("get_translations", { lang })), - // Retrieves current license activation status - getLicenseStatus: () => typedError(__TAURI_INVOKE("get_license_status")), - // Activates a license key with optional email - activateLicense: (key: string, email: string | null) => typedError(__TAURI_INVOKE("activate_license", { key, email })), - // Deactivates the current license - deactivateLicense: () => typedError(__TAURI_INVOKE("deactivate_license")), - // Checks if a specific feature is enabled by the current license - checkFeature: (feature: string) => typedError(__TAURI_INVOKE("check_feature", { feature })), - // Retrieves current theme color palette + /** Retrieves translation strings for the specified language */ + getTranslations: (lang: string) => typedError(__TAURI_INVOKE("get_translations", { lang })), + /** Retrieves current theme color palette */ getThemeColors: () => typedError<{ [key in string]: string }, AppError>(__TAURI_INVOKE("get_theme_colors")), - // Retrieves persisted window settings (size, position, maximized state) + /** Retrieves persisted window settings (size, position, maximized state) */ getWindowSettings: () => typedError(__TAURI_INVOKE("get_window_settings")), - // Saves window dimensions to disk + /** Saves window dimensions to disk */ saveWindowSize: (width: number, height: number) => typedError(__TAURI_INVOKE("save_window_size", { width, height })), - // Saves window screen position to disk + /** Saves window screen position to disk */ saveWindowPosition: (x: number, y: number) => typedError(__TAURI_INVOKE("save_window_position", { x, y })), - // Saves maximized/unmaximized state to disk + /** Saves maximized/unmaximized state to disk */ saveMaximizedState: (maximized: boolean) => typedError(__TAURI_INVOKE("save_maximized_state", { maximized })), - // Saves global zoom level to UI state + /** Saves global zoom level to UI state */ saveZoomLevel: (zoom: number) => typedError(__TAURI_INVOKE("save_zoom_level", { zoom })), /** * Set `WebView` zoom level and persist for current resolution. * Uses native WebView zoom so layout metrics stay consistent with the rendered size. */ setWebviewZoom: (zoom: number) => typedError(__TAURI_INVOKE("set_webview_zoom", { zoom })), - // Persist zoom for the active monitor resolution without touching the WebView. + /** Persist zoom for the active monitor resolution without touching the WebView. */ saveCurrentResolutionZoom: (zoom: number) => typedError(__TAURI_INVOKE("save_current_resolution_zoom", { zoom })), - // Retrieves current global `WebView` zoom level + /** Retrieves current global `WebView` zoom level */ getWebviewZoom: () => typedError(__TAURI_INVOKE("get_webview_zoom")), /** * Get the effective zoom for the current monitor resolution. * Read-only — never auto-saves, so "user set" is always distinguishable from "defaulted". */ getResolutionZoom: () => typedError(__TAURI_INVOKE("get_resolution_zoom")), - // Retrieves window configuration settings + /** Retrieves window configuration settings */ getWindowConfig: () => __TAURI_INVOKE("get_window_config"), - // Calculates window layout policy based on screen size and zoom + /** Calculates window layout policy based on screen size and zoom */ getWindowPolicy: () => typedError(__TAURI_INVOKE("get_window_policy")), - // Retrieves persisted UI state (sidebar, zoom, selected modules) - getUiState: () => typedError(__TAURI_INVOKE("get_ui_state")), - // Saves UI state to persistent storage - saveUiState: (state: UIState) => typedError(__TAURI_INVOKE("save_ui_state", { state })), - // Retrieves all application state and configuration during app startup - getAppBootstrapData: () => typedError(__TAURI_INVOKE("get_app_bootstrap_data")), - // Saves anAPI key securely to system credential storage + /** Retrieves persisted UI state (sidebar, zoom, selected modules) */ + getUiState: () => typedError(__TAURI_INVOKE("get_ui_state")).then((v) => ((v.status === "ok" ? { ...v, data: ({...v.data,resolution_zoom:Object.fromEntries(Object.entries(v.data.resolution_zoom).map(([k,v])=>[k,v]))}) } : v) as typeof v)), + /** Saves UI state to persistent storage */ + saveUiState: (state: UIState) => typedError(__TAURI_INVOKE("save_ui_state", { state: ({...state,resolution_zoom:Object.fromEntries(Object.entries(state.resolution_zoom).map(([k,v])=>[k,v]))}) })), + /** Retrieves all application state and configuration during app startup */ + getAppBootstrapData: () => typedError(__TAURI_INVOKE("get_app_bootstrap_data")).then((v) => ((v.status === "ok" ? { ...v, data: ({...v.data,uiState:({...v.data.uiState,resolution_zoom:Object.fromEntries(Object.entries(v.data.uiState.resolution_zoom).map(([k,v])=>[k,v]))})}) } : v) as typeof v)), + /** Saves anAPI key securely to system credential storage */ saveSecureKey: (service: string, key: string) => typedError(__TAURI_INVOKE("save_secure_key", { service, key })), - // Retrieves a frontend-managed secret from system credential storage + /** Removes a frontend-managed secret from system credential storage */ + removeSecureKey: (service: string) => typedError(__TAURI_INVOKE("remove_secure_key", { service })), + /** Retrieves a frontend-managed secret from system credential storage */ getSecureKey: (service: string) => typedError(__TAURI_INVOKE("get_secure_key", { service })), - // Checks whether a non-empty API key exists in secure storage + /** Checks whether a non-empty API key exists in secure storage */ hasSecureKey: (service: string) => typedError(__TAURI_INVOKE("has_secure_key", { service })), - // Returns non-sensitive metadata for a stored key without exposing the secret. + /** Returns non-sensitive metadata for a stored key without exposing the secret. */ getSecureKeyMeta: (service: string) => typedError(__TAURI_INVOKE("get_secure_key_meta", { service })), - // Sends a chat message to the AI provider and streams the response - sendChatMessage: (request: ChatRequest, chatChannel: Channel, thoughtChannel: Channel) => typedError(__TAURI_INVOKE("send_chat_message", { request, chatChannel, thoughtChannel })), - // Cancels an active streamed chat request by request identifier. + /** Sends a chat message to the AI provider and streams the response */ + sendChatMessage: (request: ChatRequest, chatChannel: Channel, thoughtChannel: Channel) => typedError(__TAURI_INVOKE("send_chat_message", { request: ({...request,messages:request.messages.map(i=>i)}), chatChannel, thoughtChannel })), + /** Cancels an active streamed chat request by request identifier. */ cancelChatGeneration: (requestId: string) => __TAURI_INVOKE("cancel_chat_generation", { requestId }), - // Validates an API key for the specified provider + /** Validates an API key for the specified provider */ validateApiKey: (provider: string, key: string) => typedError(__TAURI_INVOKE("validate_api_key", { provider, key })), - // Validates the stored provider key without exposing it to the frontend + /** Validates the stored provider key without exposing it to the frontend */ validateStoredApiKey: (provider: string) => typedError(__TAURI_INVOKE("validate_stored_api_key", { provider })), - // Clears chat history for a specific session + /** Clears chat history for a specific session */ clearChatHistory: (sessionId: string) => typedError(__TAURI_INVOKE("clear_chat_history", { sessionId })), - // Retrieves chat history for a specific session - getChatHistory: (sessionId: string) => typedError(__TAURI_INVOKE("get_chat_history", { sessionId })), - // Removes the latest user turn and any following assistant replies from a session. + /** Retrieves chat history for a specific session */ + getChatHistory: (sessionId: string) => typedError(__TAURI_INVOKE("get_chat_history", { sessionId })).then((v) => ((v.status === "ok" ? { ...v, data: v.data.map(i=>i) } : v) as typeof v)), + /** Removes the latest user turn and any following assistant replies from a session. */ rewindLastTurn: (sessionId: string) => typedError(__TAURI_INVOKE("rewind_last_turn", { sessionId })), - // Counts tokens in text for the specified model + /** Counts tokens in text for the specified model */ countTokens: (text: string, model: string | null) => typedError(__TAURI_INVOKE("count_tokens", { text, model })), - // Sends an image generation request to the connected AI provider - generateImage: (request: ImageGenerationRequest) => typedError(__TAURI_INVOKE("generate_image", { request })), - // Starts image generation as a detached backend task and restores the window on completion. - generateImageBackground: (request: ImageGenerationRequest) => typedError(__TAURI_INVOKE("generate_image_background", { request })), - // Cancels the current image generation request for the selected provider. + /** Sends an image generation request to the connected AI provider */ + generateImage: (request: ImageGenerationRequest) => typedError(__TAURI_INVOKE("generate_image", { request: ({...request,cfg_scale:request.cfg_scale==null?request.cfg_scale:request.cfg_scale,denoising_strength:request.denoising_strength==null?request.denoising_strength:request.denoising_strength}) })), + /** Cancels the current image generation request for the selected provider. */ cancelImageGeneration: (provider: string) => typedError(__TAURI_INVOKE("cancel_image_generation", { provider })), - // Returns the latest image-generation preview when the local image engine writes one. + /** Returns the latest image-generation preview when the local image engine writes one. */ getImageGenerationPreview: () => typedError<{ - // Data URL of the latest preview image. + /** Data URL of the latest preview image. */ data_url: string, - // File modification timestamp in Unix milliseconds. + /** File modification timestamp in Unix milliseconds. */ updated_at_ms: number, - // Current image-generation progress, normalized to 0.0..1.0 when the engine exposes it. + /** Current image-generation progress, normalized to 0.0..1.0 when the engine exposes it. */ progress: number | null, - // Current sampling step when available. + /** Current sampling step when available. */ step: number | null, - // Total sampling steps when available. + /** Total sampling steps when available. */ total: number | null, - // Latest reported generation speed when available, for example `1.07s/it`. + /** Latest reported generation speed when available, for example `1.07s/it`. */ speed: string | null, - // Estimated remaining seconds when the engine exposes it. + /** Estimated remaining seconds when the engine exposes it. */ eta_relative: number | null, -} | null, AppError>(__TAURI_INVOKE("get_image_generation_preview")), - // Deletes a previously saved chat image from disk. +} | null, AppError>(__TAURI_INVOKE("get_image_generation_preview")).then((v) => ((v.status === "ok" ? { ...v, data: v.data==null?v.data:({...v.data,progress:v.data.progress==null?v.data.progress:v.data.progress,eta_relative:v.data.eta_relative==null?v.data.eta_relative:v.data.eta_relative}) } : v) as typeof v)), + /** Deletes a previously saved chat image from disk. */ deleteChatImage: (filePath: string) => typedError(__TAURI_INVOKE("delete_chat_image", { filePath })), - // Opens the saved chat image folder in the system file manager. + /** Opens the saved chat image folder in the system file manager. */ openChatImageLocation: (filePath: string, folderPath: string) => typedError(__TAURI_INVOKE("open_chat_image_location", { filePath, folderPath })), - // Saves a chat image to the default Pictures/axelate directory and returns the final path. + /** Saves a chat image to the default Pictures/axelate directory and returns the final path. */ saveChatImageDefault: (base64Data: string, mimeType: string) => typedError(__TAURI_INVOKE("save_chat_image_default", { base64Data, mimeType })), - // Captures one voice utterance with the native platform recognizer. + /** Captures one voice utterance with the native platform recognizer. */ recognizeVoiceOnce: (request: VoiceRecognitionRequest) => typedError(__TAURI_INVOKE("recognize_voice_once", { request })), - // Cancels the active native voice recognition request, if one is running. + /** Cancels the active native voice recognition request, if one is running. */ cancelVoiceRecognition: () => typedError(__TAURI_INVOKE("cancel_voice_recognition")), - // Opens the native Windows speech privacy settings page. + /** Opens the native Windows speech privacy settings page. */ openVoicePrivacySettings: () => typedError(__TAURI_INVOKE("open_voice_privacy_settings")), - // Retrieves all custom AI models configured by the user - getCustomModels: () => typedError(__TAURI_INVOKE("get_custom_models")), - // Adds a new custom AI model configuration + /** Retrieves all custom AI models configured by the user */ + getCustomModels: () => typedError(__TAURI_INVOKE("get_custom_models")).then((v) => ((v.status === "ok" ? { ...v, data: v.data.map(i=>i) } : v) as typeof v)), + /** Adds a new custom AI model configuration */ addCustomModel: (providerId: string, id: string, name: string, baseModelId: string) => typedError(__TAURI_INVOKE("add_custom_model", { providerId, id, name, baseModelId })), - // Removes a custom AI model by ID + /** Removes a custom AI model by ID */ removeCustomModel: (id: string) => typedError(__TAURI_INVOKE("remove_custom_model", { id })), - // Processes file content for AI context (extracts text from files and archives) + /** Processes file content for AI context (extracts text from files and archives) */ processFileContent: (name: string, data: number[]) => typedError(__TAURI_INVOKE("process_file_content", { name, data })), - // Starts a local engine. Hot-swaps if another engine is active. + /** Starts a local engine. Hot-swaps if another engine is active. */ startEngine: (config: EngineConfig) => typedError(__TAURI_INVOKE("start_engine", { config })), - // Stops all running engines. + /** Stops all running engines. */ stopEngine: () => typedError(__TAURI_INVOKE("stop_engine")), - // Stops the engine in a specific capability slot (text, image, vision). + /** Stops the engine in a specific capability slot (text, image, vision). */ stopEngineSlot: (capability: Capability) => typedError(__TAURI_INVOKE("stop_engine_slot", { capability })), - // Gets the current engine state (idle, starting, ready, error). + /** Gets the current engine state (idle, starting, ready, error). */ getEngineState: () => typedError(__TAURI_INVOKE("get_engine_state")), - // Checks if an engine binary is present (in ENGINES_DIR or system PATH). + /** Checks if an engine binary is present (in ENGINES_DIR or system PATH). */ checkEngineInstalled: (engineId: string, binaryName: string | null) => __TAURI_INVOKE("check_engine_installed", { engineId, binaryName }), - // Returns all registered engine definitions with real-time installation status. - getEngineDefinitions: () => typedError(__TAURI_INVOKE("get_engine_definitions")), - // Returns the persisted user config for an engine, or defaults if none saved yet. + /** Deletes an Axelate-managed engine from local storage. */ + deleteEngine: (engineId: string) => typedError(__TAURI_INVOKE("delete_engine", { engineId })), + /** Returns all registered engine definitions with real-time installation status. */ + getEngineDefinitions: () => typedError(__TAURI_INVOKE("get_engine_definitions")).then((v) => ((v.status === "ok" ? { ...v, data: v.data.map(i=>({...i,config_schema:i.config_schema==null?i.config_schema:i.config_schema})) } : v) as typeof v)), + /** Returns the persisted user config for an engine, or defaults if none saved yet. */ getEngineConfig: (engineId: string) => typedError(__TAURI_INVOKE("get_engine_config", { engineId })), - // Returns the local engine modal payload in a single backend round-trip. + /** Returns the local engine modal payload in a single backend round-trip. */ getEngineSettingsPayload: (engineId: string) => typedError(__TAURI_INVOKE("get_engine_settings_payload", { engineId })), - // Persists user engine config (compute mode, context_size, model_path, extra_args). + /** Persists user engine config (compute mode, context_size, model_path, extra_args). */ setEngineConfig: (config: EngineConfig) => typedError(__TAURI_INVOKE("set_engine_config", { config })), }; /* Types */ -// Complete AI model definition +/** Complete AI model definition */ export type AiModel = { - // Model ID (moved from dict key) + /** Model ID (moved from dict key) */ id: string, - // Localization key for description + /** Localization key for description */ descKey?: string, - // Display name + /** Display name */ name: string, - // Human-readable description + /** Human-readable description */ desc: string, - // Tier classification (Weak < Medium < Strong) + /** Tier classification (Weak < Medium < Strong) */ tier: ModelTier, - // Model size classification (optional) + /** Model size classification (optional) */ modelSize: string | null, - // Release date string (YYYY-MM) + /** Release date string (YYYY-MM) */ releaseDate: string | null, - // Context window size in tokens + /** Context window size in tokens */ contextWindow: number | null, - // Maximum output tokens allowed + /** Maximum output tokens allowed */ maxOutputTokens: number | null, - // Whether the model is deprecated - deprecated: boolean | null, - // Pricing configuration (New Object Format) + /** Pricing configuration */ pricing: PricingConfig | null, - // Performance statistics + /** Performance statistics */ stats: ModelStats, - // Capabilities + /** Capabilities */ capabilities: ModelCapabilities | null, - // API model identifiers (mapped to `apiModels` in JSON) + /** API model identifiers (mapped to `apiModels` in JSON) */ apiModels: ApiModelConfig | null, }; -// API model identifiers for different capabilities +/** API model identifiers for different capabilities */ export type ApiModelConfig = { - // Model ID for text generation + /** Model ID for text generation */ text: string | null, - // Model ID for image generation + /** Model ID for image generation */ image: string | null, }; -// Configuration for an AI API provider (OpenAI, Gemini, Claude, etc.) +/** Configuration for an AI API provider (OpenAI, Gemini, Claude, etc.) */ export type ApiProvider = { - // Unique identifier (e.g., "gpt", "gemini") + /** Unique identifier (e.g., "gpt", "gemini") */ id: string, - // Display name (e.g., "GPT", "Gemini") + /** Display name (e.g., "GPT", "Gemini") */ name: string, - // Localization key for description + /** Localization key for description */ descKey?: string | null, - // Direct description text + /** Direct description text */ description?: string | null, - // Icon/emoji for UI display + /** Icon/emoji for UI display */ icon?: string | null, - // Provider type + /** Provider type */ type: ProviderType | null, - // Base URL for API endpoints + /** Base URL for API endpoints */ baseUrl?: string | null, - // Environment variable name for API key + /** Environment variable name for API key */ apiKeyEnv?: string | null, - // Available models configuration + /** Available models configuration */ models?: AiModel[] | null, - // Provider output capabilities exposed in the launcher catalog + /** Provider output capabilities exposed in the launcher catalog */ capabilities?: string[] | null, - // Model aliases (UI name → API ID mappings) + /** Model aliases (UI name → API ID mappings) */ modelAliases?: { [key in string]: string } | null, }; -// Orchestrated application configuration +/** Orchestrated application configuration */ export type AppConfig = AppConfig_Serialize | AppConfig_Deserialize; -// Orchestrated application configuration +/** Orchestrated application configuration */ export type AppConfig_Deserialize = { - // Configuration version + /** Configuration version */ version: string, - // Available AI providers (loaded from resources/api_providers) + /** Available AI providers (loaded from resources/api_providers) */ apiProviders: ApiProvider[], - // Catalog of available apps/services (local + cloud virtual modules) + /** Catalog of available apps/services (local + cloud virtual modules) */ catalog: ConfigCatalog_Deserialize, }; -// Orchestrated application configuration +/** Orchestrated application configuration */ export type AppConfig_Serialize = { - // Configuration version + /** Configuration version */ version: string, - // Available AI providers (loaded from resources/api_providers) + /** Available AI providers (loaded from resources/api_providers) */ apiProviders: ApiProvider[], - // Catalog of available apps/services (local + cloud virtual modules) + /** Catalog of available apps/services (local + cloud virtual modules) */ catalog: ConfigCatalog_Serialize, }; -// Application-level errors +/** Application-level errors */ export type AppError = -// Validation error (invalid input, malformed data) -({ Validation: string }) & { Config?: never; External?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never } | -// Resource not found error -({ NotFound: string }) & { Config?: never; External?: never; Internal?: never; Io?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | -// Permission denied or unauthorized access -({ PermissionDenied: string }) & { Config?: never; External?: never; Internal?: never; Io?: never; NotFound?: never; Serialization?: never; Validation?: never } | -// File system I/O error -({ Io: string }) & { Config?: never; External?: never; Internal?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | -// JSON serialization/deserialization error -({ Serialization: string }) & { Config?: never; External?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Validation?: never } | -// Configuration loading or parsing error -({ Config: string }) & { External?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | -// External service or API error +/** Validation error (invalid input, malformed data) */ +({ Validation: string }) & { Config?: never; External?: never; FrontendSecretForbidden?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never } | +/** Resource not found error */ +({ NotFound: string }) & { Config?: never; External?: never; FrontendSecretForbidden?: never; Internal?: never; Io?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | +/** Permission denied or unauthorized access */ +({ PermissionDenied: string }) & { Config?: never; External?: never; FrontendSecretForbidden?: never; Internal?: never; Io?: never; NotFound?: never; Serialization?: never; Validation?: never } | +/** Frontend tried to access a secret outside the managed allowlist */ +({ FrontendSecretForbidden: string }) & { Config?: never; External?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | +/** File system I/O error */ +({ Io: string }) & { Config?: never; External?: never; FrontendSecretForbidden?: never; Internal?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | +/** JSON serialization/deserialization error */ +({ Serialization: string }) & { Config?: never; External?: never; FrontendSecretForbidden?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Validation?: never } | +/** Configuration loading or parsing error */ +({ Config: string }) & { External?: never; FrontendSecretForbidden?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | +/** External service or API error */ ({ External: { - // Unique request identifier for tracing + /** Unique request identifier for tracing */ request_id: string | null, - // error message + /** error message */ message: string, -} }) & { Config?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | -// Internal server error (unexpected failures) +} }) & { Config?: never; FrontendSecretForbidden?: never; Internal?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never } | +/** Internal server error (unexpected failures) */ ({ Internal: { - // Unique request identifier for tracing + /** Unique request identifier for tracing */ request_id: string | null, - // error message + /** error message */ message: string, -} }) & { Config?: never; External?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never }; +} }) & { Config?: never; External?: never; FrontendSecretForbidden?: never; Io?: never; NotFound?: never; PermissionDenied?: never; Serialization?: never; Validation?: never }; -// Global application settings +/** Global application settings */ export type AppSettings = { - // UI theme ("dark" or "light") + /** UI theme ("dark" or "light") */ theme: string, - // Interface language code (e.g., "en", "ru", "zh") + /** Interface language code (e.g., "en", "ru", "zh") */ language: string, - // Enable GPU acceleration for monitoring + /** Enable GPU acceleration for monitoring */ use_gpu: boolean, - // Enable debug mode and logging + /** Enable debug mode and logging */ debug_mode: boolean, -} & -// Dynamic extra settings (module-specific, etc.) -({ [key in string]: string }); +} & { [key in string]: string }; -// Batch log entry from frontend +/** Batch log entry from frontend */ export type BatchLogEntry = { - // Log level ("info", "warn", "error") + /** Log level ("info", "warn", "error") */ level: string, - // Log message content + /** Log message content */ message: string, }; -// Application bootstrap data sent to frontend during initialization +/** Application bootstrap data sent to frontend during initialization */ export type BootstrapData = { - // Persisted UI state + /** Persisted UI state */ uiState: UIState, - // Window configuration settings + /** Window configuration settings */ windowConfig: WindowConfig, - // Detected system language + /** Detected system language */ systemLanguage: string, - // Effective zoom for the current monitor resolution + /** Effective zoom for the current monitor resolution */ initialZoom: number, }; -// Breakpoints configuration. +/** Breakpoints configuration. */ export type Breakpoints = { - // Width for compact layout. + /** Width for compact layout. */ compact: number, - // Width for medium layout. + /** Width for medium layout. */ medium: number, - // Width for large layout. + /** Width for large layout. */ large: number, }; -// What an engine can do +/** What an engine can do */ export type Capability = -// Text generation (LLM) +/** Text generation (LLM) */ "text" | -// Image generation (diffusion) +/** Image generation (diffusion) */ "image" | -// Image understanding (multimodal LLM) +/** Image understanding (multimodal LLM) */ "vision"; -// AI chat message with role and content +/** AI chat message with role and content */ export type ChatMessage = { - // Unique message identifier (UUID v4) + /** Unique message identifier (UUID v4) */ id?: string, - // Role ("user", "assistant", "system") + /** Role ("user", "assistant", "system") */ role: string, - // Message content (text or structured data) - content: "Null" | ({ Bool: boolean }) & { Array?: never; Number?: never; Object?: never; String?: never } | ({ Number: ({ f64: number }) & { i64?: never; u64?: never } | ({ i64: number }) & { f64?: never; u64?: never } | ({ u64: number }) & { f64?: never; i64?: never } }) & { Array?: never; Bool?: never; Object?: never; String?: never } | ({ String: string }) & { Array?: never; Bool?: never; Number?: never; Object?: never } | ({ Array: Value[] }) & { Bool?: never; Number?: never; Object?: never; String?: never } | ({ Object: { [key in string]: Value } }) & { Array?: never; Bool?: never; Number?: never; String?: never }, - // Optional signature for extended thinking + /** Message content (text or structured data) */ + content: unknown, + /** Optional signature for extended thinking */ thought_signature: string | null, }; -// AI reply content +/** AI reply content */ export type ChatReply = { - // Reply text + /** Reply text */ text: string, - // Role (typically "assistant") + /** Role (typically "assistant") */ role: string, }; -// AI chat request parameters +/** AI chat request parameters */ export type ChatRequest = { - // AI provider ("openai", "gemini", "local") - largely ignored now as we route via OpenRouter + /** AI provider ("openai", "gemini", "local") - largely ignored now as we route via OpenRouter */ provider: string, - // Model identifier + /** Model identifier */ model: string, - // Chat history and new message + /** Chat history and new message */ messages: ChatMessage[], - // Optional API key + /** Optional API key */ api_key: string | null, - // Thinking level ("low", "medium", "high") + /** Thinking level ("low", "medium", "high") */ thinking_level: string | null, - // Optional max output tokens + /** Optional max output tokens */ max_tokens: number | null, - // Client-generated request identifier for stream isolation + /** Client-generated request identifier for stream isolation */ request_id: string | null, - // Session identifier for history tracking + /** Session identifier for history tracking */ session_id: string | null, - // Optional web search controls for cloud/API providers + /** Optional web search controls for cloud/API providers */ web_search?: WebSearchOptions | null, }; -// AI chat response +/** AI chat response */ export type ChatResponse = { - // Corresponding request identifier + /** Corresponding request identifier */ id?: string, - // Whether request was successful + /** Whether request was successful */ ok: boolean, - // AI reply content + /** AI reply content */ reply: ChatReply | null, - // Error message if failed + /** Error message if failed */ error: string | null, - // Model used + /** Model used */ model: string | null, - // Thinking signature + /** Thinking signature */ thought_signature: string | null, - // Token usage metrics + /** Token usage metrics */ usage: TokenUsage | null, }; -// Application catalog containing available modules and services +/** Application catalog containing available modules and services */ export type ConfigCatalog = ConfigCatalog_Serialize | ConfigCatalog_Deserialize; -// Application catalog containing available modules and services +/** Application catalog containing available modules and services */ export type ConfigCatalog_Deserialize = { - // AI generation modules (text, images, `LocalAI`) + /** AI generation modules (text, images, `LocalAI`) */ ai: ModuleItem_Deserialize[], - // Service integrations and external automation + /** Service integrations and external automation */ services: ModuleItem_Deserialize[], - // Starred/Favorite module IDs + /** Starred/Favorite module IDs */ stars: string[], }; -// Application catalog containing available modules and services +/** Application catalog containing available modules and services */ export type ConfigCatalog_Serialize = { - // AI generation modules (text, images, `LocalAI`) + /** AI generation modules (text, images, `LocalAI`) */ ai: ModuleItem_Serialize[], - // Service integrations and external automation + /** Service integrations and external automation */ services: ModuleItem_Serialize[], - // Starred/Favorite module IDs + /** Starred/Favorite module IDs */ stars: string[], }; -// Configuration field schema for module settings +/** Configuration field schema for module settings */ export type ConfigField = { - // Field type ("text", "password", "select", "checkbox") + /** Field type ("text", "password", "select", "checkbox") */ fieldType: string, - // Display label in UI + /** Display label in UI */ label: string, - // Optional field description/help text shown under the control. + /** Optional field description/help text shown under the control. */ description?: string | null, - // Optional placeholder text for text inputs and textareas. + /** Optional placeholder text for text inputs and textareas. */ placeholder?: string | null, - // Default value - default: "Null" | ({ Bool: boolean }) & { Array?: never; Number?: never; Object?: never; String?: never } | ({ Number: ({ f64: number }) & { i64?: never; u64?: never } | ({ i64: number }) & { f64?: never; u64?: never } | ({ u64: number }) & { f64?: never; i64?: never } }) & { Array?: never; Bool?: never; Object?: never; String?: never } | ({ String: string }) & { Array?: never; Bool?: never; Number?: never; Object?: never } | ({ Array: Value[] }) & { Bool?: never; Number?: never; Object?: never; String?: never } | ({ Object: { [key in string]: Value } }) & { Array?: never; Bool?: never; Number?: never; String?: never } | null, - // Whether field is required + /** Default value */ + default: unknown | null, + /** Whether field is required */ required: boolean, - // Optional minimum numeric value for number and range controls. + /** Optional minimum numeric value for number and range controls. */ min?: number | null, - // Optional maximum numeric value for number and range controls. + /** Optional maximum numeric value for number and range controls. */ max?: number | null, - // Optional numeric step for number and range controls. + /** Optional numeric step for number and range controls. */ step?: number | null, - // Preferred row count for multiline textareas. + /** Preferred row count for multiline textareas. */ rows?: number | null, - // Optional section/group label for form grouping. + /** Optional section/group label for form grouping. */ section?: string | null, - // Optional ordering hint inside a form or section. + /** Optional ordering hint inside a form or section. */ order?: number | null, - // Available options for "select" fields. + /** Available options for "select" fields. */ options?: string[] | null, }; -// Console log view metadata for frontend tabs. +/** Console log view metadata for frontend tabs. */ export type ConsoleLogView = { - // Stable view identifier. + /** Stable view identifier. */ id: string, - // Human-readable label. + /** Human-readable label. */ label: string, }; -// Aggregated console metadata payload. +/** Aggregated console metadata payload. */ export type ConsoleOverview = { - // Available log views including the default general tab. + /** Available log views including the default general tab. */ views: ConsoleLogView[], - // Runtime status rows for engines and modules. + /** Runtime status rows for engines and modules. */ status_items: ConsoleStatusItem[], }; -// Runtime status used by the console overview. +/** Runtime status used by the console overview. */ export type ConsoleRuntimeStatus = -// Process is currently running. +/** Process is currently running. */ "running" | -// Process is starting or switching. +/** Process is starting or switching. */ "starting" | -// Process failed or status lookup failed. +/** Process failed or status lookup failed. */ "failed" | -// Process is stopped. +/** Process is stopped. */ "stopped"; -// Console status row for engines or modules. +/** Console status row for engines or modules. */ export type ConsoleStatusItem = { - // Stable item identifier. + /** Stable item identifier. */ id: string, - // Human-readable label. + /** Human-readable label. */ label: string, - // Status category discriminator. + /** Status category discriminator. */ kind: string, - // Runtime status. + /** Runtime status. */ status: ConsoleRuntimeStatus, - // Additional detail text. + /** Additional detail text. */ detail: string, }; -// Module control request from frontend +/** Module control request from frontend */ export type ControlRequest = { - // Module identifier (optional for global actions) + /** Module identifier (optional for global actions) */ module_id: string | null, - // Control action ("start", "stop", "restart") + /** Control action ("start", "stop", "restart") */ action: string, }; -// Module control response to frontend +/** Module control response to frontend */ export type ControlResponse = { - // Whether the operation succeeded + /** Whether the operation succeeded */ success: boolean, - // Human-readable result message + /** Human-readable result message */ message: string, - // Current module status after operation + /** Current module status after operation */ status: string | null, }; -// CPU (Central Processing Unit) statistics +/** CPU (Central Processing Unit) statistics */ export type CpuStats = { - // CPU usage percentage (0-100) + /** CPU usage percentage (0-100) */ percent: number, - // Number of logical cores + /** Number of logical cores */ cores: number, - // CPU model name + /** CPU model name */ name: string, }; -// User-created fine-tuned or custom AI model +/** User-created fine-tuned or custom AI model */ export type CustomModel = { - // Unique identifier + /** Unique identifier */ id: string, - // Display name + /** Display name */ name: string, - // Provider ID (e.g., "gpt", "deepseek") + /** Provider ID (e.g., "gpt", "deepseek") */ provider_id: string, - // Base model identifier (e.g., "ft:gpt-3.5-turbo:...") + /** Base model identifier (e.g., "ft:gpt-3.5-turbo:...") */ base_model_id: string, - // Creation timestamp (Unix epoch) + /** Creation timestamp (Unix epoch) */ created_at: number, }; -// Disk I/O (Input/Output) statistics +/** Disk I/O (Input/Output) statistics */ export type DiskStats = { - // Read speed (bytes/sec) + /** Read speed (bytes/sec) */ readRate: number, - // Write speed (bytes/sec) + /** Write speed (bytes/sec) */ writeRate: number, - // Disk utilization percentage (0-100) + /** Disk utilization percentage (0-100) */ utilization: number, - // Total disk capacity (GB) + /** Total disk capacity (GB) */ totalGb: number, - // Disk space currently used (GB) + /** Disk space currently used (GB) */ usedGb: number, - // Disk activity percentage (0-100) + /** Disk activity percentage (0-100) */ activityPercent: number, }; -// Preferred compute backend for a local engine. +/** Preferred compute backend for a local engine. */ export type EngineComputeMode = -// Let the engine use available GPU devices automatically. +/** Let the engine use available GPU devices automatically. */ "gpu" | -// Force CPU execution and disable GPU offload. +/** Force CPU execution and disable GPU offload. */ "cpu"; -// Runtime configuration for starting an engine +/** Runtime configuration for starting an engine */ export type EngineConfig = { - // Engine identifier (matches EngineDefinition.id) + /** Engine identifier (matches EngineDefinition.id) */ engine_id: string, - // Preferred compute backend. + /** Preferred compute backend. */ compute_mode?: EngineComputeMode, - // Context window size + /** Context window size */ context_size?: number, - // Path to model file + /** Path to model file */ model_path: string | null, - // Optional companion VAE path for image engines - vae_path?: string | null, - // Optional companion LLM path for multimodal image engines - llm_path?: string | null, - // Extra CLI arguments + /** Extra CLI arguments */ extra_args?: string[], }; -// Static engine definition (from local_modules.json) +/** Static engine definition (from local_modules.json) */ export type EngineDefinition = { - // Unique identifier (e.g. "llamacpp") + /** Unique identifier (e.g. "llamacpp") */ id: string, - // Display name + /** Display name */ name: string, - // Description + /** Description */ desc?: string, - // Icon emoji + /** Icon emoji */ icon?: string, - // What this engine can do + /** What this engine can do */ capabilities?: Capability[], - // Binary name for local engines + /** Binary name for local engines */ binary?: string | null, - // GitHub repository URL (for releases/downloads) + /** GitHub repository URL (for releases/downloads) */ repo_url?: string | null, - // Current version + /** Current version */ version?: string, - // Default port (extracted from configSchema.port.default) + /** Default port (extracted from configSchema.port.default) */ default_port?: number, - // Default context window size (extracted from configSchema.contextSize.default) + /** Default context window size (extracted from configSchema.contextSize.default) */ default_context_size?: number, - // Raw configuration schema for UI rendering (kept for frontend) - config_schema?: "Null" | ({ Bool: boolean }) & { Array?: never; Number?: never; Object?: never; String?: never } | ({ Number: ({ f64: number }) & { i64?: never; u64?: never } | ({ i64: number }) & { f64?: never; u64?: never } | ({ u64: number }) & { f64?: never; i64?: never } }) & { Array?: never; Bool?: never; Object?: never; String?: never } | ({ String: string }) & { Array?: never; Bool?: never; Number?: never; Object?: never } | ({ Array: Value[] }) & { Bool?: never; Number?: never; Object?: never; String?: never } | ({ Object: { [key in string]: Value } }) & { Array?: never; Bool?: never; Number?: never; String?: never } | null, - // Whether the engine binary is currently installed (populated at runtime, not from JSON) + /** Raw configuration schema for UI rendering (kept for frontend) */ + config_schema?: unknown | null, + /** Whether the engine binary is currently installed (populated at runtime, not from JSON) */ installed?: boolean, - // True when the launcher connects to a user-managed external engine instead of installing it + /** + * Compute modes present in the Axelate-managed install metadata. + * + * Empty means unknown, usually a system PATH install or an older install without metadata. + */ + installed_compute_modes?: EngineComputeMode[], + /** True when the launcher connects to a user-managed external engine instead of installing it */ managed_externally?: boolean, }; -// Aggregated payload for the local engine settings modal. +/** Aggregated payload for the local engine settings modal. */ export type EngineSettingsPayload = { - // Fully merged engine config for the selected engine. + /** Fully merged engine config for the selected engine. */ config: EngineConfig, }; -// Engine lifecycle state (for frontend) +/** Engine lifecycle state (for frontend) */ export type EngineState = -// No engine loaded +/** No engine loaded */ "idle" | -// Engine is starting up +/** Engine is starting up */ ({ starting: { - // ID of the engine being started + /** ID of the engine being started */ engine_id: string, } }) & { error?: never; ready?: never; swapping?: never } | -// Swapping from one engine to another within a slot +/** Swapping from one engine to another within a slot */ ({ swapping: { - // ID of the engine being stopped + /** ID of the engine being stopped */ from: string, - // ID of the engine being started + /** ID of the engine being started */ to: string, } }) & { error?: never; ready?: never; starting?: never } | -// One or more engines are running +/** One or more engines are running */ ({ ready: { - // Active slots (one per capability) + /** Active slots (one per capability) */ slots: SlotStatus[], } }) & { error?: never; starting?: never; swapping?: never } | -// Engine encountered an error +/** Engine encountered an error */ ({ error: { - // ID of the failed engine + /** ID of the failed engine */ engine_id: string, - // Error description + /** Error description */ message: string, } }) & { ready?: never; starting?: never; swapping?: never }; -// Currently running engine +/** Currently running engine */ export type EngineStatus = { - // Engine identifier + /** Engine identifier */ id: string, - // Display name + /** Display name */ name: string, - // Capabilities + /** Capabilities */ capabilities: Capability[], - // HTTP endpoint (e.g. "http://localhost:8081") + /** HTTP endpoint (e.g. "http://localhost:8081") */ endpoint: string, - // Is the engine healthy and ready + /** Is the engine healthy and ready */ healthy: boolean, }; -// Public system GPU probe result used by the frontend and downloader. +/** Public system GPU probe result used by the frontend and downloader. */ export type GpuInfo = { - // Whether a usable GPU adapter was detected. + /** Whether a usable GPU adapter was detected. */ detected: boolean, - // Human-readable adapter name. + /** Human-readable adapter name. */ name: string, - // Whether CUDA-capable NVIDIA hardware was detected. + /** Whether CUDA-capable NVIDIA hardware was detected. */ cuda: boolean, - // Preferred runtime backend hint (`cuda`, `vulkan`, `cpu`, `hip`, `sycl`). + /** Preferred runtime backend hint (`cuda`, `vulkan`, `cpu`, `hip`, `sycl`). */ backend: string, - // Total GPU memory in megabytes, when available. + /** Total GPU memory in megabytes, when available. */ memory: number, - // CUDA driver major version, when available. + /** CUDA driver major version, when available. */ cuda_driver_major: number | null, - // CUDA driver minor version, when available. + /** CUDA driver minor version, when available. */ cuda_driver_minor: number | null, }; -// GPU (Graphics Processing Unit) statistics +/** GPU (Graphics Processing Unit) statistics */ export type GpuStats = { - // GPU usage percentage (0-100) + /** GPU usage percentage (0-100) */ usage: number, - // Memory currently used (bytes) + /** Memory currently used (bytes) */ memoryUsed: number, - // Total available memory (bytes) + /** Total available memory (bytes) */ memoryTotal: number, - // GPU temperature (Celsius) + /** GPU temperature (Celsius) */ temp: number, - // GPU model name + /** GPU model name */ name: string, }; -// Live preview payload for in-progress image generation. +/** Live preview payload for in-progress image generation. */ export type ImageGenerationPreview = { - // Data URL of the latest preview image. + /** Data URL of the latest preview image. */ data_url: string, - // File modification timestamp in Unix milliseconds. + /** File modification timestamp in Unix milliseconds. */ updated_at_ms: number, - // Current image-generation progress, normalized to 0.0..1.0 when the engine exposes it. + /** Current image-generation progress, normalized to 0.0..1.0 when the engine exposes it. */ progress: number | null, - // Current sampling step when available. + /** Current sampling step when available. */ step: number | null, - // Total sampling steps when available. + /** Total sampling steps when available. */ total: number | null, - // Latest reported generation speed when available, for example `1.07s/it`. + /** Latest reported generation speed when available, for example `1.07s/it`. */ speed: string | null, - // Estimated remaining seconds when the engine exposes it. + /** Estimated remaining seconds when the engine exposes it. */ eta_relative: number | null, }; -// Image generation request parameters +/** Image generation request parameters */ export type ImageGenerationRequest = { - // AI provider or local engine ID + /** AI provider or local engine ID */ provider: string, - // The text prompt for generation + /** The text prompt for generation */ prompt: string, - // Original user text before UI prompt prefixes + /** Original user text before UI prompt prefixes */ original_prompt: string | null, - // Model identifier + /** Model identifier */ model: string, - // Optional settings namespace key when UI-selected module differs from provider id + /** Optional settings namespace key when UI-selected module differs from provider id */ settings_key: string | null, - // Session identifier for history tracking + /** Session identifier for history tracking */ session_id: string | null, - // Number of inference steps + /** Number of inference steps */ steps: number | null, - // Guidance scale (CFG) + /** Guidance scale (CFG) */ cfg_scale: number | null, - // Denoising strength for image-to-image capable backends + /** Denoising strength for image-to-image capable backends */ denoising_strength: number | null, - // Image width in pixels + /** Image width in pixels */ width: number | null, - // Image height in pixels + /** Image height in pixels */ height: number | null, - // Sampler algorithm + /** Sampler algorithm */ sampler: string | null, - // Random seed + /** Random seed */ seed: number | null, - // Clip skip + /** Clip skip */ clip_skip: number | null, - // Optional negative prompt + /** Optional negative prompt */ negative_prompt: string | null, - // Number of images to generate (batch size) + /** Number of images to generate (batch size) */ batch_size: number | null, - // Scheduler algorithm + /** Scheduler algorithm */ scheduler: string | null, }; -// Image generation response +/** Image generation response */ export type ImageGenerationResponse = { - // Base64 encoded images or URLs + /** Base64 encoded images or URLs */ images: string[], - // Whether request was successful + /** Whether request was successful */ ok: boolean, - // Error message if failed + /** Error message if failed */ error: string | null, }; -// License tier status -export type LicenseStatus = -// Free tier -"Free" | -// Pro tier -"Pro" | -// Enterprise tier -"Enterprise" | -// Expired license -"Expired" | -// Invalid license key -"Invalid"; - -// License activation status response -export type LicenseStatusResponse = { - // Current license activation status - status: LicenseStatus, - // Email address associated with the license - email: string | null, -}; - -// Log entry for frontend display +/** Log entry for frontend display */ export type LogEntry = { - // Unix timestamp + /** Unix timestamp */ timestamp: number, - // Log source component + /** Log source component */ source: string, - // Log level ("info", "warn", "error") + /** Log level ("info", "warn", "error") */ level: string, - // Log message + /** Log message */ message: string, - // Resolved module/runtime identifier when the log belongs to a module. + /** Resolved module/runtime identifier when the log belongs to a module. */ module_id: string | null, - // Parsed time component extracted from the message when present. + /** Parsed time component extracted from the message when present. */ display_time: string | null, - // Normalized level used by the console UI. + /** Normalized level used by the console UI. */ normalized_level: string | null, - // Parsed scope segment when present. + /** Parsed scope segment when present. */ scope: string | null, - // Precomputed summary message for console rendering. + /** Precomputed summary message for console rendering. */ summary_message: string | null, - // Human-friendly source label for console rendering. + /** Human-friendly source label for console rendering. */ source_label: string | null, - // CSS-friendly source class for console rendering. + /** CSS-friendly source class for console rendering. */ source_class: string | null, - // Page identifier extracted from navigation logs. + /** Page identifier extracted from navigation logs. */ page: string | null, - // Action extracted from module control logs. + /** Action extracted from module control logs. */ action: string | null, - // Expected manifest or artifact hint from error logs. + /** Expected manifest or artifact hint from error logs. */ expected: string | null, }; -// Model capability flags (JSON Compatible) +/** Model capability flags (JSON Compatible) */ export type ModelCapabilities = { - // Supports reasoning/thinking steps + /** Supports reasoning/thinking steps */ reasoning?: boolean, - // Supports image input/vision + /** Supports image input/vision */ vision?: boolean, - // Supports multimodal input (audio/video) + /** Supports multimodal input (audio/video) */ multimodal?: boolean, - // Supports large context windows (>128k) + /** Supports large context windows (>128k) */ longContext?: boolean, - // Supports token streaming + /** Supports token streaming */ streaming?: boolean, - // Supports function/tool calling + /** Supports function/tool calling */ functionCalling?: boolean, }; -// Performance characteristics of an AI model (0-10 scale) +/** Performance characteristics of an AI model (0-10 scale) */ export type ModelStats = { - // Response speed rating + /** Response speed rating */ speed: number, - // Logical reasoning capability + /** Logical reasoning capability */ logic: number, - // Creative output quality + /** Creative output quality */ creative: number, }; -// Tier classification for AI models +/** Tier classification for AI models */ export type ModelTier = -// Entry-level or fast models +/** Entry-level or fast models */ "weak" | -// Balanced models +/** Balanced models */ "medium" | -// Flagship or reasoning-heavy models +/** Flagship or reasoning-heavy models */ "strong"; -// Complete module metadata and state +/** Complete module metadata and state */ export type Module = { - // Unique module identifier + /** Unique module identifier */ id: string, - // Display name + /** Display name */ name: string, - // User-facing description + /** User-facing description */ description: string, - // Semantic version (e.g., "1.0.0") + /** Semantic version (e.g., "1.0.0") */ version: string, - // Author username or organization + /** Author username or organization */ author: string, - // Category ("ai" or "service") + /** Category ("ai" or "service") */ category: string, - // Icon/emoji for UI display + /** Icon/emoji for UI display */ icon: string, - // Module-owned card preview metadata. + /** Module-owned card preview metadata. */ preview?: ModulePreview | null, - // Absolute filesystem path to module directory + /** Absolute filesystem path to module directory */ path: string, - // Whether module files are present locally + /** Whether module files are present locally */ installed: boolean, - // Whether module is user-installed (vs. built-in) + /** Whether module is user-installed (vs. built-in) */ local: boolean, - // Whether module is enabled for auto-start + /** Whether module is enabled for auto-start */ enabled: boolean, - // Current runtime status ("running", "stopped", "error") + /** Current runtime status ("running", "stopped", "error") */ status: string | null, - // Whether module can be deleted by user + /** Whether module can be deleted by user */ isDeletable: boolean, - // Current configuration values - config: { [key in string]: "Null" | ({ Bool: boolean }) & { Array?: never; Number?: never; Object?: never; String?: never } | ({ Number: ({ f64: number }) & { i64?: never; u64?: never } | ({ i64: number }) & { f64?: never; u64?: never } | ({ u64: number }) & { f64?: never; i64?: never } }) & { Array?: never; Bool?: never; Object?: never; String?: never } | ({ String: string }) & { Array?: never; Bool?: never; Number?: never; Object?: never } | ({ Array: Value[] }) & { Bool?: never; Number?: never; Object?: never; String?: never } | ({ Object: { [key in string]: Value } }) & { Array?: never; Bool?: never; Number?: never; String?: never } }, - // Configuration schema definition + /** Current configuration values */ + config: { [key in string]: unknown }, + /** Configuration schema definition */ configSchema: { [key in string]: ConfigField } | null, - // Relative path to the module-owned settings UI entry file. + /** Relative path to the module-owned settings UI entry file. */ settingsUi: string | null, }; -// Catalog item for downloadable modules +/** Catalog item for downloadable modules */ export type ModuleItem = ModuleItem_Serialize | ModuleItem_Deserialize; -// Catalog item for downloadable modules +/** Catalog item for downloadable modules */ export type ModuleItem_Deserialize = { - // Unique module identifier + /** Unique module identifier */ id: string, - // Localization key for name + /** Localization key for name */ nameKey: string, - // Localization key for description + /** Localization key for description */ descKey: string, - // Display name + /** Display name */ name: string, - // Description text + /** Description text */ desc: string, - // Icon/emoji + /** Icon/emoji */ icon: string, - // Optional module-owned card preview metadata. + /** Optional module-owned card preview metadata. */ preview?: ModulePreview | null, - // Module type ("api" or "service") + /** Module type ("api" or "service") */ type: string, - // Download type ("source" or "release") + /** Download type ("source" or "release") */ dlType?: string | null, - // Engine capabilities (e.g. `["text"]`, `["image"]`) + /** Engine capabilities (e.g. `["text"]`, `["image"]`) */ capabilities?: string[], - // Binary executable name for local engines (e.g. "llama-server") + /** Binary executable name for local engines (e.g. "llama-server") */ binary?: string | null, - // GitHub repository URL + /** GitHub repository URL */ repoUrl: string | null, - // SHA-256 hash for integrity verification + /** SHA-256 hash for integrity verification */ expectedHash: string | null, - // Marks catalog entries that should render as placeholders and not be launchable yet + /** Marks catalog entries that should render as placeholders and not be launchable yet */ comingSoon?: boolean, - // True when the launcher should treat this engine as user-managed and skip install checks + /** True when the launcher should treat this engine as user-managed and skip install checks */ managedExternally?: boolean, - // Semantic version (e.g., "1.0.0") + /** Semantic version (e.g., "1.0.0") */ version?: string, - // Raw configSchema from JSON (used by engine registry to extract typed defaults) - configSchema?: "Null" | ({ Bool: boolean }) & { Array?: never; Number?: never; Object?: never; String?: never } | ({ Number: ({ f64: number }) & { i64?: never; u64?: never } | ({ i64: number }) & { f64?: never; u64?: never } | ({ u64: number }) & { f64?: never; i64?: never } }) & { Array?: never; Bool?: never; Object?: never; String?: never } | ({ String: string }) & { Array?: never; Bool?: never; Number?: never; Object?: never } | ({ Array: Value[] }) & { Bool?: never; Number?: never; Object?: never; String?: never } | ({ Object: { [key in string]: Value } }) & { Array?: never; Bool?: never; Number?: never; String?: never } | null, + /** Raw configSchema from JSON (used by engine registry to extract typed defaults) */ + configSchema?: unknown | null, }; -// Catalog item for downloadable modules +/** Catalog item for downloadable modules */ export type ModuleItem_Serialize = { - // Unique module identifier + /** Unique module identifier */ id: string, - // Localization key for name + /** Localization key for name */ nameKey: string, - // Localization key for description + /** Localization key for description */ descKey: string, - // Display name + /** Display name */ name: string, - // Description text + /** Description text */ desc: string, - // Icon/emoji + /** Icon/emoji */ icon: string, - // Optional module-owned card preview metadata. + /** Optional module-owned card preview metadata. */ preview: ModulePreview | null, - // Module type ("api" or "service") + /** Module type ("api" or "service") */ type: string, - // Download type ("source" or "release") + /** Download type ("source" or "release") */ dlType: string | null, - // Engine capabilities (e.g. `["text"]`, `["image"]`) + /** Engine capabilities (e.g. `["text"]`, `["image"]`) */ capabilities: string[], - // Binary executable name for local engines (e.g. "llama-server") + /** Binary executable name for local engines (e.g. "llama-server") */ binary: string | null, - // GitHub repository URL + /** GitHub repository URL */ repoUrl: string | null, - // SHA-256 hash for integrity verification + /** SHA-256 hash for integrity verification */ expectedHash: string | null, - // Marks catalog entries that should render as placeholders and not be launchable yet + /** Marks catalog entries that should render as placeholders and not be launchable yet */ comingSoon: boolean, - // True when the launcher should treat this engine as user-managed and skip install checks + /** True when the launcher should treat this engine as user-managed and skip install checks */ managedExternally: boolean, - // Semantic version (e.g., "1.0.0") + /** Semantic version (e.g., "1.0.0") */ version: string, - // Whether module is currently installed (runtime only) + /** Whether module is currently installed (runtime only) */ installed: boolean, - // Raw configSchema from JSON (used by engine registry to extract typed defaults) - configSchema: "Null" | ({ Bool: boolean }) & { Array?: never; Number?: never; Object?: never; String?: never } | ({ Number: ({ f64: number }) & { i64?: never; u64?: never } | ({ i64: number }) & { f64?: never; u64?: never } | ({ u64: number }) & { f64?: never; i64?: never } }) & { Array?: never; Bool?: never; Object?: never; String?: never } | ({ String: string }) & { Array?: never; Bool?: never; Number?: never; Object?: never } | ({ Array: Value[] }) & { Bool?: never; Number?: never; Object?: never; String?: never } | ({ Object: { [key in string]: Value } }) & { Array?: never; Bool?: never; Number?: never; String?: never } | null, - // Configuration schema definition (runtime only, built from raw_config_schema) + /** Raw configSchema from JSON (used by engine registry to extract typed defaults) */ + configSchema: unknown | null, + /** Configuration schema definition (runtime only, built from raw_config_schema) */ configSchema: { [key in string]: ConfigField } | null, }; -// Module-owned card preview metadata. +/** Module-owned card preview metadata. */ export type ModulePreview = { - // Optional card title override. + /** Optional card title override. */ title?: string | null, - // Optional card description override. + /** Optional card description override. */ description?: string | null, - // Optional emoji/text sticker shown when no image is provided. + /** Optional emoji/text sticker shown when no image is provided. */ sticker?: string | null, - // Optional image URL or data URL for the card preview. + /** Optional image URL or data URL for the card preview. */ image?: string | null, - // Optional directory with localized preview JSON files. + /** Optional directory with localized preview JSON files. */ i18n?: string | null, }; -// Network I/O statistics +/** Network I/O statistics */ export type NetworkStats = { - // Download speed (bytes/sec) + /** Download speed (bytes/sec) */ downloadRate: number, - // Upload speed (bytes/sec) + /** Upload speed (bytes/sec) */ uploadRate: number, - // Total bytes received since boot + /** Total bytes received since boot */ totalReceived: number, - // Total bytes sent since boot + /** Total bytes sent since boot */ totalSent: number, - // Network utilization percentage (0-100) + /** Network utilization percentage (0-100) */ utilization: number, - // Network activity percentage (0-100) + /** Network activity percentage (0-100) */ activityPercent: number, }; -// Pricing configuration for a model +/** Pricing configuration for a model */ export type PricingConfig = { - // Cost per 1M input tokens - input_per_1m: number | null, - // Cost per 1M output tokens - output_per_1m: number | null, - // Currency code + /** Input-side cost or score shown in the launcher UI */ + input: number | null, + /** Output-side cost or score shown in the launcher UI */ + output: number | null, + /** Currency code */ currency: string | null, - // Additional notes + /** Additional notes */ notes: string | null, }; -// Processed file content result +/** Processed file content result */ export type ProcessedFile = { - // File name + /** File name */ name: string, - // Extracted text content + /** Extracted text content */ content: string, - // Whether file was a ZIP archive + /** Whether file was a ZIP archive */ is_archive: boolean, - // Processing error if any + /** Processing error if any */ error: string | null, - // Estimated token count for extracted text content. + /** Estimated token count for extracted text content. */ token_estimate: number, }; -// Type of AI provider +/** Type of AI provider */ export type ProviderType = -// Standard OpenAI API +/** Standard OpenAI API */ "openai" | -// Google Gemini API +/** Google Gemini API */ "google" | -// Anthropic Claude API (via OpenRouter or direct) +/** Anthropic Claude API (via OpenRouter or direct) */ "anthropic" | -// OpenAI-compatible local or cloud API +/** OpenAI-compatible local or cloud API */ "openai-compatible" | -// Generic API provider +/** Generic API provider */ "api" | -// Local module inference +/** Local module inference */ "local"; -// RAM (Random Access Memory) statistics +/** RAM (Random Access Memory) statistics */ export type RamStats = { - // RAM usage percentage (0-100) + /** RAM usage percentage (0-100) */ percent: number, - // RAM currently used (GB) + /** RAM currently used (GB) */ usedGb: number, - // Total RAM capacity (GB) + /** Total RAM capacity (GB) */ totalGb: number, - // RAM available for allocation (GB) + /** RAM available for allocation (GB) */ availableGb: number, }; -// Result of saving a generated chat image to disk. +/** User-facing compute target for release package selection. */ +export type ReleaseComputeTarget = +/** Let Axelate choose the best compatible package for this machine. */ +"auto" | +/** Prefer a GPU package, for example CUDA, Vulkan, HIP, or SYCL. */ +"gpu" | +/** Prefer a CPU package. */ +"cpu" | +/** Download both CPU and GPU packages when both are compatible. */ +"both"; + +/** User-visible release download options for a single module. */ +export type ReleaseDownloadOptions = { + /** Module identifier these options belong to. */ + module_id: string, + /** GitHub release versions in newest-first order. */ + versions: ReleaseDownloadVersion[], +}; + +/** Explicit release package selection passed from the frontend. */ +export type ReleaseDownloadSelection = { + /** GitHub release tag to download. `None` means the newest compatible release. */ + tag_name: string | null, + /** Compute target selected by the user. */ + compute_target?: ReleaseComputeTarget, +}; + +/** User-visible package variant for one compute target. */ +export type ReleaseDownloadVariant = { + /** Compute target represented by this variant. */ + compute_target: ReleaseComputeTarget, + /** Asset filenames that will be downloaded. */ + assets: string[], + /** Combined download size in bytes. */ + total_size: number, +}; + +/** User-visible package choices for a GitHub release version. */ +export type ReleaseDownloadVersion = { + /** GitHub release tag. */ + tag_name: string, + /** GitHub release publish timestamp when available. */ + published_at: string | null, + /** CPU package choice for this release, when compatible. */ + cpu: ReleaseDownloadVariant | null, + /** GPU package choice for this release, when compatible. */ + gpu: ReleaseDownloadVariant | null, + /** Recommended package target for this machine. */ + recommended: ReleaseComputeTarget, +}; + +/** Result of saving a generated chat image to disk. */ export type SavedChatImage = { - // Absolute path to the saved image file. + /** Absolute path to the saved image file. */ file_path: string, - // Absolute path to the folder containing the saved image. + /** Absolute path to the folder containing the saved image. */ folder_path: string, }; -// Non-sensitive metadata for a securely stored key. +/** Non-sensitive metadata for a securely stored key. */ export type SecureKeyMeta = { - // Whether a non-empty key exists for the requested service. + /** Whether a non-empty key exists for the requested service. */ exists: boolean, - // Character length of the stored key, if present. + /** Character length of the stored key, if present. */ length: number, }; -// Currently selected module in UI +/** Currently selected module in UI */ export type SelectedModule = { - // Module identifier + /** Module identifier */ id: string, - // Display name + /** Display name */ name: string, - // Localization key for name + /** Localization key for name */ nameKey: string | null, - // Icon/emoji + /** Icon/emoji */ icon: string, - // Module type + /** Module type */ type: string, - // Localization key for description + /** Localization key for description */ descKey: string | null, - // Description text + /** Description text */ desc: string, }; -// Status of a single capability slot +/** Status of a single capability slot */ export type SlotStatus = { - // Which capability this slot serves + /** Which capability this slot serves */ capability: Capability, - // Engine running in this slot + /** Engine running in this slot */ engine: EngineStatus, }; -// Streaming payload delivered from the backend to the frontend chat channels. +/** Streaming payload delivered from the backend to the frontend chat channels. */ export type StreamChunkPayload = { - // Correlates the chunk with the originating frontend request. + /** Correlates the chunk with the originating frontend request. */ request_id: string, - // Identifies the assistant message currently being streamed. + /** Identifies the assistant message currently being streamed. */ message_id: string, - // Describes how the frontend should handle this stream event. + /** Describes how the frontend should handle this stream event. */ kind: StreamPayloadKind, - // The incremental text fragment emitted by the model. + /** The incremental text fragment emitted by the model. */ content: string, }; -// Kind of streaming payload delivered to frontend chat channels. +/** Kind of streaming payload delivered to frontend chat channels. */ export type StreamPayloadKind = -// A visible assistant text fragment. +/** A visible assistant text fragment. */ "chat_chunk" | -// A reasoning/thinking text fragment. +/** A reasoning/thinking text fragment. */ "thought_chunk" | -// End-of-stream marker after all chunks have been delivered. +/** End-of-stream marker after all chunks have been delivered. */ "done"; -// Complete system statistics snapshot +/** Complete system statistics snapshot */ export type SystemStats = { - // CPU usage and information + /** CPU usage and information */ cpu: CpuStats, - // RAM usage and availability + /** RAM usage and availability */ ram: RamStats, - // GPU usage (if available) + /** GPU usage (if available) */ gpu: GpuStats | null, - // VRAM usage (if GPU present) + /** VRAM usage (if GPU present) */ vram: VramStats | null, - // Disk I/O statistics + /** Disk I/O statistics */ disk: DiskStats, - // Network I/O statistics + /** Network I/O statistics */ network: NetworkStats, - // Current process ID + /** Current process ID */ pid: number, - // CPU usage of the current process (0-100) + /** CPU usage of the current process (0-100) */ appCpu: number, - // Memory used by the current process (bytes) + /** Memory used by the current process (bytes) */ appMemory: number, }; -// Thresholds configuration. +/** Thresholds configuration. */ export type Thresholds = { - // Warning threshold width. + /** Warning threshold width. */ warningWidth: number, - // Warning threshold height. + /** Warning threshold height. */ warningHeight: number, - // Small screen threshold width. + /** Small screen threshold width. */ smallScreenWidth: number, - // Small screen threshold height. + /** Small screen threshold height. */ smallScreenHeight: number, }; -// Token usage statistics +/** Token usage statistics */ export type TokenUsage = { - // Tokens in the prompt + /** Tokens in the prompt */ prompt_tokens: number, - // Tokens in the completion + /** Tokens in the completion */ completion_tokens: number, - // Total tokens used + /** Total tokens used */ total_tokens: number, }; -// UI State that persists across sessions +/** UI State that persists across sessions */ export type UIState = { - // Sidebar collapsed state + /** Sidebar collapsed state */ sidebar_collapsed: boolean, - // User manually overrode responsive sidebar compaction + /** User manually overrode responsive sidebar compaction */ sidebar_manual_override?: boolean, - // Sidebar width in pixels + /** Sidebar width in pixels */ sidebar_width: number, - // Hidden navigation items (page IDs) + /** Hidden navigation items (page IDs) */ hidden_nav_items: string[], - // Hidden system monitor items + /** Hidden system monitor items */ hidden_monitors: string[], - // Card widths map (`card_id` -> "full" | "half") + /** Card widths map (`card_id` -> "full" | "half") */ card_widths: { [key in string]: string }, - // Download settings + /** Download settings */ download_limit_enabled: boolean, - // Maximum download speed in MB/s + /** Maximum download speed in MB/s */ download_max_speed: number, - // Selected modules by category + /** Selected modules by category */ selected_modules: { [key in string]: SelectedModule }, - // Global Zoom Level + /** Global Zoom Level */ zoom_level: number, - // Selected AI Models (`AppID` -> `ModelKey`) + /** Selected AI Models (`AppID` -> `ModelKey`) */ selected_ai_models: { [key in string]: string }, - // Last visited page ID + /** Last visited page ID */ last_page: string | null, /** * Per-resolution zoom levels ("WxH" -> value) * Per-resolution zoom levels (e.g., "1920x1080" -> 1.2) */ resolution_zoom: { [key in string]: number }, - // Sound effects enabled state + /** Sound effects enabled state */ sound_enabled: boolean, - // Selected reasoning level by AI provider + /** Selected reasoning level by AI provider */ ai_thinking_level?: { [key in string]: string }, - // Enables provider-side internet search by AI provider + /** Enables provider-side internet search by AI provider */ ai_web_search_enabled?: { [key in string]: boolean }, - // Current persistent AI session identifier - ai_session_id?: string | null, - // Preferred launcher interface language + /** Per-provider local model output token limits. */ + local_max_output_tokens?: { [key in string]: number }, + /** Last directory used by the custom integration import dialog. */ + integration_import_last_directory?: string | null, + /** Preferred launcher interface language */ preferred_language?: string | null, - // Request to reopen the chat and reveal the latest message after background work. + /** Request to reopen the chat and reveal the latest message after background work. */ pending_chat_reveal?: boolean, }; -// One-shot voice recognition request. +/** One-shot voice recognition request. */ export type VoiceRecognitionRequest = { - // Preferred UI language code, for example `en`, `ru`, or `ru-RU`. + /** Preferred UI language code, for example `en`, `ru`, or `ru-RU`. */ language: string | null, }; -// One-shot voice recognition response. +/** One-shot voice recognition response. */ export type VoiceRecognitionResponse = { - // Recognized final text. + /** Recognized final text. */ text: string, - // Native recognizer status. + /** Native recognizer status. */ status: string, - // Native confidence bucket when available. + /** Native confidence bucket when available. */ confidence: string | null, }; -// VRAM (Video RAM) statistics +/** VRAM (Video RAM) statistics */ export type VramStats = { - // VRAM usage percentage (0-100) + /** VRAM usage percentage (0-100) */ percent: number, - // VRAM currently used (GB) + /** VRAM currently used (GB) */ usedGb: number, - // Total VRAM capacity (GB) + /** Total VRAM capacity (GB) */ totalGb: number, }; -// Optional web search configuration for provider-backed chat requests. +/** Optional web search configuration for provider-backed chat requests. */ export type WebSearchOptions = { - // Enables provider-side web search. + /** Enables provider-side web search. */ enabled?: boolean, - // Search engine preference (`auto`, `native`, `exa`, ...). + /** Search engine preference (`auto`, `native`, `exa`, ...). */ engine?: string | null, - // Maximum results per search call. + /** Maximum results per search call. */ max_results?: number | null, - // Maximum results across all search calls in one request. + /** Maximum results across all search calls in one request. */ max_total_results?: number | null, - // Search context size (`low`, `medium`, `high`). + /** Search context size (`low`, `medium`, `high`). */ search_context_size?: string | null, - // Optional allow-list of domains. + /** Optional allow-list of domains. */ allowed_domains?: string[], - // Optional deny-list of domains. + /** Optional deny-list of domains. */ excluded_domains?: string[], }; -// Overall window configuration combining breakpoints and thresholds. +/** Overall window configuration combining breakpoints and thresholds. */ export type WindowConfig = { - // Breakpoint settings. + /** Breakpoint settings. */ breakpoints: Breakpoints, - // Threshold settings. + /** Threshold settings. */ thresholds: Thresholds, }; -// Layout policy based on screen size and current window dimensions. +/** Layout policy based on screen size and current window dimensions. */ export type WindowPolicy = { - // True if the screen is considered "small" (mobile/tablet/small laptop). + /** True if the screen is considered "small" (mobile/tablet/small laptop). */ isSmallScreen: boolean, - // True if a layout warning should be shown. + /** True if a layout warning should be shown. */ showWarning: boolean, }; -// Persistent window state. +/** Persistent window state. */ export type WindowSettings = { - // Window width. + /** Window width. */ width: number, - // Window height. + /** Window height. */ height: number, - // Horizontal screen position. + /** Horizontal screen position. */ x: number | null, - // Vertical screen position. + /** Vertical screen position. */ y: number | null, - // True if the window is maximized. + /** True if the window is maximized. */ maximized: boolean, }; diff --git a/src/shared/types/coreTypes.ts b/src/shared/types/coreTypes.ts index 8f0f756c..649bde04 100644 --- a/src/shared/types/coreTypes.ts +++ b/src/shared/types/coreTypes.ts @@ -6,31 +6,6 @@ import type { IUIState } from '../services/state/UiStateStore'; import type { IWindowConfig } from '../services/WindowService'; -/** - * Interface for the Tauri host instance. - */ -export interface ITauriInstance { - core: { - invoke: (_cmd: string, _args?: Record) => Promise; - }; - window: { - getCurrentWindow: () => { - isMaximized: () => Promise; - setSize: (_size: { width: number; height: number }) => Promise; - center: () => Promise; - innerSize: () => Promise<{ width: number; height: number }>; - outerPosition: () => Promise<{ x: number; y: number }>; - }; - LogicalSize: new (_width: number, _height: number) => { width: number; height: number }; - }; - event: { - listen: ( - _event: string, - _handler: (_event: { payload: unknown }) => void, - ) => Promise<() => void>; - }; -} - /** * Application/Module metadata from the catalog. */ @@ -51,6 +26,7 @@ export interface IApp { type?: 'api' | 'local'; capability?: 'text' | 'image'; // AI output capability; used for modal filter tabs installed?: boolean; + installedComputeModes?: Array<'gpu' | 'cpu'>; repoUrl?: string; expectedHash?: string; dlType?: string; @@ -124,6 +100,32 @@ export interface IModuleDownloadState { error?: unknown; } +export type ReleaseComputeTarget = 'auto' | 'gpu' | 'cpu' | 'both'; + +export interface ReleaseDownloadSelection { + tag_name: string | null; + compute_target: ReleaseComputeTarget; +} + +export interface ReleaseDownloadVariant { + compute_target: ReleaseComputeTarget; + assets: string[]; + total_size: number; +} + +export interface ReleaseDownloadVersion { + tag_name: string; + published_at?: string | null; + cpu?: ReleaseDownloadVariant | null; + gpu?: ReleaseDownloadVariant | null; + recommended: ReleaseComputeTarget; +} + +export interface ReleaseDownloadOptions { + module_id: string; + versions: ReleaseDownloadVersion[]; +} + /** * Unified application bootstrap data from backend. */ diff --git a/src/shared/types/global.d.ts b/src/shared/types/global.d.ts index fe077ff7..27a9ff15 100644 --- a/src/shared/types/global.d.ts +++ b/src/shared/types/global.d.ts @@ -1,30 +1,8 @@ -import { type IApp } from '@/shared/types/coreTypes'; - export {}; declare global { var __APP_VERSION__: string; - var __TAURI__: { - core: { - invoke: (_cmd: string, _args?: Record) => Promise; - }; - invoke: (_cmd: string, _args?: Record) => Promise; - event: { - listen: ( - _event: string, - _handler: (_event: { payload: T }) => void, - ) => Promise<() => void>; - }; - window: { - getCurrentWindow: () => { - isMaximized: () => Promise; - setSize: (_size: { width: number; height: number }) => Promise; - center: () => Promise; - }; - LogicalSize: new (_width: number, _height: number) => { width: number; height: number }; - }; - }; var __TAURI_INTERNALS__: | { invoke?: (_cmd: string, _args?: Record) => Promise; @@ -32,11 +10,7 @@ declare global { } | undefined; - var openModuleSettings: (_app: IApp) => void; - interface Window { - __TAURI__: typeof __TAURI__; __TAURI_INTERNALS__: typeof __TAURI_INTERNALS__; - openModuleSettings: typeof openModuleSettings; } } diff --git a/src/shared/types/global_bridge_types.ts b/src/shared/types/global_bridge_types.ts deleted file mode 100644 index 46d91a2e..00000000 --- a/src/shared/types/global_bridge_types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ITauriInstance } from './coreTypes'; - -/** - * Minimal bridge-facing types kept only for modules that still inspect runtime globals. - */ -export type TTranslateFunction = (key: string, defaultValue?: string) => string; - -export interface IGlobalRuntime { - __TAURI__?: ITauriInstance; - __TAURI_INTERNALS__?: unknown; -} - -export type TGlobalWin = typeof globalThis & IGlobalRuntime; diff --git a/src/shared/utils/cssSelectors.ts b/src/shared/utils/cssSelectors.ts new file mode 100644 index 00000000..d1818ff3 --- /dev/null +++ b/src/shared/utils/cssSelectors.ts @@ -0,0 +1,8 @@ +export function escapeCssSelectorValue(value: string): string { + const cssApi = (globalThis as { CSS?: { escape?: (selector: string) => string } }).CSS; + if (typeof cssApi?.escape === 'function') { + return cssApi.escape(value); + } + + return value.replace(/["\\]/gu, '\\$&'); +} diff --git a/src/shared/utils/customProviderSupport.ts b/src/shared/utils/customProviderSupport.ts index b606006c..c45e87a7 100644 --- a/src/shared/utils/customProviderSupport.ts +++ b/src/shared/utils/customProviderSupport.ts @@ -23,7 +23,7 @@ const CUSTOM_PROVIDER_SPECS: readonly CustomProviderSpec[] = [ nameKey: 'ui.launcher.app.custom_text.name', desc: 'Use any OpenRouter text model by pasting its model ID manually.', descKey: 'ui.launcher.app.custom_text.desc', - icon: '🧩', + icon: '🔤', }, { id: CUSTOM_IMAGE_PROVIDER_ID, @@ -33,7 +33,7 @@ const CUSTOM_PROVIDER_SPECS: readonly CustomProviderSpec[] = [ nameKey: 'ui.launcher.app.custom_image.name', desc: 'Use any OpenRouter image model by pasting its model ID manually.', descKey: 'ui.launcher.app.custom_image.desc', - icon: '🎛️', + icon: '🪄', }, ]; diff --git a/src/shared/utils/moduleCategoryPolicy.test.ts b/src/shared/utils/moduleCategoryPolicy.test.ts index 26d5de54..eb2b0978 100644 --- a/src/shared/utils/moduleCategoryPolicy.test.ts +++ b/src/shared/utils/moduleCategoryPolicy.test.ts @@ -7,16 +7,9 @@ import { isAiCategory, resolveCatalogCategory, resolveModalCategory, - shouldLaunchOnSelection, } from './moduleCategoryPolicy'; describe('moduleCategoryPolicy', () => { - it('keeps AI slots selectable without immediate launch', () => { - expect(shouldLaunchOnSelection(CategoryKey.AI_TEXT)).toBe(false); - expect(shouldLaunchOnSelection(CategoryKey.AI_IMAGE)).toBe(false); - expect(shouldLaunchOnSelection(CategoryKey.SERVICES)).toBe(true); - }); - it('normalizes AI categories for catalog and modal routing', () => { expect(isAiCategory(CategoryKey.AI_IMAGE)).toBe(true); expect(resolveCatalogCategory(CategoryKey.AI_IMAGE)).toBe(CategoryKey.AI); diff --git a/src/shared/utils/moduleCategoryPolicy.ts b/src/shared/utils/moduleCategoryPolicy.ts index 7e38e5fa..a2d857f7 100644 --- a/src/shared/utils/moduleCategoryPolicy.ts +++ b/src/shared/utils/moduleCategoryPolicy.ts @@ -24,10 +24,6 @@ export function getOtherAiSlot(category: string): AiSlotCategory { return category === CategoryKey.AI_IMAGE ? CategoryKey.AI_TEXT : CategoryKey.AI_IMAGE; } -export function shouldLaunchOnSelection(category: string): boolean { - return !isAiCategory(category); -} - export function resolveCatalogCategory(category: string): string { return isAiCategory(category) ? CategoryKey.AI : category; } diff --git a/src/shared/utils/providerSupport.ts b/src/shared/utils/providerSupport.ts index c3f05e8e..490f2fb7 100644 --- a/src/shared/utils/providerSupport.ts +++ b/src/shared/utils/providerSupport.ts @@ -18,30 +18,10 @@ const CLOUD_PROVIDER_IDS = new Set([ CUSTOM_IMAGE_PROVIDER_ID, ]); -const IMAGE_PROVIDER_IDS = new Set([ - 'sdcpp', - 'stable-diffusion', - 'comfyui', - 'gemini-image', - 'gpt-image', - 'seedream-image', - CUSTOM_IMAGE_PROVIDER_ID, -]); - -const MANAGED_LOCAL_IMAGE_PROVIDER_IDS = new Set(['sdcpp', 'stable-diffusion']); - export function isCloudProviderId(providerId: string): boolean { return CLOUD_PROVIDER_IDS.has(providerId); } -export function isImageProviderId(providerId: string): boolean { - return IMAGE_PROVIDER_IDS.has(providerId); -} - -export function isManagedLocalImageProviderId(providerId: string): boolean { - return MANAGED_LOCAL_IMAGE_PROVIDER_IDS.has(providerId); -} - export function getSharedCloudSecretService(): string { return `${SHARED_CLOUD_KEY_PROVIDER_ID}_api_key`; } diff --git a/src/styles/base/design-tokens.css b/src/styles/base/design-tokens.css index 02a45870..b993c15e 100644 --- a/src/styles/base/design-tokens.css +++ b/src/styles/base/design-tokens.css @@ -11,7 +11,7 @@ @font-face { font-family: 'Cubic11'; - src: url('../../assets/fonts/Cubic_11.woff2') format('woff2'); + src: url('../../assets/fonts/Cubic_11.zh-subset.woff2') format('woff2'); font-weight: normal; font-style: normal; font-display: block; diff --git a/src/styles/base/document-reset.css b/src/styles/base/document-reset.css index 708838f1..13084b5d 100644 --- a/src/styles/base/document-reset.css +++ b/src/styles/base/document-reset.css @@ -77,9 +77,8 @@ button:focus-visible, a[href]:focus-visible, summary:focus-visible, [tabindex]:not([tabindex='-1']):focus-visible { - outline: 1px solid rgba(var(--primary-raw), 0.58); - outline-offset: 2px; - box-shadow: 0 0 0 3px rgba(var(--primary-raw), 0.14); + outline: none; + box-shadow: none; } /* Globals moved to top */ diff --git a/src/styles/features/ai-module-settings.css b/src/styles/features/ai-module-settings.css index 9ca67df5..457b7cff 100644 --- a/src/styles/features/ai-module-settings.css +++ b/src/styles/features/ai-module-settings.css @@ -483,6 +483,7 @@ flex-direction: column; align-items: center; justify-content: center; + color: var(--text-primary); } .thinking-option-card:hover { @@ -507,6 +508,7 @@ font-size: 0.95rem; margin-bottom: 0.1rem; pointer-events: none; + color: inherit; } /* === API KEY SECTION (AXELATE PREMIUM V9 - NO DOUBLE BORDER) === */ @@ -723,14 +725,26 @@ /* === LOCAL ENGINE SETTINGS === */ +#module-settings-modal .module-settings-content:has(.local-engine-config) { + padding: 0.65rem 0.85rem 1rem; +} + .local-engine-config { - gap: 0.85rem; + width: min(100%, 920px); + margin: 0 auto; + gap: 0.55rem; +} + +.local-engine-config .ai-content-panel { + padding: 0.72rem 0.85rem; + border-radius: 14px; + border-color: rgba(255, 255, 255, 0.03); } .local-engine-layout { display: flex; flex-direction: column; - gap: 0.85rem; + gap: 0.55rem; } .local-engine-section { @@ -744,13 +758,13 @@ align-items: center; text-align: center; gap: 0; - margin-bottom: 0.7rem; + margin-bottom: 0.42rem; } .local-engine-section-header h3 { - font-size: 1.2rem; + font-size: 0.98rem; line-height: 1.15; - letter-spacing: 0.02em; + letter-spacing: 0; width: 100%; text-align: center; } @@ -785,10 +799,10 @@ .local-engine-panel-card { background: var(--glass-surface); border: 1px solid var(--glass-border); - border-radius: 16px; + border-radius: 12px; padding: 0.9rem 1rem; box-sizing: border-box; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.22); + box-shadow: none; display: flex; flex-direction: column; gap: 0.65rem; @@ -807,7 +821,7 @@ .local-engine-field-stack, .local-engine-field-grid { display: grid; - gap: 1rem; + gap: 0.58rem; } .local-engine-field-stack--tight { @@ -826,7 +840,7 @@ .local-engine-field-stack--tight .local-engine-field-label { width: 100%; text-align: center; - margin-bottom: 0.3rem; + margin-bottom: 0.14rem; color: var(--text-primary); } @@ -834,10 +848,15 @@ justify-content: center; } +.local-engine-field-row--compute-mode .local-engine-label-row, +.local-engine-field-row--compute-mode .local-engine-field-hint { + display: none; +} + .local-engine-field-row { display: flex; flex-direction: column; - gap: 0.3rem; + gap: 0.18rem; min-width: 0; } @@ -857,9 +876,22 @@ gap: 0; } +.local-engine-compute-toggle { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.55rem; + width: 100%; +} + +.local-engine-compute-option { + min-height: 44px; + padding: 0.5rem 0.7rem; + border-radius: 12px; +} + .local-engine-field-label { color: var(--text-secondary); - font-size: 0.92rem; + font-size: 0.84rem; font-weight: 600; } @@ -1002,7 +1034,7 @@ .local-engine-info-btn:hover { background: rgba(255, 255, 255, 0.06); - border-color: rgba(179, 112, 255, 0.28); + border-color: rgba(255, 255, 255, 0.12); color: var(--text-primary); transform: translateY(-1px); } @@ -1014,15 +1046,16 @@ z-index: 5200; display: flex; flex-direction: column; - gap: 0.85rem; + gap: 0.78rem; padding: 1rem; - border-radius: var(--modal-shell-radius); - border: 1px solid rgba(255, 255, 255, 0.03); - background: rgba(var(--background-raw), 0.065); + border-radius: 16px; + border: 1px solid var(--glass-border); + background: var(--glass-surface); box-shadow: - inset -1px 0 0 rgba(255, 255, 255, 0.018), - 0 18px 48px rgba(0, 0, 0, 0.2); - width: calc(var(--app-modal-popover-width, 344px) / var(--module-settings-zoom, 1)); + inset 0 1px 0 rgba(255, 255, 255, 0.035), + 0 18px 34px rgba(0, 0, 0, 0.18); + width: calc(var(--app-modal-popover-width, 390px) / var(--module-settings-zoom, 1)); + min-width: calc(360px / var(--module-settings-zoom, 1)); height: calc((100% - (var(--app-modal-edge-gap, 16px) * 2)) / var(--module-settings-zoom, 1)); max-height: calc( (100% - (var(--app-modal-edge-gap, 16px) * 2)) / var(--module-settings-zoom, 1) @@ -1045,7 +1078,10 @@ .local-engine-args-popover.closing { opacity: 0 !important; pointer-events: none; - transform: translateX(18px) scale(var(--module-settings-zoom, 1)); + transform: translateX(14px) scale(var(--module-settings-zoom, 1)); + transition: + transform 0.18s cubic-bezier(0.55, 0, 1, 0.45), + opacity 0.18s ease; } @keyframes engineArgsPanelIn { @@ -1061,36 +1097,43 @@ } .local-engine-args-popover-title { - font-size: 1rem; + font-size: 1.05rem; line-height: 1.15; color: var(--text-primary); font-weight: 700; - text-align: left; + text-align: center; } .local-engine-args-popover-subtitle { margin: 0; color: var(--text-secondary); font-size: 0.82rem; - line-height: 1.42; + line-height: 1.36; + text-align: center; } .local-engine-args-popover-actions { - display: flex; - gap: 0.5rem; - justify-content: flex-end; + display: grid; + grid-template-columns: 1fr; + gap: 0.55rem; + padding: 0; + border: none; + background: transparent; } -.local-engine-args-recommended, -.local-engine-args-copy-all { - border: 1px solid rgba(255, 255, 255, 0.06); - background: rgba(255, 255, 255, 0.035); - color: var(--text-primary); - border-radius: 12px; +.local-engine-args-recommended { + border: 1px solid var(--premium-purple-border); + background: var(--premium-purple-bg); + color: #ffffff; + border-radius: 14px; font-family: var(--app-font-family); font-size: 0.82rem; font-weight: 700; cursor: pointer; + min-height: 2.5rem; + white-space: normal; + overflow-wrap: anywhere; + text-align: center; transition: background 0.18s ease, border-color 0.18s ease, @@ -1099,19 +1142,14 @@ .local-engine-args-recommended { padding: 0.48rem 0.76rem; - border-color: rgba(179, 112, 255, 0.28); - background: rgba(139, 71, 255, 0.16); -} - -.local-engine-args-copy-all { - padding: 0.48rem 0.76rem; } -.local-engine-args-recommended:hover, -.local-engine-args-copy-all:hover { - background: rgba(255, 255, 255, 0.08); - border-color: rgba(255, 255, 255, 0.14); - transform: translateY(-1px); +.local-engine-args-recommended:hover { + background: var(--premium-purple-bg); + border-color: var(--premium-purple-border); + color: #ffffff; + filter: brightness(1.04); + transform: none; } .local-engine-args-list { @@ -1119,7 +1157,7 @@ flex-direction: column; gap: 0.5rem; overflow-y: auto; - padding-right: 0.25rem; + padding-right: 0.32rem; flex: 1; min-height: 0; } @@ -1127,21 +1165,19 @@ .local-engine-args-item { display: block; align-items: start; - padding: 0.7rem 0.75rem; + padding: 0.7rem 0.78rem; border-radius: 12px; - background: rgba(255, 255, 255, 0.025); - border: 1px solid rgba(255, 255, 255, 0.045); + background: rgba(255, 255, 255, 0.022); + border: 1px solid rgba(255, 255, 255, 0.038); cursor: pointer; transition: background 0.18s ease, - border-color 0.18s ease, - transform 0.18s ease; + border-color 0.18s ease; } .local-engine-args-item:hover { - background: rgba(255, 255, 255, 0.05); - border-color: rgba(255, 255, 255, 0.08); - transform: translateY(-1px); + background: rgba(255, 255, 255, 0.032); + border-color: rgba(255, 255, 255, 0.055); } .local-engine-args-item-meta { @@ -1155,7 +1191,8 @@ color: #fff; font-family: var(--app-font-family); font-size: 0.88rem; - word-break: break-word; + line-height: 1.18; + overflow-wrap: anywhere; } .local-engine-args-desc { @@ -1165,6 +1202,17 @@ line-height: 1.35; } +@media (max-width: 760px) { + .local-engine-args-popover { + width: calc((100vw - 2rem) / var(--module-settings-zoom, 1)); + min-width: 0; + } + + .local-engine-args-popover-actions { + grid-template-columns: 1fr; + } +} + .local-engine-field-hint { display: block; margin: 0 0 0.18rem; @@ -1196,14 +1244,14 @@ flex: 1; min-width: 0; border-radius: 12px; - padding: 0.72rem 0.9rem; + padding: 0.58rem 0.78rem; border: none !important; background: transparent !important; color: var(--text-primary); transition: all 0.2s ease; outline: none !important; box-sizing: border-box; - font-size: 0.98rem; + font-size: 0.88rem; font-family: var(--app-font-family); } @@ -1224,103 +1272,111 @@ flex: 1; min-width: 0; display: flex; - flex-wrap: wrap; align-items: center; + flex-wrap: wrap; gap: 0.45rem; - border-radius: 14px; - padding: 0.16rem 0.18rem; + border-radius: 12px; + padding: 0.55rem; border: none; background: transparent; + cursor: text; } -.local-engine-tags-chips { - display: flex; +.local-engine-extra-args-input { + display: none; +} + +.local-engine-extra-args-chips { + display: inline-flex; + align-items: center; flex-wrap: wrap; - gap: 0.5rem; + gap: 0.42rem; min-width: 0; - padding: 0.15rem 0; - align-items: center; } -.local-engine-tag-chip { +.local-engine-extra-arg-chip { display: inline-flex; align-items: center; - gap: 0.5rem; - padding: 0.48rem 0.82rem; + max-width: 100%; + min-height: 32px; + border: 1px solid rgba(255, 255, 255, 0.048); border-radius: 10px; - border: none !important; - background: var(--primary) !important; - color: #fff !important; - font-family: var(--app-font-family); - font-size: 0.88rem; - font-weight: 700; - cursor: pointer; - transition: all 0.2s ease; + background: rgba(255, 255, 255, 0.024); + overflow: hidden; } -.local-engine-tag-chip:hover { - filter: brightness(1.12); - transform: translateY(-1px); +.local-engine-extra-arg-edit, +.local-engine-extra-arg-remove { + border: none; + background: transparent; + color: var(--text-primary); + font-family: var(--app-font-family); + font-weight: 700; } -.local-engine-tag-chip-label { +.local-engine-extra-arg-edit { + min-width: 0; + padding: 0.34rem 0.58rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: text; line-height: 1; } -.local-engine-tag-chip-remove { - opacity: 0.72; - font-size: 0.78rem; +.local-engine-extra-arg-remove { + order: -1; + width: 0; + min-width: 0; + padding: 0; + opacity: 0; + border-right: 1px solid rgba(255, 255, 255, 0.055); + border-radius: 9px 0 0 9px; + color: rgba(255, 255, 255, 0.68); + cursor: pointer; line-height: 1; + overflow: hidden; + pointer-events: none; + transition: + width 0.16s ease, + opacity 0.16s ease, + color 0.16s ease, + background 0.16s ease; } -.local-engine-performance-toggle { - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.8rem; - min-height: 52px; - padding: 0.78rem 0.95rem; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.06); - background: rgba(255, 255, 255, 0.03); - color: var(--text-primary); - font-family: var(--app-font-family); - cursor: pointer; - transition: - border-color 0.2s ease, - background 0.2s ease, - transform 0.18s ease, - filter 0.18s ease; +.local-engine-extra-arg-chip:hover .local-engine-extra-arg-remove, +.local-engine-extra-arg-chip:focus-within .local-engine-extra-arg-remove { + width: 30px; + opacity: 1; + pointer-events: auto; } -.local-engine-performance-toggle:hover { - background: rgba(255, 255, 255, 0.05); - border-color: rgba(255, 255, 255, 0.12); - transform: translateY(-1px); +.local-engine-extra-arg-remove:hover { + background: transparent; + color: #ffffff; } -.local-engine-performance-toggle.active { - background: linear-gradient(180deg, rgba(154, 83, 243, 0.24), rgba(109, 56, 184, 0.2)); - border-color: rgba(179, 112, 255, 0.34); +.local-engine-extra-arg-chip:hover { + background: rgba(255, 255, 255, 0.038); } -.local-engine-performance-toggle-status { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 88px; - padding: 0.38rem 0.7rem; - border-radius: 12px; - background: rgba(255, 255, 255, 0.06); - color: var(--text-secondary); - font-size: 0.8rem; - font-weight: 700; +.local-engine-extra-args-draft { + width: auto; + flex: 1 1 180px; + min-width: 160px; + min-height: 32px; + border: none; + outline: none; + background: transparent; + color: var(--text-primary); + font-family: var(--app-font-family); + font-size: 0.92rem; + padding: 0.34rem 0.25rem; + box-sizing: border-box; } -.local-engine-performance-toggle.active .local-engine-performance-toggle-status { - background: rgba(0, 0, 0, 0.18); - color: #ffffff; +.local-engine-extra-args-draft::placeholder { + color: rgba(255, 255, 255, 0.2); } /* Combined with the other .local-engine-input--readonly below */ @@ -1349,6 +1405,20 @@ transition: all 0.2s ease; } +.local-engine-field-row--extra-args.full-width .local-engine-input-row { + background: transparent; + border: none; + padding: 0; + gap: 0.55rem; + overflow: hidden; +} + +.local-engine-field-row--extra-args.full-width .local-engine-input-row.focused { + background: transparent; + border-color: transparent; + box-shadow: none; +} + .local-engine-field-row.full-width .local-engine-input, .local-engine-split-row .local-engine-input { background: transparent; @@ -1391,7 +1461,80 @@ background: transparent; border: none; box-shadow: none; - padding: 0.12rem 0.16rem; + padding: 0; +} + +.local-engine-field-row--extra-args.full-width .local-engine-input-row { + min-height: 50px; + border-radius: 12px; + padding: 0.9rem 1rem; + background: var(--glass-surface); + border: 1px solid var(--glass-border); +} + +.local-engine-field-row--extra-args.full-width .local-engine-tags-editor { + min-height: 50px; + border: 1px solid rgba(255, 255, 255, 0.038); + border-radius: 12px; + background: rgba(255, 255, 255, 0.022); +} + +.local-engine-field-row--extra-args.full-width .local-engine-extra-args-input { + min-height: 50px; + padding-left: 0.65rem; + padding-right: 0.65rem; +} + +.local-engine-field-row--extra-args.full-width .local-engine-info-btn { + align-self: stretch; + width: 52px; + min-width: 52px; + min-height: 50px; + margin: 0; + border: 1px solid rgba(255, 255, 255, 0.038) !important; + border-radius: 12px; + background: rgba(255, 255, 255, 0.022) !important; + color: var(--text-secondary); + transform: none; + transition: + background 0.18s ease, + color 0.18s ease; +} + +.local-engine-field-row--extra-args.full-width .local-engine-info-btn:hover { + background: rgba(255, 255, 255, 0.032) !important; + color: var(--text-primary); + transform: none; +} + +.local-engine-field-row--extra-args.full-width .local-engine-info-btn:focus, +.local-engine-field-row--extra-args.full-width .local-engine-info-btn:focus-visible, +.local-engine-field-row--extra-args.full-width .local-engine-info-btn:active { + outline: none; + box-shadow: none !important; + border-color: rgba(255, 255, 255, 0.038) !important; + background: rgba(255, 255, 255, 0.022) !important; + color: var(--text-secondary); + transform: none; +} + +.local-engine-field-row--extra-args.full-width .local-engine-extra-args-input:focus { + outline: none; + box-shadow: none; +} + +.local-engine-field-row--extra-args.full-width .local-engine-tags-editor:focus-within { + border-color: rgba(255, 255, 255, 0.038); + box-shadow: none; +} + +.local-engine-field-row--extra-args.full-width .local-engine-extra-args-draft:focus, +.local-engine-field-row--extra-args.full-width .local-engine-extra-arg-edit:focus, +.local-engine-field-row--extra-args.full-width .local-engine-extra-arg-edit:focus-visible, +.local-engine-field-row--extra-args.full-width .local-engine-extra-arg-remove:focus, +.local-engine-field-row--extra-args.full-width .local-engine-extra-arg-remove:focus-visible { + outline: none; + box-shadow: none; } .local-engine-split-row { @@ -1434,6 +1577,59 @@ transform: translateY(-1px); } +.local-engine-browse-btn:active, +.local-engine-browse-btn:focus, +.local-engine-browse-btn:focus-visible { + outline: none; + box-shadow: none !important; +} + +.local-engine-field-row--model-path .local-engine-input-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 300px); + gap: 0.85rem; + padding: 0.9rem 1rem; + background: var(--glass-surface); + border: 1px solid var(--glass-border); + border-radius: 12px; +} + +.local-engine-field-row--model-path .local-engine-input { + min-height: 58px; + padding: 0.75rem 1rem; + border: 1px solid rgba(255, 255, 255, 0.038) !important; + border-radius: 14px; + background: rgba(255, 255, 255, 0.022) !important; +} + +.local-engine-field-row--model-path .local-engine-input:focus { + border-color: rgba(255, 255, 255, 0.055) !important; + background: rgba(255, 255, 255, 0.032) !important; +} + +.local-engine-field-row--model-path .local-engine-browse-btn { + width: 100%; + min-width: 0; + min-height: 58px; + border: 1px solid var(--premium-purple-border) !important; + border-radius: 14px; + background: var(--premium-purple-bg) !important; + box-shadow: var(--premium-purple-shadow) !important; + transition: + background 0.18s ease, + border-color 0.18s ease, + color 0.18s ease, + filter 0.18s ease; +} + +.local-engine-field-row--model-path .local-engine-browse-btn:hover, +.local-engine-field-row--model-path .local-engine-browse-btn:active, +.local-engine-field-row--model-path .local-engine-browse-btn:focus, +.local-engine-field-row--model-path .local-engine-browse-btn:focus-visible { + filter: brightness(1.04); + transform: none; +} + .local-engine-warning { color: var(--color-warning, #f59e0b); font-size: 0.84rem; @@ -1469,22 +1665,21 @@ border-radius: 0; } -.local-engine-segmented-option { - min-height: 58px; - padding: 0.75rem; - border: 1px solid rgba(255, 255, 255, 0.038); - border-radius: 14px; - background: rgba(255, 255, 255, 0.022); - color: var(--text-secondary); - font-family: var(--app-font-family); - font-size: 0.95rem; - font-weight: 700; - cursor: pointer; - transition: - background 0.18s ease, - border-color 0.18s ease, - color 0.18s ease, - filter 0.18s ease; +.local-engine-input-row:has(.local-engine-select) { + padding: 0; + gap: 0; + background: transparent; + border: none; + border-radius: 0; +} + +.local-engine-field-row--compute-mode .local-engine-input-row, +.local-engine-input-row:has(.local-engine-compute-toggle) { + padding: 0; + gap: 0; + background: transparent; + border: none; + border-radius: 0; } .local-engine-section--context .local-engine-input { @@ -1503,19 +1698,6 @@ border-radius: 14px; } -.local-engine-segmented-option:hover { - background: rgba(255, 255, 255, 0.032); - border-color: rgba(255, 255, 255, 0.055); - color: var(--text-primary); -} - -.local-engine-segmented-option.is-selected { - background: var(--premium-purple-bg); - border-color: var(--premium-purple-border); - color: #ffffff; - box-shadow: var(--premium-purple-shadow); -} - .local-engine-select { position: relative; width: 100%; @@ -1544,13 +1726,9 @@ .local-engine-select-trigger:hover, .local-engine-select.open .local-engine-select-trigger { - border-color: rgba(179, 112, 255, 0.38); - background: - linear-gradient(180deg, rgba(154, 83, 243, 0.18), rgba(109, 56, 184, 0.14)), - rgba(255, 255, 255, 0.045); - box-shadow: - 0 0 0 3px rgba(166, 100, 247, 0.12), - 0 10px 24px rgba(76, 28, 148, 0.2); + border-color: rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.055); + box-shadow: none; } .local-engine-select-text { @@ -1579,14 +1757,9 @@ overflow-y: auto; padding: 0.35rem; border-radius: 14px; - border: 1px solid rgba(176, 110, 255, 0.22); - background: - linear-gradient(180deg, rgba(31, 27, 49, 0.98), rgba(18, 16, 29, 0.98)), - rgba(20, 19, 29, 0.98); - box-shadow: - 0 22px 50px rgba(0, 0, 0, 0.48), - 0 0 0 1px rgba(173, 107, 255, 0.08), - inset 0 1px 0 rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(18, 18, 26, 0.98); + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.38); animation: localSelectFade 0.14s ease; } @@ -1624,7 +1797,7 @@ } .local-engine-select-option.selected { - background: rgba(166, 100, 247, 0.16); + background: rgba(255, 255, 255, 0.08); color: var(--text-primary); } @@ -1672,3 +1845,200 @@ width: 100%; } } + +/* Local image engines reuse the same visual system as API provider settings. */ +.local-engine-section--core .local-engine-field-stack--tight { + gap: 0.58rem; +} + +.local-engine-field-row--model-path .local-engine-input-row, +.local-engine-field-row--extra-args.full-width .local-engine-input-row { + display: flex; + align-items: center; + width: 100%; + min-height: 48px; + gap: 0.42rem; + padding: 0 5px 0 11px; + border-radius: 13px; + border: 1px solid rgba(255, 255, 255, 0.04); + background: rgba(255, 255, 255, 0.022); + box-shadow: none; + overflow: hidden; +} + +.local-engine-field-row--extra-args.full-width .local-engine-input-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 42px; + align-items: start; + gap: 0.5rem; + padding: 0.52rem; +} + +.local-engine-field-row--model-path .local-engine-input-row.focused, +.local-engine-field-row--extra-args.full-width .local-engine-input-row.focused { + border-color: rgba(255, 255, 255, 0.04); + background: rgba(255, 255, 255, 0.022); + box-shadow: none; +} + +.local-engine-field-row--model-path .local-engine-input, +.local-engine-field-row--extra-args.full-width .local-engine-extra-args-input { + min-height: 46px; + padding: 0 0.45rem; + border: none !important; + background: transparent !important; +} + +.local-engine-section--core .local-engine-field-row--context-size .local-engine-input-row, +.local-engine-section--core + .local-engine-field-row--llamacpp-system-prompt + .local-engine-input-row { + padding: 0; + background: transparent; + border: none; + border-radius: 0; +} + +.local-engine-section--core .local-engine-field-row--context-size .local-engine-input { + min-height: 42px; + padding: 0.48rem 0.78rem; + text-align: center; + background: rgba(255, 255, 255, 0.022) !important; + border: 1px solid rgba(255, 255, 255, 0.038) !important; + border-radius: 14px; +} + +.local-engine-section--core + .local-engine-field-row--llamacpp-system-prompt + .local-engine-input--textarea { + min-height: 72px; + padding: 0.72rem 0.85rem; + background: rgba(255, 255, 255, 0.022) !important; + border: 1px solid rgba(255, 255, 255, 0.038) !important; + border-radius: 14px; +} + +.local-engine-field-row--model-path .local-engine-browse-btn { + width: auto; + min-width: 118px; + min-height: 36px; + height: 36px; + border-radius: var(--module-button-radius); + border: 1px solid var(--premium-purple-border) !important; + background: var(--premium-purple-bg) !important; + box-shadow: none !important; + transform: none !important; + filter: none !important; +} + +.local-engine-field-row--model-path .local-engine-browse-btn:hover, +.local-engine-field-row--model-path .local-engine-browse-btn:active, +.local-engine-field-row--model-path .local-engine-browse-btn:focus, +.local-engine-field-row--model-path .local-engine-browse-btn:focus-visible { + filter: none !important; + transform: none !important; + box-shadow: none !important; +} + +.local-engine-field-row--extra-args.full-width .local-engine-tags-editor { + min-height: 42px; + border: none; + background: transparent; + transition: none !important; + padding: 0; + gap: 0.42rem; +} + +.local-engine-field-row--extra-args.full-width .local-engine-input-row, +.local-engine-field-row--extra-args.full-width .local-engine-input-row:hover, +.local-engine-field-row--extra-args.full-width .local-engine-input-row:active, +.local-engine-field-row--extra-args.full-width .local-engine-input-row:focus-within, +.local-engine-field-row--extra-args.full-width .local-engine-input-row.focused { + border-color: rgba(255, 255, 255, 0.04) !important; + background: rgba(255, 255, 255, 0.022) !important; + box-shadow: none !important; + transform: none !important; + filter: none !important; + transition: none !important; +} + +.local-engine-field-row--extra-args.full-width .local-engine-tags-editor, +.local-engine-field-row--extra-args.full-width .local-engine-tags-editor:hover, +.local-engine-field-row--extra-args.full-width .local-engine-tags-editor:active, +.local-engine-field-row--extra-args.full-width .local-engine-tags-editor:focus-within, +.local-engine-field-row--extra-args.full-width .local-engine-extra-args-draft, +.local-engine-field-row--extra-args.full-width .local-engine-extra-args-draft:hover, +.local-engine-field-row--extra-args.full-width .local-engine-extra-args-draft:active, +.local-engine-field-row--extra-args.full-width .local-engine-extra-args-draft:focus, +.local-engine-field-row--extra-args.full-width .local-engine-extra-args-draft:focus-visible { + border: none !important; + outline: none !important; + background: transparent !important; + box-shadow: none !important; + transform: none !important; + filter: none !important; + transition: none !important; + appearance: none; + -webkit-appearance: none; +} + +.local-engine-field-row--extra-args.full-width .local-engine-info-btn { + width: 42px; + min-width: 42px; + align-self: center; + min-height: 42px; + height: 42px; + border-radius: var(--module-button-radius); + border: 1px solid rgba(255, 255, 255, 0.04) !important; + background: rgba(255, 255, 255, 0.035) !important; + box-shadow: none !important; + transform: none !important; +} + +.local-engine-field-row--extra-args.full-width .local-engine-info-btn:hover, +.local-engine-field-row--extra-args.full-width .local-engine-info-btn:active, +.local-engine-field-row--extra-args.full-width .local-engine-info-btn:focus, +.local-engine-field-row--extra-args.full-width .local-engine-info-btn:focus-visible { + background: rgba(255, 255, 255, 0.055) !important; + color: var(--text-primary); + transform: none !important; +} + +.local-engine-model-profiles { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.local-engine-profile-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.75rem; +} + +.local-engine-profile-card { + min-height: 108px; + justify-content: center; +} + +.local-engine-profile-card .ai-model-card-copy { + flex: 0; + justify-content: center; +} + +.local-engine-profile-card .model-name { + margin-bottom: 0; + overflow-wrap: anywhere; +} + +.local-engine-profile-card .model-desc { + overflow-wrap: anywhere; +} + +.local-engine-profile-card .ai-model-card-action { + position: absolute; + left: 0.85rem; + right: 0.85rem; + bottom: 0.72rem; + width: auto; + margin: 0; +} diff --git a/src/styles/features/chat-page.css b/src/styles/features/chat-page.css index 97625c4a..98429f72 100644 --- a/src/styles/features/chat-page.css +++ b/src/styles/features/chat-page.css @@ -17,6 +17,12 @@ align-items: flex-start; } +.chat-row--generated-image { + flex-direction: column; + gap: 0.55rem; + align-items: flex-start; +} + .chat-bubble { position: relative; max-width: 82%; @@ -405,8 +411,6 @@ .chat-generated-media.hidden, .chat-generated-caption.hidden, -.chat-generated-control.hidden, -.chat-generated-controls.hidden, .chat-generated-progress-summary.hidden { display: none !important; } @@ -414,8 +418,45 @@ .chat-image-generation { position: relative; gap: 0.46rem; - min-width: min(16rem, 100%); - padding: 0.68rem; + min-width: min(18rem, 100%); + max-width: min(100%, 32rem); + padding: 0.82rem 0.95rem; +} + +.chat-row--generated-image .chat-image-generation { + width: min(100%, 32rem); + max-width: min(100%, 32rem); + box-sizing: border-box; +} + +.chat-row--generated-image.is-complete { + gap: 0; +} + +.chat-row--generated-image.is-complete .chat-generated-media { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.chat-row--generated-image.is-complete .chat-image-generation { + border-top: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; + background: rgba(255, 255, 255, 0.022); + box-shadow: none; +} + +.chat-row--generated-image.is-complete .chat-image-generation.has-no-caption { + display: contents; + height: 0; + min-height: 0; + padding: 0; + border: 0; + background: transparent; +} + +.chat-row--generated-image.is-complete .chat-image-generation::before { + display: none; } .chat-image-generation.is-complete { @@ -423,29 +464,37 @@ } .chat-generated-media { - width: 100%; - min-height: min(56vw, 18rem); - max-height: min(60vh, 32rem); + position: relative; + width: auto; aspect-ratio: 1 / 1; + max-width: min(100%, 32rem); + max-height: min(70vh, 42rem); + display: inline-flex; + align-items: center; + justify-content: center; + align-self: flex-start; overflow: hidden; border-radius: 14px; - background: - linear-gradient(135deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)), - rgba(255, 255, 255, 0.025); + background: transparent; +} + +.chat-generated-media .chat-message-actions { + top: calc(100% + 0.42rem); + left: 0; } .chat-generated-image { display: block; - width: 100%; - height: 100%; - max-height: none; + width: auto; + height: auto; + max-width: 100%; + max-height: min(70vh, 42rem); + margin-top: 0; background: rgba(255, 255, 255, 0.025); -} - -.chat-image-generation .chat-generated-media.hidden { - display: block !important; - visibility: hidden; - opacity: 0; + border: 0; + border-radius: 14px; + cursor: zoom-in; + object-fit: contain; } .chat-image-generation.chat-error .chat-generated-media.hidden, @@ -462,8 +511,7 @@ .chat-image-generation.is-complete .chat-generated-status, .chat-image-generation.is-complete .chat-generated-progress, -.chat-image-generation.is-complete .chat-generated-status-row, -.chat-image-generation.is-complete .chat-generated-controls { +.chat-image-generation.is-complete .chat-generated-status-row { display: none !important; } @@ -471,34 +519,38 @@ display: flex; align-items: center; justify-content: space-between; - gap: 0.8rem; + gap: 1rem; } .chat-generated-status { - min-width: 0; + flex: 1 1 auto; + min-width: max-content; color: var(--text-primary); - font-size: 0.78rem; + font-size: 0.82rem; font-weight: 600; line-height: 1.35; opacity: 0.92; - overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; } .chat-generated-progress-summary { - flex: 0 0 auto; + flex: 0 1 auto; + min-width: 0; color: var(--text-muted); - font-size: 0.66rem; + font-size: 0.7rem; line-height: 1.2; font-variant-numeric: tabular-nums; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: right; } .chat-generated-progress { width: 100%; - height: 5px; + height: 6px; overflow: hidden; - border-radius: 4px; + border-radius: 999px; background: rgba(255, 255, 255, 0.055); } @@ -515,66 +567,6 @@ animation: chatGeneratedPulse 1.2s ease-in-out infinite alternate; } -.chat-generated-controls { - position: absolute; - top: 0.58rem; - right: 0.58rem; - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - opacity: 0; - pointer-events: none; - transform: translateY(-2px); - transition: - opacity 0.16s ease, - transform 0.16s ease; -} - -.chat-image-generation:hover .chat-generated-controls, -.chat-image-generation:focus-within .chat-generated-controls { - opacity: 1; - pointer-events: auto; - transform: translateY(0); -} - -.chat-generated-control { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 28px; - padding: 0.28rem 0.58rem; - border: 1px solid rgba(255, 255, 255, 0.075); - border-radius: 7px; - background: rgba(20, 18, 26, 0.86); - color: var(--text-primary); - cursor: pointer; - font-family: var(--app-font-family); - font-size: 0.68rem; - box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22); - transition: - background 0.18s ease, - border-color 0.18s ease, - color 0.18s ease, - transform 0.18s ease; -} - -.chat-generated-control:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.065); - border-color: rgba(255, 255, 255, 0.12); - color: white; - transform: translateY(-1px); -} - -.chat-generated-control:disabled { - opacity: 0.55; - cursor: progress; -} - -.chat-generated-control.is-cancel:hover:not(:disabled) { - background: rgba(var(--danger-raw), 0.12); - border-color: rgba(var(--danger-raw), 0.26); -} - @keyframes chatGeneratedPulse { from { opacity: 0.75; @@ -612,6 +604,7 @@ justify-content: center; width: min(100%, 1400px); height: min(100%, 1000px); + cursor: zoom-out; } .chat-image-viewer-img { @@ -622,6 +615,55 @@ height: auto; border-radius: 20px; box-shadow: 0 28px 80px rgba(0, 0, 0, 0.45); + cursor: default; + transform-origin: center; + will-change: opacity, transform; +} + +.chat-image-viewer-img.is-opening { + animation: chatImageViewerOpen 0.18s ease both; +} + +.chat-image-viewer-img.is-entering-forward { + animation: chatImageViewerNext 0.18s ease both; +} + +.chat-image-viewer-img.is-entering-backward { + animation: chatImageViewerPrev 0.18s ease both; +} + +@keyframes chatImageViewerOpen { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes chatImageViewerNext { + from { + opacity: 0; + transform: translateX(1.25rem); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes chatImageViewerPrev { + from { + opacity: 0; + transform: translateX(-1.25rem); + } + + to { + opacity: 1; + transform: translateX(0); + } } .chat-image-viewer-close { @@ -660,6 +702,66 @@ transform: translateY(-1px); } +.chat-image-viewer-nav { + position: absolute; + top: 50%; + z-index: 2; + width: 52px; + height: 72px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + background: rgba(23, 23, 30, 0.62); + color: rgba(255, 255, 255, 0.86); + cursor: pointer; + transform: translateY(-50%); + transition: + opacity 0.18s ease, + background 0.18s ease, + border-color 0.18s ease, + color 0.18s ease, + transform 0.18s ease; +} + +.chat-image-viewer-nav:hover { + background: rgba(34, 34, 44, 0.86); + border-color: rgba(255, 255, 255, 0.18); + color: #fff; +} + +.chat-image-viewer-prev { + left: max(1.25rem, env(safe-area-inset-left)); +} + +.chat-image-viewer-next { + right: max(1.25rem, env(safe-area-inset-right)); +} + +.chat-image-viewer-prev:hover { + transform: translateY(-50%) translateX(-2px); +} + +.chat-image-viewer-next:hover { + transform: translateY(-50%) translateX(2px); +} + +.chat-image-viewer-counter { + position: absolute; + left: 50%; + bottom: 1.25rem; + padding: 0.36rem 0.62rem; + border-radius: 999px; + background: rgba(23, 23, 30, 0.72); + color: rgba(255, 255, 255, 0.86); + font-size: 0.72rem; + font-variant-numeric: tabular-nums; + line-height: 1; + transform: translateX(-50%); + pointer-events: none; +} + /* --- CHAT PAGE --- */ #page-chat.active #chat-container { animation: chatContentRise 0.28s cubic-bezier(0.22, 1, 0.36, 1); @@ -844,6 +946,7 @@ #chat-messages::-webkit-scrollbar { width: 8px; + height: 8px; } #chat-messages::-webkit-scrollbar-track { @@ -851,25 +954,19 @@ } #chat-messages::-webkit-scrollbar-thumb { - background: transparent; + background: transparent !important; border-radius: 999px; border: 2px solid transparent; background-clip: padding-box; } -#chat-messages:hover, -#chat-messages:focus-within { +#chat-container.has-messages:hover #chat-messages { + scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.14) transparent; } -#chat-messages:hover::-webkit-scrollbar, -#chat-messages:focus-within::-webkit-scrollbar { - width: 8px; -} - -#chat-messages:hover::-webkit-scrollbar-thumb, -#chat-messages:focus-within::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.14); +#chat-container.has-messages:hover #chat-messages::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.14) !important; } #chat-messages.has-messages { @@ -1277,6 +1374,50 @@ 0 0 0 1px rgba(255, 255, 255, 0.035); } +.chat-attach-menu { + position: absolute; + left: 0.65rem; + bottom: calc(100% + 0.55rem); + z-index: 45; + display: flex; + flex-direction: column; + gap: 0.22rem; + min-width: 176px; + padding: 0.38rem; + border-radius: 8px; + background: rgba(var(--background-raw), 0.96); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.36); +} + +.chat-attach-menu-item { + width: 100%; + min-height: 38px; + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0 0.75rem; + border-radius: 6px; + color: rgba(255, 255, 255, 0.88); + background: transparent; + font: inherit; + text-align: left; + cursor: pointer; +} + +.chat-attach-menu-item .icon { + width: 18px; + height: 18px; + flex: 0 0 auto; +} + +.chat-attach-menu-item:hover, +.chat-attach-menu-item:focus-visible { + background: rgba(var(--primary-raw), 0.2); + color: #fff; + outline: none; +} + .chat-action-btn { background: transparent !important; width: 40px; @@ -2075,6 +2216,23 @@ padding: 0.82rem 0.9rem 0.72rem; } + .chat-row--generated-image .chat-image-generation, + .chat-row--generated-image:not(.is-complete) .chat-image-generation { + width: min(100%, 22rem); + } + + .chat-generated-status-row { + flex-direction: column; + align-items: flex-start; + gap: 0.34rem; + } + + .chat-generated-progress-summary { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + } + .chat-row { margin-bottom: 1.05rem; } @@ -2119,6 +2277,38 @@ height: 38px; } + .chat-image-viewer { + padding: 0.8rem; + } + + .chat-image-viewer-img { + max-width: 96vw; + max-height: 86vh; + border-radius: 14px; + } + + .chat-image-viewer-close { + top: 0.75rem; + right: 0.75rem; + width: 40px; + height: 40px; + border-radius: 12px; + } + + .chat-image-viewer-nav { + width: 42px; + height: 58px; + border-radius: 12px; + } + + .chat-image-viewer-prev { + left: 0.55rem; + } + + .chat-image-viewer-next { + right: 0.55rem; + } + .chat-textarea { font-size: 0.88rem; padding: 0.48rem 0.64rem 0.46rem; diff --git a/src/styles/features/console-page.css b/src/styles/features/console-page.css index 5086e2f6..dd8bb498 100644 --- a/src/styles/features/console-page.css +++ b/src/styles/features/console-page.css @@ -135,7 +135,7 @@ .console-workspace { display: grid; - grid-template-columns: minmax(0, 1fr) 112px; + grid-template-columns: minmax(0, 1fr) 104px; gap: 0.45rem; flex: 1; min-height: 0; @@ -144,10 +144,10 @@ .console-controls-panel { display: flex; flex-direction: column; - gap: 0.52rem; + gap: 0.44rem; min-width: 0; min-height: 0; - padding: 0.48rem; + padding: 0.42rem; border: 1px solid rgba(255, 255, 255, 0.04); border-radius: 12px; background: rgba(255, 255, 255, 0.018); @@ -163,7 +163,8 @@ .console-controls-label { color: var(--text-muted); font-size: 0.58rem; - line-height: 1; + line-height: 1.1; + text-align: center; text-transform: uppercase; } @@ -182,15 +183,18 @@ display: inline-flex; align-items: center; justify-content: center; + width: 100%; min-width: 0; - height: 28px; - padding: 0 0.48rem; + height: 26px; + padding: 0 0.36rem; border: 1px solid var(--module-button-border); border-radius: 8px; background: var(--module-button-bg-soft); color: var(--module-button-text); font-family: var(--app-font-family); - font-size: 0.66rem; + font-size: 0.62rem; + line-height: 1; + text-align: center; cursor: pointer; white-space: nowrap; transition: @@ -226,7 +230,7 @@ align-items: center; justify-content: center; width: 100%; - height: 28px; + height: 26px; padding: 0; border: 1px solid var(--module-button-border); border-radius: 8px; @@ -279,6 +283,9 @@ font-family: var(--app-font-family); font-size: 0.88rem; line-height: 1.42; + scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.08) transparent; user-select: text !important; -webkit-user-select: text !important; cursor: text !important; @@ -520,7 +527,7 @@ } .console-logs-area::-webkit-scrollbar { - width: 8px; + width: 12px; } .console-logs-area::-webkit-scrollbar-track { @@ -529,6 +536,8 @@ .console-logs-area::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); + background-clip: content-box; + border: 3px solid transparent; border-radius: 999px; } diff --git a/src/styles/features/downloads-page.css b/src/styles/features/downloads-page.css index 63048195..d3ed4194 100644 --- a/src/styles/features/downloads-page.css +++ b/src/styles/features/downloads-page.css @@ -276,6 +276,7 @@ align-items: center; justify-content: center; cursor: pointer; + -webkit-app-region: no-drag; transition: transform 0.18s ease, background-color 0.18s ease, @@ -285,6 +286,13 @@ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); } +.downloads-action-btn *, +.downloads-action-btn svg, +.downloads-action-btn use { + cursor: pointer; + pointer-events: none; +} + .downloads-action-btn:hover { transform: translateY(-1px); } @@ -326,8 +334,10 @@ } .downloads-action-icon { - width: 13px; - height: 13px; + display: block; + width: 14px; + height: 14px; + flex-shrink: 0; } .downloads-progress-section { diff --git a/src/styles/features/home-page-and-module-cards.css b/src/styles/features/home-page-and-module-cards.css index 20f3b4b9..7143f722 100644 --- a/src/styles/features/home-page-and-module-cards.css +++ b/src/styles/features/home-page-and-module-cards.css @@ -132,21 +132,25 @@ body[data-platform='macos'] .module-card:hover { } #page-modules.active .models-container { - animation: pageContentRise 0.3s cubic-bezier(0.22, 1, 0.36, 1); + animation: modulesPageEnter 0.24s cubic-bezier(0.22, 1, 0.36, 1) both; } /* State when model selection or settings is open */ .models-container.content-hidden { opacity: 0; + transform: none; pointer-events: none; + transition: none; } -@keyframes modelsPageFadeIn { +@keyframes modulesPageEnter { from { opacity: 0; + transform: translateY(8px); } to { opacity: 1; + transform: translateY(0); } } @@ -648,7 +652,8 @@ input[type='password']::-webkit-credentials-auto-fill-button { .app-delete-badge:hover .badge-text { opacity: 1; - max-width: 100px; + max-width: 10rem; + padding-left: 6px; padding-right: 10px; } @@ -805,7 +810,8 @@ input[type='password']::-webkit-credentials-auto-fill-button { .module-action-badge:hover .badge-text { opacity: 1; - max-width: 100px; + max-width: 10rem; + padding-left: 6px; padding-right: 10px; } @@ -825,7 +831,7 @@ input[type='password']::-webkit-credentials-auto-fill-button { .module-action-badge.right:hover .badge-text { padding-left: 10px; - padding-right: 0; + padding-right: 6px; } /* Settings variant */ diff --git a/src/styles/features/module-selection-modal.css b/src/styles/features/module-selection-modal.css index 363fd72b..58a24eb8 100644 --- a/src/styles/features/module-selection-modal.css +++ b/src/styles/features/module-selection-modal.css @@ -224,13 +224,13 @@ body.snapping .modal-backdrop { .app-modal-tab:focus, .app-close-btn:focus { outline: none; + box-shadow: none; } .app-modal-tab:focus-visible, .app-close-btn:focus-visible { - outline: 1px solid rgba(var(--primary-raw), 0.58); - outline-offset: -3px; - box-shadow: 0 0 0 3px rgba(var(--primary-raw), 0.14); + outline: none; + box-shadow: none; } .app-close-btn { @@ -683,6 +683,175 @@ body.snapping .modal-backdrop { filter: none !important; } +.integration-import-card { + cursor: pointer !important; +} + +.integration-import-card::before { + background: rgba(var(--primary-raw), 0.22); +} + +.integration-import-icon svg { + width: 42px; + height: 42px; + color: var(--text-primary); +} + +.integration-import-card .module-selection-card-description { + max-width: 22ch; +} + +.integration-import-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.55rem; + height: 48px; + bottom: 24px; +} + +.integration-import-actions .integration-import-action-btn { + width: 100%; + max-width: none; + min-width: 0; + min-height: 44px; + padding: 0.6rem 0.85rem !important; + font-size: 0.95rem !important; + line-height: 1; + background: var(--premium-purple-bg) !important; + border: 1px solid var(--premium-purple-border) !important; + color: var(--premium-purple-text) !important; + box-shadow: var(--premium-purple-shadow) !important; +} + +.integration-import-actions .integration-import-action-btn:hover { + background: var(--premium-purple-bg-hover) !important; + border-color: var(--premium-purple-border-strong) !important; + color: var(--premium-purple-text) !important; + box-shadow: var(--premium-purple-shadow) !important; +} + +.integration-help-badge { + border: 0; + background: transparent; + color: #ff4d4d; + z-index: 45; +} + +.integration-help-badge .badge-icon { + display: inline-flex; + align-items: center; + justify-content: center; + color: #ff4d4d; + font-family: var(--app-font-family); + font-size: 18px; + font-weight: 800; + line-height: 1; + text-shadow: 0 2px 8px rgba(255, 64, 64, 0.28); +} + +.integration-help-badge .badge-text { + display: none !important; +} + +.integration-help-badge:hover, +.integration-help-badge:focus-visible { + color: #ff6868; + background: transparent; + border-color: transparent; + min-width: 32px; +} + +.integration-import-dialog-view { + position: fixed; + top: var(--header-height, 60px); + right: 0; + bottom: 0; + left: var(--sidebar-width, 280px); + z-index: 1900; + display: flex; + align-items: center; + justify-content: center; + padding: 1.25rem; + background: transparent; + color: var(--text-primary); + pointer-events: auto; +} + +.integration-import-dialog-view:focus, +.integration-import-dialog-view:focus-visible { + outline: none; +} + +.integration-import-dialog-panel { + width: min(480px, calc(100% - 32px)); + display: grid; + gap: 0.8rem; + padding: 1rem; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 16px; + background: rgb(18, 17, 24); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 20px 54px rgba(0, 0, 0, 0.42); +} + +.integration-import-dialog-panel h3, +.integration-import-dialog-panel p { + margin: 0; +} + +.integration-import-dialog-panel h3 { + font-size: 1rem; +} + +.integration-import-dialog-panel p { + color: var(--text-secondary); + font-size: 0.8rem; + line-height: 1.45; +} + +.integration-import-url-input { + min-height: 46px; + padding: 0 0.85rem; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + background: rgba(255, 255, 255, 0.045); + color: var(--text-primary); + font: 0.86rem var(--app-font-family); +} + +.integration-import-url-input:focus, +.integration-import-url-input:focus-visible { + outline: none; + border-color: rgba(255, 255, 255, 0.08); + box-shadow: none; +} + +.integration-import-dialog-actions { + display: grid; + grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr); + gap: 0.55rem; +} + +.integration-import-dialog-actions button { + min-height: 44px; + border-radius: var(--module-button-radius); + color: var(--module-button-text); + font-family: var(--app-font-family); + font-weight: 700; + cursor: pointer; +} + +.integration-import-dialog-cancel { + border: 1px solid rgba(255, 255, 255, 0.05); + background: rgba(255, 255, 255, 0.04); +} + +.integration-import-dialog-confirm { + border: 1px solid var(--premium-purple-border); + background: var(--premium-purple-bg); +} + .module-selection-card-actions .modal-btn-secondary { background: linear-gradient( 90deg, @@ -815,3 +984,318 @@ body.snapping .modal-backdrop { box-shadow: none !important; transform: none !important; } + +body.app-selection-open #main-area, +body.settings-modal-open #main-area, +body.download-selection-open #main-area, +body.integration-import-open #main-area { + visibility: hidden !important; + opacity: 0 !important; + pointer-events: none !important; +} + +.download-selection-view { + --download-selection-zoom: var(--app-zoom, 1); + position: fixed; + top: calc(var(--header-height, 60px) * var(--download-selection-zoom)); + right: 0; + bottom: 0; + left: calc(var(--sidebar-width, 280px) * var(--download-selection-zoom)); + z-index: 1700; + display: flex; + align-items: center; + justify-content: center; + padding: 1.25rem; + background: transparent; + color: var(--text-primary); + pointer-events: auto; +} + +.download-selection-view:focus, +.download-selection-view:focus-visible { + outline: none; +} + +.download-selection-panel { + width: min(680px, 100%); + max-height: min(760px, 100%); + min-height: 0; + display: flex; + flex-direction: column; + gap: 0.85rem; + padding: 1rem; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 16px; + background: rgb(18, 17, 24); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035); + overflow: hidden; + pointer-events: auto; +} + +.download-selection-header { + display: grid; + grid-template-columns: 54px minmax(0, 1fr); + gap: 0.85rem; + align-items: center; +} + +.download-selection-icon { + display: flex; + align-items: center; + justify-content: center; + width: 54px; + height: 54px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.04); + font-size: 1.55rem; +} + +.download-selection-header h3, +.download-selection-header p { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.download-selection-header h3 { + font-size: 1rem; + line-height: 1.2; +} + +.download-selection-header p { + margin-top: 0.25rem; + color: var(--text-secondary); + font-size: 0.78rem; +} + +.download-selection-targets { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.55rem; +} + +.download-selection-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.7rem; + min-height: 252px; + border: 1px solid rgba(255, 255, 255, 0.04); + border-radius: 14px; + background: rgb(25, 24, 32); + color: var(--text-secondary); + font-size: 0.82rem; + text-align: center; +} + +.download-selection-loading--error { + color: var(--danger, #c22131); +} + +.download-selection-spinner { + width: 20px; + height: 20px; + border: 2px solid rgba(255, 255, 255, 0.12); + border-top-color: var(--primary-light); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.download-selection-target { + display: flex; + min-width: 0; + min-height: 68px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.28rem; + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 14px; + background: rgb(29, 27, 36); + color: var(--text-primary); + font-family: var(--app-font-family); + cursor: pointer; +} + +.download-selection-target.selected { + border-color: var(--premium-purple-border); + background: var(--premium-purple-bg); + color: #ffffff; +} + +.download-selection-target:disabled { + cursor: default; + opacity: 0.42; +} + +.download-selection-target:focus, +.download-selection-target:focus-visible, +.download-selection-version-item:focus, +.download-selection-version-item:focus-visible, +.download-selection-actions button:focus, +.download-selection-actions button:focus-visible { + outline: none; + box-shadow: none; +} + +.download-selection-target span { + font-size: 0.95rem; + font-weight: 700; +} + +.download-selection-target small { + color: inherit; + opacity: 0.72; + font-size: 0.72rem; +} + +.download-selection-version { + display: grid; + flex: 1 1 auto; + gap: 0.42rem; + min-height: 0; + color: var(--text-secondary); + font-size: 0.78rem; + font-weight: 700; +} + +.download-selection-version-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.download-selection-version-header small { + min-width: 0; + overflow: hidden; + color: var(--text-muted); + font-size: 0.72rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.download-selection-version-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.38rem; + min-height: 0; + max-height: min(320px, 38vh); + overflow-y: auto; + padding: 0.4rem; + scroll-padding: 0.4rem; + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 12px; + background: rgba(255, 255, 255, 0.014); +} + +.download-selection-version-item { + display: grid; + min-width: 0; + min-height: 58px; + gap: 0.18rem; + padding: 0.5rem 0.6rem; + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 10px; + background: rgb(29, 27, 36); + color: var(--text-primary); + font-family: var(--app-font-family); + text-align: left; + cursor: pointer; +} + +.download-selection-version-item:hover { + border-color: rgba(255, 255, 255, 0.085); + background: rgba(255, 255, 255, 0.04); +} + +.download-selection-version-item.selected { + border-color: var(--premium-purple-border); + background: var(--premium-purple-bg); + color: #ffffff; + box-shadow: none; +} + +.download-selection-version-item span, +.download-selection-version-item small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.download-selection-version-item span { + font-size: 0.9rem; +} + +.download-selection-version-item small { + color: var(--text-secondary); + font-size: 0.74rem; +} + +.download-selection-summary { + display: grid; + gap: 0.55rem; + min-height: 96px; + padding: 0.75rem; + border: 1px solid rgba(255, 255, 255, 0.04); + border-radius: 14px; + background: rgb(25, 24, 32); +} + +.download-selection-summary-meta { + display: flex; + justify-content: space-between; + gap: 0.75rem; + color: var(--text-secondary); + font-size: 0.74rem; +} + +.download-selection-summary ul { + display: grid; + gap: 0.3rem; + max-height: 108px; + overflow-y: auto; + padding: 0; + margin: 0; + list-style: none; +} + +.download-selection-summary li { + overflow: hidden; + color: var(--text-primary); + font-size: 0.74rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.download-selection-actions { + display: grid; + grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr); + gap: 0.55rem; +} + +.download-selection-actions button { + min-height: 46px; + border-radius: var(--module-button-radius); + color: var(--module-button-text); + font-family: var(--app-font-family); + font-weight: 700; + cursor: pointer; +} + +.download-selection-actions button:disabled { + cursor: default; + opacity: 0.45; +} + +.download-selection-cancel { + border: 1px solid rgba(255, 255, 255, 0.05); + background: rgba(255, 255, 255, 0.04); +} + +.download-selection-confirm { + border: 1px solid var(--premium-purple-border); + background: var(--premium-purple-bg); +} diff --git a/src/styles/layouts/app-sidebar.css b/src/styles/layouts/app-sidebar.css index ba81931e..dc2a9877 100644 --- a/src/styles/layouts/app-sidebar.css +++ b/src/styles/layouts/app-sidebar.css @@ -20,9 +20,9 @@ -webkit-app-region: no-drag; z-index: auto; box-sizing: border-box; - transition: - width 0.28s var(--ease-smooth), - padding 0.28s var(--ease-smooth); + contain: layout paint style; + will-change: width; + transition: width 0.22s var(--ease-smooth); } /* ... */ @@ -44,9 +44,7 @@ padding: 0; transition: background 0.2s ease, - opacity 0.2s ease, - padding 0.28s var(--ease-smooth), - margin 0.28s var(--ease-smooth); + opacity 0.2s ease; } .logo-icon-wrapper { @@ -258,9 +256,10 @@ overflow: hidden; white-space: nowrap; margin: 0; + transform: translateX(-4px); transition: - width 0.3s var(--ease-smooth), - opacity 0.2s ease; + opacity 0.16s ease, + transform 0.16s var(--ease-smooth); } #sidebar.collapsed .sidebar-title-wrapper { @@ -330,7 +329,10 @@ opacity: 0; visibility: hidden; transform: translateX(-10px); - transition: all 0.2s; + transition: + opacity 0.2s ease, + transform 0.2s ease, + visibility 0.2s ease; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 10; } @@ -369,12 +371,7 @@ #sidebar .main-menu, #sidebar .bottom-menu, #sidebar .nav-section { - transition: - margin 0.28s var(--ease-smooth), - padding 0.28s var(--ease-smooth), - width 0.28s var(--ease-smooth), - gap 0.28s var(--ease-smooth), - transform 0.28s var(--ease-smooth); + transition: none; } #system-monitor { @@ -415,17 +412,12 @@ position: relative; overflow: hidden; transition: - height 0.3s cubic-bezier(0.4, 0, 0.2, 1), - max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1), - margin 0.3s cubic-bezier(0.4, 0, 0.2, 1), - padding 0.3s cubic-bezier(0.4, 0, 0.2, 1), - gap 0.3s cubic-bezier(0.4, 0, 0.2, 1), - justify-content 0.3s cubic-bezier(0.4, 0, 0.2, 1), background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, - color 0.2s ease; + color 0.2s ease, + transform 0.2s ease; height: 44px; max-height: 44px; outline: none; @@ -525,26 +517,21 @@ white-space: nowrap; text-overflow: ellipsis; transform-origin: left center; + transform: translateX(0); transition: - opacity 0.24s var(--ease-smooth), - width 0.28s var(--ease-smooth), - margin 0.28s var(--ease-smooth), - transform 0.28s var(--ease-smooth), - visibility 0.28s; + opacity 0.18s ease, + transform 0.18s var(--ease-smooth); } body.snapping #sidebar { - transition: - width 0.3s var(--ease-smooth), - padding 0.3s var(--ease-smooth); + transition: width 0.22s var(--ease-smooth); } body.snapping .nav-btn span, body.snapping #sidebar.collapsed .nav-btn span { transition: - opacity 0.3s var(--ease-smooth), - width 0.3s var(--ease-smooth), - visibility 0.3s; + opacity 0.16s ease, + transform 0.16s var(--ease-smooth); } #sidebar:not(.collapsed).monitor-hidden .nav-btn { diff --git a/src/styles/layouts/modal-shell.css b/src/styles/layouts/modal-shell.css index 32adbe27..6bd7514f 100644 --- a/src/styles/layouts/modal-shell.css +++ b/src/styles/layouts/modal-shell.css @@ -13,6 +13,18 @@ --modal-shell-padding-block-start: 24px; --modal-shell-padding-block-end: 24px; --modal-shell-radius: 24px; + outline: none !important; + box-shadow: none; +} + +.modal-backdrop:focus, +.modal-backdrop:focus-visible, +#app-selection-modal:focus, +#app-selection-modal:focus-visible, +#module-settings-modal:focus, +#module-settings-modal:focus-visible { + outline: none !important; + box-shadow: none !important; } /* Keep app selection on the shared modal geometry used by module settings. */ diff --git a/src/test/helpers/catalogTestUtils.ts b/src/test/helpers/catalogTestUtils.ts index 5a870178..07ea6690 100644 --- a/src/test/helpers/catalogTestUtils.ts +++ b/src/test/helpers/catalogTestUtils.ts @@ -43,7 +43,8 @@ export function createCatalogHarness(): { globalThis.dispatchEvent = vi.fn(); const mockBridge = createMockBridge() as unknown as MockCatalogBridge; - const tracer: Pick = { + const tracer: Pick = { + debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), diff --git a/src/test/integration/CatalogService.integration.test.ts b/src/test/integration/CatalogService.integration.test.ts index e3163c5f..f85283d6 100644 --- a/src/test/integration/CatalogService.integration.test.ts +++ b/src/test/integration/CatalogService.integration.test.ts @@ -1,13 +1,12 @@ /** * @module test/integration/CatalogService.integration.test.ts * @description Integration tests for CatalogService — verifies full lifecycle - * from bridge calls through catalog hydration to globalThis sync. + * from backend calls through catalog hydration to update events. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { CatalogService } from '@/shared/services/CatalogService'; +import type { CatalogService } from '@/shared/services/CatalogService'; import type { IModule } from '@/shared/types/coreTypes'; -import { FALLBACK_CONFIG } from '@/shared/config/catalog_fallback'; import { createCatalogHarness, createMockAppConfig, @@ -54,15 +53,15 @@ describe('CatalogService Integration', () => { expect(catalog.services.at(0)?.id).toBe('my-worker'); }); - it('should handle Tauri backend failure and use fallback', async () => { + it('should handle Tauri backend failure with an empty catalog', async () => { mockBridge.isTauri.mockReturnValue(true); mockBridge.invoke.mockResolvedValue(null); await service.loadCatalog(); const catalog = service.getCatalog(); - // Fallback config should populate the catalog - expect(catalog.ai.length).toBe(FALLBACK_CONFIG.catalog.ai.length); + expect(catalog.ai).toHaveLength(0); + expect(catalog.services).toHaveLength(0); }); it('should dispatch catalog-loaded event after successful load', async () => { @@ -79,7 +78,7 @@ describe('CatalogService Integration', () => { ); }); - it('should handle empty config and keep empty catalog (fallback is also empty)', async () => { + it('should handle empty config and keep empty catalog', async () => { const mockConfig = createMockAppConfig({ catalog: { ai: [], services: [] }, }); @@ -89,7 +88,6 @@ describe('CatalogService Integration', () => { await service.loadCatalog(); const catalog = service.getCatalog(); - // FALLBACK_CONFIG is also empty, so catalog stays empty expect(catalog.ai).toHaveLength(0); expect(catalog.services).toHaveLength(0); }); @@ -117,13 +115,13 @@ describe('CatalogService Integration', () => { expect(catalog.ai.at(0)?.installed).toBe(true); // API modules always installed }); - it('should use bundled fallback when Tauri bridge is unavailable', async () => { + it('should use an empty catalog when Tauri bridge is unavailable', async () => { mockBridge.isTauri.mockReturnValue(false); await service.loadCatalog(); const catalog = service.getCatalog(); - expect(catalog.ai.length).toBe(FALLBACK_CONFIG.catalog.ai.length); - expect(catalog.services.length).toBe(FALLBACK_CONFIG.catalog.services.length); + expect(catalog.ai).toHaveLength(0); + expect(catalog.services).toHaveLength(0); }); }); diff --git a/src/test/integration/CoreContainer.test.ts b/src/test/integration/CoreContainer.test.ts index 3529e4ca..1465ed8a 100644 --- a/src/test/integration/CoreContainer.test.ts +++ b/src/test/integration/CoreContainer.test.ts @@ -1,7 +1,7 @@ /** * @module test/integration/CoreContainer.test.ts * @description Integration tests for CoreContainer — verifies service registration, - * backward compat with globalThis, and container lifecycle. + * catalog access, and container lifecycle. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; diff --git a/src/test/mocks/mockUiStateStore.ts b/src/test/mocks/mockUiStateStore.ts index 5772ab1f..a6bc24b5 100644 --- a/src/test/mocks/mockUiStateStore.ts +++ b/src/test/mocks/mockUiStateStore.ts @@ -19,7 +19,7 @@ export function createMockStore(initial?: Partial): UiStateStore { ai_thinking_level: {}, ai_web_search_enabled: {}, local_max_output_tokens: {}, - ai_session_id: null, + integration_import_last_directory: null, pending_chat_reveal: false, ...initial, }; diff --git a/src/test/setup.test.ts b/src/test/setup.test.ts index 2967c985..48990845 100644 --- a/src/test/setup.test.ts +++ b/src/test/setup.test.ts @@ -9,10 +9,6 @@ describe('Testing Setup', () => { it('should have mocked Tauri invoke', () => { const win = globalThis as unknown as Record; - expect(win['__TAURI__']).toBeDefined(); - expect(typeof (win['__TAURI__'] as { core: { invoke: unknown } }).core.invoke).toBe( - 'function', - ); expect(typeof (win['__TAURI_INTERNALS__'] as { invoke: unknown }).invoke).toBe('function'); }); diff --git a/src/test/setup.ts b/src/test/setup.ts index cba834f9..2acd2d8f 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,5 +1,11 @@ import { beforeEach, vi } from 'vitest'; +type TauriInternalsMock = { + invoke: (cmd?: string, args?: unknown) => Promise; + transformCallback: () => number; + eventListen: (event?: string, callback?: (payload: unknown) => void) => Promise<() => void>; +}; + // Mock localStorage for JSDOM const localStorageMock = (() => { let store: Record = {}; @@ -25,29 +31,31 @@ Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock }); function installDefaultTauriGlobals(): void { const win = globalThis as unknown as Record; - win['__TAURI__'] = { - invoke: async () => {}, - core: { invoke: async () => {} }, - event: { listen: async () => () => {} }, - }; - win['__TAURI_INTERNALS__'] = { - invoke: async () => {}, + const internals: TauriInternalsMock = { + invoke: () => Promise.resolve(), transformCallback: () => 0, + eventListen: () => Promise.resolve(() => {}), }; + win['__TAURI_INTERNALS__'] = internals; win['t'] = vi.fn((key: string, fallback?: string) => fallback ?? key); } +function getTauriInternals(): Partial | null { + const win = globalThis as unknown as Record; + const internals = win['__TAURI_INTERNALS__']; + if (internals === null || typeof internals !== 'object') { + return null; + } + return internals as Partial; +} + // Mock Tauri APIs vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn().mockImplementation((cmd: string, args?: unknown) => { - const win = globalThis as unknown as Record; - const tauri = win['__TAURI__'] as Record | undefined; + const internals = getTauriInternals(); - if (tauri && typeof tauri['invoke'] === 'function') { - return tauri['invoke'](cmd, args); - } - if (typeof tauri?.['core']?.['invoke'] === 'function') { - return tauri['core']['invoke'](cmd, args); + if (typeof internals?.invoke === 'function') { + return internals.invoke(cmd, args); } return Promise.resolve(); }), @@ -55,12 +63,11 @@ vi.mock('@tauri-apps/api/core', () => ({ })); vi.mock('@tauri-apps/api/event', () => ({ - listen: vi.fn().mockImplementation((event: string, callback: (payload: any) => void) => { - const win = globalThis as unknown as Record; - const tauri = win['__TAURI__'] as Record | undefined; + listen: vi.fn().mockImplementation((event: string, callback: (payload: unknown) => void) => { + const internals = getTauriInternals(); - if (typeof tauri?.['event']?.['listen'] === 'function') { - return tauri['event']['listen'](event, callback); + if (typeof internals?.eventListen === 'function') { + return internals.eventListen(event, callback); } return Promise.resolve(() => {}); }), diff --git a/src/vite.config.ts b/src/vite.config.ts index 03d2d12a..b1a2bb88 100644 --- a/src/vite.config.ts +++ b/src/vite.config.ts @@ -95,9 +95,14 @@ export default defineConfig({ minify: process.env['TAURI_DEBUG'] ? false : 'terser', terserOptions: { + module: true, compress: { drop_console: true, drop_debugger: true, + keep_fargs: false, + passes: 3, + pure_getters: true, + unsafe_arrows: true, }, mangle: { toplevel: true, @@ -120,14 +125,24 @@ export default defineConfig({ }, output: { manualChunks(id) { + const normalizedId = id.replaceAll('\\', '/'); if ( - id.includes('marked') || - id.includes('dompurify') || - id.includes('marked-alert') || - id.includes('marked-footnote') + normalizedId.includes('marked') || + normalizedId.includes('dompurify') || + normalizedId.includes('marked-alert') || + normalizedId.includes('marked-footnote') ) { return 'vendor-markdown'; } + if (normalizedId.includes('/src/features/chat/')) { + return 'feature-chat'; + } + if (normalizedId.includes('/src/features/ai/')) { + return 'feature-ai'; + } + if (normalizedId.includes('/src/shared/shell/')) { + return 'app-shell'; + } return undefined; }, // Cleaner asset naming @@ -151,6 +166,10 @@ export default defineConfig({ exclude: [ // Pure TypeScript interface — no executable lines, always 0% '**/shared/types/IBridge.ts', + // Generated Specta bindings. Coverage belongs to the generator contract, not app tests. + '**/shared/types/bindings.ts', + '**/*.d.ts', + '**/coverage/**', ], thresholds: { 'src/features/**/services/*.ts': {