diff --git a/apps/desktop/e2e-tests/test/specs/popup-smoke.e2e.js b/apps/desktop/e2e-tests/test/specs/popup-smoke.e2e.js new file mode 100644 index 00000000..5f4e6ffb --- /dev/null +++ b/apps/desktop/e2e-tests/test/specs/popup-smoke.e2e.js @@ -0,0 +1,61 @@ +import { + closePopupsAndReturn, + expectDisplayedByTestId, + openPopupWindowFromMain, +} from '../support/windows.js'; + +async function waitForEitherDisplayed(testIds) { + await browser.waitUntil( + async () => { + for (const testId of testIds) { + const element = await $(`[data-testid='${testId}']`); + if (await element.isDisplayed().catch(() => false)) { + return true; + } + } + return false; + }, + { + timeout: 30000, + timeoutMsg: `Expected one of these test ids to be displayed: ${testIds.join(', ')}`, + } + ); +} + +describe('TouchAI popup smoke', () => { + it('opens the model dropdown popup through the registered popup window', async () => { + const { mainWindowHandle } = await openPopupWindowFromMain( + 'model-dropdown-popup', + 'openModelDropdownPopup', + 'model-dropdown-popup' + ); + + try { + await expectDisplayedByTestId('model-dropdown-popup'); + await expectDisplayedByTestId('model-dropdown-search-input'); + await waitForEitherDisplayed(['model-dropdown-option', 'model-dropdown-empty']); + } finally { + await closePopupsAndReturn(mainWindowHandle); + } + }); + + it('opens the session history popup through the registered popup window', async () => { + const { mainWindowHandle } = await openPopupWindowFromMain( + 'session-history-popup', + 'openSessionHistoryPopup', + 'session-history-popup' + ); + + try { + await expectDisplayedByTestId('session-history-popup'); + await expectDisplayedByTestId('session-history-search-input'); + await waitForEitherDisplayed([ + 'session-history-item', + 'session-history-empty', + 'session-history-loading', + ]); + } finally { + await closePopupsAndReturn(mainWindowHandle); + } + }); +}); diff --git a/apps/desktop/e2e-tests/test/specs/search-smoke.e2e.js b/apps/desktop/e2e-tests/test/specs/search-smoke.e2e.js index c4ca081d..4dab3979 100644 --- a/apps/desktop/e2e-tests/test/specs/search-smoke.e2e.js +++ b/apps/desktop/e2e-tests/test/specs/search-smoke.e2e.js @@ -1,20 +1,40 @@ +import { waitForE2eBridge } from '../support/windows.js'; + describe('TouchAI search smoke', () => { - it('opens the quick-search panel after typing into the search editor', async () => { + it('opens and closes quick-search results after typing into the search editor', async () => { const editor = await $("[data-testid='search-editor-host'] .ProseMirror"); const quickSearchPanel = await $("[data-testid='quick-search-panel']"); await editor.waitForDisplayed(); - await browser.waitUntil(async () => { - return browser.execute(() => Boolean(window.__TOUCHAI_E2E__)); - }); + await waitForE2eBridge(); await browser.execute((text) => { window.__TOUCHAI_E2E__.setSearchQuery(text); }, 'touchai'); await quickSearchPanel.waitForDisplayed(); + const resultItem = await $("[data-testid='quick-search-result-item']"); + await resultItem.waitForDisplayed(); + + const resultName = await resultItem.getAttribute('data-result-name'); + if (!resultName?.toLowerCase().includes('touchai')) { + throw new Error( + `Expected quick-search result to match "touchai", received "${resultName}"` + ); + } + const editorText = await editor.getText(); if (!editorText.includes('touchai')) { throw new Error(`Expected editor text to contain "touchai", received "${editorText}"`); } + + const viewToggle = await $("[data-testid='quick-search-view-toggle']"); + await viewToggle.waitForDisplayed(); + await viewToggle.click(); + await $("[data-testid='quick-search-result-item']").waitForDisplayed(); + + await browser.execute(() => { + window.__TOUCHAI_E2E__.setSearchQuery(''); + }); + await quickSearchPanel.waitForDisplayed({ reverse: true }); }); }); diff --git a/apps/desktop/e2e-tests/test/specs/settings-smoke.e2e.js b/apps/desktop/e2e-tests/test/specs/settings-smoke.e2e.js index 9efaac97..54610c6c 100644 --- a/apps/desktop/e2e-tests/test/specs/settings-smoke.e2e.js +++ b/apps/desktop/e2e-tests/test/specs/settings-smoke.e2e.js @@ -1,72 +1,41 @@ +import { expectDisplayedByTestId, withSettingsWindow } from '../support/windows.js'; + describe('TouchAI settings smoke', () => { - it('opens the settings window and persists the start-minimized toggle', async () => { - const mainWindowHandle = await browser.getWindowHandle(); - let settingsHandle = null; + it('opens settings, persists launch preferences, and navigates critical sections', async () => { + await withSettingsWindow(async () => { + const settingsView = await expectDisplayedByTestId('settings-view'); + const generalSection = await expectDisplayedByTestId('settings-general-section'); + const startMinimizedToggle = await $("[data-testid='settings-start-minimized-toggle']"); - await browser.waitUntil(async () => { - return browser.execute(() => Boolean(window.__TOUCHAI_E2E__)); - }); + await settingsView.waitForDisplayed(); + await generalSection.waitForDisplayed(); - await browser - .executeAsync((done) => { - window.__TOUCHAI_E2E__ - .openSettingsWindow() - .then(() => done({ ok: true })) - .catch((error) => done({ ok: false, error: String(error) })); - }) - .then((result) => { - if (!result?.ok) { - throw new Error( - `Failed to open settings window: ${result?.error ?? 'unknown error'}` - ); - } + const initialPressed = await startMinimizedToggle.getAttribute('aria-pressed'); + + await startMinimizedToggle.click(); + await browser.waitUntil(async () => { + return (await startMinimizedToggle.getAttribute('aria-pressed')) !== initialPressed; }); - await browser.waitUntil(async () => { - const handles = await browser.getWindowHandles(); - for (const handle of handles) { - if (handle === mainWindowHandle) { - continue; - } + await startMinimizedToggle.click(); + await browser.waitUntil(async () => { + return (await startMinimizedToggle.getAttribute('aria-pressed')) === initialPressed; + }); - await browser.switchToWindow(handle); - const currentUrl = await browser.getUrl(); - if (currentUrl.includes('/settings')) { - settingsHandle = handle; - return true; - } + const sectionChecks = [ + ['ai-services', 'settings-ai-services-panel'], + ['built-in-tools', 'settings-built-in-tools-panel'], + ['mcp-tools', 'settings-mcp-tools-panel'], + ['data-management', 'settings-data-history-list'], + ['general', 'settings-general-section'], + ]; + + for (const [section, expectedTestId] of sectionChecks) { + const navItem = await $(`[data-testid='settings-nav-${section}']`); + await navItem.waitForDisplayed(); + await navItem.click(); + await expectDisplayedByTestId(expectedTestId); } - - await browser.switchToWindow(mainWindowHandle); - return false; }); - - if (!settingsHandle) { - throw new Error('Unable to locate settings window handle.'); - } - - await browser.switchToWindow(settingsHandle); - - const settingsView = await $("[data-testid='settings-view']"); - const generalSection = await $("[data-testid='settings-general-section']"); - const startMinimizedToggle = await $("[data-testid='settings-start-minimized-toggle']"); - - await settingsView.waitForDisplayed(); - await generalSection.waitForDisplayed(); - - const initialPressed = await startMinimizedToggle.getAttribute('aria-pressed'); - - await startMinimizedToggle.click(); - await browser.waitUntil(async () => { - return (await startMinimizedToggle.getAttribute('aria-pressed')) !== initialPressed; - }); - - await startMinimizedToggle.click(); - await browser.waitUntil(async () => { - return (await startMinimizedToggle.getAttribute('aria-pressed')) === initialPressed; - }); - - await browser.closeWindow(); - await browser.switchToWindow(mainWindowHandle); }); }); diff --git a/apps/desktop/e2e-tests/test/support/windows.js b/apps/desktop/e2e-tests/test/support/windows.js new file mode 100644 index 00000000..deca2bde --- /dev/null +++ b/apps/desktop/e2e-tests/test/support/windows.js @@ -0,0 +1,298 @@ +export async function waitForE2eBridge() { + await browser.waitUntil(async () => browser.execute(() => Boolean(window.__TOUCHAI_E2E__)), { + timeoutMsg: 'TouchAI E2E bridge was not installed in the main window.', + }); +} + +async function readWindowSnapshot() { + const url = await browser.getUrl().catch((error) => `unavailable: ${String(error)}`); + const dom = await browser + .execute(() => ({ + bodyText: document.body?.innerText?.replace(/\s+/g, ' ').trim().slice(0, 500) ?? '', + hash: window.location.hash, + href: window.location.href, + readyState: document.readyState, + testIds: Array.from(document.querySelectorAll('[data-testid]')) + .map((element) => element.getAttribute('data-testid')) + .filter(Boolean) + .slice(0, 80), + })) + .catch((error) => ({ + bodyText: '', + hash: '', + href: '', + readyState: `unavailable: ${String(error)}`, + testIds: [], + })); + + return { url, ...dom }; +} + +async function hasElementByTestId(testId) { + return browser.execute( + (id) => Boolean(document.querySelector(`[data-testid='${id}']`)), + testId + ); +} + +async function readLocationHash() { + return browser.execute(() => window.location.hash).catch(() => ''); +} + +export async function switchToMainWindowWithE2eBridge() { + const originalHandle = await browser.getWindowHandle().catch(() => null); + const handles = await browser.getWindowHandles(); + + for (const handle of handles) { + await browser.switchToWindow(handle); + const hasBridge = await browser + .execute(() => Boolean(window.__TOUCHAI_E2E__)) + .catch(() => false); + if (hasBridge) { + return handle; + } + } + + if (originalHandle) { + await browser.switchToWindow(originalHandle).catch(() => undefined); + } + + throw new Error('Unable to locate main window with TouchAI E2E bridge.'); +} + +async function invokeE2eBridge(methodName, ...args) { + await switchToMainWindowWithE2eBridge(); + await waitForE2eBridge(); + + const result = await browser.executeAsync( + (method, methodArgs, done) => { + const bridge = window.__TOUCHAI_E2E__; + const methodToInvoke = bridge?.[method]; + if (typeof methodToInvoke !== 'function') { + done({ + ok: false, + error: `TouchAI E2E bridge method '${method}' is not available.`, + }); + return; + } + + Promise.resolve(methodToInvoke(...methodArgs)) + .then((value) => done({ ok: true, value })) + .catch((error) => done({ ok: false, error: String(error) })); + }, + methodName, + args + ); + + if (!result?.ok) { + throw new Error(`TouchAI E2E bridge method '${methodName}' failed: ${result?.error}`); + } + + return result.value; +} + +async function describeOpenWindows() { + const activeHandle = await browser.getWindowHandle().catch(() => null); + const handles = await browser.getWindowHandles(); + const snapshots = []; + + for (const handle of handles) { + await browser.switchToWindow(handle); + snapshots.push({ + handle, + ...(await readWindowSnapshot()), + }); + } + + if (activeHandle) { + await browser.switchToWindow(activeHandle).catch(() => undefined); + } + + return snapshots; +} + +export async function openSettingsWindowFromMain() { + const existingHandles = new Set(await browser.getWindowHandles()); + const mainWindowHandle = await switchToMainWindowWithE2eBridge(); + + await waitForE2eBridge(); + + const result = await browser.executeAsync((done) => { + window.__TOUCHAI_E2E__ + .openSettingsWindow() + .then(() => done({ ok: true })) + .catch((error) => done({ ok: false, error: String(error) })); + }); + + if (!result?.ok) { + throw new Error(`Failed to open settings window: ${result?.error ?? 'unknown error'}`); + } + + let settingsHandle = null; + try { + await browser.waitUntil( + async () => { + const handles = await browser.getWindowHandles(); + const candidateHandles = [ + ...handles.filter((handle) => !existingHandles.has(handle)), + ...handles.filter((handle) => existingHandles.has(handle)), + ]; + + for (const handle of candidateHandles) { + if (handle === mainWindowHandle) { + continue; + } + + await browser.switchToWindow(handle); + const currentHash = await readLocationHash(); + if (currentHash !== '#/settings') { + continue; + } + + const settingsViewReady = await hasElementByTestId('settings-view'); + if (settingsViewReady) { + settingsHandle = handle; + return true; + } + } + + await browser.switchToWindow(mainWindowHandle); + return false; + }, + { + timeout: 30000, + timeoutMsg: `Unable to locate settings window with mounted settings view. Known handles before open: ${[ + ...existingHandles, + ].join(', ')}`, + } + ); + } catch (error) { + const windows = await describeOpenWindows(); + await browser.switchToWindow(mainWindowHandle).catch(() => undefined); + throw new Error( + `Unable to locate settings window with mounted settings view. ${String( + error + )}\nOpen windows: ${JSON.stringify(windows, null, 2)}`, + { cause: error } + ); + } + + if (!settingsHandle) { + throw new Error('Unable to locate settings window handle.'); + } + + await browser.switchToWindow(settingsHandle); + return { mainWindowHandle, settingsHandle }; +} + +export async function openPopupWindowFromMain(popupType, bridgeMethodName, expectedTestId) { + const existingHandles = new Set(await browser.getWindowHandles()); + const mainWindowHandle = await switchToMainWindowWithE2eBridge(); + + await invokeE2eBridge(bridgeMethodName); + + let popupHandle = null; + try { + await browser.waitUntil( + async () => { + const handles = await browser.getWindowHandles(); + const candidateHandles = [ + ...handles.filter((handle) => !existingHandles.has(handle)), + ...handles.filter((handle) => existingHandles.has(handle)), + ]; + + for (const handle of candidateHandles) { + if (handle === mainWindowHandle) { + continue; + } + + await browser.switchToWindow(handle); + const currentHash = await readLocationHash(); + if (!currentHash.startsWith('#/popup') || !currentHash.includes(popupType)) { + continue; + } + + if (await hasElementByTestId(expectedTestId)) { + popupHandle = handle; + return true; + } + } + + await browser.switchToWindow(mainWindowHandle); + return false; + }, + { + timeout: 30000, + timeoutMsg: `Unable to locate ${popupType} popup window.`, + } + ); + } catch (error) { + const windows = await describeOpenWindows(); + await browser.switchToWindow(mainWindowHandle).catch(() => undefined); + throw new Error( + `Unable to locate ${popupType} popup window. ${String( + error + )}\nOpen windows: ${JSON.stringify(windows, null, 2)}`, + { cause: error } + ); + } + + if (!popupHandle) { + throw new Error(`Unable to locate ${popupType} popup handle.`); + } + + await browser.switchToWindow(popupHandle); + return { mainWindowHandle, popupHandle }; +} + +export async function closePopupsAndReturn(mainWindowHandle) { + await browser.switchToWindow(mainWindowHandle); + await invokeE2eBridge('closePopups'); +} + +export async function closeSettingsWindowAndReturn(mainWindowHandle) { + if (mainWindowHandle) { + await browser.closeWindow(); + await browser.switchToWindow(mainWindowHandle); + return; + } + + const handles = await browser.getWindowHandles(); + for (const handle of handles) { + await browser.switchToWindow(handle); + const hasBridge = await browser + .execute(() => Boolean(window.__TOUCHAI_E2E__)) + .catch(() => false); + if (hasBridge) { + return; + } + } +} + +export async function withSettingsWindow(run) { + const { mainWindowHandle, settingsHandle } = await openSettingsWindowFromMain(); + + try { + return await run({ mainWindowHandle, settingsHandle }); + } finally { + await closeSettingsWindowAndReturn(mainWindowHandle); + } +} + +export async function expectDisplayedByTestId(testId, options = {}) { + const timeout = options.timeout ?? 30000; + const element = await $(`[data-testid='${testId}']`); + try { + await element.waitForExist({ timeout }); + await element.waitForDisplayed({ timeout }); + } catch (error) { + const windows = await describeOpenWindows(); + throw new Error( + `Expected [data-testid='${testId}'] to be displayed. ${String( + error + )}\nOpen windows: ${JSON.stringify(windows, null, 2)}`, + { cause: error } + ); + } + return element; +} diff --git a/apps/desktop/e2e-tests/wdio.conf.js b/apps/desktop/e2e-tests/wdio.conf.js index d179ffa6..4a5fb6ff 100644 --- a/apps/desktop/e2e-tests/wdio.conf.js +++ b/apps/desktop/e2e-tests/wdio.conf.js @@ -175,6 +175,7 @@ export const config = { [ path.join(__dirname, 'test/specs/app-start.e2e.js'), path.join(__dirname, 'test/specs/search-smoke.e2e.js'), + path.join(__dirname, 'test/specs/popup-smoke.e2e.js'), path.join(__dirname, 'test/specs/settings-smoke.e2e.js'), ], ], diff --git a/apps/desktop/src/application/agentErrors.ts b/apps/desktop/src/application/agentErrors.ts new file mode 100644 index 00000000..a5bc3a71 --- /dev/null +++ b/apps/desktop/src/application/agentErrors.ts @@ -0,0 +1,255 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +import { AiErrorCode, type SerializedAiError } from '@/contracts/agentErrors'; +import { type SourceText, tt } from '@/i18n'; + +export { AiErrorCode }; + +/** + * 错误消息映射表 + */ +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') || + originalMessage.includes('\u53d6\u6d88') || + (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(): SerializedAiError { + return { + name: 'AiError', + code: this.code, + message: this.message, + details: this.details, + }; + } +} 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/bootstrap.ts b/apps/desktop/src/bootstrap.ts index 334d10ea..a18c35a8 100644 --- a/apps/desktop/src/bootstrap.ts +++ b/apps/desktop/src/bootstrap.ts @@ -22,6 +22,7 @@ import router from './router'; import { updateModelMetadata } from './services/AgentService/infrastructure/modelMetadata'; import { useSettingsStore } from './stores/settings'; import { initializeFontLoader } from './utils/font'; +import { isE2eTestMode } from './utils/runtimeMode'; const MANAGED_DEEP_LINK_WINDOW_LABEL = 'main'; @@ -170,6 +171,10 @@ async function setupDeepLinkListener(): Promise { async function initializeModelMetadata(): Promise { try { + if (await isE2eTestMode()) { + return; + } + if (await isLlmMetadataEmpty()) { await updateModelMetadata(); return; diff --git a/apps/desktop/src/composables/agent/useAgent.ts b/apps/desktop/src/composables/agent/useAgent.ts index bb5cb378..ef311de3 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 '@/application/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..3b08850b --- /dev/null +++ b/apps/desktop/src/contracts/agentErrors.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +/** + * AI service error codes shared across runtime boundaries. + */ +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', +} + +export interface SerializedAiError { + name: 'AiError'; + code: AiErrorCode; + message: string; + details?: unknown; +} 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/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/databaseRuntime.ts b/apps/desktop/src/contracts/databaseRuntime.ts new file mode 100644 index 00000000..a05486fe --- /dev/null +++ b/apps/desktop/src/contracts/databaseRuntime.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +export type SqlValue = string | number | boolean | null | Uint8Array; +export type SqlParams = SqlValue[]; + +export type DatabaseQueryMethod = 'run' | 'all' | 'get' | 'values'; +export type DatabaseTransactionBehavior = 'deferred' | 'immediate' | 'exclusive'; +export type DatabaseImportMode = 'chat_only' | 'full'; + +export interface DatabaseQueryRequest { + sql: string; + params?: SqlParams; + method: DatabaseQueryMethod; +} + +export interface DatabaseQueryResponse { + rows: Array>; + rowsAffected: number; + lastInsertId: number | null; +} + +export interface DatabaseImportRequest { + sourcePath: string; + mode: DatabaseImportMode; +} diff --git a/apps/desktop/src/contracts/index.ts b/apps/desktop/src/contracts/index.ts new file mode 100644 index 00000000..e75af9b6 --- /dev/null +++ b/apps/desktop/src/contracts/index.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +export type { SerializedAiError } from './agentErrors'; +export { AiErrorCode } from './agentErrors'; +export type * from './attachments'; +export type * from './builtInToolPresentation'; +export type * from './databaseRuntime'; +export type * from './json'; +export type * from './popup'; +export type * from './popupManifest'; +export type * from './providerConfig'; +export type * from './sessionStatus'; +export type * from './tooling'; +export type * from './widgets'; 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/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/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/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 new file mode 100644 index 00000000..de71866f --- /dev/null +++ b/apps/desktop/src/contracts/tooling.ts @@ -0,0 +1,181 @@ +// 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'; + +/** + * 暴露给模型的工具定义。 + */ +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; +} + +/** + * 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/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/database/driver.ts b/apps/desktop/src/database/driver.ts index 2ac56fbb..f77594a1 100644 --- a/apps/desktop/src/database/driver.ts +++ b/apps/desktop/src/database/driver.ts @@ -1,14 +1,14 @@ // 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 { SqlValue } from './schema'; +import type { SqlValue } from '@/contracts/databaseRuntime'; +import { type DatabaseQueryMethod, databaseRuntime } from '@/services/DatabaseRuntimeService'; + import * as schema from './schema'; type ProxyQueryResult = { rows: unknown[] }; @@ -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/database/schema.ts b/apps/desktop/src/database/schema.ts index 5f94c259..96c49051 100644 --- a/apps/desktop/src/database/schema.ts +++ b/apps/desktop/src/database/schema.ts @@ -3,18 +3,6 @@ import { sql } from 'drizzle-orm'; import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; -// ==================== Tauri 相关类型 ==================== - -/** - * SQL 参数类型 - */ -export type SqlValue = string | number | boolean | null | Uint8Array; - -/** - * SQL 参数类型 - */ -export type SqlParams = SqlValue[]; - // ==================== 表定义(Drizzle) ==================== /** 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/contracts/errors.ts b/apps/desktop/src/services/AgentService/contracts/errors.ts index bfe067c6..fa050393 100644 --- a/apps/desktop/src/services/AgentService/contracts/errors.ts +++ b/apps/desktop/src/services/AgentService/contracts/errors.ts @@ -1,295 +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') || - originalMessage.includes('\u53d6\u6d88') || // "取消" (cancel in Chinese) - (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 '@/application/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/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 fd560275..44c6c656 100644 --- a/apps/desktop/src/services/AgentService/execution/runtime.ts +++ b/apps/desktop/src/services/AgentService/execution/runtime.ts @@ -3,17 +3,17 @@ import { updateModelLastUsed } from '@database/queries'; import type { SessionTurnEntity } from '@database/types'; +import { isTouchAiManagedMode, parseProviderConfigJson } from '@/application/providerConfigPolicy'; import { t } from '@/i18n'; import { type AttachmentIndex, ensurePersistedAttachmentIndex, getModelAttachmentCapabilities, getUnsupportedAttachmentTypes, -} from '@/services/AgentService/infrastructure/attachments'; +} from '@/services/AttachmentService'; 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/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/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/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 febbb2bd..0d00ce65 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/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/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..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/AgentService/infrastructure/attachments'; +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/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/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/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/service.ts b/apps/desktop/src/services/BuiltInToolService/service.ts index 0d0a0995..69a0752f 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 '@/application/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..2bd46b13 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 '@/application/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..a2965ae1 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 '@/application/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/helper.ts b/apps/desktop/src/services/BuiltInToolService/tools/setting/helper.ts index f10db14d..d2b870c6 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/setting/helper.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/setting/helper.ts @@ -1,9 +1,23 @@ // Copyright (c) 2026. 千诚. Licensed under GPL v3 +import { getSettingValue, setSetting } from '@database/queries'; +import { AppEvent, eventService, type GeneralSettingKey } from '@services/EventService'; import { native } from '@services/NativeService'; -import { t, tt } from '@/i18n'; -import { type GeneralSettingsData, useSettingsStore } from '@/stores/settings'; +import { + DEFAULT_SEARCH_WINDOW_SIZE_PRESET, + resolveSearchWindowDefaultSize, + type SearchWindowDefaultSize, + type SearchWindowSizePreset, +} from '@/config/searchWindow'; +import { + type AppLocale, + normalizeLocale, + resolveFirstLaunchLocale, + setLocale, + t, + tt, +} from '@/i18n'; import { parseToolArguments } from '../../utils/toolSchema'; import { @@ -19,6 +33,18 @@ import { TOOL_KEY_TO_STORE_KEY, } from './constants'; +export type OutputScrollBehavior = 'follow_output' | 'stay_position' | 'jump_to_top'; + +export interface GeneralSettingsData { + globalShortcut: string; + startOnBoot: boolean; + startMinimized: boolean; + outputScrollBehavior: OutputScrollBehavior; + searchWindowSizePreset: SearchWindowSizePreset; + searchWindowDefaultSize: SearchWindowDefaultSize; + language: AppLocale; +} + export type ParsedSettingRequest = | { action: 'list'; @@ -38,6 +64,191 @@ export type ParsedSettingRequest = export type SettingsStore = Awaited>; +const DEFAULT_GENERAL_SETTINGS: GeneralSettingsData = { + globalShortcut: 'Alt+Space', + startOnBoot: false, + startMinimized: true, + outputScrollBehavior: 'follow_output', + searchWindowSizePreset: DEFAULT_SEARCH_WINDOW_SIZE_PRESET, + searchWindowDefaultSize: resolveSearchWindowDefaultSize(DEFAULT_SEARCH_WINDOW_SIZE_PRESET), + language: 'zh-CN', +}; + +const outputScrollBehaviorValues = ['follow_output', 'stay_position', 'jump_to_top'] as const; + +function createDefaultGeneralSettings(): GeneralSettingsData { + return { + ...DEFAULT_GENERAL_SETTINGS, + searchWindowDefaultSize: { + ...DEFAULT_GENERAL_SETTINGS.searchWindowDefaultSize, + }, + }; +} + +function normalizeOutputScrollBehavior(value: string | null): OutputScrollBehavior { + return outputScrollBehaviorValues.includes(value as OutputScrollBehavior) + ? (value as OutputScrollBehavior) + : DEFAULT_GENERAL_SETTINGS.outputScrollBehavior; +} + +function normalizeSearchWindowSizePreset(value: unknown): SearchWindowSizePreset { + const result = settingValueSchemaByKey.search_window_size_preset.safeParse(value); + return result.success ? result.data : DEFAULT_GENERAL_SETTINGS.searchWindowSizePreset; +} + +function resolvePersistedLanguage(language: string | null): AppLocale { + if (language === null) { + return resolveFirstLaunchLocale(); + } + + return normalizeLocale(language); +} + +function applySearchWindowSizePreset( + settings: GeneralSettingsData, + preset: SearchWindowSizePreset +): void { + settings.searchWindowSizePreset = preset; + settings.searchWindowDefaultSize = { + ...resolveSearchWindowDefaultSize(preset), + }; +} + +function serializeSetting(settings: GeneralSettingsData, key: GeneralSettingKey): string { + switch (key) { + case 'global_shortcut': + return settings.globalShortcut; + case 'start_on_boot': + return String(settings.startOnBoot); + case 'start_minimized': + return String(settings.startMinimized); + case 'output_scroll_behavior': + return settings.outputScrollBehavior; + case 'search_window_size_preset': + return settings.searchWindowSizePreset; + case 'language': + return settings.language; + default: + return ''; + } +} + +function payloadValueForEvent(settings: GeneralSettingsData, key: GeneralSettingKey) { + switch (key) { + case 'global_shortcut': + return settings.globalShortcut; + case 'start_on_boot': + return settings.startOnBoot; + case 'start_minimized': + return settings.startMinimized; + case 'output_scroll_behavior': + return settings.outputScrollBehavior; + case 'search_window_size_preset': + return settings.searchWindowSizePreset; + case 'language': + return settings.language; + default: + return ''; + } +} + +async function persistDefaultIfMissing( + settings: GeneralSettingsData, + key: GeneralSettingKey, + currentValue: string | null +): Promise { + if (currentValue !== null) { + return; + } + + await setSetting({ key, value: serializeSetting(settings, key) }); +} + +async function loadGeneralSettings(): Promise { + const settings = createDefaultGeneralSettings(); + const [ + globalShortcut, + startOnBoot, + startMinimized, + outputScroll, + searchWindowSizePreset, + language, + ] = await Promise.all([ + getSettingValue({ key: 'global_shortcut' }), + getSettingValue({ key: 'start_on_boot' }), + getSettingValue({ key: 'start_minimized' }), + getSettingValue({ key: 'output_scroll_behavior' }), + getSettingValue({ key: 'search_window_size_preset' }), + getSettingValue({ key: 'language' }), + ]); + + settings.globalShortcut = globalShortcut || DEFAULT_GENERAL_SETTINGS.globalShortcut; + settings.startOnBoot = + startOnBoot === null ? DEFAULT_GENERAL_SETTINGS.startOnBoot : startOnBoot === 'true'; + settings.startMinimized = + startMinimized === null + ? DEFAULT_GENERAL_SETTINGS.startMinimized + : startMinimized === 'true'; + settings.outputScrollBehavior = normalizeOutputScrollBehavior(outputScroll); + applySearchWindowSizePreset(settings, normalizeSearchWindowSizePreset(searchWindowSizePreset)); + settings.language = resolvePersistedLanguage(language); + setLocale(settings.language); + + await Promise.allSettled([ + persistDefaultIfMissing(settings, 'global_shortcut', globalShortcut), + persistDefaultIfMissing(settings, 'start_on_boot', startOnBoot), + persistDefaultIfMissing(settings, 'start_minimized', startMinimized), + persistDefaultIfMissing(settings, 'output_scroll_behavior', outputScroll), + persistDefaultIfMissing(settings, 'search_window_size_preset', searchWindowSizePreset), + persistDefaultIfMissing(settings, 'language', language), + ]); + + return settings; +} + +function applySetting( + settings: GeneralSettingsData, + key: SupportedSettingKey, + value: unknown +): void { + switch (key) { + case 'global_shortcut': + settings.globalShortcut = String(value || DEFAULT_GENERAL_SETTINGS.globalShortcut); + break; + case 'start_on_boot': + settings.startOnBoot = typeof value === 'boolean' ? value : String(value) === 'true'; + break; + case 'start_minimized': + settings.startMinimized = typeof value === 'boolean' ? value : String(value) === 'true'; + break; + case 'output_scroll_behavior': + settings.outputScrollBehavior = normalizeOutputScrollBehavior(String(value)); + break; + case 'search_window_size_preset': + applySearchWindowSizePreset(settings, normalizeSearchWindowSizePreset(value)); + break; + case 'language': + settings.language = normalizeLocale(value); + setLocale(settings.language); + break; + } +} + +async function updateSettingValue( + settings: GeneralSettingsData, + key: SupportedSettingKey, + value: SupportedSettingValue +): Promise { + applySetting(settings, key, value); + await setSetting({ key, value: serializeSetting(settings, key) }); + await eventService.emit(AppEvent.SETTINGS_GENERAL_UPDATED, { + sourceId: 'built-in-setting-tool', + windowLabel: 'agent', + key, + value: payloadValueForEvent(settings, key), + }); +} + function normalizeUpdateValue(key: SupportedSettingKey, value: unknown): SupportedSettingValue { const result = settingValueSchemaByKey[key].safeParse(value); if (!result.success) { @@ -83,9 +294,23 @@ export function parseSettingRequest(args: Record): ParsedSettin } export async function prepareSettingsStore() { - const settingsStore = useSettingsStore(); - await settingsStore.initialize(); - return settingsStore; + const settings = await loadGeneralSettings(); + + return { + settings, + updateGlobalShortcut: (value: GeneralSettingsData['globalShortcut']) => + updateSettingValue(settings, 'global_shortcut', value), + updateStartOnBoot: (value: GeneralSettingsData['startOnBoot']) => + updateSettingValue(settings, 'start_on_boot', value), + updateStartMinimized: (value: GeneralSettingsData['startMinimized']) => + updateSettingValue(settings, 'start_minimized', value), + updateOutputScrollBehavior: (value: GeneralSettingsData['outputScrollBehavior']) => + updateSettingValue(settings, 'output_scroll_behavior', value), + updateSearchWindowSizePreset: (value: GeneralSettingsData['searchWindowSizePreset']) => + updateSettingValue(settings, 'search_window_size_preset', value), + updateLanguage: (value: GeneralSettingsData['language']) => + updateSettingValue(settings, 'language', value), + }; } function toStoreSettingKey(key: SupportedSettingKey): StoreSettingKey { @@ -93,7 +318,7 @@ function toStoreSettingKey(key: SupportedSettingKey): StoreSettingKey { } function readSettingValueFromStoreState( - settingsStore: ReturnType, + settingsStore: SettingsStore, key: SupportedSettingKey ): SupportedSettingValue { const storeKey = toStoreSettingKey(key); @@ -101,7 +326,7 @@ function readSettingValueFromStoreState( } export async function readCurrentSettingValue( - settingsStore: ReturnType, + settingsStore: SettingsStore, key: SupportedSettingKey ): Promise { if (key === 'start_on_boot') { diff --git a/apps/desktop/src/services/BuiltInToolService/tools/setting/index.ts b/apps/desktop/src/services/BuiltInToolService/tools/setting/index.ts index bd60fb60..e3236d35 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/setting/index.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/setting/index.ts @@ -3,9 +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'; import { @@ -25,6 +24,7 @@ import { import { formatShortcutRegistrationError, formatSingleUpdate, + type GeneralSettingsData, getSettings, listSupportedSettings, type ParsedSettingRequest, 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 de78212d..f5ef5af0 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 f7d9e197..0cff1f74 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/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/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..8b2bc9cc 100644 --- a/apps/desktop/src/services/BuiltInToolService/types.ts +++ b/apps/desktop/src/services/BuiltInToolService/types.ts @@ -3,16 +3,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 '@/services/AgentService/contracts/tooling'; -import type { AttachmentIndex } from '@/services/AgentService/infrastructure/attachments'; +} 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/database/backup.ts b/apps/desktop/src/services/DataManagementService/backup.ts similarity index 100% rename from apps/desktop/src/database/backup.ts rename to apps/desktop/src/services/DataManagementService/backup.ts 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..9e3b6a54 --- /dev/null +++ b/apps/desktop/src/services/DatabaseRuntimeService/index.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +import { native } from '@services/NativeService'; + +export type { + DatabaseImportMode, + DatabaseImportRequest, + DatabaseQueryMethod, + DatabaseQueryRequest, + DatabaseQueryResponse, + DatabaseTransactionBehavior, + SqlParams, + SqlValue, +} from '@/contracts/databaseRuntime'; + +export const databaseRuntime = native.database; diff --git a/apps/desktop/src/services/EventService/types.ts b/apps/desktop/src/services/EventService/types.ts index 280dfd93..1ccb60a4 100644 --- a/apps/desktop/src/services/EventService/types.ts +++ b/apps/desktop/src/services/EventService/types.ts @@ -9,11 +9,10 @@ import type { PopupReadyPayload, PopupSessionOpenPayload, PopupSessionSearchQueryChangePayload, -} from '@services/PopupService/types'; +} from '@/contracts/popup'; +import type { SessionStatusReminderKind } from '@/contracts/sessionStatus'; -import type { SessionStatusReminderKind } from '@/utils/session'; - -export type { SessionStatusReminderKind } from '@/utils/session'; +export type { SessionStatusReminderKind } from '@/contracts/sessionStatus'; /** * 事件类型定义 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/services/NativeService/database.ts b/apps/desktop/src/services/NativeService/database.ts index 89952937..867e506c 100644 --- a/apps/desktop/src/services/NativeService/database.ts +++ b/apps/desktop/src/services/NativeService/database.ts @@ -1,28 +1,24 @@ // Copyright (c) 2026. 千诚. Licensed under GPL v3. -import type { SqlValue } from '@database/schema'; import { invoke } from '@tauri-apps/api/core'; -export type DatabaseQueryMethod = 'run' | 'all' | 'get' | 'values'; -export type DatabaseTransactionBehavior = 'deferred' | 'immediate' | 'exclusive'; -export type DatabaseImportMode = 'chat_only' | 'full'; - -export interface DatabaseQueryRequest { - sql: string; - params?: SqlValue[]; - method: DatabaseQueryMethod; -} - -export interface DatabaseQueryResponse { - rows: Array>; - rowsAffected: number; - lastInsertId: number | null; -} - -export interface DatabaseImportRequest { - sourcePath: string; - mode: DatabaseImportMode; -} +import type { + DatabaseImportRequest, + DatabaseQueryRequest, + DatabaseQueryResponse, + DatabaseTransactionBehavior, +} from '@/contracts/databaseRuntime'; + +export type { + DatabaseImportMode, + DatabaseImportRequest, + DatabaseQueryMethod, + DatabaseQueryRequest, + DatabaseQueryResponse, + DatabaseTransactionBehavior, + SqlParams, + SqlValue, +} from '@/contracts/databaseRuntime'; export const database = { query(request: DatabaseQueryRequest): Promise { 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/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/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/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..1dd491e4 100644 --- a/apps/desktop/src/types/session.ts +++ b/apps/desktop/src/types/session.ts @@ -2,13 +2,13 @@ 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 { 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/src/views/PopupView/components/ModelDropdownPopup/index.vue b/apps/desktop/src/views/PopupView/components/ModelDropdownPopup/index.vue index 24869ae7..b881ada2 100644 --- a/apps/desktop/src/views/PopupView/components/ModelDropdownPopup/index.vue +++ b/apps/desktop/src/views/PopupView/components/ModelDropdownPopup/index.vue @@ -2,6 +2,7 @@