From c1f2b5be76d854e8c066a559855ce4cd756e875a Mon Sep 17 00:00:00 2001 From: xlgzsgf <51521689+hiqiancheng@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:01:39 +0800 Subject: [PATCH 01/13] test(architecture): add import boundary ratchet --- .../architecture/import-boundaries.test.ts | 417 ++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 apps/desktop/tests/architecture/import-boundaries.test.ts diff --git a/apps/desktop/tests/architecture/import-boundaries.test.ts b/apps/desktop/tests/architecture/import-boundaries.test.ts new file mode 100644 index 00000000..79204539 --- /dev/null +++ b/apps/desktop/tests/architecture/import-boundaries.test.ts @@ -0,0 +1,417 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const srcRoot = resolve(__dirname, '../../src'); + +const ignoredDirectories = new Set(['node_modules', 'target', 'dist', 'build', 'coverage']); +const sourceFileExtensions = new Set(['.ts', '.tsx', '.vue']); + +interface ImportEdge { + fromFile: string; + fromModule: string; + specifier: string; + toModule: string; +} + +interface RawImport { + fromFile: string; + fromModule: string; + specifier: string; +} + +const importPattern = + /\b(?:import|export)\s+(?:type\s+)?(?:[^'";]+?\s+from\s+)?['"]([^'"]+)['"]|\bimport\(\s*['"]([^'"]+)['"]\s*\)/g; + +const allowedBaselineViolationFragments = [ + 'database -> services/NativeService', + 'services/AuthService -> services/AgentService', + 'services/BuiltInToolService -> services/AgentService', + 'services/EventService -> services/PopupService', + 'services/NativeService -> utils', + 'services/PopupService -> views/PopupView', +]; + +const allowedBaselineCycles = [ + ['database', 'services/NativeService'], + ['services/AgentService', 'services/AuthService'], + ['services/AgentService', 'services/BuiltInToolService'], + ['services/AgentService', 'types'], + ['services/EventService', 'services/PopupService'], + ['services/EventService', 'utils'], + ['services/NativeService', 'utils'], + ['services/PopupService', 'views/PopupView'], +]; + +function withoutAllowedBaselineViolations(violations: string[]): string[] { + return violations.filter( + (violation) => + !allowedBaselineViolationFragments.some((fragment) => violation.includes(fragment)) + ); +} + +function isAllowedBaselineCycle(cycle: string[]): boolean { + return allowedBaselineCycles.some( + (allowedCycle) => + allowedCycle.length === cycle.length && + allowedCycle.every((moduleName) => cycle.includes(moduleName)) + ); +} + +function walkFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + if (ignoredDirectories.has(entry.name)) { + return []; + } + + const fullPath = join(directory, entry.name); + if (entry.isDirectory()) { + return walkFiles(fullPath); + } + + const extension = entry.name.slice(entry.name.lastIndexOf('.')); + return sourceFileExtensions.has(extension) ? [fullPath] : []; + }); +} + +function moduleNameFor(filePath: string): string { + const relativePath = relative(srcRoot, filePath).replace(/\\/g, '/'); + const [first = '', second] = relativePath.split('/'); + + if (first === 'services' && second) { + return `services/${second}`; + } + + if (first === 'views' && second) { + return `views/${second}`; + } + + return first; +} + +function resolveSpecifier(fromFile: string, specifier: string): string | null { + if (specifier.startsWith('@/')) { + return join(srcRoot, specifier.slice(2)); + } + + const aliases: Record = { + '@assets': join(srcRoot, 'assets'), + '@components': join(srcRoot, 'components'), + '@composables': join(srcRoot, 'composables'), + '@database': join(srcRoot, 'database'), + '@services': join(srcRoot, 'services'), + '@styles': join(srcRoot, 'styles'), + '@types': join(srcRoot, 'types'), + '@utils': join(srcRoot, 'utils'), + }; + + for (const [alias, target] of Object.entries(aliases)) { + if (specifier === alias) { + return target; + } + + if (specifier.startsWith(`${alias}/`)) { + return join(target, specifier.slice(alias.length + 1)); + } + } + + if (specifier.startsWith('.')) { + return resolve(dirname(fromFile), specifier); + } + + return null; +} + +function collectRawImports(): RawImport[] { + return walkFiles(srcRoot).flatMap((fromFile) => { + const text = readFileSync(fromFile, 'utf8'); + const fromModule = moduleNameFor(fromFile); + const imports: RawImport[] = []; + + for (const match of text.matchAll(importPattern)) { + const specifier = match[1] ?? match[2]; + if (!specifier) { + continue; + } + + imports.push({ + fromFile: relative(srcRoot, fromFile).replace(/\\/g, '/'), + fromModule, + specifier, + }); + } + + return imports; + }); +} + +function collectEdges(): ImportEdge[] { + return collectRawImports().flatMap((rawImport) => { + const fromFile = join(srcRoot, rawImport.fromFile); + const fromModule = rawImport.fromModule; + const specifier = rawImport.specifier; + const resolved = resolveSpecifier(fromFile, specifier); + if (!resolved || !resolved.startsWith(srcRoot)) { + return []; + } + + const toModule = moduleNameFor(resolved); + if (toModule !== fromModule) { + return [ + { + fromFile: rawImport.fromFile, + fromModule, + specifier, + toModule, + }, + ]; + } + + return []; + }); +} + +function findForbiddenEdges( + edges: ImportEdge[], + rules: Array<{ from: RegExp; to: RegExp; reason: string }> +): string[] { + return edges.flatMap((edge) => { + const rule = rules.find( + (candidate) => candidate.from.test(edge.fromModule) && candidate.to.test(edge.toModule) + ); + + if (!rule) { + return []; + } + + return [ + `${edge.fromFile}: ${edge.fromModule} -> ${edge.toModule} via ${edge.specifier} (${rule.reason})`, + ]; + }); +} + +function findForbiddenRawImports( + imports: RawImport[], + rules: Array<{ from: RegExp; specifier: RegExp; reason: string }> +): string[] { + return imports.flatMap((rawImport) => { + const rule = rules.find( + (candidate) => + candidate.from.test(rawImport.fromModule) && + candidate.specifier.test(rawImport.specifier) + ); + + if (!rule) { + return []; + } + + return [ + `${rawImport.fromFile}: ${rawImport.fromModule} imports ${rawImport.specifier} (${rule.reason})`, + ]; + }); +} + +function findStronglyConnectedComponents(edges: ImportEdge[]): string[][] { + const modules = new Set(); + const adjacency = new Map>(); + + for (const edge of edges) { + modules.add(edge.fromModule); + modules.add(edge.toModule); + if (!adjacency.has(edge.fromModule)) { + adjacency.set(edge.fromModule, new Set()); + } + adjacency.get(edge.fromModule)?.add(edge.toModule); + } + + let nextIndex = 0; + const stack: string[] = []; + const onStack = new Set(); + const indexes = new Map(); + const lowLinks = new Map(); + const components: string[][] = []; + + function visit(moduleName: string): void { + indexes.set(moduleName, nextIndex); + lowLinks.set(moduleName, nextIndex); + nextIndex += 1; + stack.push(moduleName); + onStack.add(moduleName); + + for (const dependency of adjacency.get(moduleName) ?? []) { + if (!indexes.has(dependency)) { + visit(dependency); + lowLinks.set( + moduleName, + Math.min(lowLinks.get(moduleName)!, lowLinks.get(dependency)!) + ); + continue; + } + + if (onStack.has(dependency)) { + lowLinks.set( + moduleName, + Math.min(lowLinks.get(moduleName)!, indexes.get(dependency)!) + ); + } + } + + if (lowLinks.get(moduleName) !== indexes.get(moduleName)) { + return; + } + + const component: string[] = []; + let current: string | undefined; + do { + current = stack.pop(); + if (!current) { + break; + } + onStack.delete(current); + component.push(current); + } while (current !== moduleName); + + if (component.length > 1) { + components.push(component.sort()); + } + } + + for (const moduleName of modules) { + if (!indexes.has(moduleName)) { + visit(moduleName); + } + } + + return components.sort((left, right) => left.join(',').localeCompare(right.join(','))); +} + +describe('architecture import boundaries', () => { + it('keeps implementation modules on approved dependency direction', () => { + const violations = findForbiddenEdges(collectEdges(), [ + { + from: /^services\//, + to: /^views\//, + reason: 'services must not import Vue view modules', + }, + { + from: /^services\/BuiltInToolService$/, + to: /^services\/AgentService$/, + reason: 'built-in tools must depend on contracts, not AgentService implementation', + }, + { + from: /^services\/AuthService$/, + to: /^services\/AgentService$/, + reason: 'auth must depend on provider config policy/contracts, not AgentService infrastructure', + }, + { + from: /^services\/EventService$/, + to: /^services\/PopupService$/, + reason: 'events must depend on popup contracts, not PopupService implementation', + }, + { + from: /^services\/NativeService$/, + to: /^utils$/, + reason: 'native bridge types must not depend on UI/session utility helpers', + }, + { + from: /^database$/, + to: /^services\/NativeService$/, + reason: 'database layer must not call native bridge directly', + }, + ]); + + expect(withoutAllowedBaselineViolations(violations)).toEqual([]); + }); + + it('keeps stable contracts independent from implementation modules', () => { + const violations = findForbiddenEdges(collectEdges(), [ + { + from: /^contracts$/, + to: /^services\//, + reason: 'contracts must not depend on implementation services', + }, + { + from: /^contracts$/, + to: /^views\//, + reason: 'contracts must not depend on UI views', + }, + { + from: /^contracts$/, + to: /^database$/, + reason: 'contracts must not depend on database implementation or entity modules', + }, + { + from: /^contracts$/, + to: /^utils$/, + reason: 'contracts must not depend on utility implementation helpers', + }, + { + from: /^contracts$/, + to: /^components$/, + reason: 'contracts must not depend on UI component modules', + }, + { + from: /^contracts$/, + to: /^composables$/, + reason: 'contracts must not depend on view/application composables', + }, + ]); + + expect(violations).toEqual([]); + }); + + it('keeps stable contracts free of UI/runtime framework imports', () => { + const violations = findForbiddenRawImports(collectRawImports(), [ + { + from: /^contracts$/, + specifier: /^vue$/, + reason: 'contracts must not import Vue', + }, + { + from: /^contracts$/, + specifier: /\.vue$/, + reason: 'contracts must not import Vue single-file components', + }, + { + from: /^contracts$/, + specifier: /^@tauri-apps\//, + reason: 'contracts must not import Tauri runtime APIs', + }, + { + from: /^contracts$/, + specifier: /^@\/(?:services|database|utils|views|components|composables|stores)\b/, + reason: 'contracts must not import implementation-layer aliases', + }, + { + from: /^contracts$/, + specifier: /^@(?:services|database|utils|components|composables)\b/, + reason: 'contracts must not import implementation-layer aliases', + }, + ]); + + expect(violations).toEqual([]); + }); + + it('does not keep the baseline frontend dependency cycles', () => { + const components = findStronglyConnectedComponents(collectEdges()); + const baselineCycles = [ + ['database', 'services/NativeService'], + ['services/AgentService', 'services/AuthService'], + ['services/AgentService', 'services/BuiltInToolService'], + ['services/AgentService', 'types'], + ['services/EventService', 'services/PopupService'], + ['services/EventService', 'utils'], + ['services/NativeService', 'utils'], + ['services/PopupService', 'views/PopupView'], + ]; + + const retainedBaselineCycles = baselineCycles.filter((cycle) => + components.some((component) => cycle.every((moduleName) => component.includes(moduleName))) + ); + + expect(retainedBaselineCycles.filter((cycle) => !isAllowedBaselineCycle(cycle))).toEqual( + [] + ); + }); +}); From 156c5a848885734882ca9c90620c5d422384343b Mon Sep 17 00:00:00 2001 From: xlgzsgf <51521689+hiqiancheng@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:27:39 +0800 Subject: [PATCH 02/13] refactor(agent): move shared tool dependencies out of service --- .../desktop/src/composables/agent/useAgent.ts | 8 +- apps/desktop/src/composables/useAskUser.ts | 2 +- apps/desktop/src/contracts/agentErrors.ts | 294 ++++++++++++++ apps/desktop/src/contracts/attachments.ts | 23 ++ apps/desktop/src/contracts/index.ts | 6 + apps/desktop/src/contracts/json.ts | 8 + apps/desktop/src/contracts/tooling.ts | 189 +++++++++ .../services/AgentService/contracts/errors.ts | 293 +------------- .../AgentService/contracts/protocol.ts | 6 +- .../AgentService/contracts/tooling.ts | 188 +-------- .../AgentService/execution/runtime.ts | 2 +- .../infrastructure/attachments/storage.ts | 365 +----------------- .../infrastructure/attachments/support.ts | 82 +--- .../infrastructure/attachments/types.ts | 20 +- .../providers/ai-sdk/messages.ts | 2 +- .../infrastructure/providers/ai-sdk/stream.ts | 2 +- .../providers/ai-sdk/tauriFetch.ts | 71 +--- .../services/AgentService/prompt/transport.ts | 2 +- .../src/services/AgentService/prompt/types.ts | 2 +- .../services/AgentService/session/history.ts | 2 +- .../src/services/AgentService/task/types.ts | 2 +- .../src/services/AttachmentService/index.ts | 24 ++ .../src/services/AttachmentService/storage.ts | 362 +++++++++++++++++ .../src/services/AttachmentService/support.ts | 76 ++++ .../services/BuiltInToolService/service.ts | 6 +- .../tools/askUser/constants.ts | 2 +- .../BuiltInToolService/tools/askUser/index.ts | 4 +- .../tools/bash/constants.ts | 2 +- .../BuiltInToolService/tools/bash/index.ts | 4 +- .../tools/fileSearch/constants.ts | 2 +- .../tools/read/constants.ts | 2 +- .../BuiltInToolService/tools/read/helper.ts | 6 +- .../tools/setting/constants.ts | 2 +- .../BuiltInToolService/tools/setting/index.ts | 2 +- .../tools/upgradeModel/constants.ts | 2 +- .../tools/upgradeModel/index.ts | 2 +- .../tools/webFetch/constants.ts | 2 +- .../tools/webFetch/index.ts | 2 +- .../tools/widgetTool/showWidget/constants.ts | 2 +- .../tools/widgetTool/showWidget/helper.ts | 5 +- .../tools/widgetTool/showWidget/index.ts | 2 +- .../widgetTool/visualizeReadMe/constants.ts | 2 +- .../src/services/BuiltInToolService/types.ts | 4 +- .../desktop/src/services/HttpService/index.ts | 3 + .../src/services/HttpService/tauriFetch.ts | 72 ++++ apps/desktop/src/stores/askUser.ts | 2 +- apps/desktop/src/types/session.ts | 4 +- .../components/AskUser/AskUserPanel.vue | 2 +- .../SearchView/components/SearchBar/index.vue | 2 +- .../SearchBar/tags/attachment/index.ts | 2 +- .../SearchView/composables/useSearchInput.ts | 2 +- .../composables/useSearchRequest.ts | 7 +- apps/desktop/src/views/SearchView/types.ts | 4 +- .../architecture/import-boundaries.test.ts | 5 +- .../tests/components/SearchInput-i18n.test.ts | 2 +- .../SearchView/useSearchInput.test.ts | 2 +- .../composables/agent/useAgent-i18n.test.ts | 2 +- .../attachment-capability-preflight.test.ts | 8 +- .../AgentService/attachments-i18n.test.ts | 4 +- .../contracts/errors-i18n.test.ts | 2 +- .../AgentService/execution/executor.test.ts | 2 +- .../AgentService/execution/retry.test.ts | 2 +- .../providers/ai-sdk/messages.test.ts | 2 +- .../infrastructure/providers/mimo.test.ts | 2 +- .../tools/webFetch/i18n.test.ts | 2 +- .../show-widget-summary-i18n.test.ts | 2 +- 66 files changed, 1145 insertions(+), 1078 deletions(-) create mode 100644 apps/desktop/src/contracts/agentErrors.ts create mode 100644 apps/desktop/src/contracts/attachments.ts create mode 100644 apps/desktop/src/contracts/index.ts create mode 100644 apps/desktop/src/contracts/json.ts create mode 100644 apps/desktop/src/contracts/tooling.ts create mode 100644 apps/desktop/src/services/AttachmentService/index.ts create mode 100644 apps/desktop/src/services/AttachmentService/storage.ts create mode 100644 apps/desktop/src/services/AttachmentService/support.ts create mode 100644 apps/desktop/src/services/HttpService/index.ts create mode 100644 apps/desktop/src/services/HttpService/tauriFetch.ts diff --git a/apps/desktop/src/composables/agent/useAgent.ts b/apps/desktop/src/composables/agent/useAgent.ts index bb5cb378..efe48489 100644 --- a/apps/desktop/src/composables/agent/useAgent.ts +++ b/apps/desktop/src/composables/agent/useAgent.ts @@ -1,16 +1,16 @@ -// Copyright (c) 2026. 千诚. Licensed under GPL v3 +// Copyright (c) 2026. 千诚. Licensed under GPL v3 import { findSessionById } from '@database/queries/sessions'; import type { SessionTurn } from '@database/schema'; import { notify } from '@services/NotificationService'; import { computed, onUnmounted, ref } from 'vue'; +import { AiError, AiErrorCode } from '@/contracts/agentErrors'; import { t } from '@/i18n'; import { sessionTaskCenter } from '@/services/AgentService'; -import { AiError, AiErrorCode } from '@/services/AgentService/contracts/errors'; -import { type Index } from '@/services/AgentService/infrastructure/attachments'; import { buildSessionHistoryFromData } from '@/services/AgentService/session'; import { getSessionData } from '@/services/AgentService/session'; +import { type Index } from '@/services/AttachmentService'; import type { InputHistorySnapshot, LoadedSessionInfo } from '@/types/session'; import { useAgentState } from './useAgentState'; @@ -328,7 +328,7 @@ export function useAgent(options: UseAiRequestOptions = {}) { function settleUserQuestion( callId: string, - answers: import('@/services/AgentService/contracts/tooling').AskUserAnswer[] | null + answers: import('@/contracts/tooling').AskUserAnswer[] | null ): boolean { if (!attachedTaskId.value) { return false; diff --git a/apps/desktop/src/composables/useAskUser.ts b/apps/desktop/src/composables/useAskUser.ts index 833a815b..9b33c6c8 100644 --- a/apps/desktop/src/composables/useAskUser.ts +++ b/apps/desktop/src/composables/useAskUser.ts @@ -4,7 +4,7 @@ import type { AskUserAnswer, AskUserQuestion, ToolApprovalDecisionRequest, -} from '@/services/AgentService/contracts/tooling'; +} from '@/contracts/tooling'; import { type AskUserConfirmOptions, useAskUserStore } from '@/stores/askUser'; export function useAskUser() { diff --git a/apps/desktop/src/contracts/agentErrors.ts b/apps/desktop/src/contracts/agentErrors.ts new file mode 100644 index 00000000..3c8b2130 --- /dev/null +++ b/apps/desktop/src/contracts/agentErrors.ts @@ -0,0 +1,294 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +import { type SourceText, tt } from '@/i18n'; + +/** + * AI 服务错误码 + */ +export enum AiErrorCode { + // 模型相关错误 (1xxx) + NO_ACTIVE_MODEL = 'NO_ACTIVE_MODEL', + MODEL_NOT_FOUND = 'MODEL_NOT_FOUND', + MODEL_DISABLED = 'MODEL_DISABLED', + PROVIDER_DISABLED = 'PROVIDER_DISABLED', + + // 请求相关错误 (2xxx) + REQUEST_CANCELLED = 'REQUEST_CANCELLED', + EMPTY_RESPONSE = 'EMPTY_RESPONSE', + STREAM_ERROR = 'STREAM_ERROR', + SESSION_ACTIVE_TASK_EXISTS = 'SESSION_ACTIVE_TASK_EXISTS', + TASK_NOT_FOUND = 'TASK_NOT_FOUND', + UNSUPPORTED_INPUT = 'UNSUPPORTED_INPUT', + + // 网络相关错误 (3xxx) - 可重试 + NETWORK_ERROR = 'NETWORK_ERROR', + API_ERROR = 'API_ERROR', + TIMEOUT = 'TIMEOUT', + RATE_LIMIT = 'RATE_LIMIT', + SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE', + BAD_GATEWAY = 'BAD_GATEWAY', + GATEWAY_TIMEOUT = 'GATEWAY_TIMEOUT', + + // 认证相关错误 (4xxx) + INVALID_API_KEY = 'INVALID_API_KEY', + UNAUTHORIZED = 'UNAUTHORIZED', + + // 配置相关错误 (5xxx) + INVALID_CONFIG = 'INVALID_CONFIG', + MISSING_ENDPOINT = 'MISSING_ENDPOINT', + + // MCP 相关错误 (6xxx) + MCP_CONNECTION_FAILED = 'MCP_CONNECTION_FAILED', + MCP_TOOL_EXECUTION_FAILED = 'MCP_TOOL_EXECUTION_FAILED', + MCP_TOOL_TIMEOUT = 'MCP_TOOL_TIMEOUT', + // 未知错误 + UNKNOWN = 'UNKNOWN', +} + +/** + * 错误消息映射表 + */ +const ERROR_MESSAGES: Record = { + // 模型相关 + [AiErrorCode.NO_ACTIVE_MODEL]: '未配置可用的 AI 模型,请前往设置页面添加模型', + [AiErrorCode.MODEL_NOT_FOUND]: '指定的模型不存在', + [AiErrorCode.MODEL_DISABLED]: '该模型已被禁用', + [AiErrorCode.PROVIDER_DISABLED]: '该服务商已被禁用', + + // 请求相关 + [AiErrorCode.REQUEST_CANCELLED]: '请求已取消', + [AiErrorCode.EMPTY_RESPONSE]: '模型返回了空回复,请尝试重新提问或更换模型', + [AiErrorCode.STREAM_ERROR]: '流式响应处理失败', + [AiErrorCode.SESSION_ACTIVE_TASK_EXISTS]: '当前会话已有正在运行的任务,请等待完成或先取消', + [AiErrorCode.TASK_NOT_FOUND]: '任务不存在或已结束', + [AiErrorCode.UNSUPPORTED_INPUT]: '当前模型不支持图片/文件输入,请选择合适模型继续。', + + // 网络相关 + [AiErrorCode.NETWORK_ERROR]: '网络连接失败,请检查网络设置', + [AiErrorCode.API_ERROR]: 'API 请求失败', + [AiErrorCode.TIMEOUT]: '请求超时,请稍后重试', + [AiErrorCode.RATE_LIMIT]: '请求频率过高,请稍后重试', + [AiErrorCode.SERVICE_UNAVAILABLE]: '服务暂时不可用,请稍后重试', + [AiErrorCode.BAD_GATEWAY]: '网关错误,请稍后重试', + [AiErrorCode.GATEWAY_TIMEOUT]: '网关超时,请稍后重试', + + // 认证相关 + [AiErrorCode.INVALID_API_KEY]: 'API Key 无效或已过期', + [AiErrorCode.UNAUTHORIZED]: '认证失败,请检查 API Key', + + // 配置相关 + [AiErrorCode.INVALID_CONFIG]: '配置无效', + [AiErrorCode.MISSING_ENDPOINT]: '缺少 API 端点配置', + + // MCP 相关 + [AiErrorCode.MCP_CONNECTION_FAILED]: 'MCP 服务器连接失败', + [AiErrorCode.MCP_TOOL_EXECUTION_FAILED]: 'MCP 工具执行失败', + [AiErrorCode.MCP_TOOL_TIMEOUT]: 'MCP 工具执行超时', + + // 未知错误 + [AiErrorCode.UNKNOWN]: '未知错误', +}; + +function isUnsupportedInputEndpointMessage(message: string): boolean { + return /no endpoints found that support\b(?=.*\b(?:image|file)s?\b).*\binputs?\b/i.test( + message + ); +} + +function isTransportNetworkErrorMessage(message: string): boolean { + return ( + message.includes('error sending request for url') || + message.includes('failed to fetch') || + message.includes('network') || + message.includes('fetch') + ); +} + +function getDisplayMessageForText(message: string): string { + const source = Object.values(ERROR_MESSAGES).find((candidate) => candidate === message); + if (source) { + return tt(source); + } + + if (isUnsupportedInputEndpointMessage(message)) { + return tt(ERROR_MESSAGES[AiErrorCode.UNSUPPORTED_INPUT]); + } + + return message; +} + +/** + * AI 服务统一错误类 + */ +export class AiError extends Error { + public readonly code: AiErrorCode; + public readonly details?: unknown; + public readonly cause?: unknown; + private readonly usesDefaultMessage: boolean; + + constructor( + code: AiErrorCode, + details?: unknown, + message?: string, + options: { cause?: unknown } = {} + ) { + const usesDefaultMessage = message === undefined || message === ''; + const finalMessage = usesDefaultMessage ? ERROR_MESSAGES[code] : message; + super(finalMessage); + + this.name = 'AiError'; + this.code = code; + this.details = details; + this.cause = options.cause; + this.usesDefaultMessage = usesDefaultMessage; + + // 保持正确的原型链 + Object.setPrototypeOf(this, AiError.prototype); + } + + /** + * 获取错误码对应的消息文本 + */ + static getMessage(code: AiErrorCode): string { + return ERROR_MESSAGES[code]; + } + + /** + * Localize known default AiError messages after they have crossed a string-only boundary. + */ + static getKnownDefaultDisplayMessage(message: string): string { + return getDisplayMessageForText(message); + } + + /** + * 获取适合 UI 展示的本地化消息。 + * + * 只有应用生成的默认错误文案会被本地化;provider/API 返回的自定义消息保持原样, + * 避免破坏远端 payload 的诊断价值。 + */ + getDisplayMessage(): string { + if (this.usesDefaultMessage) { + return tt(ERROR_MESSAGES[this.code]); + } + + return getDisplayMessageForText(this.message); + } + + /** + * 获取任意错误对象适合 UI 展示的消息。 + */ + static getDisplayMessage(error: unknown): string { + if (error instanceof AiError) { + return error.getDisplayMessage(); + } + + if (error instanceof Error) { + return getDisplayMessageForText(error.message); + } + + return getDisplayMessageForText(String(error)); + } + + /** + * 判断是否为特定错误码 + */ + is(code: AiErrorCode): boolean { + return this.code === code; + } + + /** + * 判断是否为可重试的错误 + */ + isRetryable(): boolean { + // 网络相关的临时性错误都可以重试 + return [ + AiErrorCode.NETWORK_ERROR, + AiErrorCode.TIMEOUT, + AiErrorCode.STREAM_ERROR, + AiErrorCode.RATE_LIMIT, + AiErrorCode.SERVICE_UNAVAILABLE, + AiErrorCode.BAD_GATEWAY, + AiErrorCode.GATEWAY_TIMEOUT, + AiErrorCode.EMPTY_RESPONSE, + ].includes(this.code); + } + + /** + * 判断是否为用户可操作的错误(需要用户修改配置) + */ + isUserActionable(): boolean { + return [ + AiErrorCode.NO_ACTIVE_MODEL, + AiErrorCode.MODEL_DISABLED, + AiErrorCode.PROVIDER_DISABLED, + AiErrorCode.INVALID_API_KEY, + AiErrorCode.UNAUTHORIZED, + AiErrorCode.INVALID_CONFIG, + AiErrorCode.MISSING_ENDPOINT, + AiErrorCode.UNSUPPORTED_INPUT, + ].includes(this.code); + } + + /** + * 从普通 Error 转换为 AiError + */ + static fromError(error: unknown, defaultCode = AiErrorCode.UNKNOWN): AiError { + if (error instanceof AiError && error.code !== AiErrorCode.UNKNOWN) { + return error; + } + + const cause = error === undefined ? undefined : error; + + if (error instanceof Error || typeof error == 'string') { + const message = error instanceof Error ? error.message.toLowerCase() : error; + const originalMessage = error instanceof Error ? error.message : String(error); + + if (isUnsupportedInputEndpointMessage(originalMessage)) { + return new AiError(AiErrorCode.UNSUPPORTED_INPUT, error, undefined, { + cause, + }); + } + + // 取消相关(abort / cancel / AbortError) + if ( + message.includes('abort') || + message.includes('cancel') || + (error instanceof Error && error.name === 'AbortError') + ) { + return new AiError(AiErrorCode.REQUEST_CANCELLED, error, undefined, { + cause, + }); + } + + // 网络错误 + if (isTransportNetworkErrorMessage(message)) { + return new AiError(AiErrorCode.NETWORK_ERROR, error, undefined, { + cause, + }); + } + + // 超时 + if (message.includes('timeout')) { + return new AiError(AiErrorCode.TIMEOUT, error, originalMessage, { + cause, + }); + } + + return new AiError(defaultCode, error, originalMessage, { cause }); + } + + return new AiError(defaultCode, undefined, String(error), { cause }); + } + + /** + * 转换为 JSON 格式(便于日志记录) + */ + toJSON() { + return { + name: this.name, + code: this.code, + message: this.message, + details: this.details, + }; + } +} diff --git a/apps/desktop/src/contracts/attachments.ts b/apps/desktop/src/contracts/attachments.ts new file mode 100644 index 00000000..75fb43f9 --- /dev/null +++ b/apps/desktop/src/contracts/attachments.ts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + */ + +export type AttachmentSupportStatus = 'supported' | 'unsupported-image' | 'unsupported-file'; + +export interface AttachmentIndex { + id: string; + attachmentId?: number; + hash?: string; + type: 'image' | 'file'; + path: string; + originPath: string; + name: string; + size?: number; + preview?: string; + mimeType?: string; + supportStatus?: AttachmentSupportStatus; + /** + * 剪贴板 mixed payload 导入时,附件应插入到纯文本草稿的哪个字符位置。 + */ + draftInsertionOffset?: number; +} diff --git a/apps/desktop/src/contracts/index.ts b/apps/desktop/src/contracts/index.ts new file mode 100644 index 00000000..a8569923 --- /dev/null +++ b/apps/desktop/src/contracts/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +export { AiError, AiErrorCode } from './agentErrors'; +export type * from './attachments'; +export type * from './json'; +export type * from './tooling'; diff --git a/apps/desktop/src/contracts/json.ts b/apps/desktop/src/contracts/json.ts new file mode 100644 index 00000000..4dbaeab4 --- /dev/null +++ b/apps/desktop/src/contracts/json.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; + +export interface JsonObject { + [key: string]: JsonValue | undefined; +} diff --git a/apps/desktop/src/contracts/tooling.ts b/apps/desktop/src/contracts/tooling.ts new file mode 100644 index 00000000..e34ff337 --- /dev/null +++ b/apps/desktop/src/contracts/tooling.ts @@ -0,0 +1,189 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +import type { JsonObject } from './json'; + +/** + * 暴露给模型的工具定义。 + */ +export interface AiToolDefinition { + name: string; + description: string; + input_schema: { + type: 'object'; + properties: Record; + required?: string[]; + [key: string]: unknown; + }; +} + +/** + * 模型在一次响应中声明的工具调用。 + */ +export interface AiToolCall { + id: string; + name: string; + arguments: string; + providerOptions?: Record; +} + +/** + * 工具调用参数在流式阶段的增量快照。 + */ +export interface AiToolCallDelta { + index: number; + callId?: string; + name?: string; + argumentsDelta?: string; + argumentsBuffer: string; + isComplete?: boolean; +} + +export type ToolExecutionSource = 'mcp' | 'builtin'; + +/** + * 工具审批卡片需要展示的标准字段。 + */ +export interface ToolApprovalRequest { + title: string; + description: string; + command: string; + riskLabel: string; + reason: string; + commandLabel: string; + approveLabel: string; + rejectLabel: string; + enterHint: string; + escHint: string; + keyboardApproveDelayMs?: number; +} + +/** + * 用于 requestToolApproval 回调的 payload。 + */ +export interface ToolApprovalDecisionRequest extends ToolApprovalRequest { + callId: string; +} + +/** + * 单个结构化提问选项。 + */ +export interface AskUserQuestionOption { + label: string; + description?: string; +} + +/** + * LLM 通过 ask_user_question 工具发起的单个结构化提问。 + */ +export interface AskUserQuestion { + question: string; + header: string; + multiSelect?: boolean; + allowOther?: boolean; + options: AskUserQuestionOption[]; +} + +/** + * 单题作答结果,整体取消时上层返回 null。 + */ +export interface AskUserAnswer { + questionIndex: number; + selectedLabels: string[]; + otherText?: string; + skipped: boolean; +} + +export interface ToolEventModelSummary { + providerId: number; + providerName: string; + modelId: string; + modelName: string; +} + +export type ToolEventBuiltInConversationSemanticAction = + | 'process' + | 'run' + | 'search' + | 'read' + | 'review' + | 'update' + | 'switch' + | 'render' + | 'remove' + | 'ask'; + +export interface ToolEventBuiltInConversationSemantic { + action: ToolEventBuiltInConversationSemanticAction; + target?: string; +} + +export type ShowWidgetMode = 'render' | 'remove'; +export type ShowWidgetPhase = 'draft' | 'ready'; + +/** + * widget 类工具向前端发出的渲染事件载荷。 + */ +export interface ShowWidgetEventPayload { + callId: string; + widgetId: string; + title: string; + description: string; + html: string; + mode: ShowWidgetMode; + phase: ShowWidgetPhase; +} + +/** + * Agent 循环过程中发给 UI 的统一事件。 + */ +export type ToolEvent = + | { + type: 'call_start'; + callId: string; + toolName: string; + namespacedName: string; + source: ToolExecutionSource; + serverId?: number | null; + sourceLabel?: string; + arguments: Record; + builtinConversationSemantic?: ToolEventBuiltInConversationSemantic; + } + | { + type: 'call_end'; + callId: string; + result: string; + isError: boolean; + durationMs: number; + finalStatus?: 'completed' | 'error' | 'rejected'; + } + | ({ type: 'approval_required'; callId: string } & ToolApprovalRequest) + | { + type: 'approval_resolved'; + callId: string; + approved: boolean; + resolutionText?: string; + } + | ({ type: 'widget_upsert' } & ShowWidgetEventPayload) + | { + type: 'widget_remove'; + callId: string; + widgetId: string; + } + | { + type: 'model_switched'; + fromModel: ToolEventModelSummary; + toModel: ToolEventModelSummary; + restart: boolean; + } + | { + type: 'request_retry'; + attempt: number; + maxRetries: number; + reason: string; + retryScope: 'restart' | 'checkpoint'; + resumeFromIteration: number; + discardVisibleOutputSinceCheckpoint: boolean; + discardToolActivitySinceCheckpoint: boolean; + } + | { type: 'iteration_start'; iteration: number } + | { type: 'iteration_end'; iteration: number }; diff --git a/apps/desktop/src/services/AgentService/contracts/errors.ts b/apps/desktop/src/services/AgentService/contracts/errors.ts index 3c8b2130..13574432 100644 --- a/apps/desktop/src/services/AgentService/contracts/errors.ts +++ b/apps/desktop/src/services/AgentService/contracts/errors.ts @@ -1,294 +1,3 @@ // Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 -import { type SourceText, tt } from '@/i18n'; - -/** - * AI 服务错误码 - */ -export enum AiErrorCode { - // 模型相关错误 (1xxx) - NO_ACTIVE_MODEL = 'NO_ACTIVE_MODEL', - MODEL_NOT_FOUND = 'MODEL_NOT_FOUND', - MODEL_DISABLED = 'MODEL_DISABLED', - PROVIDER_DISABLED = 'PROVIDER_DISABLED', - - // 请求相关错误 (2xxx) - REQUEST_CANCELLED = 'REQUEST_CANCELLED', - EMPTY_RESPONSE = 'EMPTY_RESPONSE', - STREAM_ERROR = 'STREAM_ERROR', - SESSION_ACTIVE_TASK_EXISTS = 'SESSION_ACTIVE_TASK_EXISTS', - TASK_NOT_FOUND = 'TASK_NOT_FOUND', - UNSUPPORTED_INPUT = 'UNSUPPORTED_INPUT', - - // 网络相关错误 (3xxx) - 可重试 - NETWORK_ERROR = 'NETWORK_ERROR', - API_ERROR = 'API_ERROR', - TIMEOUT = 'TIMEOUT', - RATE_LIMIT = 'RATE_LIMIT', - SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE', - BAD_GATEWAY = 'BAD_GATEWAY', - GATEWAY_TIMEOUT = 'GATEWAY_TIMEOUT', - - // 认证相关错误 (4xxx) - INVALID_API_KEY = 'INVALID_API_KEY', - UNAUTHORIZED = 'UNAUTHORIZED', - - // 配置相关错误 (5xxx) - INVALID_CONFIG = 'INVALID_CONFIG', - MISSING_ENDPOINT = 'MISSING_ENDPOINT', - - // MCP 相关错误 (6xxx) - MCP_CONNECTION_FAILED = 'MCP_CONNECTION_FAILED', - MCP_TOOL_EXECUTION_FAILED = 'MCP_TOOL_EXECUTION_FAILED', - MCP_TOOL_TIMEOUT = 'MCP_TOOL_TIMEOUT', - // 未知错误 - UNKNOWN = 'UNKNOWN', -} - -/** - * 错误消息映射表 - */ -const ERROR_MESSAGES: Record = { - // 模型相关 - [AiErrorCode.NO_ACTIVE_MODEL]: '未配置可用的 AI 模型,请前往设置页面添加模型', - [AiErrorCode.MODEL_NOT_FOUND]: '指定的模型不存在', - [AiErrorCode.MODEL_DISABLED]: '该模型已被禁用', - [AiErrorCode.PROVIDER_DISABLED]: '该服务商已被禁用', - - // 请求相关 - [AiErrorCode.REQUEST_CANCELLED]: '请求已取消', - [AiErrorCode.EMPTY_RESPONSE]: '模型返回了空回复,请尝试重新提问或更换模型', - [AiErrorCode.STREAM_ERROR]: '流式响应处理失败', - [AiErrorCode.SESSION_ACTIVE_TASK_EXISTS]: '当前会话已有正在运行的任务,请等待完成或先取消', - [AiErrorCode.TASK_NOT_FOUND]: '任务不存在或已结束', - [AiErrorCode.UNSUPPORTED_INPUT]: '当前模型不支持图片/文件输入,请选择合适模型继续。', - - // 网络相关 - [AiErrorCode.NETWORK_ERROR]: '网络连接失败,请检查网络设置', - [AiErrorCode.API_ERROR]: 'API 请求失败', - [AiErrorCode.TIMEOUT]: '请求超时,请稍后重试', - [AiErrorCode.RATE_LIMIT]: '请求频率过高,请稍后重试', - [AiErrorCode.SERVICE_UNAVAILABLE]: '服务暂时不可用,请稍后重试', - [AiErrorCode.BAD_GATEWAY]: '网关错误,请稍后重试', - [AiErrorCode.GATEWAY_TIMEOUT]: '网关超时,请稍后重试', - - // 认证相关 - [AiErrorCode.INVALID_API_KEY]: 'API Key 无效或已过期', - [AiErrorCode.UNAUTHORIZED]: '认证失败,请检查 API Key', - - // 配置相关 - [AiErrorCode.INVALID_CONFIG]: '配置无效', - [AiErrorCode.MISSING_ENDPOINT]: '缺少 API 端点配置', - - // MCP 相关 - [AiErrorCode.MCP_CONNECTION_FAILED]: 'MCP 服务器连接失败', - [AiErrorCode.MCP_TOOL_EXECUTION_FAILED]: 'MCP 工具执行失败', - [AiErrorCode.MCP_TOOL_TIMEOUT]: 'MCP 工具执行超时', - - // 未知错误 - [AiErrorCode.UNKNOWN]: '未知错误', -}; - -function isUnsupportedInputEndpointMessage(message: string): boolean { - return /no endpoints found that support\b(?=.*\b(?:image|file)s?\b).*\binputs?\b/i.test( - message - ); -} - -function isTransportNetworkErrorMessage(message: string): boolean { - return ( - message.includes('error sending request for url') || - message.includes('failed to fetch') || - message.includes('network') || - message.includes('fetch') - ); -} - -function getDisplayMessageForText(message: string): string { - const source = Object.values(ERROR_MESSAGES).find((candidate) => candidate === message); - if (source) { - return tt(source); - } - - if (isUnsupportedInputEndpointMessage(message)) { - return tt(ERROR_MESSAGES[AiErrorCode.UNSUPPORTED_INPUT]); - } - - return message; -} - -/** - * AI 服务统一错误类 - */ -export class AiError extends Error { - public readonly code: AiErrorCode; - public readonly details?: unknown; - public readonly cause?: unknown; - private readonly usesDefaultMessage: boolean; - - constructor( - code: AiErrorCode, - details?: unknown, - message?: string, - options: { cause?: unknown } = {} - ) { - const usesDefaultMessage = message === undefined || message === ''; - const finalMessage = usesDefaultMessage ? ERROR_MESSAGES[code] : message; - super(finalMessage); - - this.name = 'AiError'; - this.code = code; - this.details = details; - this.cause = options.cause; - this.usesDefaultMessage = usesDefaultMessage; - - // 保持正确的原型链 - Object.setPrototypeOf(this, AiError.prototype); - } - - /** - * 获取错误码对应的消息文本 - */ - static getMessage(code: AiErrorCode): string { - return ERROR_MESSAGES[code]; - } - - /** - * Localize known default AiError messages after they have crossed a string-only boundary. - */ - static getKnownDefaultDisplayMessage(message: string): string { - return getDisplayMessageForText(message); - } - - /** - * 获取适合 UI 展示的本地化消息。 - * - * 只有应用生成的默认错误文案会被本地化;provider/API 返回的自定义消息保持原样, - * 避免破坏远端 payload 的诊断价值。 - */ - getDisplayMessage(): string { - if (this.usesDefaultMessage) { - return tt(ERROR_MESSAGES[this.code]); - } - - return getDisplayMessageForText(this.message); - } - - /** - * 获取任意错误对象适合 UI 展示的消息。 - */ - static getDisplayMessage(error: unknown): string { - if (error instanceof AiError) { - return error.getDisplayMessage(); - } - - if (error instanceof Error) { - return getDisplayMessageForText(error.message); - } - - return getDisplayMessageForText(String(error)); - } - - /** - * 判断是否为特定错误码 - */ - is(code: AiErrorCode): boolean { - return this.code === code; - } - - /** - * 判断是否为可重试的错误 - */ - isRetryable(): boolean { - // 网络相关的临时性错误都可以重试 - return [ - AiErrorCode.NETWORK_ERROR, - AiErrorCode.TIMEOUT, - AiErrorCode.STREAM_ERROR, - AiErrorCode.RATE_LIMIT, - AiErrorCode.SERVICE_UNAVAILABLE, - AiErrorCode.BAD_GATEWAY, - AiErrorCode.GATEWAY_TIMEOUT, - AiErrorCode.EMPTY_RESPONSE, - ].includes(this.code); - } - - /** - * 判断是否为用户可操作的错误(需要用户修改配置) - */ - isUserActionable(): boolean { - return [ - AiErrorCode.NO_ACTIVE_MODEL, - AiErrorCode.MODEL_DISABLED, - AiErrorCode.PROVIDER_DISABLED, - AiErrorCode.INVALID_API_KEY, - AiErrorCode.UNAUTHORIZED, - AiErrorCode.INVALID_CONFIG, - AiErrorCode.MISSING_ENDPOINT, - AiErrorCode.UNSUPPORTED_INPUT, - ].includes(this.code); - } - - /** - * 从普通 Error 转换为 AiError - */ - static fromError(error: unknown, defaultCode = AiErrorCode.UNKNOWN): AiError { - if (error instanceof AiError && error.code !== AiErrorCode.UNKNOWN) { - return error; - } - - const cause = error === undefined ? undefined : error; - - if (error instanceof Error || typeof error == 'string') { - const message = error instanceof Error ? error.message.toLowerCase() : error; - const originalMessage = error instanceof Error ? error.message : String(error); - - if (isUnsupportedInputEndpointMessage(originalMessage)) { - return new AiError(AiErrorCode.UNSUPPORTED_INPUT, error, undefined, { - cause, - }); - } - - // 取消相关(abort / cancel / AbortError) - if ( - message.includes('abort') || - message.includes('cancel') || - (error instanceof Error && error.name === 'AbortError') - ) { - return new AiError(AiErrorCode.REQUEST_CANCELLED, error, undefined, { - cause, - }); - } - - // 网络错误 - if (isTransportNetworkErrorMessage(message)) { - return new AiError(AiErrorCode.NETWORK_ERROR, error, undefined, { - cause, - }); - } - - // 超时 - if (message.includes('timeout')) { - return new AiError(AiErrorCode.TIMEOUT, error, originalMessage, { - cause, - }); - } - - return new AiError(defaultCode, error, originalMessage, { cause }); - } - - return new AiError(defaultCode, undefined, String(error), { cause }); - } - - /** - * 转换为 JSON 格式(便于日志记录) - */ - toJSON() { - return { - name: this.name, - code: this.code, - message: this.message, - details: this.details, - }; - } -} +export { AiError, AiErrorCode } from '@/contracts/agentErrors'; diff --git a/apps/desktop/src/services/AgentService/contracts/protocol.ts b/apps/desktop/src/services/AgentService/contracts/protocol.ts index 56f519dd..f3bbd331 100644 --- a/apps/desktop/src/services/AgentService/contracts/protocol.ts +++ b/apps/desktop/src/services/AgentService/contracts/protocol.ts @@ -176,8 +176,4 @@ export interface AiResponse { finishReason?: string; } -export type JsonPrimitive = string | number | boolean | null; -export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; -export interface JsonObject { - [key: string]: JsonValue | undefined; -} +export type { JsonObject, JsonPrimitive, JsonValue } from '@/contracts/json'; diff --git a/apps/desktop/src/services/AgentService/contracts/tooling.ts b/apps/desktop/src/services/AgentService/contracts/tooling.ts index 020a4459..1e6bc196 100644 --- a/apps/desktop/src/services/AgentService/contracts/tooling.ts +++ b/apps/desktop/src/services/AgentService/contracts/tooling.ts @@ -1,189 +1,3 @@ // Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 -import type { JsonObject } from './protocol'; - -/** - * 暴露给模型的工具定义。 - */ -export interface AiToolDefinition { - name: string; - description: string; - input_schema: { - type: 'object'; - properties: Record; - required?: string[]; - [key: string]: unknown; - }; -} - -/** - * 模型在一次响应中声明的工具调用。 - */ -export interface AiToolCall { - id: string; - name: string; - arguments: string; - providerOptions?: Record; -} - -/** - * 工具调用参数在流式阶段的增量快照。 - */ -export interface AiToolCallDelta { - index: number; - callId?: string; - name?: string; - argumentsDelta?: string; - argumentsBuffer: string; - isComplete?: boolean; -} - -export type ToolExecutionSource = 'mcp' | 'builtin'; - -/** - * 工具审批卡片需要展示的标准字段。 - */ -export interface ToolApprovalRequest { - title: string; - description: string; - command: string; - riskLabel: string; - reason: string; - commandLabel: string; - approveLabel: string; - rejectLabel: string; - enterHint: string; - escHint: string; - keyboardApproveDelayMs?: number; -} - -/** - * 用于 requestToolApproval 回调的 payload。 - */ -export interface ToolApprovalDecisionRequest extends ToolApprovalRequest { - callId: string; -} - -/** - * 单个结构化提问选项。 - */ -export interface AskUserQuestionOption { - label: string; - description?: string; -} - -/** - * LLM 通过 ask_user_question 工具发起的单个结构化提问。 - */ -export interface AskUserQuestion { - question: string; - header: string; - multiSelect?: boolean; - allowOther?: boolean; - options: AskUserQuestionOption[]; -} - -/** - * 单题作答结果,整体取消时上层返回 null。 - */ -export interface AskUserAnswer { - questionIndex: number; - selectedLabels: string[]; - otherText?: string; - skipped: boolean; -} - -export interface ToolEventModelSummary { - providerId: number; - providerName: string; - modelId: string; - modelName: string; -} - -export type ToolEventBuiltInConversationSemanticAction = - | 'process' - | 'run' - | 'search' - | 'read' - | 'review' - | 'update' - | 'switch' - | 'render' - | 'remove' - | 'ask'; - -export interface ToolEventBuiltInConversationSemantic { - action: ToolEventBuiltInConversationSemanticAction; - target?: string; -} - -export type ShowWidgetMode = 'render' | 'remove'; -export type ShowWidgetPhase = 'draft' | 'ready'; - -/** - * widget 类工具向前端发出的渲染事件载荷。 - */ -export interface ShowWidgetEventPayload { - callId: string; - widgetId: string; - title: string; - description: string; - html: string; - mode: ShowWidgetMode; - phase: ShowWidgetPhase; -} - -/** - * Agent 循环过程中发给 UI 的统一事件。 - */ -export type ToolEvent = - | { - type: 'call_start'; - callId: string; - toolName: string; - namespacedName: string; - source: ToolExecutionSource; - serverId?: number | null; - sourceLabel?: string; - arguments: Record; - builtinConversationSemantic?: ToolEventBuiltInConversationSemantic; - } - | { - type: 'call_end'; - callId: string; - result: string; - isError: boolean; - durationMs: number; - finalStatus?: 'completed' | 'error' | 'rejected'; - } - | ({ type: 'approval_required'; callId: string } & ToolApprovalRequest) - | { - type: 'approval_resolved'; - callId: string; - approved: boolean; - resolutionText?: string; - } - | ({ type: 'widget_upsert' } & ShowWidgetEventPayload) - | { - type: 'widget_remove'; - callId: string; - widgetId: string; - } - | { - type: 'model_switched'; - fromModel: ToolEventModelSummary; - toModel: ToolEventModelSummary; - restart: boolean; - } - | { - type: 'request_retry'; - attempt: number; - maxRetries: number; - reason: string; - retryScope: 'restart' | 'checkpoint'; - resumeFromIteration: number; - discardVisibleOutputSinceCheckpoint: boolean; - discardToolActivitySinceCheckpoint: boolean; - } - | { type: 'iteration_start'; iteration: number } - | { type: 'iteration_end'; iteration: number }; +export type * from '@/contracts/tooling'; diff --git a/apps/desktop/src/services/AgentService/execution/runtime.ts b/apps/desktop/src/services/AgentService/execution/runtime.ts index fd560275..bcd01cfc 100644 --- a/apps/desktop/src/services/AgentService/execution/runtime.ts +++ b/apps/desktop/src/services/AgentService/execution/runtime.ts @@ -9,7 +9,7 @@ import { ensurePersistedAttachmentIndex, getModelAttachmentCapabilities, getUnsupportedAttachmentTypes, -} from '@/services/AgentService/infrastructure/attachments'; +} from '@/services/AttachmentService'; import type { InputHistorySnapshot } from '@/types/session'; import { AiError, AiErrorCode } from '../contracts/errors'; diff --git a/apps/desktop/src/services/AgentService/infrastructure/attachments/storage.ts b/apps/desktop/src/services/AgentService/infrastructure/attachments/storage.ts index 735c4906..42e10dd4 100644 --- a/apps/desktop/src/services/AgentService/infrastructure/attachments/storage.ts +++ b/apps/desktop/src/services/AgentService/infrastructure/attachments/storage.ts @@ -2,362 +2,9 @@ * Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 */ -import { type DatabaseExecutor, db } from '@database'; -import { - createAttachmentRecord, - findAttachmentByHash, - type MessageAttachmentRow, -} from '@database/queries'; -import type { AttachmentEntity } from '@database/types'; -import { native } from '@services/NativeService'; -import { convertFileSrc } from '@tauri-apps/api/core'; -import { join } from '@tauri-apps/api/path'; -import { copyFile, exists, mkdir, writeFile } from '@tauri-apps/plugin-fs'; -import { - fullName as getFileName, - icon as getFileIcon, - size as getFileSize, -} from 'tauri-plugin-fs-pro-api'; - -import { bytesToArrayBuffer } from './content'; -import type { AttachmentIndex } from './types'; - -const imageMimeMap: Record = { - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - png: 'image/png', - gif: 'image/gif', - webp: 'image/webp', - bmp: 'image/bmp', - svg: 'image/svg+xml', - avif: 'image/avif', -}; - -function normalizeExtension(extension: string | null | undefined): string { - return (extension || '').replace('.', '').trim().toLowerCase(); -} - -async function resolveMimeType( - type: AttachmentIndex['type'], - path: string -): Promise { - if (type !== 'image') return undefined; - const extension = normalizeExtension(path.split('.').pop()); - return imageMimeMap[extension] || 'image/png'; -} - -async function buildPreview( - type: AttachmentIndex['type'], - path: string -): Promise { - if (type === 'image') { - return convertFileSrc(path); - } - - const iconPath = await getFileIcon(path, { size: 256 }); - return convertFileSrc(iconPath); -} - -async function computeAttachmentHash(path: string): Promise { - const response = await fetch(convertFileSrc(path)); - if (!response.ok) { - throw new Error(`Failed to read attachment: ${response.statusText}`); - } - - const buffer = await response.arrayBuffer(); - const digest = await crypto.subtle.digest('SHA-256', buffer); - return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join( - '' - ); -} - -async function computeAttachmentHashFromBytes(bytes: Uint8Array): Promise { - const digest = await crypto.subtle.digest('SHA-256', bytesToArrayBuffer(bytes)); - return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join( - '' - ); -} - -let cachedCacheDirectory: string | null = null; - -async function ensureCacheDirectory(): Promise { - if (cachedCacheDirectory) { - return cachedCacheDirectory; - } - - const cachePath = await native.paths.getAppDirectoryPath('CACHE'); - const attachmentsPath = await join(cachePath, 'attachments'); - await mkdir(attachmentsPath, { recursive: true }); - cachedCacheDirectory = attachmentsPath; - return attachmentsPath; -} - -function getAttachmentBucket(type: AttachmentIndex['type']): 'images' | 'files' { - return type === 'image' ? 'images' : 'files'; -} - -/** - * 按固定规则推导附件缓存路径,避免把纯派生值冗余存进数据库。 - * - * 目录结构固定为: - * - cache/attachments/images// - * - cache/attachments/files// - * - * @param type 附件类型。 - * @param hash 附件内容哈希。 - * @returns 缓存副本的绝对路径。 - */ -async function buildAttachmentStoragePath( - type: AttachmentIndex['type'], - hash: string -): Promise { - const cacheDir = await ensureCacheDirectory(); - const bucketDir = await join(cacheDir, getAttachmentBucket(type)); - const shardDir = await join(bucketDir, hash.slice(0, 3)); - await mkdir(shardDir, { recursive: true }); - return join(shardDir, hash); -} - -async function ensureAttachmentRecord( - type: AttachmentIndex['type'], - path: string, - originPath: string, - database: DatabaseExecutor = db -): Promise { - const [hash, name, mimeType, size] = await Promise.all([ - computeAttachmentHash(path), - getFileName(path), - resolveMimeType(type, path), - getFileSize(path), - ]); - - const existing = await findAttachmentByHash(hash, database); - if (existing) { - return existing; - } - - const targetPath = await buildAttachmentStoragePath(type, hash); - await copyFile(path, targetPath); - - try { - return await createAttachmentRecord( - { - hash, - type, - original_name: name, - origin_path: originPath, - mime_type: mimeType ?? null, - size, - }, - database - ); - } catch (error) { - const duplicated = await findAttachmentByHash(hash, database); - if (duplicated) { - return duplicated; - } - - throw error; - } -} - -async function ensureAttachmentRecordFromBytes( - options: { - type: AttachmentIndex['type']; - name: string; - originPath: string; - mimeType?: string; - size?: number; - data: Uint8Array; - }, - database: DatabaseExecutor = db -): Promise { - const hash = await computeAttachmentHashFromBytes(options.data); - const targetPath = await buildAttachmentStoragePath(options.type, hash); - - if (!(await exists(targetPath))) { - await writeFile(targetPath, options.data); - } - - const existing = await findAttachmentByHash(hash, database); - if (existing) { - return existing; - } - - try { - return await createAttachmentRecord( - { - hash, - type: options.type, - original_name: options.name, - origin_path: options.originPath, - mime_type: options.mimeType ?? null, - size: options.size ?? options.data.byteLength, - }, - database - ); - } catch (error) { - const duplicated = await findAttachmentByHash(hash, database); - if (duplicated) { - return duplicated; - } - - throw error; - } -} - -async function toAttachmentIndex( - attachment: AttachmentEntity, - overrides: { - originPath?: string; - name?: string; - size?: number; - mimeType?: string; - } = {} -): Promise { - const storagePath = await buildAttachmentStoragePath(attachment.type, attachment.hash); - return { - id: crypto.randomUUID(), - attachmentId: attachment.id, - hash: attachment.hash, - type: attachment.type, - path: storagePath, - originPath: overrides.originPath ?? attachment.origin_path, - name: overrides.name ?? attachment.original_name, - size: overrides.size ?? attachment.size ?? undefined, - preview: await buildPreview(attachment.type, storagePath), - mimeType: overrides.mimeType ?? attachment.mime_type ?? undefined, - supportStatus: 'supported', - }; -} - -/** - * 基于原始文件创建草稿附件引用。 - * - * 草稿阶段只保留发送前展示所需的最小元数据,不立即复制到缓存目录, - * 避免用户临时粘贴但最终未发送的附件也落入长期缓存。 - * - * @param type 附件类型。 - * @param path 原始文件路径。 - * @returns 指向原始文件的草稿附件引用。 - */ -export async function createAttachment( - type: 'image' | 'file', - path: string -): Promise { - const [name, mimeType, size, preview] = await Promise.all([ - getFileName(path), - resolveMimeType(type, path), - getFileSize(path), - buildPreview(type, path), - ]); - - return { - id: crypto.randomUUID(), - type, - path, - originPath: path, - name, - size, - preview, - mimeType, - supportStatus: 'supported', - }; -} - -/** - * 基于已有字节内容直接创建并持久化附件。 - * - * 主要用于工具结果里的媒体内容:它们没有“原始本地文件”, - * 但仍应复用统一附件缓存、去重和后续 transport 逻辑。 - */ -export async function createPersistedAttachmentFromData( - options: { - type: 'image' | 'file'; - name: string; - originPath: string; - mimeType?: string; - size?: number; - data: Uint8Array; - }, - database: DatabaseExecutor = db -): Promise { - const persisted = await ensureAttachmentRecordFromBytes(options, database); - return toAttachmentIndex(persisted, { - // 字节型附件可能命中去重记录,但当前调用方的来源路径/名称 - // 仍然属于这一次消息语义,不能被旧记录回灌覆盖。 - originPath: options.originPath, - name: options.name, - size: options.size ?? options.data.byteLength, - mimeType: options.mimeType ?? persisted.mime_type ?? undefined, - }); -} - -/** - * 将数据库附件记录恢复成前端附件引用。 - * - * @param attachments 持久化层返回的附件行。 - * @returns 可直接挂到消息上的附件列表。 - */ -export async function hydratePersistedAttachments( - attachments: MessageAttachmentRow[] -): Promise { - return Promise.all( - attachments.map((attachment) => - toAttachmentIndex({ - id: attachment.id, - hash: attachment.hash, - type: attachment.type, - original_name: attachment.original_name, - origin_path: attachment.origin_path, - mime_type: attachment.mime_type, - size: attachment.size, - created_at: attachment.created_at, - }) - ) - ); -} - -/** - * 确保持久化层可识别当前附件引用。 - * - * 草稿附件在真正发送前不会写入缓存;只有请求开始持久化时, - * 才会复制到 `cache/attachments` 并创建数据库记录。 - * 历史恢复的附件则已经带上 `attachmentId/hash`,可直接复用。 - * - * @param attachment 前端附件引用。 - * @returns 可写入 message_attachments 的附件记录。 - */ -export async function ensurePersistedAttachmentIndex( - attachment: AttachmentIndex, - database: DatabaseExecutor = db -): Promise { - if (attachment.attachmentId && attachment.hash) { - return { - id: attachment.attachmentId, - hash: attachment.hash, - type: attachment.type, - original_name: attachment.name, - origin_path: attachment.originPath, - mime_type: attachment.mimeType ?? null, - size: attachment.size ?? null, - created_at: new Date().toISOString(), - }; - } - - const persisted = await ensureAttachmentRecord( - attachment.type, - attachment.path, - attachment.originPath, - database - ); - attachment.attachmentId = persisted.id; - attachment.hash = persisted.hash; - attachment.path = await buildAttachmentStoragePath(persisted.type, persisted.hash); - attachment.name = persisted.original_name; - attachment.size = persisted.size ?? undefined; - attachment.mimeType = persisted.mime_type ?? undefined; - attachment.preview = await buildPreview(persisted.type, attachment.path); - - return persisted; -} +export { + createAttachment, + createPersistedAttachmentFromData, + ensurePersistedAttachmentIndex, + hydratePersistedAttachments, +} from '@/services/AttachmentService'; diff --git a/apps/desktop/src/services/AgentService/infrastructure/attachments/support.ts b/apps/desktop/src/services/AgentService/infrastructure/attachments/support.ts index edda4824..7458b924 100644 --- a/apps/desktop/src/services/AgentService/infrastructure/attachments/support.ts +++ b/apps/desktop/src/services/AgentService/infrastructure/attachments/support.ts @@ -2,76 +2,12 @@ * Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 */ -import { tt } from '@/i18n'; -import { parseModelModalities } from '@/utils/modelSchemas'; - -import type { AttachmentIndex, AttachmentSupportStatus } from './types'; - -export interface AttachmentCapabilities { - supportsImages: boolean; - supportsFiles: boolean; -} - -export type AttachmentType = AttachmentIndex['type']; - -export function getModelAttachmentCapabilities(model: { - modalities?: string | null; - attachment?: number | null; -}): AttachmentCapabilities { - const modalities = parseModelModalities(model.modalities); - return { - supportsImages: Boolean(modalities.input?.includes('image')), - supportsFiles: model.attachment === 1, - }; -} - -export function getUnsupportedAttachmentTypes( - attachments: Pick[], - capabilities: AttachmentCapabilities -): AttachmentType[] { - const unsupportedTypes = new Set(); - - for (const attachment of attachments) { - if (attachment.type === 'image' && !capabilities.supportsImages) { - unsupportedTypes.add('image'); - } - if (attachment.type === 'file' && !capabilities.supportsFiles) { - unsupportedTypes.add('file'); - } - } - - return Array.from(unsupportedTypes); -} - -/** - * 根据文件类型和模型能力计算附件支持状态。 - * - * @param fileType 附件类型('image' | 'file')。 - * @param capabilities 当前模型的能力标志。 - * @returns 'supported' | 'unsupported-image' | 'unsupported-file'。 - */ -export function resolveAttachmentSupportStatus( - fileType: 'image' | 'file', - capabilities: AttachmentCapabilities -): AttachmentSupportStatus { - if (fileType === 'image' && !capabilities.supportsImages) return 'unsupported-image'; - if (fileType === 'file' && !capabilities.supportsFiles) return 'unsupported-file'; - return 'supported'; -} - -export function isAttachmentSupported(attachment: AttachmentIndex): boolean { - return ( - attachment.supportStatus !== 'unsupported-image' && - attachment.supportStatus !== 'unsupported-file' - ); -} - -export function getAttachmentSupportMessage(attachment: AttachmentIndex): string | null { - if (attachment.supportStatus === 'unsupported-image') { - return tt('该模型不支持图片'); - } - if (attachment.supportStatus === 'unsupported-file') { - return tt('该模型不支持文件'); - } - return null; -} +export { + type AttachmentCapabilities, + type AttachmentType, + getAttachmentSupportMessage, + getModelAttachmentCapabilities, + getUnsupportedAttachmentTypes, + isAttachmentSupported, + resolveAttachmentSupportStatus, +} from '@/services/AttachmentService'; diff --git a/apps/desktop/src/services/AgentService/infrastructure/attachments/types.ts b/apps/desktop/src/services/AgentService/infrastructure/attachments/types.ts index 75fb43f9..86e02c7d 100644 --- a/apps/desktop/src/services/AgentService/infrastructure/attachments/types.ts +++ b/apps/desktop/src/services/AgentService/infrastructure/attachments/types.ts @@ -2,22 +2,4 @@ * Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 */ -export type AttachmentSupportStatus = 'supported' | 'unsupported-image' | 'unsupported-file'; - -export interface AttachmentIndex { - id: string; - attachmentId?: number; - hash?: string; - type: 'image' | 'file'; - path: string; - originPath: string; - name: string; - size?: number; - preview?: string; - mimeType?: string; - supportStatus?: AttachmentSupportStatus; - /** - * 剪贴板 mixed payload 导入时,附件应插入到纯文本草稿的哪个字符位置。 - */ - draftInsertionOffset?: number; -} +export type { AttachmentIndex, AttachmentSupportStatus } from '@/contracts/attachments'; diff --git a/apps/desktop/src/services/AgentService/infrastructure/providers/ai-sdk/messages.ts b/apps/desktop/src/services/AgentService/infrastructure/providers/ai-sdk/messages.ts index 1c555e0c..cbefc0a5 100644 --- a/apps/desktop/src/services/AgentService/infrastructure/providers/ai-sdk/messages.ts +++ b/apps/desktop/src/services/AgentService/infrastructure/providers/ai-sdk/messages.ts @@ -14,6 +14,7 @@ import { type UserContent, } from 'ai'; +import type { AiToolDefinition } from '@/contracts/tooling'; import type { AiContentPart, AiMessage, @@ -21,7 +22,6 @@ import type { AttachmentDeliveryManifestRequest, AttachmentPromptMeta, } from '@/services/AgentService/contracts/protocol'; -import type { AiToolDefinition } from '@/services/AgentService/contracts/tooling'; import { type AttachmentDeliveryPlan, type AttachmentDeliveryPlanEntry, diff --git a/apps/desktop/src/services/AgentService/infrastructure/providers/ai-sdk/stream.ts b/apps/desktop/src/services/AgentService/infrastructure/providers/ai-sdk/stream.ts index 88ec8621..43a78363 100644 --- a/apps/desktop/src/services/AgentService/infrastructure/providers/ai-sdk/stream.ts +++ b/apps/desktop/src/services/AgentService/infrastructure/providers/ai-sdk/stream.ts @@ -2,8 +2,8 @@ import type { FinishReason, TextStreamPart, ToolSet } from 'ai'; +import type { AiToolCall } from '@/contracts/tooling'; import type { AiStreamChunk, JsonObject } from '@/services/AgentService/contracts/protocol'; -import type { AiToolCall } from '@/services/AgentService/contracts/tooling'; import { normalizeToolName, toJsonObjectRecord } from './utils'; diff --git a/apps/desktop/src/services/AgentService/infrastructure/providers/ai-sdk/tauriFetch.ts b/apps/desktop/src/services/AgentService/infrastructure/providers/ai-sdk/tauriFetch.ts index 57a26a38..835f8a5b 100644 --- a/apps/desktop/src/services/AgentService/infrastructure/providers/ai-sdk/tauriFetch.ts +++ b/apps/desktop/src/services/AgentService/infrastructure/providers/ai-sdk/tauriFetch.ts @@ -1,72 +1,3 @@ // Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 -import { fetch as tauriFetch } from '@tauri-apps/plugin-http'; - -/** - * 包装 ReadableStream,使 cancel 操作幂等 - * Tauri HTTP 插件的 fetch_cancel_body 在资源已释放后再次调用会抛出 - * "The resource id xxx is invalid" 错误,这里吞掉重复 cancel 的异常 - */ -function wrapBodyStream(body: ReadableStream): ReadableStream { - let cancelled = false; - let closed = false; - return new ReadableStream({ - start(controller) { - const reader = body.getReader(); - function pump(): void { - reader.read().then( - ({ done, value }) => { - if (cancelled || closed) { - return; - } - if (done) { - closed = true; - controller.close(); - return; - } - try { - controller.enqueue(value); - pump(); - } catch (err) { - // 流已关闭,忽略 enqueue 错误 - if (!closed) { - controller.error(err); - } - } - }, - (err) => { - if (!closed) { - controller.error(err); - } - } - ); - } - pump(); - }, - cancel(reason) { - if (cancelled) return; - cancelled = true; - closed = true; - return body.cancel(reason).catch(() => { - // 忽略重复 cancel 导致的 resource id invalid 错误 - }); - }, - }); -} - -export function createTauriFetch(): typeof fetch { - return (async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const url = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - const response = await tauriFetch(url, init); - - if (!response.body) return response; - - const wrappedBody = wrapBodyStream(response.body); - return new Response(wrappedBody, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); - }) as typeof fetch; -} +export { createTauriFetch } from '@/services/HttpService'; diff --git a/apps/desktop/src/services/AgentService/prompt/transport.ts b/apps/desktop/src/services/AgentService/prompt/transport.ts index 112db51e..1c11de67 100644 --- a/apps/desktop/src/services/AgentService/prompt/transport.ts +++ b/apps/desktop/src/services/AgentService/prompt/transport.ts @@ -4,7 +4,7 @@ import { type AttachmentCapabilities, type AttachmentIndex, getUnsupportedAttachmentTypes, -} from '@/services/AgentService/infrastructure/attachments'; +} from '@/services/AttachmentService'; import type { AiContentPart, AiMessage } from '../contracts/protocol'; import { buildAttachmentParts } from '../infrastructure/attachments'; diff --git a/apps/desktop/src/services/AgentService/prompt/types.ts b/apps/desktop/src/services/AgentService/prompt/types.ts index fbb52451..f2d8fe9c 100644 --- a/apps/desktop/src/services/AgentService/prompt/types.ts +++ b/apps/desktop/src/services/AgentService/prompt/types.ts @@ -2,7 +2,7 @@ import type { JSONContent } from '@tiptap/core'; -import type { AttachmentIndex } from '@/services/AgentService/infrastructure/attachments'; +import type { AttachmentIndex } from '@/contracts/attachments'; import type { AttachmentDerivedKind, AttachmentSemanticIntent } from '../contracts/protocol'; import type { ModelLanguageContext } from '../languageContext'; diff --git a/apps/desktop/src/services/AgentService/session/history.ts b/apps/desktop/src/services/AgentService/session/history.ts index 770cab25..443fc686 100644 --- a/apps/desktop/src/services/AgentService/session/history.ts +++ b/apps/desktop/src/services/AgentService/session/history.ts @@ -7,7 +7,7 @@ import type { SessionTurnHistoryRow } from '@database/queries/sessionTurns'; import type { PersistedToolLogStatus } from '@database/schema'; import { tt } from '@/i18n'; -import { hydratePersistedAttachments } from '@/services/AgentService/infrastructure/attachments'; +import { hydratePersistedAttachments } from '@/services/AttachmentService'; import { buildBuiltInToolConversationPresentation, resolveBuiltInToolConversationSemantic, diff --git a/apps/desktop/src/services/AgentService/task/types.ts b/apps/desktop/src/services/AgentService/task/types.ts index f1565c8c..4aab512c 100644 --- a/apps/desktop/src/services/AgentService/task/types.ts +++ b/apps/desktop/src/services/AgentService/task/types.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 -import type { AttachmentIndex } from '@/services/AgentService/infrastructure/attachments'; +import type { AttachmentIndex } from '@/contracts/attachments'; import type { InputHistorySnapshot, PendingToolApproval, SessionMessage } from '@/types/session'; import type { AskUserQuestion } from '../contracts/tooling'; diff --git a/apps/desktop/src/services/AttachmentService/index.ts b/apps/desktop/src/services/AttachmentService/index.ts new file mode 100644 index 00000000..dd30e6a3 --- /dev/null +++ b/apps/desktop/src/services/AttachmentService/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + */ + +export { + createAttachment, + createPersistedAttachmentFromData, + ensurePersistedAttachmentIndex, + hydratePersistedAttachments, +} from './storage'; +export { + type AttachmentCapabilities, + type AttachmentType, + getAttachmentSupportMessage, + getModelAttachmentCapabilities, + getUnsupportedAttachmentTypes, + isAttachmentSupported, + resolveAttachmentSupportStatus, +} from './support'; +export type { + AttachmentIndex, + AttachmentSupportStatus, + AttachmentIndex as Index, +} from '@/contracts/attachments'; diff --git a/apps/desktop/src/services/AttachmentService/storage.ts b/apps/desktop/src/services/AttachmentService/storage.ts new file mode 100644 index 00000000..17ce1958 --- /dev/null +++ b/apps/desktop/src/services/AttachmentService/storage.ts @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + */ + +import { type DatabaseExecutor, db } from '@database'; +import { + createAttachmentRecord, + findAttachmentByHash, + type MessageAttachmentRow, +} from '@database/queries'; +import type { AttachmentEntity } from '@database/types'; +import { native } from '@services/NativeService'; +import { convertFileSrc } from '@tauri-apps/api/core'; +import { join } from '@tauri-apps/api/path'; +import { copyFile, exists, mkdir, writeFile } from '@tauri-apps/plugin-fs'; +import { + fullName as getFileName, + icon as getFileIcon, + size as getFileSize, +} from 'tauri-plugin-fs-pro-api'; + +import type { AttachmentIndex } from '@/contracts/attachments'; + +const imageMimeMap: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp', + bmp: 'image/bmp', + svg: 'image/svg+xml', + avif: 'image/avif', +}; + +function normalizeExtension(extension: string | null | undefined): string { + return (extension || '').replace('.', '').trim().toLowerCase(); +} + +async function resolveMimeType( + type: AttachmentIndex['type'], + path: string +): Promise { + if (type !== 'image') return undefined; + const extension = normalizeExtension(path.split('.').pop()); + return imageMimeMap[extension] || 'image/png'; +} + +async function buildPreview( + type: AttachmentIndex['type'], + path: string +): Promise { + if (type === 'image') { + return convertFileSrc(path); + } + + const iconPath = await getFileIcon(path, { size: 256 }); + return convertFileSrc(iconPath); +} + +async function computeAttachmentHash(path: string): Promise { + const response = await fetch(convertFileSrc(path)); + if (!response.ok) { + throw new Error(`Failed to read attachment: ${response.statusText}`); + } + + const buffer = await response.arrayBuffer(); + const digest = await crypto.subtle.digest('SHA-256', buffer); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join( + '' + ); +} + +async function computeAttachmentHashFromBytes(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest('SHA-256', bytes.slice().buffer); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join( + '' + ); +} + +let cachedCacheDirectory: string | null = null; + +async function ensureCacheDirectory(): Promise { + if (cachedCacheDirectory) { + return cachedCacheDirectory; + } + + const cachePath = await native.paths.getAppDirectoryPath('CACHE'); + const attachmentsPath = await join(cachePath, 'attachments'); + await mkdir(attachmentsPath, { recursive: true }); + cachedCacheDirectory = attachmentsPath; + return attachmentsPath; +} + +function getAttachmentBucket(type: AttachmentIndex['type']): 'images' | 'files' { + return type === 'image' ? 'images' : 'files'; +} + +/** + * 按固定规则推导附件缓存路径,避免把纯派生值冗余存进数据库。 + * + * 目录结构固定为: + * - cache/attachments/images// + * - cache/attachments/files// + * + * @param type 附件类型。 + * @param hash 附件内容哈希。 + * @returns 缓存副本的绝对路径。 + */ +async function buildAttachmentStoragePath( + type: AttachmentIndex['type'], + hash: string +): Promise { + const cacheDir = await ensureCacheDirectory(); + const bucketDir = await join(cacheDir, getAttachmentBucket(type)); + const shardDir = await join(bucketDir, hash.slice(0, 3)); + await mkdir(shardDir, { recursive: true }); + return join(shardDir, hash); +} + +async function ensureAttachmentRecord( + type: AttachmentIndex['type'], + path: string, + originPath: string, + database: DatabaseExecutor = db +): Promise { + const [hash, name, mimeType, size] = await Promise.all([ + computeAttachmentHash(path), + getFileName(path), + resolveMimeType(type, path), + getFileSize(path), + ]); + + const existing = await findAttachmentByHash(hash, database); + if (existing) { + return existing; + } + + const targetPath = await buildAttachmentStoragePath(type, hash); + await copyFile(path, targetPath); + + try { + return await createAttachmentRecord( + { + hash, + type, + original_name: name, + origin_path: originPath, + mime_type: mimeType ?? null, + size, + }, + database + ); + } catch (error) { + const duplicated = await findAttachmentByHash(hash, database); + if (duplicated) { + return duplicated; + } + + throw error; + } +} + +async function ensureAttachmentRecordFromBytes( + options: { + type: AttachmentIndex['type']; + name: string; + originPath: string; + mimeType?: string; + size?: number; + data: Uint8Array; + }, + database: DatabaseExecutor = db +): Promise { + const hash = await computeAttachmentHashFromBytes(options.data); + const targetPath = await buildAttachmentStoragePath(options.type, hash); + + if (!(await exists(targetPath))) { + await writeFile(targetPath, options.data); + } + + const existing = await findAttachmentByHash(hash, database); + if (existing) { + return existing; + } + + try { + return await createAttachmentRecord( + { + hash, + type: options.type, + original_name: options.name, + origin_path: options.originPath, + mime_type: options.mimeType ?? null, + size: options.size ?? options.data.byteLength, + }, + database + ); + } catch (error) { + const duplicated = await findAttachmentByHash(hash, database); + if (duplicated) { + return duplicated; + } + + throw error; + } +} + +async function toAttachmentIndex( + attachment: AttachmentEntity, + overrides: { + originPath?: string; + name?: string; + size?: number; + mimeType?: string; + } = {} +): Promise { + const storagePath = await buildAttachmentStoragePath(attachment.type, attachment.hash); + return { + id: crypto.randomUUID(), + attachmentId: attachment.id, + hash: attachment.hash, + type: attachment.type, + path: storagePath, + originPath: overrides.originPath ?? attachment.origin_path, + name: overrides.name ?? attachment.original_name, + size: overrides.size ?? attachment.size ?? undefined, + preview: await buildPreview(attachment.type, storagePath), + mimeType: overrides.mimeType ?? attachment.mime_type ?? undefined, + supportStatus: 'supported', + }; +} + +/** + * 基于原始文件创建草稿附件引用。 + * + * 草稿阶段只保留发送前展示所需的最小元数据,不立即复制到缓存目录, + * 避免用户临时粘贴但最终未发送的附件也落入长期缓存。 + * + * @param type 附件类型。 + * @param path 原始文件路径。 + * @returns 指向原始文件的草稿附件引用。 + */ +export async function createAttachment( + type: 'image' | 'file', + path: string +): Promise { + const [name, mimeType, size, preview] = await Promise.all([ + getFileName(path), + resolveMimeType(type, path), + getFileSize(path), + buildPreview(type, path), + ]); + + return { + id: crypto.randomUUID(), + type, + path, + originPath: path, + name, + size, + preview, + mimeType, + supportStatus: 'supported', + }; +} + +/** + * 基于已有字节内容直接创建并持久化附件。 + * + * 主要用于工具结果里的媒体内容:它们没有“原始本地文件”, + * 但仍应复用统一附件缓存、去重和后续 transport 逻辑。 + */ +export async function createPersistedAttachmentFromData( + options: { + type: 'image' | 'file'; + name: string; + originPath: string; + mimeType?: string; + size?: number; + data: Uint8Array; + }, + database: DatabaseExecutor = db +): Promise { + const persisted = await ensureAttachmentRecordFromBytes(options, database); + return toAttachmentIndex(persisted, { + // 字节型附件可能命中去重记录,但当前调用方的来源路径/名称 + // 仍然属于这一次消息语义,不能被旧记录回灌覆盖。 + originPath: options.originPath, + name: options.name, + size: options.size ?? options.data.byteLength, + mimeType: options.mimeType ?? persisted.mime_type ?? undefined, + }); +} + +/** + * 将数据库附件记录恢复成前端附件引用。 + * + * @param attachments 持久化层返回的附件行。 + * @returns 可直接挂到消息上的附件列表。 + */ +export async function hydratePersistedAttachments( + attachments: MessageAttachmentRow[] +): Promise { + return Promise.all( + attachments.map((attachment) => + toAttachmentIndex({ + id: attachment.id, + hash: attachment.hash, + type: attachment.type, + original_name: attachment.original_name, + origin_path: attachment.origin_path, + mime_type: attachment.mime_type, + size: attachment.size, + created_at: attachment.created_at, + }) + ) + ); +} + +/** + * 确保持久化层可识别当前附件引用。 + * + * 草稿附件在真正发送前不会写入缓存;只有请求开始持久化时, + * 才会复制到 `cache/attachments` 并创建数据库记录。 + * 历史恢复的附件则已经带上 `attachmentId/hash`,可直接复用。 + * + * @param attachment 前端附件引用。 + * @returns 可写入 message_attachments 的附件记录。 + */ +export async function ensurePersistedAttachmentIndex( + attachment: AttachmentIndex, + database: DatabaseExecutor = db +): Promise { + if (attachment.attachmentId && attachment.hash) { + return { + id: attachment.attachmentId, + hash: attachment.hash, + type: attachment.type, + original_name: attachment.name, + origin_path: attachment.originPath, + mime_type: attachment.mimeType ?? null, + size: attachment.size ?? null, + created_at: new Date().toISOString(), + }; + } + + const persisted = await ensureAttachmentRecord( + attachment.type, + attachment.path, + attachment.originPath, + database + ); + attachment.attachmentId = persisted.id; + attachment.hash = persisted.hash; + attachment.path = await buildAttachmentStoragePath(persisted.type, persisted.hash); + attachment.name = persisted.original_name; + attachment.size = persisted.size ?? undefined; + attachment.mimeType = persisted.mime_type ?? undefined; + attachment.preview = await buildPreview(persisted.type, attachment.path); + + return persisted; +} diff --git a/apps/desktop/src/services/AttachmentService/support.ts b/apps/desktop/src/services/AttachmentService/support.ts new file mode 100644 index 00000000..d87006a5 --- /dev/null +++ b/apps/desktop/src/services/AttachmentService/support.ts @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + */ + +import type { AttachmentIndex, AttachmentSupportStatus } from '@/contracts/attachments'; +import { tt } from '@/i18n'; +import { parseModelModalities } from '@/utils/modelSchemas'; + +export interface AttachmentCapabilities { + supportsImages: boolean; + supportsFiles: boolean; +} + +export type AttachmentType = AttachmentIndex['type']; + +export function getModelAttachmentCapabilities(model: { + modalities?: string | null; + attachment?: number | null; +}): AttachmentCapabilities { + const modalities = parseModelModalities(model.modalities); + return { + supportsImages: Boolean(modalities.input?.includes('image')), + supportsFiles: model.attachment === 1, + }; +} + +export function getUnsupportedAttachmentTypes( + attachments: Pick[], + capabilities: AttachmentCapabilities +): AttachmentType[] { + const unsupportedTypes = new Set(); + + for (const attachment of attachments) { + if (attachment.type === 'image' && !capabilities.supportsImages) { + unsupportedTypes.add('image'); + } + if (attachment.type === 'file' && !capabilities.supportsFiles) { + unsupportedTypes.add('file'); + } + } + + return Array.from(unsupportedTypes); +} + +/** + * 根据文件类型和模型能力计算附件支持状态。 + * + * @param fileType 附件类型('image' | 'file')。 + * @param capabilities 当前模型的能力标志。 + * @returns 'supported' | 'unsupported-image' | 'unsupported-file'。 + */ +export function resolveAttachmentSupportStatus( + fileType: 'image' | 'file', + capabilities: AttachmentCapabilities +): AttachmentSupportStatus { + if (fileType === 'image' && !capabilities.supportsImages) return 'unsupported-image'; + if (fileType === 'file' && !capabilities.supportsFiles) return 'unsupported-file'; + return 'supported'; +} + +export function isAttachmentSupported(attachment: AttachmentIndex): boolean { + return ( + attachment.supportStatus !== 'unsupported-image' && + attachment.supportStatus !== 'unsupported-file' + ); +} + +export function getAttachmentSupportMessage(attachment: AttachmentIndex): string | null { + if (attachment.supportStatus === 'unsupported-image') { + return tt('该模型不支持图片'); + } + if (attachment.supportStatus === 'unsupported-file') { + return tt('该模型不支持文件'); + } + return null; +} diff --git a/apps/desktop/src/services/BuiltInToolService/service.ts b/apps/desktop/src/services/BuiltInToolService/service.ts index 0d0a0995..41abac54 100644 --- a/apps/desktop/src/services/BuiltInToolService/service.ts +++ b/apps/desktop/src/services/BuiltInToolService/service.ts @@ -10,8 +10,7 @@ import { import type { ModelWithProvider } from '@database/queries/models'; import type { ToolLogKind } from '@database/schema'; -import { tt } from '@/i18n'; -import { AiError, AiErrorCode } from '@/services/AgentService/contracts/errors'; +import { AiError, AiErrorCode } from '@/contracts/agentErrors'; import type { AiToolCall, AiToolDefinition, @@ -20,7 +19,8 @@ import type { ToolApprovalDecisionRequest, ToolApprovalRequest, ToolEvent, -} from '@/services/AgentService/contracts/tooling'; +} from '@/contracts/tooling'; +import { tt } from '@/i18n'; import { builtInToolRegistry } from './registry'; import type { diff --git a/apps/desktop/src/services/BuiltInToolService/tools/askUser/constants.ts b/apps/desktop/src/services/BuiltInToolService/tools/askUser/constants.ts index d6495826..d5ef67ec 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/askUser/constants.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/askUser/constants.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 -import type { AiToolDefinition } from '@/services/AgentService/contracts/tooling'; +import type { AiToolDefinition } from '@/contracts/tooling'; export const ASK_USER_TOOL_NAME = 'ask_user_question'; diff --git a/apps/desktop/src/services/BuiltInToolService/tools/askUser/index.ts b/apps/desktop/src/services/BuiltInToolService/tools/askUser/index.ts index 548dd1a6..8c5a4cde 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/askUser/index.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/askUser/index.ts @@ -1,8 +1,8 @@ // Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 +import { AiError, AiErrorCode } from '@/contracts/agentErrors'; +import type { AskUserAnswer, AskUserQuestion } from '@/contracts/tooling'; import { t } from '@/i18n'; -import { AiError, AiErrorCode } from '@/services/AgentService/contracts/errors'; -import type { AskUserAnswer, AskUserQuestion } from '@/services/AgentService/contracts/tooling'; import { type BaseBuiltInToolExecutionContext, diff --git a/apps/desktop/src/services/BuiltInToolService/tools/bash/constants.ts b/apps/desktop/src/services/BuiltInToolService/tools/bash/constants.ts index 46826b72..f7079bf1 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/bash/constants.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/bash/constants.ts @@ -2,7 +2,7 @@ import type { BuiltInBashExecutionResponse } from '@services/NativeService'; -import type { AiToolDefinition } from '@/services/AgentService/contracts/tooling'; +import type { AiToolDefinition } from '@/contracts/tooling'; import { nonEmptyTrimmedStringSchema, diff --git a/apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts b/apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts index 8eae42d3..f0efaa2b 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts @@ -2,9 +2,9 @@ import { native } from '@services/NativeService'; +import { AiError, AiErrorCode } from '@/contracts/agentErrors'; +import type { ToolApprovalRequest } from '@/contracts/tooling'; import { t, tt } from '@/i18n'; -import { AiError, AiErrorCode } from '@/services/AgentService/contracts/errors'; -import type { ToolApprovalRequest } from '@/services/AgentService/contracts/tooling'; import { normalizeOptionalString, truncateText } from '@/utils/text'; import { diff --git a/apps/desktop/src/services/BuiltInToolService/tools/fileSearch/constants.ts b/apps/desktop/src/services/BuiltInToolService/tools/fileSearch/constants.ts index ccaecdc9..f89e7c1e 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/fileSearch/constants.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/fileSearch/constants.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026. 千诚. Licensed under GPL v3 -import type { AiToolDefinition } from '@/services/AgentService/contracts/tooling'; +import type { AiToolDefinition } from '@/contracts/tooling'; import { arrayFromScalarSchema, diff --git a/apps/desktop/src/services/BuiltInToolService/tools/read/constants.ts b/apps/desktop/src/services/BuiltInToolService/tools/read/constants.ts index 44bc2490..3811c360 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/read/constants.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/read/constants.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026. 千诚. Licensed under GPL v3 -import type { AiToolDefinition } from '@/services/AgentService/contracts/tooling'; +import type { AiToolDefinition } from '@/contracts/tooling'; import { nonEmptyTrimmedStringSchema, diff --git a/apps/desktop/src/services/BuiltInToolService/tools/read/helper.ts b/apps/desktop/src/services/BuiltInToolService/tools/read/helper.ts index bdfa4c80..d5abf7bc 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/read/helper.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/read/helper.ts @@ -9,10 +9,10 @@ import { } from '@tauri-apps/api/path'; import { type DirEntry, open, readDir, readTextFileLines, stat } from '@tauri-apps/plugin-fs'; +import type { AttachmentIndex } from '@/contracts/attachments'; +import type { ToolApprovalRequest } from '@/contracts/tooling'; import { t, tt } from '@/i18n'; -import type { ToolApprovalRequest } from '@/services/AgentService/contracts/tooling'; -import type { AttachmentIndex } from '@/services/AgentService/infrastructure/attachments'; -import { createAttachment } from '@/services/AgentService/infrastructure/attachments'; +import { createAttachment } from '@/services/AttachmentService'; import { normalizeOptionalString, truncateText } from '@/utils/text'; import type { BaseBuiltInToolExecutionContext, BuiltInToolConversationSemantic } from '../../types'; diff --git a/apps/desktop/src/services/BuiltInToolService/tools/setting/constants.ts b/apps/desktop/src/services/BuiltInToolService/tools/setting/constants.ts index 8370c6e8..ee4e39f9 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/setting/constants.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/setting/constants.ts @@ -1,9 +1,9 @@ // Copyright (c) 2026. 千诚. Licensed under GPL v3 import { SearchWindowSizePreset as SearchWindowSizePresets } from '@/config/searchWindow'; +import type { AiToolDefinition } from '@/contracts/tooling'; import { SUPPORTED_LOCALES } from '@/i18n/locales'; import type { SourceText } from '@/i18n/textMap'; -import type { AiToolDefinition } from '@/services/AgentService/contracts/tooling'; import { arrayFromScalarSchema, nonEmptyTrimmedStringSchema, z } from '../../utils/toolSchema'; diff --git a/apps/desktop/src/services/BuiltInToolService/tools/setting/index.ts b/apps/desktop/src/services/BuiltInToolService/tools/setting/index.ts index bd60fb60..aa3b0c26 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/setting/index.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/setting/index.ts @@ -3,8 +3,8 @@ import { native } from '@services/NativeService'; import { resolveSearchWindowDefaultSize } from '@/config/searchWindow'; +import type { ToolApprovalRequest } from '@/contracts/tooling'; import { tt } from '@/i18n'; -import type { ToolApprovalRequest } from '@/services/AgentService/contracts/tooling'; import type { GeneralSettingsData } from '@/stores/settings'; import { truncateText } from '@/utils/text'; diff --git a/apps/desktop/src/services/BuiltInToolService/tools/upgradeModel/constants.ts b/apps/desktop/src/services/BuiltInToolService/tools/upgradeModel/constants.ts index e1750725..75b91395 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/upgradeModel/constants.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/upgradeModel/constants.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026. 千诚. Licensed under GPL v3 -import type { AiToolDefinition } from '@/services/AgentService/contracts/tooling'; +import type { AiToolDefinition } from '@/contracts/tooling'; import { z } from '../../utils/toolSchema'; diff --git a/apps/desktop/src/services/BuiltInToolService/tools/upgradeModel/index.ts b/apps/desktop/src/services/BuiltInToolService/tools/upgradeModel/index.ts index 3c38ec80..e06dcddf 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/upgradeModel/index.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/upgradeModel/index.ts @@ -3,8 +3,8 @@ import { findModelByProviderAndModelId } from '@database/queries'; import type { ModelWithProvider } from '@database/queries/models'; +import type { ToolApprovalRequest } from '@/contracts/tooling'; import { tt } from '@/i18n'; -import type { ToolApprovalRequest } from '@/services/AgentService/contracts/tooling'; import { type BaseBuiltInToolExecutionContext, diff --git a/apps/desktop/src/services/BuiltInToolService/tools/webFetch/constants.ts b/apps/desktop/src/services/BuiltInToolService/tools/webFetch/constants.ts index 6aaad5ac..dabe24cf 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/webFetch/constants.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/webFetch/constants.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026. 千诚. Licensed under GPL v3 -import type { AiToolDefinition } from '@/services/AgentService/contracts/tooling'; +import type { AiToolDefinition } from '@/contracts/tooling'; import { integerInRangeSchema, diff --git a/apps/desktop/src/services/BuiltInToolService/tools/webFetch/index.ts b/apps/desktop/src/services/BuiltInToolService/tools/webFetch/index.ts index 8136b162..0dcb845c 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/webFetch/index.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/webFetch/index.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026. 千诚. Licensed under GPL v3 import { t, tt } from '@/i18n'; -import { createTauriFetch } from '@/services/AgentService/infrastructure/providers'; +import { createTauriFetch } from '@/services/HttpService'; import { normalizeOptionalString, truncateText } from '@/utils/text'; import { diff --git a/apps/desktop/src/services/BuiltInToolService/tools/widgetTool/showWidget/constants.ts b/apps/desktop/src/services/BuiltInToolService/tools/widgetTool/showWidget/constants.ts index c7d6098c..66ba81a0 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/widgetTool/showWidget/constants.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/widgetTool/showWidget/constants.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026. 千诚. Licensed under GPL v3 -import type { AiToolDefinition } from '@/services/AgentService/contracts/tooling'; +import type { AiToolDefinition } from '@/contracts/tooling'; import { optionalTrimmedStringSchema, z } from '../../../utils/toolSchema'; diff --git a/apps/desktop/src/services/BuiltInToolService/tools/widgetTool/showWidget/helper.ts b/apps/desktop/src/services/BuiltInToolService/tools/widgetTool/showWidget/helper.ts index 88aa81df..ee039916 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/widgetTool/showWidget/helper.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/widgetTool/showWidget/helper.ts @@ -1,10 +1,7 @@ // Copyright (c) 2026. 千诚. Licensed under GPL v3 +import type { ShowWidgetEventPayload, ShowWidgetMode } from '@/contracts/tooling'; import { tt } from '@/i18n'; -import type { - ShowWidgetEventPayload, - ShowWidgetMode, -} from '@/services/AgentService/contracts/tooling'; import { normalizeString } from '@/utils/text'; import type { BaseBuiltInToolExecutionContext } from '../../../types'; diff --git a/apps/desktop/src/services/BuiltInToolService/tools/widgetTool/showWidget/index.ts b/apps/desktop/src/services/BuiltInToolService/tools/widgetTool/showWidget/index.ts index b626d604..23b36ef6 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/widgetTool/showWidget/index.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/widgetTool/showWidget/index.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026. 千诚. Licensed under GPL v3 +import type { ShowWidgetEventPayload } from '@/contracts/tooling'; import { tt } from '@/i18n'; -import type { ShowWidgetEventPayload } from '@/services/AgentService/contracts/tooling'; import { normalizeOptionalString, truncateText } from '@/utils/text'; import { diff --git a/apps/desktop/src/services/BuiltInToolService/tools/widgetTool/visualizeReadMe/constants.ts b/apps/desktop/src/services/BuiltInToolService/tools/widgetTool/visualizeReadMe/constants.ts index ce07b402..85817a64 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/widgetTool/visualizeReadMe/constants.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/widgetTool/visualizeReadMe/constants.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026. 千诚. Licensed under GPL v3 -import type { AiToolDefinition } from '@/services/AgentService/contracts/tooling'; +import type { AiToolDefinition } from '@/contracts/tooling'; import { z } from '../../../utils/toolSchema'; diff --git a/apps/desktop/src/services/BuiltInToolService/types.ts b/apps/desktop/src/services/BuiltInToolService/types.ts index 68f1572e..e3b3e72b 100644 --- a/apps/desktop/src/services/BuiltInToolService/types.ts +++ b/apps/desktop/src/services/BuiltInToolService/types.ts @@ -3,6 +3,7 @@ import type { ModelWithProvider } from '@database/queries/models'; import type { BuiltInToolEntity } from '@database/types'; +import type { AttachmentIndex } from '@/contracts/attachments'; import type { AiToolDefinition, AskUserAnswer, @@ -11,8 +12,7 @@ import type { ToolEvent, ToolEventBuiltInConversationSemantic, ToolEventBuiltInConversationSemanticAction, -} from '@/services/AgentService/contracts/tooling'; -import type { AttachmentIndex } from '@/services/AgentService/infrastructure/attachments'; +} from '@/contracts/tooling'; /** * 当前内置工具体系允许暴露给模型的稳定工具标识。 diff --git a/apps/desktop/src/services/HttpService/index.ts b/apps/desktop/src/services/HttpService/index.ts new file mode 100644 index 00000000..8cd54d36 --- /dev/null +++ b/apps/desktop/src/services/HttpService/index.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +export { createTauriFetch } from './tauriFetch'; diff --git a/apps/desktop/src/services/HttpService/tauriFetch.ts b/apps/desktop/src/services/HttpService/tauriFetch.ts new file mode 100644 index 00000000..57a26a38 --- /dev/null +++ b/apps/desktop/src/services/HttpService/tauriFetch.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +import { fetch as tauriFetch } from '@tauri-apps/plugin-http'; + +/** + * 包装 ReadableStream,使 cancel 操作幂等 + * Tauri HTTP 插件的 fetch_cancel_body 在资源已释放后再次调用会抛出 + * "The resource id xxx is invalid" 错误,这里吞掉重复 cancel 的异常 + */ +function wrapBodyStream(body: ReadableStream): ReadableStream { + let cancelled = false; + let closed = false; + return new ReadableStream({ + start(controller) { + const reader = body.getReader(); + function pump(): void { + reader.read().then( + ({ done, value }) => { + if (cancelled || closed) { + return; + } + if (done) { + closed = true; + controller.close(); + return; + } + try { + controller.enqueue(value); + pump(); + } catch (err) { + // 流已关闭,忽略 enqueue 错误 + if (!closed) { + controller.error(err); + } + } + }, + (err) => { + if (!closed) { + controller.error(err); + } + } + ); + } + pump(); + }, + cancel(reason) { + if (cancelled) return; + cancelled = true; + closed = true; + return body.cancel(reason).catch(() => { + // 忽略重复 cancel 导致的 resource id invalid 错误 + }); + }, + }); +} + +export function createTauriFetch(): typeof fetch { + return (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + const response = await tauriFetch(url, init); + + if (!response.body) return response; + + const wrappedBody = wrapBodyStream(response.body); + return new Response(wrappedBody, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + }) as typeof fetch; +} diff --git a/apps/desktop/src/stores/askUser.ts b/apps/desktop/src/stores/askUser.ts index 7df04271..b2104861 100644 --- a/apps/desktop/src/stores/askUser.ts +++ b/apps/desktop/src/stores/askUser.ts @@ -7,7 +7,7 @@ import type { AskUserAnswer, AskUserQuestion, ToolApprovalDecisionRequest, -} from '@/services/AgentService/contracts/tooling'; +} from '@/contracts/tooling'; export type AskUserKind = 'approval' | 'confirm' | 'question'; diff --git a/apps/desktop/src/types/session.ts b/apps/desktop/src/types/session.ts index 1b5361a8..60bee05b 100644 --- a/apps/desktop/src/types/session.ts +++ b/apps/desktop/src/types/session.ts @@ -2,8 +2,8 @@ import type { JSONContent } from '@tiptap/core'; -import type { ToolExecutionSource as AiToolExecutionSource } from '@/services/AgentService/contracts/tooling'; -import type { Index } from '@/services/AgentService/infrastructure/attachments'; +import type { AttachmentIndex as Index } from '@/contracts/attachments'; +import type { ToolExecutionSource as AiToolExecutionSource } from '@/contracts/tooling'; import type { BuiltInToolConversationPresentation, BuiltInToolConversationSemantic, diff --git a/apps/desktop/src/views/SearchView/components/AskUser/AskUserPanel.vue b/apps/desktop/src/views/SearchView/components/AskUser/AskUserPanel.vue index 699037a4..2affd471 100644 --- a/apps/desktop/src/views/SearchView/components/AskUser/AskUserPanel.vue +++ b/apps/desktop/src/views/SearchView/components/AskUser/AskUserPanel.vue @@ -35,8 +35,8 @@ import { AnimatePresence, motion } from 'motion-v'; import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'; + import type { AskUserAnswer as StoreAnswer } from '@/contracts/tooling'; import { t } from '@/i18n'; - import type { AskUserAnswer as StoreAnswer } from '@/services/AgentService/contracts/tooling'; import { type AskUserApprovalRequest, type AskUserConfirmRequest, diff --git a/apps/desktop/src/views/SearchView/components/SearchBar/index.vue b/apps/desktop/src/views/SearchView/components/SearchBar/index.vue index 325d5ef7..dfafc41f 100644 --- a/apps/desktop/src/views/SearchView/components/SearchBar/index.vue +++ b/apps/desktop/src/views/SearchView/components/SearchBar/index.vue @@ -58,7 +58,7 @@ import type { ComponentPublicInstance } from 'vue'; import { onMounted, onUnmounted, ref, toRefs, watch } from 'vue'; - import type { Index } from '@/services/AgentService/infrastructure/attachments'; + import type { AttachmentIndex as Index } from '@/contracts/attachments'; import { type ModelCapabilities, useSearchInput } from './composables/useSearchLogic'; import { insertAttachmentTag } from './tags/attachment'; diff --git a/apps/desktop/src/views/SearchView/components/SearchBar/tags/attachment/index.ts b/apps/desktop/src/views/SearchView/components/SearchBar/tags/attachment/index.ts index f62b30b4..2f3b5431 100644 --- a/apps/desktop/src/views/SearchView/components/SearchBar/tags/attachment/index.ts +++ b/apps/desktop/src/views/SearchView/components/SearchBar/tags/attachment/index.ts @@ -12,7 +12,7 @@ import { TextSelection } from '@tiptap/pm/state'; import { VueNodeViewRenderer } from '@tiptap/vue-3'; import type { Component } from 'vue'; -import { type AttachmentSupportStatus } from '@/services/AgentService/infrastructure/attachments'; +import { type AttachmentSupportStatus } from '@/contracts/attachments'; import { createSearchTagNode, createTagCloseButton } from '../factory'; import { isRegisteredTagNode, registerSearchTag } from '../registry'; diff --git a/apps/desktop/src/views/SearchView/composables/useSearchInput.ts b/apps/desktop/src/views/SearchView/composables/useSearchInput.ts index af703412..5def96ff 100644 --- a/apps/desktop/src/views/SearchView/composables/useSearchInput.ts +++ b/apps/desktop/src/views/SearchView/composables/useSearchInput.ts @@ -10,7 +10,7 @@ import { createAttachment, type Index, isAttachmentSupported, -} from '@/services/AgentService/infrastructure/attachments'; +} from '@/services/AttachmentService'; import { clipboardService } from '@/services/ClipboardService'; import type { ClipboardPayload } from '@/services/NativeService/types'; diff --git a/apps/desktop/src/views/SearchView/composables/useSearchRequest.ts b/apps/desktop/src/views/SearchView/composables/useSearchRequest.ts index 36e54f3e..fb2eb790 100644 --- a/apps/desktop/src/views/SearchView/composables/useSearchRequest.ts +++ b/apps/desktop/src/views/SearchView/composables/useSearchRequest.ts @@ -1,4 +1,4 @@ -/** +/** * SearchView 请求层。 * 收口请求提交、排队与会话续发逻辑,让输入层与页面层保持解耦。 */ @@ -7,11 +7,8 @@ import type { SessionEntity } from '@database/types'; import { notify } from '@services/NotificationService'; import { onUnmounted, type Ref, ref } from 'vue'; -import { - type Index, - isAttachmentSupported, -} from '@/services/AgentService/infrastructure/attachments'; import { dismissSessionTerminalStatus, listSessions } from '@/services/AgentService/session'; +import { type Index, isAttachmentSupported } from '@/services/AttachmentService'; import { eventService } from '@/services/EventService'; import { AppEvent } from '@/services/EventService/types'; import { diff --git a/apps/desktop/src/views/SearchView/types.ts b/apps/desktop/src/views/SearchView/types.ts index 9cb104f5..e6e407fb 100644 --- a/apps/desktop/src/views/SearchView/types.ts +++ b/apps/desktop/src/views/SearchView/types.ts @@ -1,6 +1,6 @@ -import type { ModelWithProvider } from '@database/queries/models'; +import type { ModelWithProvider } from '@database/queries/models'; -import type { Index } from '@/services/AgentService/infrastructure/attachments'; +import type { AttachmentIndex as Index } from '@/contracts/attachments'; import type { InputHistorySnapshot, SessionMessage } from '@/types/session'; import type { diff --git a/apps/desktop/tests/architecture/import-boundaries.test.ts b/apps/desktop/tests/architecture/import-boundaries.test.ts index 79204539..1394c40e 100644 --- a/apps/desktop/tests/architecture/import-boundaries.test.ts +++ b/apps/desktop/tests/architecture/import-boundaries.test.ts @@ -27,7 +27,6 @@ const importPattern = const allowedBaselineViolationFragments = [ 'database -> services/NativeService', 'services/AuthService -> services/AgentService', - 'services/BuiltInToolService -> services/AgentService', 'services/EventService -> services/PopupService', 'services/NativeService -> utils', 'services/PopupService -> views/PopupView', @@ -407,7 +406,9 @@ describe('architecture import boundaries', () => { ]; const retainedBaselineCycles = baselineCycles.filter((cycle) => - components.some((component) => cycle.every((moduleName) => component.includes(moduleName))) + components.some((component) => + cycle.every((moduleName) => component.includes(moduleName)) + ) ); expect(retainedBaselineCycles.filter((cycle) => !isAllowedBaselineCycle(cycle))).toEqual( diff --git a/apps/desktop/tests/components/SearchInput-i18n.test.ts b/apps/desktop/tests/components/SearchInput-i18n.test.ts index 3b7a4366..03f97c0c 100644 --- a/apps/desktop/tests/components/SearchInput-i18n.test.ts +++ b/apps/desktop/tests/components/SearchInput-i18n.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; import { ref } from 'vue'; +import type { AttachmentIndex as Index } from '@/contracts/attachments'; import { setLocale } from '@/i18n'; -import type { Index } from '@/services/AgentService/infrastructure/attachments'; import { useSearchAttachments } from '@/views/SearchView/composables/useSearchInput'; function createAttachmentIndex( diff --git a/apps/desktop/tests/composables/SearchView/useSearchInput.test.ts b/apps/desktop/tests/composables/SearchView/useSearchInput.test.ts index 10f61a56..71980c77 100644 --- a/apps/desktop/tests/composables/SearchView/useSearchInput.test.ts +++ b/apps/desktop/tests/composables/SearchView/useSearchInput.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ref } from 'vue'; -import type { Index } from '@/services/AgentService/infrastructure/attachments'; +import type { AttachmentIndex as Index } from '@/contracts/attachments'; import type { ClipboardPayload } from '@/services/NativeService/types'; const { createAttachmentMock, readExplicitPastePayloadMock } = vi.hoisted(() => ({ diff --git a/apps/desktop/tests/composables/agent/useAgent-i18n.test.ts b/apps/desktop/tests/composables/agent/useAgent-i18n.test.ts index 0a5a33d0..a898bf97 100644 --- a/apps/desktop/tests/composables/agent/useAgent-i18n.test.ts +++ b/apps/desktop/tests/composables/agent/useAgent-i18n.test.ts @@ -3,8 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { defineComponent } from 'vue'; import { useAgent } from '@/composables/agent/useAgent'; +import { AiError, AiErrorCode } from '@/contracts/agentErrors'; import { setLocale } from '@/i18n'; -import { AiError, AiErrorCode } from '@/services/AgentService/contracts/errors'; const { notifyMock, startTaskMock } = vi.hoisted(() => ({ notifyMock: vi.fn(), diff --git a/apps/desktop/tests/services/AgentService/attachment-capability-preflight.test.ts b/apps/desktop/tests/services/AgentService/attachment-capability-preflight.test.ts index 64bbccdc..18583abd 100644 --- a/apps/desktop/tests/services/AgentService/attachment-capability-preflight.test.ts +++ b/apps/desktop/tests/services/AgentService/attachment-capability-preflight.test.ts @@ -2,14 +2,14 @@ import type { MessageRow, ToolLogHistoryRow } from '@database/queries/messages'; import type { ModelWithProvider } from '@database/queries/models'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { AiErrorCode } from '@/services/AgentService/contracts/errors'; +import { AiErrorCode } from '@/contracts/agentErrors'; +import type { AttachmentIndex } from '@/contracts/attachments'; import { AiConversationRuntime } from '@/services/AgentService/execution/runtime'; -import type { AttachmentIndex } from '@/services/AgentService/infrastructure/attachments'; +import { findUnsupportedSessionAttachmentTypes } from '@/services/AgentService/session/transport'; import { getModelAttachmentCapabilities, getUnsupportedAttachmentTypes, -} from '@/services/AgentService/infrastructure/attachments'; -import { findUnsupportedSessionAttachmentTypes } from '@/services/AgentService/session/transport'; +} from '@/services/AttachmentService'; const BASE_TIME = '2026-06-03T10:00:00.000Z'; diff --git a/apps/desktop/tests/services/AgentService/attachments-i18n.test.ts b/apps/desktop/tests/services/AgentService/attachments-i18n.test.ts index bf9ea643..719bb7b3 100644 --- a/apps/desktop/tests/services/AgentService/attachments-i18n.test.ts +++ b/apps/desktop/tests/services/AgentService/attachments-i18n.test.ts @@ -1,12 +1,12 @@ import { beforeEach, describe, expect, it } from 'vitest'; +import type { AttachmentIndex } from '@/contracts/attachments'; import { setLocale } from '@/i18n'; -import type { AttachmentIndex } from '@/services/AgentService/infrastructure/attachments'; import { getAttachmentSupportMessage, isAttachmentSupported, resolveAttachmentSupportStatus, -} from '@/services/AgentService/infrastructure/attachments'; +} from '@/services/AttachmentService'; function attachment(supportStatus: AttachmentIndex['supportStatus']): AttachmentIndex { return { diff --git a/apps/desktop/tests/services/AgentService/contracts/errors-i18n.test.ts b/apps/desktop/tests/services/AgentService/contracts/errors-i18n.test.ts index 6705c4d6..2572addb 100644 --- a/apps/desktop/tests/services/AgentService/contracts/errors-i18n.test.ts +++ b/apps/desktop/tests/services/AgentService/contracts/errors-i18n.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; +import { AiError, AiErrorCode } from '@/contracts/agentErrors'; import { setLocale } from '@/i18n'; -import { AiError, AiErrorCode } from '@/services/AgentService/contracts/errors'; import { mapHttpStatusToAiError } from '@/services/AgentService/infrastructure/providers/ai-sdk/base'; describe('AiError display localization', () => { diff --git a/apps/desktop/tests/services/AgentService/execution/executor.test.ts b/apps/desktop/tests/services/AgentService/execution/executor.test.ts index 54c5ba1a..6ad81b54 100644 --- a/apps/desktop/tests/services/AgentService/execution/executor.test.ts +++ b/apps/desktop/tests/services/AgentService/execution/executor.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { AiError, AiErrorCode } from '@/services/AgentService/contracts/errors'; +import { AiError, AiErrorCode } from '@/contracts/agentErrors'; import { AiRequestExecutor } from '@/services/AgentService/execution'; import type { AttemptCheckpoint } from '@/services/AgentService/execution/executor'; diff --git a/apps/desktop/tests/services/AgentService/execution/retry.test.ts b/apps/desktop/tests/services/AgentService/execution/retry.test.ts index 0fbbadcf..cf142c85 100644 --- a/apps/desktop/tests/services/AgentService/execution/retry.test.ts +++ b/apps/desktop/tests/services/AgentService/execution/retry.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { AiError, AiErrorCode } from '@/services/AgentService/contracts/errors'; +import { AiError, AiErrorCode } from '@/contracts/agentErrors'; import { shouldRetryRequestFailure } from '@/services/AgentService/execution/retry'; describe('AgentService retry policy', () => { diff --git a/apps/desktop/tests/services/AgentService/infrastructure/providers/ai-sdk/messages.test.ts b/apps/desktop/tests/services/AgentService/infrastructure/providers/ai-sdk/messages.test.ts index e9583069..cfe46ae7 100644 --- a/apps/desktop/tests/services/AgentService/infrastructure/providers/ai-sdk/messages.test.ts +++ b/apps/desktop/tests/services/AgentService/infrastructure/providers/ai-sdk/messages.test.ts @@ -1,8 +1,8 @@ import type { ToolSet } from 'ai'; import { beforeEach, describe, expect, it } from 'vitest'; +import type { AiToolDefinition } from '@/contracts/tooling'; import { setLocale } from '@/i18n'; -import type { AiToolDefinition } from '@/services/AgentService/contracts/tooling'; import { buildModelMessages, buildToolSet, diff --git a/apps/desktop/tests/services/AgentService/infrastructure/providers/mimo.test.ts b/apps/desktop/tests/services/AgentService/infrastructure/providers/mimo.test.ts index bee76731..0bc20c20 100644 --- a/apps/desktop/tests/services/AgentService/infrastructure/providers/mimo.test.ts +++ b/apps/desktop/tests/services/AgentService/infrastructure/providers/mimo.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { AiError, AiErrorCode } from '@/services/AgentService/contracts/errors'; +import { AiError, AiErrorCode } from '@/contracts/agentErrors'; import { MiMoProviderAdapter } from '@/services/AgentService/infrastructure/providers/adapters/mimo'; const fetchMock = vi.hoisted(() => vi.fn()); diff --git a/apps/desktop/tests/services/BuiltInToolService/tools/webFetch/i18n.test.ts b/apps/desktop/tests/services/BuiltInToolService/tools/webFetch/i18n.test.ts index a1263540..626ff581 100644 --- a/apps/desktop/tests/services/BuiltInToolService/tools/webFetch/i18n.test.ts +++ b/apps/desktop/tests/services/BuiltInToolService/tools/webFetch/i18n.test.ts @@ -15,7 +15,7 @@ const { tauriFetchMock } = vi.hoisted(() => ({ tauriFetchMock: vi.fn(), })); -vi.mock('@/services/AgentService/infrastructure/providers', () => ({ +vi.mock('@/services/HttpService', () => ({ createTauriFetch: () => tauriFetchMock, })); diff --git a/apps/desktop/tests/services/BuiltInToolService/tools/widgetTool/show-widget-summary-i18n.test.ts b/apps/desktop/tests/services/BuiltInToolService/tools/widgetTool/show-widget-summary-i18n.test.ts index e95781a5..280695ff 100644 --- a/apps/desktop/tests/services/BuiltInToolService/tools/widgetTool/show-widget-summary-i18n.test.ts +++ b/apps/desktop/tests/services/BuiltInToolService/tools/widgetTool/show-widget-summary-i18n.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; +import type { ShowWidgetEventPayload } from '@/contracts/tooling'; import { setLocale } from '@/i18n'; -import type { ShowWidgetEventPayload } from '@/services/AgentService/contracts/tooling'; import { showWidgetTool } from '@/services/BuiltInToolService/tools/widgetTool/showWidget'; import { buildShowWidgetSummary } from '@/services/BuiltInToolService/tools/widgetTool/showWidget/helper'; import { buildShowWidgetDraftFromArgumentsBuffer } from '@/services/BuiltInToolService/tools/widgetTool/showWidget/runtime'; From 1ae223cf36faf45c5795e5962c6dc0238ba2e3d0 Mon Sep 17 00:00:00 2001 From: xlgzsgf <51521689+hiqiancheng@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:41:21 +0800 Subject: [PATCH 03/13] refactor(session): move display contracts out of services Related to #434 --- .../src/contracts/builtInToolPresentation.ts | 23 ++++++++++++++++ apps/desktop/src/contracts/index.ts | 3 +++ apps/desktop/src/contracts/sessionStatus.ts | 3 +++ apps/desktop/src/contracts/tooling.ts | 24 ++++++----------- apps/desktop/src/contracts/widgets.ts | 19 +++++++++++++ .../services/AgentService/session/history.ts | 6 ++--- .../AgentService/task/projection/widgets.ts | 2 +- .../BuiltInToolService/presentation.ts | 10 +++---- .../tools/widgetTool/showWidget/runtime.ts | 15 ++--------- .../src/services/BuiltInToolService/types.ts | 27 ++++++------------- .../src/services/EventService/types.ts | 4 +-- .../src/services/NativeService/types.ts | 2 +- apps/desktop/src/types/session.ts | 6 ++--- apps/desktop/src/utils/session.ts | 3 ++- .../architecture/import-boundaries.test.ts | 1 - 15 files changed, 82 insertions(+), 66 deletions(-) create mode 100644 apps/desktop/src/contracts/builtInToolPresentation.ts create mode 100644 apps/desktop/src/contracts/sessionStatus.ts create mode 100644 apps/desktop/src/contracts/widgets.ts diff --git a/apps/desktop/src/contracts/builtInToolPresentation.ts b/apps/desktop/src/contracts/builtInToolPresentation.ts new file mode 100644 index 00000000..10df516a --- /dev/null +++ b/apps/desktop/src/contracts/builtInToolPresentation.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +import type { + ToolEventBuiltInConversationSemantic, + ToolEventBuiltInConversationSemanticAction, +} from './tooling'; + +export type BuiltInToolConversationStatus = + | 'executing' + | 'awaiting_approval' + | 'completed' + | 'error' + | 'rejected' + | 'cancelled'; + +export type BuiltInToolConversationSemanticAction = ToolEventBuiltInConversationSemanticAction; + +export type BuiltInToolConversationSemantic = ToolEventBuiltInConversationSemantic; + +export interface BuiltInToolConversationPresentation { + verb: string; + content?: string; +} diff --git a/apps/desktop/src/contracts/index.ts b/apps/desktop/src/contracts/index.ts index a8569923..f73ddc4c 100644 --- a/apps/desktop/src/contracts/index.ts +++ b/apps/desktop/src/contracts/index.ts @@ -2,5 +2,8 @@ export { AiError, AiErrorCode } from './agentErrors'; export type * from './attachments'; +export type * from './builtInToolPresentation'; export type * from './json'; +export type * from './sessionStatus'; export type * from './tooling'; +export type * from './widgets'; diff --git a/apps/desktop/src/contracts/sessionStatus.ts b/apps/desktop/src/contracts/sessionStatus.ts new file mode 100644 index 00000000..3609b2ce --- /dev/null +++ b/apps/desktop/src/contracts/sessionStatus.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +export type SessionStatusReminderKind = 'completed' | 'failed' | 'waiting_approval'; diff --git a/apps/desktop/src/contracts/tooling.ts b/apps/desktop/src/contracts/tooling.ts index e34ff337..de71866f 100644 --- a/apps/desktop/src/contracts/tooling.ts +++ b/apps/desktop/src/contracts/tooling.ts @@ -1,6 +1,14 @@ // Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 import type { JsonObject } from './json'; +import type { ShowWidgetEventPayload } from './widgets'; + +export type { + ShowWidgetEventPayload, + ShowWidgetMode, + ShowWidgetPayload, + ShowWidgetPhase, +} from './widgets'; /** * 暴露给模型的工具定义。 @@ -117,22 +125,6 @@ export interface ToolEventBuiltInConversationSemantic { target?: string; } -export type ShowWidgetMode = 'render' | 'remove'; -export type ShowWidgetPhase = 'draft' | 'ready'; - -/** - * widget 类工具向前端发出的渲染事件载荷。 - */ -export interface ShowWidgetEventPayload { - callId: string; - widgetId: string; - title: string; - description: string; - html: string; - mode: ShowWidgetMode; - phase: ShowWidgetPhase; -} - /** * Agent 循环过程中发给 UI 的统一事件。 */ diff --git a/apps/desktop/src/contracts/widgets.ts b/apps/desktop/src/contracts/widgets.ts new file mode 100644 index 00000000..17661082 --- /dev/null +++ b/apps/desktop/src/contracts/widgets.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +export type ShowWidgetMode = 'render' | 'remove'; +export type ShowWidgetPhase = 'draft' | 'ready'; + +/** + * Stable payload shared by the show_widget runtime, agent projection, and session display. + */ +export interface ShowWidgetPayload { + callId: string; + widgetId: string; + title: string; + description: string; + html: string; + mode: ShowWidgetMode; + phase: ShowWidgetPhase; +} + +export type ShowWidgetEventPayload = ShowWidgetPayload; diff --git a/apps/desktop/src/services/AgentService/session/history.ts b/apps/desktop/src/services/AgentService/session/history.ts index 443fc686..44b4ade0 100644 --- a/apps/desktop/src/services/AgentService/session/history.ts +++ b/apps/desktop/src/services/AgentService/session/history.ts @@ -6,16 +6,14 @@ import type { SessionTurnAttemptHistoryRow } from '@database/queries/sessionTurn import type { SessionTurnHistoryRow } from '@database/queries/sessionTurns'; import type { PersistedToolLogStatus } from '@database/schema'; +import type { ShowWidgetPayload } from '@/contracts/widgets'; import { tt } from '@/i18n'; import { hydratePersistedAttachments } from '@/services/AttachmentService'; import { buildBuiltInToolConversationPresentation, resolveBuiltInToolConversationSemantic, } from '@/services/BuiltInToolService/presentation'; -import { - SHOW_WIDGET_TOOL_NAME, - type ShowWidgetPayload, -} from '@/services/BuiltInToolService/tools/widgetTool'; +import { SHOW_WIDGET_TOOL_NAME } from '@/services/BuiltInToolService/tools/widgetTool'; import { createInputHistorySnapshot, type SessionMessage, diff --git a/apps/desktop/src/services/AgentService/task/projection/widgets.ts b/apps/desktop/src/services/AgentService/task/projection/widgets.ts index 7d424a5a..05e6aef3 100644 --- a/apps/desktop/src/services/AgentService/task/projection/widgets.ts +++ b/apps/desktop/src/services/AgentService/task/projection/widgets.ts @@ -7,10 +7,10 @@ * 负责会话消息中 `WidgetInfo` 的创建、更新、匹配与删除。 */ +import type { ShowWidgetPayload } from '@/contracts/widgets'; import { buildShowWidgetDraftFromArgumentsBuffer, SHOW_WIDGET_TOOL_NAME, - type ShowWidgetPayload, } from '@/services/BuiltInToolService/tools/widgetTool'; import type { SessionMessage, WidgetInfo } from '@/types/session'; import { normalizeString } from '@/utils/text'; diff --git a/apps/desktop/src/services/BuiltInToolService/presentation.ts b/apps/desktop/src/services/BuiltInToolService/presentation.ts index 78835662..8123a654 100644 --- a/apps/desktop/src/services/BuiltInToolService/presentation.ts +++ b/apps/desktop/src/services/BuiltInToolService/presentation.ts @@ -1,15 +1,15 @@ // Copyright (c) 2026. 千诚. Licensed under GPL v3 -import { type MessageKey, t } from '@/i18n'; - -import { builtInToolRegistry } from './registry'; import type { BuiltInToolConversationPresentation, BuiltInToolConversationSemantic, BuiltInToolConversationSemanticAction, BuiltInToolConversationStatus, - BuiltInToolId, -} from './types'; +} from '@/contracts/builtInToolPresentation'; +import { type MessageKey, t } from '@/i18n'; + +import { builtInToolRegistry } from './registry'; +import type { BuiltInToolId } from './types'; const BUILTIN_TOOL_PREFIX = 'builtin__'; const BUILTIN_TOOL_VERB_KEYS: Record< diff --git a/apps/desktop/src/services/BuiltInToolService/tools/widgetTool/showWidget/runtime.ts b/apps/desktop/src/services/BuiltInToolService/tools/widgetTool/showWidget/runtime.ts index 23e2ee49..76154d8a 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/widgetTool/showWidget/runtime.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/widgetTool/showWidget/runtime.ts @@ -3,6 +3,7 @@ import DOMPurify from 'dompurify'; import type morphdom from 'morphdom'; +import type { ShowWidgetMode, ShowWidgetPayload, ShowWidgetPhase } from '@/contracts/widgets'; import { tt } from '@/i18n'; import { @@ -24,19 +25,7 @@ import { */ export { SHOW_WIDGET_ALLOWED_RESOURCE_HOSTS, SHOW_WIDGET_TOOL_NAME } from './runtimeConstants'; - -export type ShowWidgetMode = 'render' | 'remove'; -export type ShowWidgetPhase = 'draft' | 'ready'; - -export interface ShowWidgetPayload { - callId: string; - widgetId: string; - title: string; - description: string; - html: string; - mode: ShowWidgetMode; - phase: ShowWidgetPhase; -} +export type { ShowWidgetMode, ShowWidgetPayload, ShowWidgetPhase } from '@/contracts/widgets'; export interface ShowWidgetDraft { widgetId?: string; diff --git a/apps/desktop/src/services/BuiltInToolService/types.ts b/apps/desktop/src/services/BuiltInToolService/types.ts index e3b3e72b..8b2bc9cc 100644 --- a/apps/desktop/src/services/BuiltInToolService/types.ts +++ b/apps/desktop/src/services/BuiltInToolService/types.ts @@ -4,16 +4,22 @@ import type { ModelWithProvider } from '@database/queries/models'; import type { BuiltInToolEntity } from '@database/types'; import type { AttachmentIndex } from '@/contracts/attachments'; +import type { BuiltInToolConversationSemantic } from '@/contracts/builtInToolPresentation'; import type { AiToolDefinition, AskUserAnswer, AskUserQuestion, ToolApprovalRequest, ToolEvent, - ToolEventBuiltInConversationSemantic, - ToolEventBuiltInConversationSemanticAction, } from '@/contracts/tooling'; +export type { + BuiltInToolConversationPresentation, + BuiltInToolConversationSemantic, + BuiltInToolConversationSemanticAction, + BuiltInToolConversationStatus, +} from '@/contracts/builtInToolPresentation'; + /** * 当前内置工具体系允许暴露给模型的稳定工具标识。 */ @@ -71,23 +77,6 @@ export interface BuiltInToolExecutionResult { controlSignal?: BuiltInToolControlSignal; } -export type BuiltInToolConversationStatus = - | 'executing' - | 'awaiting_approval' - | 'completed' - | 'error' - | 'rejected' - | 'cancelled'; - -export type BuiltInToolConversationSemanticAction = ToolEventBuiltInConversationSemanticAction; - -export type BuiltInToolConversationSemantic = ToolEventBuiltInConversationSemantic; - -export interface BuiltInToolConversationPresentation { - verb: string; - content?: string; -} - /** * 单个内置工具的静态描述与执行入口。 */ diff --git a/apps/desktop/src/services/EventService/types.ts b/apps/desktop/src/services/EventService/types.ts index 280dfd93..f05b690d 100644 --- a/apps/desktop/src/services/EventService/types.ts +++ b/apps/desktop/src/services/EventService/types.ts @@ -11,9 +11,9 @@ import type { PopupSessionSearchQueryChangePayload, } from '@services/PopupService/types'; -import type { SessionStatusReminderKind } from '@/utils/session'; +import type { SessionStatusReminderKind } from '@/contracts/sessionStatus'; -export type { SessionStatusReminderKind } from '@/utils/session'; +export type { SessionStatusReminderKind } from '@/contracts/sessionStatus'; /** * 事件类型定义 diff --git a/apps/desktop/src/services/NativeService/types.ts b/apps/desktop/src/services/NativeService/types.ts index a3a52379..ec527ddd 100644 --- a/apps/desktop/src/services/NativeService/types.ts +++ b/apps/desktop/src/services/NativeService/types.ts @@ -1,6 +1,6 @@ import type { AppUpdateChannel } from '@/config/appUpdate'; import type { SearchWindowDefaultSize, SearchWindowHeightMode } from '@/config/searchWindow'; -import type { SessionStatusReminderKind } from '@/utils/session'; +import type { SessionStatusReminderKind } from '@/contracts/sessionStatus'; export type { AppUpdateChannel } from '@/config/appUpdate'; export type { SearchWindowDefaultSize, SearchWindowHeightMode }; diff --git a/apps/desktop/src/types/session.ts b/apps/desktop/src/types/session.ts index 60bee05b..1dd491e4 100644 --- a/apps/desktop/src/types/session.ts +++ b/apps/desktop/src/types/session.ts @@ -3,12 +3,12 @@ import type { JSONContent } from '@tiptap/core'; import type { AttachmentIndex as Index } from '@/contracts/attachments'; -import type { ToolExecutionSource as AiToolExecutionSource } from '@/contracts/tooling'; import type { BuiltInToolConversationPresentation, BuiltInToolConversationSemantic, -} from '@/services/BuiltInToolService'; -import type { ShowWidgetPayload } from '@/services/BuiltInToolService/tools/widgetTool'; +} from '@/contracts/builtInToolPresentation'; +import type { ToolExecutionSource as AiToolExecutionSource } from '@/contracts/tooling'; +import type { ShowWidgetPayload } from '@/contracts/widgets'; /** * SearchView 与 agent 运行时共享的会话展示模型。 diff --git a/apps/desktop/src/utils/session.ts b/apps/desktop/src/utils/session.ts index 97a00631..bcf2b1c9 100644 --- a/apps/desktop/src/utils/session.ts +++ b/apps/desktop/src/utils/session.ts @@ -1,7 +1,8 @@ +import type { SessionStatusReminderKind } from '@/contracts/sessionStatus'; import { tt } from '@/i18n'; import type { TextMessagePart } from '@/types/session'; -export type SessionStatusReminderKind = 'completed' | 'failed' | 'waiting_approval'; +export type { SessionStatusReminderKind } from '@/contracts/sessionStatus'; /** * 创建带唯一 ID 的文本消息片段。 diff --git a/apps/desktop/tests/architecture/import-boundaries.test.ts b/apps/desktop/tests/architecture/import-boundaries.test.ts index 1394c40e..2ffd006b 100644 --- a/apps/desktop/tests/architecture/import-boundaries.test.ts +++ b/apps/desktop/tests/architecture/import-boundaries.test.ts @@ -28,7 +28,6 @@ const allowedBaselineViolationFragments = [ 'database -> services/NativeService', 'services/AuthService -> services/AgentService', 'services/EventService -> services/PopupService', - 'services/NativeService -> utils', 'services/PopupService -> views/PopupView', ]; From 9a17627912e36783c13652d759bac35c3a62b52f Mon Sep 17 00:00:00 2001 From: xlgzsgf <51521689+hiqiancheng@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:55:06 +0800 Subject: [PATCH 04/13] refactor(auth): move provider config policy out of agent Related to #434 --- .../src/application/providerConfigPolicy.ts | 47 ++++++++++++++++ apps/desktop/src/contracts/index.ts | 1 + apps/desktop/src/contracts/providerConfig.ts | 15 ++++++ .../AgentService/catalog/providers.ts | 3 +- .../AgentService/execution/executor.ts | 7 +-- .../AgentService/execution/runtime.ts | 2 +- .../infrastructure/providers/adapters/mimo.ts | 5 +- .../infrastructure/providers/config.ts | 53 +++---------------- .../infrastructure/providers/drivers.ts | 2 +- .../infrastructure/providers/index.ts | 12 ++--- .../infrastructure/providers/types.ts | 18 ++----- .../desktop/src/services/AuthService/index.ts | 10 ++-- .../architecture/import-boundaries.test.ts | 1 - .../AgentService/catalog/providers.test.ts | 5 +- 14 files changed, 99 insertions(+), 82 deletions(-) create mode 100644 apps/desktop/src/application/providerConfigPolicy.ts create mode 100644 apps/desktop/src/contracts/providerConfig.ts diff --git a/apps/desktop/src/application/providerConfigPolicy.ts b/apps/desktop/src/application/providerConfigPolicy.ts new file mode 100644 index 00000000..05af2507 --- /dev/null +++ b/apps/desktop/src/application/providerConfigPolicy.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +import type { ProviderConfigJson } from '@/contracts/providerConfig'; +import { safeParseJsonWithSchema, z } from '@/utils/zod'; + +export const TOUCHAI_HUB_GATEWAY_BASE_URL = 'https://hub.touch-ai.org/api/v1'; +export const MIMO_CUSTOM_API_BASE_URL = 'https://token-plan-cn.xiaomimimo.com/v1'; + +const providerConfigJsonSchema = z.object({ + headers: z.record(z.string(), z.string()).optional(), + queryParams: z.record(z.string(), z.string()).optional(), + managedAuth: z + .object({ + login: z.string().trim().min(1).optional(), + avatarUrl: z.string().trim().min(1).optional(), + }) + .optional(), + touchAiMode: z.enum(['managed', 'custom']).optional(), + touchAiCustom: z + .object({ + apiEndpoint: z.string().trim().min(1).optional(), + apiKey: z.string().trim().min(1).optional(), + }) + .optional(), +}); + +export function parseProviderConfigJson(configJson?: string | null): ProviderConfigJson { + return safeParseJsonWithSchema(providerConfigJsonSchema, configJson, {}); +} + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.trim().replace(/\/+$/, ''); +} + +export function isTouchAiManagedMode(config: ProviderConfigJson, baseUrl: string): boolean { + if (config.touchAiMode === 'custom') { + return false; + } + + const isHubEndpoint = normalizeBaseUrl(baseUrl) === TOUCHAI_HUB_GATEWAY_BASE_URL; + + if (config.touchAiMode === 'managed') { + return isHubEndpoint; + } + + return isHubEndpoint; +} diff --git a/apps/desktop/src/contracts/index.ts b/apps/desktop/src/contracts/index.ts index f73ddc4c..9748c515 100644 --- a/apps/desktop/src/contracts/index.ts +++ b/apps/desktop/src/contracts/index.ts @@ -4,6 +4,7 @@ export { AiError, AiErrorCode } from './agentErrors'; export type * from './attachments'; export type * from './builtInToolPresentation'; export type * from './json'; +export type * from './providerConfig'; export type * from './sessionStatus'; export type * from './tooling'; export type * from './widgets'; diff --git a/apps/desktop/src/contracts/providerConfig.ts b/apps/desktop/src/contracts/providerConfig.ts new file mode 100644 index 00000000..b01fa1be --- /dev/null +++ b/apps/desktop/src/contracts/providerConfig.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +export interface ProviderConfigJson { + headers?: Record; + queryParams?: Record; + managedAuth?: { + login?: string; + avatarUrl?: string; + }; + touchAiMode?: 'managed' | 'custom'; + touchAiCustom?: { + apiEndpoint?: string; + apiKey?: string; + }; +} diff --git a/apps/desktop/src/services/AgentService/catalog/providers.ts b/apps/desktop/src/services/AgentService/catalog/providers.ts index f6fc672c..c8c66e48 100644 --- a/apps/desktop/src/services/AgentService/catalog/providers.ts +++ b/apps/desktop/src/services/AgentService/catalog/providers.ts @@ -3,10 +3,11 @@ import type { ModelWithProvider } from '@database/queries/models'; import type { ProviderDriver } from '@database/schema'; +import { parseProviderConfigJson } from '@/application/providerConfigPolicy'; + import { type AiProvider, createProviderFromRegistry, - parseProviderConfigJson, parseProviderDriver, } from '../infrastructure/providers'; diff --git a/apps/desktop/src/services/AgentService/execution/executor.ts b/apps/desktop/src/services/AgentService/execution/executor.ts index cf541cb1..55f3d6b1 100644 --- a/apps/desktop/src/services/AgentService/execution/executor.ts +++ b/apps/desktop/src/services/AgentService/execution/executor.ts @@ -4,6 +4,7 @@ import { createMcpToolLog, updateMcpToolLogByCallId } from '@database/queries'; import type { ModelWithProvider } from '@database/queries/models'; import type { ProviderDriver, ToolLogKind } from '@database/schema'; +import { parseProviderConfigJson } from '@/application/providerConfigPolicy'; import { tt } from '@/i18n'; import { type BuiltInToolControlSignal, @@ -30,11 +31,7 @@ import { getUnsupportedAttachmentTypes, } from '../infrastructure/attachments'; import { mcpManager } from '../infrastructure/mcp'; -import { - type AiProvider, - createProviderFromRegistry, - parseProviderConfigJson, -} from '../infrastructure/providers'; +import { type AiProvider, createProviderFromRegistry } from '../infrastructure/providers'; import type { ModelLanguageContext } from '../languageContext'; import { PersistenceProjector } from '../outputs/persistence'; import type { TurnEvent } from './runtime'; diff --git a/apps/desktop/src/services/AgentService/execution/runtime.ts b/apps/desktop/src/services/AgentService/execution/runtime.ts index bcd01cfc..44c6c656 100644 --- a/apps/desktop/src/services/AgentService/execution/runtime.ts +++ b/apps/desktop/src/services/AgentService/execution/runtime.ts @@ -3,6 +3,7 @@ import { updateModelLastUsed } from '@database/queries'; import type { SessionTurnEntity } from '@database/types'; +import { isTouchAiManagedMode, parseProviderConfigJson } from '@/application/providerConfigPolicy'; import { t } from '@/i18n'; import { type AttachmentIndex, @@ -13,7 +14,6 @@ import { import type { InputHistorySnapshot } from '@/types/session'; import { AiError, AiErrorCode } from '../contracts/errors'; -import { isTouchAiManagedMode, parseProviderConfigJson } from '../infrastructure/providers/config'; import { getCurrentModelLanguageContext } from '../languageContext'; import { PersistenceProjector } from '../outputs/persistence'; import { composePromptSnapshot } from '../prompt/composer'; diff --git a/apps/desktop/src/services/AgentService/infrastructure/providers/adapters/mimo.ts b/apps/desktop/src/services/AgentService/infrastructure/providers/adapters/mimo.ts index 36abd1be..5947c6c0 100644 --- a/apps/desktop/src/services/AgentService/infrastructure/providers/adapters/mimo.ts +++ b/apps/desktop/src/services/AgentService/infrastructure/providers/adapters/mimo.ts @@ -2,11 +2,14 @@ import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; +import { + isTouchAiManagedMode, + TOUCHAI_HUB_GATEWAY_BASE_URL, +} from '@/application/providerConfigPolicy'; import { z } from '@/utils/zod'; import { AiError, AiErrorCode } from '../../../contracts/errors'; import { AiSdkProviderBase, buildUrlWithQueryParams } from '../ai-sdk/base'; -import { isTouchAiManagedMode, TOUCHAI_HUB_GATEWAY_BASE_URL } from '../config'; import type { ModelInfo, ProviderApiTargets } from '../types'; import { resolveOpenAiStyleSdkBaseUrl } from '../utils'; diff --git a/apps/desktop/src/services/AgentService/infrastructure/providers/config.ts b/apps/desktop/src/services/AgentService/infrastructure/providers/config.ts index 68b6b25b..14e2b337 100644 --- a/apps/desktop/src/services/AgentService/infrastructure/providers/config.ts +++ b/apps/desktop/src/services/AgentService/infrastructure/providers/config.ts @@ -1,48 +1,9 @@ // Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 -import { safeParseJsonWithSchema, z } from '@/utils/zod'; - -import type { ProviderConfigJson } from './types'; - -export const TOUCHAI_HUB_GATEWAY_BASE_URL = 'https://hub.touch-ai.org/api/v1'; -export const MIMO_CUSTOM_API_BASE_URL = 'https://token-plan-cn.xiaomimimo.com/v1'; - -const providerConfigJsonSchema = z.object({ - headers: z.record(z.string(), z.string()).optional(), - queryParams: z.record(z.string(), z.string()).optional(), - managedAuth: z - .object({ - login: z.string().trim().min(1).optional(), - avatarUrl: z.string().trim().min(1).optional(), - }) - .optional(), - touchAiMode: z.enum(['managed', 'custom']).optional(), - touchAiCustom: z - .object({ - apiEndpoint: z.string().trim().min(1).optional(), - apiKey: z.string().trim().min(1).optional(), - }) - .optional(), -}); - -export function parseProviderConfigJson(configJson?: string | null): ProviderConfigJson { - return safeParseJsonWithSchema(providerConfigJsonSchema, configJson, {}); -} - -function normalizeBaseUrl(baseUrl: string): string { - return baseUrl.trim().replace(/\/+$/, ''); -} - -export function isTouchAiManagedMode(config: ProviderConfigJson, baseUrl: string): boolean { - if (config.touchAiMode === 'custom') { - return false; - } - - const isHubEndpoint = normalizeBaseUrl(baseUrl) === TOUCHAI_HUB_GATEWAY_BASE_URL; - - if (config.touchAiMode === 'managed') { - return isHubEndpoint; - } - - return isHubEndpoint; -} +export { + isTouchAiManagedMode, + MIMO_CUSTOM_API_BASE_URL, + parseProviderConfigJson, + TOUCHAI_HUB_GATEWAY_BASE_URL, +} from '@/application/providerConfigPolicy'; +export type { ProviderConfigJson } from '@/contracts/providerConfig'; diff --git a/apps/desktop/src/services/AgentService/infrastructure/providers/drivers.ts b/apps/desktop/src/services/AgentService/infrastructure/providers/drivers.ts index f729ed1f..13588e08 100644 --- a/apps/desktop/src/services/AgentService/infrastructure/providers/drivers.ts +++ b/apps/desktop/src/services/AgentService/infrastructure/providers/drivers.ts @@ -2,6 +2,7 @@ import type { ProviderDriver } from '@database/schema'; +import { MIMO_CUSTOM_API_BASE_URL } from '@/application/providerConfigPolicy'; import { tt } from '@/i18n'; import { AlibabaProviderAdapter } from './adapters/alibaba'; @@ -16,7 +17,6 @@ import { OpenAIProviderAdapter } from './adapters/openai'; import { OpenAICompatibleProviderAdapter } from './adapters/openai-compatible'; import { XaiProviderAdapter } from './adapters/xai'; import { ZhipuProviderAdapter } from './adapters/zhipu'; -import { MIMO_CUSTOM_API_BASE_URL } from './config'; import type { AiProvider, AiProviderConfig } from './types'; export interface ProviderDriverDefinition { diff --git a/apps/desktop/src/services/AgentService/infrastructure/providers/index.ts b/apps/desktop/src/services/AgentService/infrastructure/providers/index.ts index 3bdbe3b6..16a69118 100644 --- a/apps/desktop/src/services/AgentService/infrastructure/providers/index.ts +++ b/apps/desktop/src/services/AgentService/infrastructure/providers/index.ts @@ -3,12 +3,6 @@ export { normalizeProviderBaseUrl } from './ai-sdk/base'; export { createTauriFetch } from './ai-sdk/tauriFetch'; export { getProviderAttachmentCapabilities } from './capabilities'; -export { - isTouchAiManagedMode, - MIMO_CUSTOM_API_BASE_URL, - parseProviderConfigJson, - TOUCHAI_HUB_GATEWAY_BASE_URL, -} from './config'; export { createProviderFromRegistry, getProviderDriverDefinition, @@ -28,3 +22,9 @@ export type { ProviderConfigJson, } from './types'; export { resolveOpenAiStyleSdkBaseUrl } from './utils'; +export { + isTouchAiManagedMode, + MIMO_CUSTOM_API_BASE_URL, + parseProviderConfigJson, + TOUCHAI_HUB_GATEWAY_BASE_URL, +} from '@/application/providerConfigPolicy'; diff --git a/apps/desktop/src/services/AgentService/infrastructure/providers/types.ts b/apps/desktop/src/services/AgentService/infrastructure/providers/types.ts index 927f2ee7..9a20c72d 100644 --- a/apps/desktop/src/services/AgentService/infrastructure/providers/types.ts +++ b/apps/desktop/src/services/AgentService/infrastructure/providers/types.ts @@ -2,29 +2,19 @@ import type { ProviderDriver } from '@database/schema'; +import type { ProviderConfigJson } from '@/contracts/providerConfig'; + import type { AiError } from '../../contracts/errors'; import type { AiRequestOptions, AiResponse, AiStreamChunk } from '../../contracts/protocol'; import type { AttachmentTransportMode } from '../../contracts/protocol'; +export type { ProviderConfigJson } from '@/contracts/providerConfig'; + export interface ModelInfo { id: string; name: string; } -export interface ProviderConfigJson { - headers?: Record; - queryParams?: Record; - managedAuth?: { - login?: string; - avatarUrl?: string; - }; - touchAiMode?: 'managed' | 'custom'; - touchAiCustom?: { - apiEndpoint?: string; - apiKey?: string; - }; -} - export interface ProviderApiTargets { normalizedBaseUrl: string; sdkBaseUrl: string; diff --git a/apps/desktop/src/services/AuthService/index.ts b/apps/desktop/src/services/AuthService/index.ts index 27c3e29b..009555c2 100644 --- a/apps/desktop/src/services/AuthService/index.ts +++ b/apps/desktop/src/services/AuthService/index.ts @@ -3,16 +3,16 @@ import { fetch } from '@tauri-apps/plugin-http'; import { openUrl } from '@tauri-apps/plugin-opener'; +import { + parseProviderConfigJson, + TOUCHAI_HUB_GATEWAY_BASE_URL, +} from '@/application/providerConfigPolicy'; +import type { ProviderConfigJson } from '@/contracts/providerConfig'; import { findAllProvidersSorted, reassignModelsAndDeleteProvider, updateProvider, } from '@/database/queries'; -import { - parseProviderConfigJson, - TOUCHAI_HUB_GATEWAY_BASE_URL, -} from '@/services/AgentService/infrastructure/providers/config'; -import type { ProviderConfigJson } from '@/services/AgentService/infrastructure/providers/types'; import { AppEvent, eventService } from '@/services/EventService'; const MIMO_DRIVER = 'mimo'; diff --git a/apps/desktop/tests/architecture/import-boundaries.test.ts b/apps/desktop/tests/architecture/import-boundaries.test.ts index 2ffd006b..2d34ec61 100644 --- a/apps/desktop/tests/architecture/import-boundaries.test.ts +++ b/apps/desktop/tests/architecture/import-boundaries.test.ts @@ -26,7 +26,6 @@ const importPattern = const allowedBaselineViolationFragments = [ 'database -> services/NativeService', - 'services/AuthService -> services/AgentService', 'services/EventService -> services/PopupService', 'services/PopupService -> views/PopupView', ]; diff --git a/apps/desktop/tests/services/AgentService/catalog/providers.test.ts b/apps/desktop/tests/services/AgentService/catalog/providers.test.ts index ab5d6975..3fc25fc1 100644 --- a/apps/desktop/tests/services/AgentService/catalog/providers.test.ts +++ b/apps/desktop/tests/services/AgentService/catalog/providers.test.ts @@ -8,11 +8,14 @@ const providerRegistryMock = vi.hoisted(() => ({ driver, config, })), - parseProviderConfigJson: vi.fn(() => ({ headers: {} })), parseProviderDriver: vi.fn((driver) => driver), })); +const providerConfigPolicyMock = vi.hoisted(() => ({ + parseProviderConfigJson: vi.fn(() => ({ headers: {} })), +})); vi.mock('@/services/AgentService/infrastructure/providers', () => providerRegistryMock); +vi.mock('@/application/providerConfigPolicy', () => providerConfigPolicyMock); function createModel(overrides: Partial = {}): ModelWithProvider { return { From 53217c829e063e7576d0db7e381fdde734ca6aaf Mon Sep 17 00:00:00 2001 From: xlgzsgf <51521689+hiqiancheng@users.noreply.github.com> Date: Mon, 8 Jun 2026 00:14:02 +0800 Subject: [PATCH 05/13] refactor(popup): split manifest from component registry Related to #434 --- apps/desktop/src/contracts/index.ts | 2 + apps/desktop/src/contracts/popup.ts | 165 ++++++++++++++++++ apps/desktop/src/contracts/popupManifest.ts | 31 ++++ .../src/services/EventService/types.ts | 3 +- .../src/services/PopupService/index.ts | 1 + .../src/services/PopupService/registry.ts | 80 ++++----- .../src/services/PopupService/types.ts | 164 +---------------- apps/desktop/src/views/PopupView/index.vue | 6 +- apps/desktop/src/views/PopupView/location.ts | 2 +- .../src/views/PopupView/popupComponents.ts | 17 ++ .../architecture/import-boundaries.test.ts | 6 +- .../services/PopupService/position.test.ts | 2 +- 12 files changed, 270 insertions(+), 209 deletions(-) create mode 100644 apps/desktop/src/contracts/popup.ts create mode 100644 apps/desktop/src/contracts/popupManifest.ts create mode 100644 apps/desktop/src/views/PopupView/popupComponents.ts diff --git a/apps/desktop/src/contracts/index.ts b/apps/desktop/src/contracts/index.ts index 9748c515..a907753a 100644 --- a/apps/desktop/src/contracts/index.ts +++ b/apps/desktop/src/contracts/index.ts @@ -4,6 +4,8 @@ export { AiError, AiErrorCode } from './agentErrors'; export type * from './attachments'; export type * from './builtInToolPresentation'; export type * from './json'; +export type * from './popup'; +export type * from './popupManifest'; export type * from './providerConfig'; export type * from './sessionStatus'; export type * from './tooling'; diff --git a/apps/desktop/src/contracts/popup.ts b/apps/desktop/src/contracts/popup.ts new file mode 100644 index 00000000..a9cfd3a0 --- /dev/null +++ b/apps/desktop/src/contracts/popup.ts @@ -0,0 +1,165 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +import type { PopupType } from './popupManifest'; + +export type SessionHistoryDisplayStatus = 'running' | 'waiting_approval' | 'completed' | 'failed'; + +/** + * 窗口信息,用于位置计算。 + */ +export interface WindowInfo { + position: { x: number; y: number }; + size: { width: number; height: number }; + innerSize: { width: number; height: number }; + scaleFactor: number; + screenSize?: { width: number; height: number }; + screenPosition?: { x: number; y: number }; +} + +/** + * 可序列化的 Popup 配置(用于传递给 Rust)。 + */ +export interface SerializablePopupConfig { + id: string; + width: number; + height: number; +} + +/** + * 弹窗窗口位置和大小。 + */ +export interface PopupPosition { + x: number; + y: number; + width: number; + height: number; +} + +/** + * 模型下拉框弹窗数据。 + */ +export interface ModelDropdownData { + activeModelId: string; + activeProviderId: number | null; + selectedModelId: string; + selectedProviderId: number | null; + searchQuery: string; + models?: ModelDropdownPopupItem[]; +} + +/** + * 模型下拉框弹窗项数据(从父窗口传递)。 + */ +export interface ModelDropdownPopupItem { + id: number; + modelId: string; + name: string; + providerId: number; + providerName: string; + reasoning: number; + tool_call: number; + modalities: string | null; + attachment: number; + open_weights: number; +} + +export interface SessionHistorySessionItem { + id: number; + session_id: string; + title: string; + model: string; + provider_id: number | null; + last_message_preview: string | null; + last_message_at: string | null; + message_count: number; + status_badge_dismissed_turn_id: number | null; + pending_terminal_status: 'completed' | 'failed' | null; + pinned_at: string | null; + archived_at: string | null; + created_at: string; + updated_at: string; + displayStatus: SessionHistoryDisplayStatus | null; +} + +/** + * 历史会话弹窗数据。 + */ +export interface SessionHistoryData { + sessions: SessionHistorySessionItem[]; + activeSessionId: number | null; + searchQuery: string; + isLoading: boolean; +} + +export type PopupData = ModelDropdownData | SessionHistoryData; + +/** + * 根据 PopupType 获取对应的数据类型。 + */ +export type PopupDataFor = T extends 'model-dropdown-popup' + ? ModelDropdownData + : T extends 'session-history-popup' + ? SessionHistoryData + : never; + +export interface PopupSessionIdentity { + popupId: string; + windowLabel: string; + popupSessionVersion: number; +} + +export interface PopupClosedPayload extends PopupSessionIdentity { + type: PopupType; +} + +export interface PopupReadyPayload { + windowLabel: string; +} + +/** + * 弹窗数据更新事件载荷。 + */ +export interface PopupDataPayload extends PopupSessionIdentity { + type: PopupType; + data: PopupData; + /** + * true 表示弹窗首次展示(来自 show()),缺省/false 表示纯数据更新(来自 updateData())。 + * 原生窗口显示由 PopupManager.show() 负责,PopupView 仅用它区分首次聚焦和普通数据刷新。 + */ + isShow?: boolean; +} + +/** + * 转发给特定 popup 窗口的键盘事件载荷。 + */ +export interface PopupKeydownPayload { + key: string; + targetType: PopupType; +} + +export interface PopupModelSelectPayload extends PopupSessionIdentity { + modelDbId: number; +} + +export interface PopupSessionOpenPayload extends PopupSessionIdentity { + sessionId: number; +} + +export interface PopupSessionSearchQueryChangePayload extends PopupSessionIdentity { + query: string; +} + +export interface PopupModelSearchQueryChangePayload extends PopupSessionIdentity { + query: string; +} + +/** + * 弹窗事件处理器。 + */ +export interface PopupEventHandlers { + onModelSelect?: (modelDbId: number) => void; + onModelSearchQueryChange?: (query: string) => void; + onSessionOpen?: (sessionId: number) => void; + onSessionSearchQueryChange?: (query: string) => void; + onClose?: (payload: PopupClosedPayload) => void; +} diff --git a/apps/desktop/src/contracts/popupManifest.ts b/apps/desktop/src/contracts/popupManifest.ts new file mode 100644 index 00000000..9dc454fa --- /dev/null +++ b/apps/desktop/src/contracts/popupManifest.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +export type PopupPositionStrategy = 'window-edge-left' | 'session-history-adaptive'; + +interface PopupManifestItem { + id: string; + width: number; + height: number; + minHeight?: number; + positionStrategy: PopupPositionStrategy; +} + +export const POPUP_MANIFEST = { + modelDropdown: { + id: 'model-dropdown-popup', + width: 320, + height: 384, + positionStrategy: 'window-edge-left', + }, + sessionHistory: { + id: 'session-history-popup', + width: 320, + height: 384, + positionStrategy: 'session-history-adaptive', + }, +} as const satisfies Record; + +export const POPUP_MANIFEST_ENTRIES = Object.values(POPUP_MANIFEST); + +export type PopupManifestEntry = (typeof POPUP_MANIFEST)[keyof typeof POPUP_MANIFEST]; +export type PopupType = PopupManifestEntry['id']; diff --git a/apps/desktop/src/services/EventService/types.ts b/apps/desktop/src/services/EventService/types.ts index f05b690d..1ccb60a4 100644 --- a/apps/desktop/src/services/EventService/types.ts +++ b/apps/desktop/src/services/EventService/types.ts @@ -9,8 +9,7 @@ import type { PopupReadyPayload, PopupSessionOpenPayload, PopupSessionSearchQueryChangePayload, -} from '@services/PopupService/types'; - +} from '@/contracts/popup'; import type { SessionStatusReminderKind } from '@/contracts/sessionStatus'; export type { SessionStatusReminderKind } from '@/contracts/sessionStatus'; diff --git a/apps/desktop/src/services/PopupService/index.ts b/apps/desktop/src/services/PopupService/index.ts index 58b6bfce..c536ab31 100644 --- a/apps/desktop/src/services/PopupService/index.ts +++ b/apps/desktop/src/services/PopupService/index.ts @@ -14,6 +14,7 @@ export type { PopupKeydownPayload, PopupModelSelectPayload, PopupPosition, + PopupPositionStrategy, PopupReadyPayload, PopupSessionIdentity, PopupSessionOpenPayload, diff --git a/apps/desktop/src/services/PopupService/registry.ts b/apps/desktop/src/services/PopupService/registry.ts index 90e4ac17..06b75607 100644 --- a/apps/desktop/src/services/PopupService/registry.ts +++ b/apps/desktop/src/services/PopupService/registry.ts @@ -1,9 +1,14 @@ // Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 -import ModelDropdownPopup from '@/views/PopupView/components/ModelDropdownPopup/index.vue'; -import SessionHistoryPopover from '@/views/PopupView/components/SessionHistoryPopover/index.vue'; +import { POPUP_MANIFEST_ENTRIES, type PopupPositionStrategy } from '@/contracts/popupManifest'; -import type { PopupConfig, PopupType, SerializablePopupConfig, WindowInfo } from './types'; +import type { + PopupConfig, + PopupType, + PositionCalculator, + SerializablePopupConfig, + WindowInfo, +} from './types'; const GAP = 5; const SHADOW_WIDTH = 7; @@ -112,6 +117,30 @@ function calculateRightAlignedPopupX( return x; } +function resolvePopupPositionCalculator(strategy: PopupPositionStrategy): PositionCalculator { + if (strategy === 'window-edge-left') { + return (triggerElement, mainWindow, dimensions) => { + const x = mainWindow.position.x - SHADOW_WIDTH; + const y = calculateWindowEdgePopupY(triggerElement, mainWindow, dimensions.height); + return { x, y }; + }; + } + + return (triggerElement, mainWindow, dimensions) => { + const isSearchViewContainer = triggerElement.classList.contains('search-view-container'); + + if (isSearchViewContainer) { + const x = mainWindow.position.x - SHADOW_WIDTH; + const y = calculateWindowEdgePopupY(triggerElement, mainWindow, dimensions.height); + return { x, y }; + } + + const x = calculateRightAlignedPopupX(triggerElement, mainWindow, dimensions.width); + const y = calculateTriggerAnchoredPopupY(triggerElement, mainWindow, dimensions.height); + return { x, y }; + }; +} + /** * Popup 注册表类 */ @@ -167,43 +196,10 @@ export const popupRegistry = new PopupRegistry(); * 初始化内置 popup 注册 */ export function initializeBuiltInPopups(): void { - // 注册模型下拉框(左侧) - popupRegistry.register({ - id: 'model-dropdown-popup', - width: 320, - height: 384, - component: ModelDropdownPopup, - calculatePosition: (triggerElement, mainWindow, dimensions) => { - const x = mainWindow.position.x - SHADOW_WIDTH; - const y = calculateWindowEdgePopupY(triggerElement, mainWindow, dimensions.height); - return { x, y }; - }, - }); - - popupRegistry.register({ - id: 'session-history-popup', - width: 320, - height: 384, - component: SessionHistoryPopover, - calculatePosition: (triggerElement, mainWindow, dimensions) => { - // 判断是否是搜索框状态 - const isSearchViewContainer = - triggerElement.classList.contains('search-view-container'); - - let x: number; - let y: number; - - if (isSearchViewContainer) { - // 搜索框状态:左对齐,贴窗口边缘 - x = mainWindow.position.x - SHADOW_WIDTH; - y = calculateWindowEdgePopupY(triggerElement, mainWindow, dimensions.height); - } else { - // 会话面板状态:右对齐,锚定在触发元素 - x = calculateRightAlignedPopupX(triggerElement, mainWindow, dimensions.width); - y = calculateTriggerAnchoredPopupY(triggerElement, mainWindow, dimensions.height); - } - - return { x, y }; - }, - }); + for (const entry of POPUP_MANIFEST_ENTRIES) { + popupRegistry.register({ + ...entry, + calculatePosition: resolvePopupPositionCalculator(entry.positionStrategy), + }); + } } diff --git a/apps/desktop/src/services/PopupService/types.ts b/apps/desktop/src/services/PopupService/types.ts index 73f6c804..d66e9300 100644 --- a/apps/desktop/src/services/PopupService/types.ts +++ b/apps/desktop/src/services/PopupService/types.ts @@ -1,29 +1,13 @@ // Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 -import type { SessionEntity } from '@database/types'; -import type { Component } from 'vue'; +import type { WindowInfo } from '@/contracts/popup'; +import type { PopupPositionStrategy, PopupType } from '@/contracts/popupManifest'; -import type { SessionTaskStatus } from '@/services/AgentService/task/types'; +export type * from '@/contracts/popup'; +export type { PopupPositionStrategy, PopupType } from '@/contracts/popupManifest'; /** - * 弹窗窗口内容类型 - */ -export type PopupType = 'model-dropdown-popup' | 'session-history-popup'; - -/** - * 窗口信息,用于位置计算 - */ -export interface WindowInfo { - position: { x: number; y: number }; - size: { width: number; height: number }; - innerSize: { width: number; height: number }; - scaleFactor: number; - screenSize?: { width: number; height: number }; // 屏幕尺寸 - screenPosition?: { x: number; y: number }; // 屏幕位置 -} - -/** - * 位置计算函数类型 + * 位置计算函数类型。 */ export type PositionCalculator = ( triggerElement: HTMLElement, @@ -32,7 +16,7 @@ export type PositionCalculator = ( ) => { x: number; y: number }; /** - * Popup 配置接口 + * Popup 服务侧配置接口。 */ export interface PopupConfig { /** 唯一标识符 */ @@ -43,142 +27,10 @@ export interface PopupConfig { height: number; /** 窗口最小高度(逻辑像素),用于内容不足时保持最低高度 */ minHeight?: number; - /** Vue 组件 */ - component: Component; + /** 声明式定位策略 */ + positionStrategy: PopupPositionStrategy; /** 位置计算函数 */ calculatePosition: PositionCalculator; /** 可选的数据验证器 */ dataValidator?: (data: unknown) => data is TData; } - -/** - * 可序列化的 Popup 配置(用于传递给 Rust) - */ -export interface SerializablePopupConfig { - id: string; - width: number; - height: number; -} - -/** - * 弹窗窗口位置和大小 - */ -export interface PopupPosition { - x: number; - y: number; - width: number; - height: number; -} - -/** - * 模型下拉框弹窗数据 - */ -export interface ModelDropdownData { - activeModelId: string; - activeProviderId: number | null; - selectedModelId: string; - selectedProviderId: number | null; - searchQuery: string; - models?: ModelDropdownPopupItem[]; -} - -/** - * 模型下拉框弹窗项数据(从父窗口传递) - */ -export interface ModelDropdownPopupItem { - id: number; - modelId: string; - name: string; - providerId: number; - providerName: string; - reasoning: number; - tool_call: number; - modalities: string | null; - attachment: number; - open_weights: number; -} - -export interface SessionHistorySessionItem extends SessionEntity { - displayStatus: Exclude | null; -} - -/** - * 历史会话弹窗数据 - */ -export interface SessionHistoryData { - sessions: SessionHistorySessionItem[]; - activeSessionId: number | null; - searchQuery: string; - isLoading: boolean; -} - -export type PopupData = ModelDropdownData | SessionHistoryData; - -/** - * 根据 PopupType 获取对应的数据类型 - */ -export type PopupDataFor = T extends 'model-dropdown-popup' - ? ModelDropdownData - : T extends 'session-history-popup' - ? SessionHistoryData - : never; - -export interface PopupSessionIdentity { - popupId: string; - windowLabel: string; - popupSessionVersion: number; -} - -export interface PopupClosedPayload extends PopupSessionIdentity { - type: PopupType; -} - -export interface PopupReadyPayload { - windowLabel: string; -} - -/** - * 弹窗数据更新事件载荷 - */ -export interface PopupDataPayload extends PopupSessionIdentity { - type: PopupType; - data: PopupData; - /** true 表示弹窗首次展示(来自 show()),缺省/false 表示纯数据更新(来自 updateData())。 - * 原生窗口显示由 PopupManager.show() 负责,PopupView 仅用它区分首次聚焦和普通数据刷新。 */ - isShow?: boolean; -} - -/** - * 转发给特定 popup 窗口的键盘事件载荷。 - */ -export interface PopupKeydownPayload { - key: string; - targetType: PopupType; -} - -export interface PopupModelSelectPayload extends PopupSessionIdentity { - modelDbId: number; -} - -export interface PopupSessionOpenPayload extends PopupSessionIdentity { - sessionId: number; -} - -export interface PopupSessionSearchQueryChangePayload extends PopupSessionIdentity { - query: string; -} - -export interface PopupModelSearchQueryChangePayload extends PopupSessionIdentity { - query: string; -} - -/** - * 弹窗事件处理器 - */ -export interface PopupEventHandlers { - onModelSelect?: (modelDbId: number) => void; - onModelSearchQueryChange?: (query: string) => void; - onSessionOpen?: (sessionId: number) => void; - onSessionSearchQueryChange?: (query: string) => void; - onClose?: (payload: PopupClosedPayload) => void; -} diff --git a/apps/desktop/src/views/PopupView/index.vue b/apps/desktop/src/views/PopupView/index.vue index 487df183..57d0515b 100644 --- a/apps/desktop/src/views/PopupView/index.vue +++ b/apps/desktop/src/views/PopupView/index.vue @@ -4,14 +4,16 @@ import { useWindowResize } from '@composables/useWindowResize'; import { AppEvent, eventService } from '@services/EventService'; import { native } from '@services/NativeService'; - import type { PopupDataPayload, PopupKeydownPayload, PopupType } from '@services/PopupService'; import { initializeBuiltInPopups, popupRegistry } from '@services/PopupService'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { computed, nextTick, onMounted, onUnmounted, ref, shallowRef } from 'vue'; + import type { PopupDataPayload, PopupKeydownPayload } from '@/contracts/popup'; + import type { PopupType } from '@/contracts/popupManifest'; import { useSettingsStore } from '@/stores/settings'; import { getPopupTypeFromLocation } from './location'; + import { getPopupComponent } from './popupComponents'; defineOptions({ name: 'PopupWindowView', @@ -30,7 +32,7 @@ const closedPopupIds = new Set(); const popupComponent = computed(() => - popupType.value ? popupRegistry.get(popupType.value)?.component : null + popupType.value ? getPopupComponent(popupType.value) : null ); const popupProps = computed(() => { return { diff --git a/apps/desktop/src/views/PopupView/location.ts b/apps/desktop/src/views/PopupView/location.ts index 741bde5f..12756449 100644 --- a/apps/desktop/src/views/PopupView/location.ts +++ b/apps/desktop/src/views/PopupView/location.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 -import type { PopupType } from '@services/PopupService'; +import type { PopupType } from '@/contracts/popupManifest'; interface LocationLike { hash: string; diff --git a/apps/desktop/src/views/PopupView/popupComponents.ts b/apps/desktop/src/views/PopupView/popupComponents.ts new file mode 100644 index 00000000..e5f32152 --- /dev/null +++ b/apps/desktop/src/views/PopupView/popupComponents.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +import type { Component } from 'vue'; + +import { POPUP_MANIFEST, type PopupType } from '@/contracts/popupManifest'; + +import ModelDropdownPopup from './components/ModelDropdownPopup/index.vue'; +import SessionHistoryPopover from './components/SessionHistoryPopover/index.vue'; + +const popupComponents = { + [POPUP_MANIFEST.modelDropdown.id]: ModelDropdownPopup, + [POPUP_MANIFEST.sessionHistory.id]: SessionHistoryPopover, +} satisfies Record; + +export function getPopupComponent(type: PopupType): Component { + return popupComponents[type]; +} diff --git a/apps/desktop/tests/architecture/import-boundaries.test.ts b/apps/desktop/tests/architecture/import-boundaries.test.ts index 2d34ec61..3434902f 100644 --- a/apps/desktop/tests/architecture/import-boundaries.test.ts +++ b/apps/desktop/tests/architecture/import-boundaries.test.ts @@ -24,11 +24,7 @@ interface RawImport { const importPattern = /\b(?:import|export)\s+(?:type\s+)?(?:[^'";]+?\s+from\s+)?['"]([^'"]+)['"]|\bimport\(\s*['"]([^'"]+)['"]\s*\)/g; -const allowedBaselineViolationFragments = [ - 'database -> services/NativeService', - 'services/EventService -> services/PopupService', - 'services/PopupService -> views/PopupView', -]; +const allowedBaselineViolationFragments = ['database -> services/NativeService']; const allowedBaselineCycles = [ ['database', 'services/NativeService'], diff --git a/apps/desktop/tests/services/PopupService/position.test.ts b/apps/desktop/tests/services/PopupService/position.test.ts index 08a115dd..b3f08432 100644 --- a/apps/desktop/tests/services/PopupService/position.test.ts +++ b/apps/desktop/tests/services/PopupService/position.test.ts @@ -22,7 +22,7 @@ describe('createPopupPositionCalculator', () => { id: 'session-history-popup', width: 320, height: 384, - component: {} as PopupConfig['component'], + positionStrategy: 'session-history-adaptive', calculatePosition, }; From b11ff1e48a10c54f05dc467dd2da98fdda7cac4b Mon Sep 17 00:00:00 2001 From: xlgzsgf <51521689+hiqiancheng@users.noreply.github.com> Date: Mon, 8 Jun 2026 00:22:45 +0800 Subject: [PATCH 06/13] refactor(database): isolate native backup operations Related to #434 --- apps/desktop/src/database/backup.ts | 141 ++---------------- apps/desktop/src/database/driver.ts | 20 +-- .../services/DataManagementService/backup.ts | 131 ++++++++++++++++ .../services/DataManagementService/index.ts | 10 ++ .../services/DatabaseRuntimeService/index.ts | 14 ++ .../src/services/StartupService/index.ts | 2 +- .../components/ImportModeDialog.vue | 2 +- .../components/DataManagement/index.vue | 10 +- .../SettingsView/data-management-i18n.test.ts | 2 +- .../architecture/import-boundaries.test.ts | 2 +- 10 files changed, 184 insertions(+), 150 deletions(-) create mode 100644 apps/desktop/src/services/DataManagementService/backup.ts create mode 100644 apps/desktop/src/services/DataManagementService/index.ts create mode 100644 apps/desktop/src/services/DatabaseRuntimeService/index.ts diff --git a/apps/desktop/src/database/backup.ts b/apps/desktop/src/database/backup.ts index 8373e5cd..28a327a4 100644 --- a/apps/desktop/src/database/backup.ts +++ b/apps/desktop/src/database/backup.ts @@ -1,131 +1,10 @@ -// Copyright (c) 2026. 千诚. Licensed under GPL v3. - -import { native } from '@services/NativeService'; -import { open, save } from '@tauri-apps/plugin-dialog'; - -import { t } from '@/i18n'; - -export type ImportMode = 'chat_only' | 'full'; - -export enum DatabaseVersionStatus { - Compatible = 'compatible', - NeedsMigration = 'needs_migration', - TooNew = 'too_new', -} - -export interface ImportResult { - sourcePath: string; - importMode: ImportMode; - currentBackupPath: string; - sourceBackupPath: string | null; - migratedSource: boolean; -} - -export interface ProgressCallback { - (message: string, progress: number): void; -} - -export class DatabaseBackupCancelledError extends Error { - readonly code = 'DATABASE_BACKUP_CANCELLED'; - - constructor(message: string) { - super(message); - this.name = 'DatabaseBackupCancelledError'; - } -} - -export function isDatabaseBackupCancelledError( - error: unknown -): error is DatabaseBackupCancelledError { - return ( - error instanceof DatabaseBackupCancelledError || - (typeof error === 'object' && - error !== null && - (error as { code?: unknown }).code === 'DATABASE_BACKUP_CANCELLED') - ); -} - -/** - * 数据库备份服务。 - * - * 备份/导入本质上属于数据库运维能力,前端只负责文件选择和进度展示。 - */ -class DatabaseBackupService { - /** - * 导出数据库备份。 - */ - async exportDatabase(onProgress?: ProgressCallback): Promise { - const exportPath = await save({ - defaultPath: this.buildBackupFileName(), - filters: [ - { - name: t('database.backup.dialog.filterName'), - extensions: ['db'], - }, - ], - title: t('database.backup.dialog.exportTitle'), - }); - - if (!exportPath) { - throw new DatabaseBackupCancelledError(t('database.backup.exportCancelled')); - } - - onProgress?.(t('database.backup.exporting'), 30); - await native.database.exportBackup(exportPath); - onProgress?.(t('database.backup.exportComplete'), 100); - - return exportPath; - } - - /** - * 导入数据库备份。 - */ - async importDatabase(mode: ImportMode, onProgress?: ProgressCallback): Promise { - const sourcePath = await open({ - filters: [ - { - name: t('database.backup.dialog.filterName'), - extensions: ['db'], - }, - ], - title: t('database.backup.dialog.importTitle'), - multiple: false, - directory: false, - }); - - if (!sourcePath) { - return { - sourcePath: '', - importMode: mode, - currentBackupPath: '', - sourceBackupPath: null, - migratedSource: false, - }; - } - - onProgress?.(t('database.backup.importing'), 30); - await native.database.importBackup({ - sourcePath, - mode, - }); - onProgress?.(t('database.backup.importComplete'), 100); - - return { - sourcePath, - importMode: mode, - currentBackupPath: '', - sourceBackupPath: null, - migratedSource: false, - }; - } - - /** - * 生成备份文件名。 - */ - private buildBackupFileName(): string { - const timestamp = Math.floor(Date.now() / 1000); - return `touchai-backup-${timestamp}.db`; - } -} - -export const databaseBackup = new DatabaseBackupService(); +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +export { + databaseBackup, + DatabaseBackupCancelledError, + type ImportMode, + type ImportResult, + isDatabaseBackupCancelledError, + type ProgressCallback, +} from '@/services/DataManagementService'; diff --git a/apps/desktop/src/database/driver.ts b/apps/desktop/src/database/driver.ts index 2ac56fbb..011ab6c6 100644 --- a/apps/desktop/src/database/driver.ts +++ b/apps/desktop/src/database/driver.ts @@ -1,13 +1,13 @@ // Copyright (c) 2026. 千诚. Licensed under GPL v3 -import { native } from '@services/NativeService'; -import type { DatabaseQueryMethod } from '@services/NativeService/database'; import { DefaultLogger } from 'drizzle-orm/logger'; import { createTableRelationsHelpers, extractTablesRelationalConfig } from 'drizzle-orm/relations'; import { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core/dialect'; import { SqliteRemoteDatabase } from 'drizzle-orm/sqlite-proxy/driver'; import { SQLiteProxyTransaction, SQLiteRemoteSession } from 'drizzle-orm/sqlite-proxy/session'; +import { type DatabaseQueryMethod, databaseRuntime } from '@/services/DatabaseRuntimeService'; + import type { SqlValue } from './schema'; import * as schema from './schema'; @@ -288,12 +288,12 @@ function mapQueryResponse( function createRuntimeQueryCallback(options?: { txId?: string }): ProxyQueryCallback { return async (sql, params, method) => { const response = options?.txId - ? await native.database.txQuery(options.txId, { + ? await databaseRuntime.txQuery(options.txId, { sql, params, method, }) - : await native.database.query({ + : await databaseRuntime.query({ sql, params, method, @@ -306,8 +306,8 @@ function createRuntimeQueryCallback(options?: { txId?: string }): ProxyQueryCall function createRuntimeBatchCallback(options?: { txId?: string }): ProxyBatchCallback { return async (queries) => { const responses = options?.txId - ? await native.database.txBatch(options.txId, queries) - : await native.database.batch(queries); + ? await databaseRuntime.txBatch(options.txId, queries) + : await databaseRuntime.batch(queries); return responses.map((response, index) => { const query = queries[index]!; @@ -342,7 +342,7 @@ export function createDrizzleDb(): DrizzleDb { const db = new SqliteRemoteDatabase('async', dialect, session, relationalSchema) as DrizzleDb; db.transaction = (async (transaction, config) => { - const txId = await native.database.txBegin(config?.behavior); + const txId = await databaseRuntime.txBegin(config?.behavior); const txSession = new SQLiteRemoteSession( createRuntimeQueryCallback({ txId, @@ -363,11 +363,11 @@ export function createDrizzleDb(): DrizzleDb { try { const result = await transaction(tx); - await native.database.txCommit(txId); + await databaseRuntime.txCommit(txId); return result; } catch (error) { try { - await native.database.txRollback(txId); + await databaseRuntime.txRollback(txId); } catch (rollbackError) { console.error('[Database] Failed to rollback transaction:', rollbackError); } @@ -385,7 +385,7 @@ export async function rawQuery>( sql: string, params: SqlValue[] = [] ): Promise { - const response = await native.database.query({ + const response = await databaseRuntime.query({ sql, params, method: 'all', diff --git a/apps/desktop/src/services/DataManagementService/backup.ts b/apps/desktop/src/services/DataManagementService/backup.ts new file mode 100644 index 00000000..8373e5cd --- /dev/null +++ b/apps/desktop/src/services/DataManagementService/backup.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2026. 千诚. Licensed under GPL v3. + +import { native } from '@services/NativeService'; +import { open, save } from '@tauri-apps/plugin-dialog'; + +import { t } from '@/i18n'; + +export type ImportMode = 'chat_only' | 'full'; + +export enum DatabaseVersionStatus { + Compatible = 'compatible', + NeedsMigration = 'needs_migration', + TooNew = 'too_new', +} + +export interface ImportResult { + sourcePath: string; + importMode: ImportMode; + currentBackupPath: string; + sourceBackupPath: string | null; + migratedSource: boolean; +} + +export interface ProgressCallback { + (message: string, progress: number): void; +} + +export class DatabaseBackupCancelledError extends Error { + readonly code = 'DATABASE_BACKUP_CANCELLED'; + + constructor(message: string) { + super(message); + this.name = 'DatabaseBackupCancelledError'; + } +} + +export function isDatabaseBackupCancelledError( + error: unknown +): error is DatabaseBackupCancelledError { + return ( + error instanceof DatabaseBackupCancelledError || + (typeof error === 'object' && + error !== null && + (error as { code?: unknown }).code === 'DATABASE_BACKUP_CANCELLED') + ); +} + +/** + * 数据库备份服务。 + * + * 备份/导入本质上属于数据库运维能力,前端只负责文件选择和进度展示。 + */ +class DatabaseBackupService { + /** + * 导出数据库备份。 + */ + async exportDatabase(onProgress?: ProgressCallback): Promise { + const exportPath = await save({ + defaultPath: this.buildBackupFileName(), + filters: [ + { + name: t('database.backup.dialog.filterName'), + extensions: ['db'], + }, + ], + title: t('database.backup.dialog.exportTitle'), + }); + + if (!exportPath) { + throw new DatabaseBackupCancelledError(t('database.backup.exportCancelled')); + } + + onProgress?.(t('database.backup.exporting'), 30); + await native.database.exportBackup(exportPath); + onProgress?.(t('database.backup.exportComplete'), 100); + + return exportPath; + } + + /** + * 导入数据库备份。 + */ + async importDatabase(mode: ImportMode, onProgress?: ProgressCallback): Promise { + const sourcePath = await open({ + filters: [ + { + name: t('database.backup.dialog.filterName'), + extensions: ['db'], + }, + ], + title: t('database.backup.dialog.importTitle'), + multiple: false, + directory: false, + }); + + if (!sourcePath) { + return { + sourcePath: '', + importMode: mode, + currentBackupPath: '', + sourceBackupPath: null, + migratedSource: false, + }; + } + + onProgress?.(t('database.backup.importing'), 30); + await native.database.importBackup({ + sourcePath, + mode, + }); + onProgress?.(t('database.backup.importComplete'), 100); + + return { + sourcePath, + importMode: mode, + currentBackupPath: '', + sourceBackupPath: null, + migratedSource: false, + }; + } + + /** + * 生成备份文件名。 + */ + private buildBackupFileName(): string { + const timestamp = Math.floor(Date.now() / 1000); + return `touchai-backup-${timestamp}.db`; + } +} + +export const databaseBackup = new DatabaseBackupService(); diff --git a/apps/desktop/src/services/DataManagementService/index.ts b/apps/desktop/src/services/DataManagementService/index.ts new file mode 100644 index 00000000..76a13758 --- /dev/null +++ b/apps/desktop/src/services/DataManagementService/index.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +export { + databaseBackup, + DatabaseBackupCancelledError, + type ImportMode, + type ImportResult, + isDatabaseBackupCancelledError, + type ProgressCallback, +} from './backup'; diff --git a/apps/desktop/src/services/DatabaseRuntimeService/index.ts b/apps/desktop/src/services/DatabaseRuntimeService/index.ts new file mode 100644 index 00000000..27e38a43 --- /dev/null +++ b/apps/desktop/src/services/DatabaseRuntimeService/index.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +import { native } from '@services/NativeService'; + +export type { + DatabaseImportMode, + DatabaseImportRequest, + DatabaseQueryMethod, + DatabaseQueryRequest, + DatabaseQueryResponse, + DatabaseTransactionBehavior, +} from '@services/NativeService/database'; + +export const databaseRuntime = native.database; diff --git a/apps/desktop/src/services/StartupService/index.ts b/apps/desktop/src/services/StartupService/index.ts index efdb771e..d0ff3db5 100644 --- a/apps/desktop/src/services/StartupService/index.ts +++ b/apps/desktop/src/services/StartupService/index.ts @@ -4,8 +4,8 @@ import { deleteMeta, getMeta } from '@database/queries/touchaiMeta'; import { MetaKey } from '@database/schema'; import { notify } from '@services/NotificationService'; -import type { ImportMode } from '@/database/backup'; import { t } from '@/i18n'; +import type { ImportMode } from '@/services/DataManagementService'; interface ImportSuccessStartupPayload { type: 'import-success'; diff --git a/apps/desktop/src/views/SettingsView/components/DataManagement/components/ImportModeDialog.vue b/apps/desktop/src/views/SettingsView/components/DataManagement/components/ImportModeDialog.vue index 7faaf6d3..1141069c 100644 --- a/apps/desktop/src/views/SettingsView/components/DataManagement/components/ImportModeDialog.vue +++ b/apps/desktop/src/views/SettingsView/components/DataManagement/components/ImportModeDialog.vue @@ -1,7 +1,7 @@