From 89e475c0c210b700c085bbe48a41a1921f4249e6 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Sat, 5 Sep 2026 09:48:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E8=A1=A5=E9=BD=90=E8=8B=B1?= =?UTF-8?q?=E6=96=87=E5=A4=B1=E8=B4=A5=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- frontend/src/api.ts | 4 +- frontend/src/features/mcp/configuration.ts | 59 ++++++++++--------- .../src/features/plugins/PluginMcpPanel.vue | 12 ++-- .../settings/ModelRoutingSettings.vue | 10 ++-- .../src/features/settings/ProviderForm.vue | 4 +- frontend/src/services/mediaService.ts | 3 +- frontend/src/services/workspaceService.ts | 5 +- frontend/src/stores/agent.ts | 3 +- frontend/src/stores/chat.ts | 7 ++- frontend/src/stores/editor.ts | 5 +- frontend/src/stores/plugin.ts | 3 +- frontend/src/stores/provider.ts | 11 ++-- frontend/src/stores/search.ts | 9 +-- frontend/src/stores/settings.ts | 10 ++-- frontend/src/stores/skill.ts | 3 +- frontend/src/stores/task.ts | 3 +- 17 files changed, 83 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 3867158..2b7a477 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ pnpm test pnpm build ``` -阶段 F 合并时的回归基线为后端 559 项、前端 103 项测试通过,TypeScript 类型检查与生产构建通过。存在一条既有 Starlette/httpx 弃用提示和 Vite 大 bundle 提示;测试数量以当前分支实际输出和 CI 为准。 +当前回归基线为后端 559 项、前端 106 项测试通过,TypeScript 类型检查与生产构建通过。存在一条既有 Starlette/httpx 弃用提示和 Vite 大 bundle 提示;测试数量以当前分支实际输出和 CI 为准。 ## 文档 diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ea07804..12a64a5 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,3 +1,5 @@ +import { t } from '@/i18n' + export interface ServiceStatus { name: string version: string @@ -10,7 +12,7 @@ const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '' export async function getServiceStatus(): Promise { const response = await fetch(`${apiBaseUrl}/api/status`) if (!response.ok) { - throw new Error(`后端请求失败:HTTP ${response.status}`) + throw new Error(`${t('后端请求失败:', 'Backend request failed: ')}HTTP ${response.status}`) } return response.json() as Promise } diff --git a/frontend/src/features/mcp/configuration.ts b/frontend/src/features/mcp/configuration.ts index 4afbab7..a12aee4 100644 --- a/frontend/src/features/mcp/configuration.ts +++ b/frontend/src/features/mcp/configuration.ts @@ -1,4 +1,5 @@ import type { McpServerInput } from '@/contracts' +import { t } from '@/i18n' export type SecretKind = 'environment' | 'header' export interface ImportedSecret { kind: SecretKind; key: string; value: string } @@ -26,38 +27,38 @@ export function emptyMcpConfig(): McpServerInput { } function object(value: unknown, label: string): Record { - if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error(`${label}必须是 JSON 对象`) + if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error(`${label}${t('必须是 JSON 对象', ' must be a JSON object')}`) return value as Record } function strings(value: unknown, label: string): string[] { if (value === undefined) return [] - if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串数组`) + if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error(`${label}${t('必须是字符串数组', ' must be a string array')}`) return [...value] } function entries(value: unknown, label: string): Record { if (value === undefined) return {} const result = object(value, label) - if (Object.values(result).some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串键值 JSON 对象`) + if (Object.values(result).some(item => typeof item !== 'string')) throw new Error(`${label}${t('必须是字符串键值 JSON 对象', ' must be a JSON object with string keys and values')}`) return { ...result } as Record } function timeout(value: unknown, fallback: number, max: number, label: string): number { if (value === undefined) return fallback - if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > max) throw new Error(`${label}必须是 1–${max} 秒之间的数字`) + if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > max) throw new Error(`${label}${t(`必须是 1–${max} 秒之间的数字`, ` must be a number from 1 to ${max} seconds`)}`) return value } // Do not silently rewrite executable arguments or secret values copied from chat. function checkUrl(value: string, label: string) { - if (/^\[https?:\/\//i.test(value)) throw new Error(`${label}请填写纯 URL,不要粘贴 Markdown 链接`) + if (/^\[https?:\/\//i.test(value)) throw new Error(`${label}${t('请填写纯 URL,不要粘贴 Markdown 链接', ': enter a plain URL instead of a Markdown link')}`) } export function parseMcpJson(raw: string, fallbackName = '', requireConnection = true) { let parsed: unknown try { parsed = JSON.parse(raw) } - catch { throw new Error('服务器配置不是有效 JSON;请检查逗号、引号和无效的 \\_ 转义') } + catch { throw new Error(t('服务器配置不是有效 JSON;请检查逗号、引号和无效的 \\_ 转义', 'The server configuration is not valid JSON. Check commas, quotes, and invalid \\_ escapes.')) } return normalizeMcpConfig(parsed, fallbackName, requireConnection) } @@ -65,54 +66,54 @@ export function parseMcpJson(raw: string, fallbackName = '', requireConnection = * Inline secrets leave the public config here and are sent only to the Secret API. */ export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireConnection = true) { - let raw = object(parsed, '服务器配置') + let raw = object(parsed, t('服务器配置', 'Server configuration')) if ('mcpServers' in raw) { const servers = Object.entries(object(raw.mcpServers, 'mcpServers')) - if (servers.length !== 1) throw new Error('请一次导入一个 MCP 服务器') + if (servers.length !== 1) throw new Error(t('请一次导入一个 MCP 服务器', 'Import one MCP server at a time')) fallbackName = servers[0]![0] - raw = object(servers[0]![1], '服务器配置') + raw = object(servers[0]![1], t('服务器配置', 'Server configuration')) } const allowed = new Set([...Object.keys(emptyMcpConfig()), 'version', 'env', 'type', 'timeout', 'sse_read_timeout']) if (Object.keys(raw).some(key => !allowed.has(key))) { // Never echo arbitrary unknown keys: pasted secrets sometimes become JSON keys. - throw new Error('服务器配置含不支持的字段;API Key 请放在 env/environment 的对应变量中,不要放在顶层') + throw new Error(t('服务器配置含不支持的字段;API Key 请放在 env/environment 的对应变量中,不要放在顶层', 'The server configuration contains unsupported fields. Put API keys in the corresponding env/environment variables, not at the top level.')) } - if (raw.env !== undefined && raw.environment !== undefined) throw new Error('env 与 environment 请只保留一个,避免覆盖配置') + if (raw.env !== undefined && raw.environment !== undefined) throw new Error(t('env 与 environment 请只保留一个,避免覆盖配置', 'Keep either env or environment, not both')) const transport = raw.transport ?? raw.type ?? (raw.url ? 'streamable_http' : 'stdio') - if (!['stdio', 'streamable_http', 'sse'].includes(transport as string)) throw new Error('transport 必须是 stdio、streamable_http 或 sse') + if (!['stdio', 'streamable_http', 'sse'].includes(transport as string)) throw new Error(t('transport 必须是 stdio、streamable_http 或 sse', 'transport must be stdio, streamable_http, or sse')) const config = emptyMcpConfig() config.transport = transport as McpServerInput['transport'] - const name = raw.name ?? (fallbackName || (typeof raw.command === 'string' ? raw.command : 'MCP 服务器')) - if (typeof name !== 'string' || (requireConnection && !name.trim()) || name.trim().length > 80) throw new Error('服务器名称必须为 1–80 个字符') + const name = raw.name ?? (fallbackName || (typeof raw.command === 'string' ? raw.command : t('MCP 服务器', 'MCP Server'))) + if (typeof name !== 'string' || (requireConnection && !name.trim()) || name.trim().length > 80) throw new Error(t('服务器名称必须为 1–80 个字符', 'The server name must contain 1–80 characters')) config.name = name.trim() for (const key of ['command', 'url'] as const) { const value = raw[key] - if (value !== undefined && value !== null && typeof value !== 'string') throw new Error(`${key}必须是字符串`) + if (value !== undefined && value !== null && typeof value !== 'string') throw new Error(`${key}${t('必须是字符串', ' must be a string')}`) config[key] = typeof value === 'string' ? value.trim() : null } config.args = strings(raw.args, 'args') - if (config.args.length > 64) throw new Error('args 最多允许 64 项') - for (const value of config.args) checkUrl(value, 'args 中的地址') + if (config.args.length > 64) throw new Error(t('args 最多允许 64 项', 'args allows at most 64 items')) + for (const value of config.args) checkUrl(value, t('args 中的地址', 'URL in args')) config.environment = entries(raw.environment ?? raw.env, 'environment/env') config.headers = entries(raw.headers, 'headers') config.secret_environment_keys = [...new Set(strings(raw.secret_environment_keys, 'secret_environment_keys'))] config.secret_header_keys = [...new Set(strings(raw.secret_header_keys, 'secret_header_keys'))] config.permissions = strings(raw.permissions, 'permissions') - config.startup_timeout_seconds = timeout(raw.startup_timeout_seconds ?? raw.timeout, 15, 120, '启动超时') + config.startup_timeout_seconds = timeout(raw.startup_timeout_seconds ?? raw.timeout, 15, 120, t('启动超时', 'Startup timeout')) // Compatibility policy: legacy read timeout becomes the tool wait budget, not an SSE transport setting. - config.tool_timeout_seconds = timeout(raw.tool_timeout_seconds ?? raw.sse_read_timeout, 30, 300, '工具超时') + config.tool_timeout_seconds = timeout(raw.tool_timeout_seconds ?? raw.sse_read_timeout, 30, 300, t('工具超时', 'Tool timeout')) if (config.transport === 'stdio') { - if (requireConnection && !config.command) throw new Error('stdio 配置必须填写 command') - if (config.url || Object.keys(config.headers).length || config.secret_header_keys.length) throw new Error('stdio 配置不能包含 URL 或 HTTP Header') + if (requireConnection && !config.command) throw new Error(t('stdio 配置必须填写 command', 'stdio configuration requires command')) + if (config.url || Object.keys(config.headers).length || config.secret_header_keys.length) throw new Error(t('stdio 配置不能包含 URL 或 HTTP Header', 'stdio configuration cannot contain a URL or HTTP headers')) } else { - if (requireConnection && !config.url) throw new Error('HTTP/SSE 配置必须填写 url') + if (requireConnection && !config.url) throw new Error(t('HTTP/SSE 配置必须填写 url', 'HTTP/SSE configuration requires a URL')) if (config.url) { checkUrl(config.url, 'url') let url: URL - try { url = new URL(config.url) } catch { throw new Error('url 必须是有效的 HTTP(S) 地址') } - if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) throw new Error('url 必须为不含账号密码或片段的 HTTP(S) 地址') + try { url = new URL(config.url) } catch { throw new Error(t('url 必须是有效的 HTTP(S) 地址', 'url must be a valid HTTP(S) address')) } + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) throw new Error(t('url 必须为不含账号密码或片段的 HTTP(S) 地址', 'url must be an HTTP(S) address without credentials or a fragment')) } - if (config.command || config.args.length || Object.keys(config.environment).length || config.secret_environment_keys.length) throw new Error('HTTP/SSE 配置不能包含 command、args 或环境变量') + if (config.command || config.args.length || Object.keys(config.environment).length || config.secret_environment_keys.length) throw new Error(t('HTTP/SSE 配置不能包含 command、args 或环境变量', 'HTTP/SSE configuration cannot contain command, args, or environment variables')) } const secrets: ImportedSecret[] = [] for (const kind of ['environment', 'header'] as const) { @@ -120,19 +121,19 @@ export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireCo const keys = kind === 'environment' ? config.secret_environment_keys : config.secret_header_keys const identity = (key: string) => kind === 'header' ? key.toLowerCase() : key const allKeys = [...Object.keys(values), ...keys] - if (kind === 'header' && (new Set(keys.map(identity)).size !== keys.length || new Set(Object.keys(values).map(identity)).size !== Object.keys(values).length)) throw new Error('HTTP Header 名称不能仅大小写不同而重复声明') + if (kind === 'header' && (new Set(keys.map(identity)).size !== keys.length || new Set(Object.keys(values).map(identity)).size !== Object.keys(values).length)) throw new Error(t('HTTP Header 名称不能仅大小写不同而重复声明', 'HTTP header names cannot be duplicated with case-only differences')) const validKey = kind === 'environment' ? /^[A-Za-z_][A-Za-z0-9_]{0,127}$/ : /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$/ - if (allKeys.some(key => !validKey.test(key))) throw new Error(`${kind === 'environment' ? '环境变量' : 'Header'}名称无效;敏感变量名只能填名称,不能填密钥值`) + if (allKeys.some(key => !validKey.test(key))) throw new Error(`${kind === 'environment' ? t('环境变量', 'Environment variable') : 'Header'}${t('名称无效;敏感变量名只能填名称,不能填密钥值', ' name is invalid; secret variable declarations accept names only, not secret values')}`) for (const [key, value] of Object.entries(values)) { const declared = keys.find(item => identity(item) === identity(key)) const sensitive = /api[_-]?key|token|secret|password|authorization|cookie|credential/i.test(key) if (declared || sensitive) { - if (!value || value.length > 32768) throw new Error('密钥值必须为 1–32768 个字符') + if (!value || value.length > 32768) throw new Error(t('密钥值必须为 1–32768 个字符', 'Secret values must contain 1–32768 characters')) const secretKey = declared ?? key if (!declared) keys.push(key) secrets.push({ kind, key: secretKey, value }) delete values[key] - } else if (/host|url|endpoint/i.test(key)) checkUrl(value, '环境变量或 Header 地址') + } else if (/host|url|endpoint/i.test(key)) checkUrl(value, t('环境变量或 Header 地址', 'Environment variable or Header URL')) } } return { config, secrets } diff --git a/frontend/src/features/plugins/PluginMcpPanel.vue b/frontend/src/features/plugins/PluginMcpPanel.vue index 2403f68..8ec3b13 100644 --- a/frontend/src/features/plugins/PluginMcpPanel.vue +++ b/frontend/src/features/plugins/PluginMcpPanel.vue @@ -81,7 +81,7 @@ async function loadActive() { } } } catch (reason) { - if (version === loadVersion) feedback(message(reason, 'MCP 数据加载失败')) + if (version === loadVersion) feedback(message(reason, t('MCP 数据加载失败', 'Failed to load MCP data'))) } finally { if (version === loadVersion) loading.value = false } @@ -94,7 +94,7 @@ async function restartHost() { host.value = await pluginService.getPluginHostStatus(props.plugin.plugin_id) await pluginStore.loadPlugins() notice.value = t('MCP Host 已重启。', 'MCP Host restarted.') - } catch (reason) { feedback(message(reason, 'MCP Host 重启失败')) } finally { busy.value = '' } + } catch (reason) { feedback(message(reason, t('MCP Host 重启失败', 'Failed to restart MCP Host'))) } finally { busy.value = '' } } function updateValue(field: PluginSettingField, raw: string | boolean) { values.value[field.key] = field.type === 'number' && typeof raw === 'string' ? (raw === '' ? null : Number(raw)) : raw @@ -107,7 +107,7 @@ async function saveSettings() { schema.value = await pluginService.updatePluginSettings(props.plugin.plugin_id, schema.value.schema_version, values.value) values.value = { ...schema.value.values } notice.value = t('普通设置已保存。', 'Settings saved.') - } catch (reason) { feedback(message(reason, '设置保存失败')) } finally { busy.value = '' } + } catch (reason) { feedback(message(reason, t('设置保存失败', 'Failed to save settings'))) } finally { busy.value = '' } } async function saveSecret(field: PluginSettingField) { const secret = secrets.value[field.key]?.trim() @@ -119,7 +119,7 @@ async function saveSecret(field: PluginSettingField) { if (schema.value) schema.value.secrets[field.key] = { configured: state.configured } secrets.value[field.key] = '' notice.value = field.label + t('已加密保存。', ' encrypted and saved.') - } catch (reason) { feedback(message(reason, '密钥保存失败')) } finally { busy.value = '' } + } catch (reason) { feedback(message(reason, t('密钥保存失败', 'Failed to save secret'))) } finally { busy.value = '' } } async function deleteSecret(field: PluginSettingField) { if (!confirm(t('删除已保存的', 'Delete saved ') + field.label + '?')) return @@ -130,7 +130,7 @@ async function deleteSecret(field: PluginSettingField) { if (schema.value) schema.value.secrets[field.key] = { configured: state.configured } secrets.value[field.key] = '' notice.value = field.label + t('已删除。', ' deleted.') - } catch (reason) { feedback(message(reason, '密钥删除失败')) } finally { busy.value = '' } + } catch (reason) { feedback(message(reason, t('密钥删除失败', 'Failed to delete secret'))) } finally { busy.value = '' } } function properties(command: PluginCommand): Record> { const result = command.parameters.properties @@ -178,7 +178,7 @@ async function execute(command: PluginCommand) { await loadActive() notice.value = t('相关数据已刷新。', 'Related data refreshed.') } else notice.value = t('命令执行完成。', 'Command completed.') - } catch (reason) { feedback(message(reason, '命令执行失败')) } finally { busy.value = '' } + } catch (reason) { feedback(message(reason, t('命令执行失败', 'Command failed'))) } finally { busy.value = '' } } diff --git a/frontend/src/features/settings/ModelRoutingSettings.vue b/frontend/src/features/settings/ModelRoutingSettings.vue index 530fb55..d0e1f91 100644 --- a/frontend/src/features/settings/ModelRoutingSettings.vue +++ b/frontend/src/features/settings/ModelRoutingSettings.vue @@ -54,7 +54,7 @@ async function load() { applyResponse(routing) conflict.value = false } catch (reason) { - if (active) error.value = `加载失败:${reason instanceof Error ? reason.message : '无法读取模型路由或提供商'}` + if (active) error.value = `${t('加载失败:', 'Load failed: ')}${reason instanceof Error ? reason.message : t('无法读取模型路由或提供商', 'Could not read model routes or providers')}` } finally { loading.value = false } } @@ -74,11 +74,11 @@ function bindingFor(capability: RoutingCapability): ModelBinding | null { if (!draft.provider_id) return null if (!available.value.some(provider => provider.provider_id === draft.provider_id)) throw new Error(t('请选择已启用且协议可用的提供商,或切换到本地。', 'Select an enabled provider with a supported protocol, or switch to local.')) if (!draft.model.trim()) throw new Error(t('请填写所选 API 的模型 ID。', 'Enter the model ID for the selected API.')) - if (!/^\/[A-Za-z0-9_/-]+$/.test(draft.endpoint) || draft.endpoint.startsWith('//')) throw new Error('Endpoint 必须是以 / 开头的相对路径,只能包含字母、数字、下划线、连字符和 /。') + if (!/^\/[A-Za-z0-9_/-]+$/.test(draft.endpoint) || draft.endpoint.startsWith('//')) throw new Error(t('Endpoint 必须是以 / 开头的相对路径,只能包含字母、数字、下划线、连字符和 /。', 'Endpoint must be a relative path beginning with / and containing only letters, numbers, underscores, hyphens, and /.')) const binding: ModelBinding = { provider_id: draft.provider_id, model: draft.model.trim(), endpoint: draft.endpoint } if (capability === 'embedding') { const dimension = String(draft.dimensions).trim() - if (dimension && (!/^\d+$/.test(dimension) || !Number.isSafeInteger(Number(dimension)) || Number(dimension) < 1 || Number(dimension) > 16384)) throw new Error('嵌入维度必须为 1–16384 的整数,或留空使用 API 默认值。') + if (dimension && (!/^\d+$/.test(dimension) || !Number.isSafeInteger(Number(dimension)) || Number(dimension) < 1 || Number(dimension) > 16384)) throw new Error(t('嵌入维度必须为 1–16384 的整数,或留空使用 API 默认值。', 'Embedding dimensions must be an integer from 1 to 16384, or blank to use the API default.')) binding.dimensions = dimension ? Number(dimension) : null } return binding @@ -100,8 +100,8 @@ async function save() { if (!active) return conflict.value = reason instanceof ApiErrorClass && /CONFLICT|VERSION|HTTP_409/i.test(reason.code) error.value = conflict.value - ? '配置版本冲突:其他窗口已修改路由。当前输入尚未保存,请重新加载最新配置后再编辑。' - : `保存失败:${reason instanceof Error ? reason.message : '请重试'}` + ? t('配置版本冲突:其他窗口已修改路由。当前输入尚未保存,请重新加载最新配置后再编辑。', 'Configuration conflict: another window changed these routes. Your input is unsaved; reload the latest settings before editing.') + : `${t('保存失败:', 'Save failed: ')}${reason instanceof Error ? reason.message : t('请重试', 'please retry')}` } finally { saving.value = false } } diff --git a/frontend/src/features/settings/ProviderForm.vue b/frontend/src/features/settings/ProviderForm.vue index 57fe802..5f600a2 100644 --- a/frontend/src/features/settings/ProviderForm.vue +++ b/frontend/src/features/settings/ProviderForm.vue @@ -40,7 +40,7 @@ async function previewRequest() { try { if (!requestJsonValid.value) throw new Error(t('请先修正 JSON。', 'Fix the JSON first.')) const response = await apiClient.post<{body:Record}>('/api/providers/request-preview', { - provider: {provider_type:form.provider_type,name:form.name || '预览',base_url:form.base_url || null, + provider: {provider_type:form.provider_type,name:form.name || t('预览', 'Preview'),base_url:form.base_url || null, default_model:form.default_model || null,request_overrides:requestOverrides.value}, stream:previewStream.value, capability:previewCapability.value, }) if (active && generation === draftGeneration) requestPreview.value = JSON.stringify(response.body, null, 2) @@ -54,7 +54,7 @@ async function probeRequest() { if (!requestJsonValid.value) throw new Error(t('请先修正 JSON。', 'Fix the JSON first.')) if (apiKey.value.trim()) throw new Error(t('请先保存新的 API Key,再进行推理验证。', 'Save the new API key before testing inference.')) const result = await apiClient.post<{message:string}>('/api/providers/request-probe', { - provider: {provider_type:form.provider_type,name:form.name || '推理验证',base_url:form.base_url || null, + provider: {provider_type:form.provider_type,name:form.name || t('推理验证', 'Inference test'),base_url:form.base_url || null, default_model:form.default_model || null,request_overrides:JSON.parse(JSON.stringify(requestOverrides.value)), credential_id:configured.value ? credentialId.value : null}, stream:previewStream.value, }) diff --git a/frontend/src/services/mediaService.ts b/frontend/src/services/mediaService.ts index 9ea1f28..4b90fd7 100644 --- a/frontend/src/services/mediaService.ts +++ b/frontend/src/services/mediaService.ts @@ -1,4 +1,5 @@ import { apiClient, resolveApiUrl } from './apiClient' +import { t } from '@/i18n' export interface Segment { segment_id: string; start_time: number; end_time: number; text: string; speaker: string | null; language?: string } export interface MediaJob { @@ -26,7 +27,7 @@ export const mediaService = { const response = await fetch(resolveApiUrl(`/api/media/attachments?filename=${encodeURIComponent(file.name)}`), { method: 'POST', headers: {'Content-Type': 'application/octet-stream', ...(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {})}, body: file, }) - if (!response.ok) throw new Error((await response.json())?.error?.message || '附件上传失败') + if (!response.ok) throw new Error((await response.json())?.error?.message || t('附件上传失败', 'Attachment upload failed')) return await response.json() as {attachment_id: string} }, } diff --git a/frontend/src/services/workspaceService.ts b/frontend/src/services/workspaceService.ts index 9ed6c66..c70e99c 100644 --- a/frontend/src/services/workspaceService.ts +++ b/frontend/src/services/workspaceService.ts @@ -7,6 +7,7 @@ import type { OperationResponse, } from '@/contracts' import apiClient from './apiClient' +import { t } from '@/i18n' import * as noteService from './noteService' /** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */ @@ -72,7 +73,7 @@ async function requireNoteId(filePath: string): Promise { await refreshTree() noteId = noteIdByPath.get(path) } - if (!noteId) throw new Error(`笔记尚未建立后端索引:${path}`) + if (!noteId) throw new Error(`${t('笔记尚未建立后端索引:', 'The note has not been indexed by the backend: ')}${path}`) return noteId } @@ -174,7 +175,7 @@ export async function deleteFile(pathValue: string): Promise { export async function moveFile(sourcePath: string, targetPath: string): Promise { const source = normalizePublicPath(sourcePath) if (typeByPath.get(source) !== 'file') { - throw new Error('当前阶段只支持移动笔记文件。') + throw new Error(t('当前阶段只支持移动笔记文件。', 'Only note files can be moved at this stage.')) } await noteService.moveNote(await requireNoteId(source), relativePath(targetPath)) await refreshTree() diff --git a/frontend/src/stores/agent.ts b/frontend/src/stores/agent.ts index cc2c219..73ba14c 100644 --- a/frontend/src/stores/agent.ts +++ b/frontend/src/stores/agent.ts @@ -3,6 +3,7 @@ import { ref, computed } from 'vue' import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts' import * as agentService from '@/services/agentService' import type { SseClient } from '@/services/sseClient' +import { t } from '@/i18n' export const useAgentStore = defineStore('agent', () => { const runs = ref([]) @@ -93,7 +94,7 @@ export const useAgentStore = defineStore('agent', () => { tool_name: String(call.name ?? 'unknown'), permission: String(data.permission ?? ''), parameters: (call.arguments ?? {}) as Record, - impact: '该工具需要获得权限后才能继续执行。', + impact: t('该工具需要获得权限后才能继续执行。', 'This tool requires permission before it can continue.'), } if (run) run.status = 'waiting_permission' } else if (['RunCompleted', 'RunFailed', 'RunCancelled'].includes(event.event)) { diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts index 5f4e50e..45b2754 100644 --- a/frontend/src/stores/chat.ts +++ b/frontend/src/stores/chat.ts @@ -3,6 +3,7 @@ import { ref, computed, reactive } from 'vue' import type { ChatMessage, Conversation } from '@/contracts' import { streamChat } from '@/services/chatService' import type { SseClient } from '@/services/sseClient' +import { t } from '@/i18n' export const useChatStore = defineStore('chat', () => { const conversations = ref([]) @@ -128,11 +129,11 @@ export const useChatStore = defineStore('chat', () => { content: String(event.data.content ?? event.data.snippet ?? ''), }) } - if (event.event === 'Error') aiMsg.content += `\n\n生成失败:${String(event.data.message ?? '未知错误')}` + if (event.event === 'Error') aiMsg.content += `\n\n${t('生成失败:', 'Generation failed: ')}${String(event.data.message ?? t('未知错误', 'Unknown error'))}` }, onError(error) { if (version !== streamVersion) return - aiMsg.content += `\n\n连接失败:${error.message}` + aiMsg.content += `\n\n${t('连接失败:', 'Connection failed: ')}${error.message}` isStreaming.value = false sseClient = null }, @@ -162,7 +163,7 @@ export const useChatStore = defineStore('chat', () => { stopGeneration() const newConv: Conversation = { conversation_id: crypto.randomUUID(), - title: '新对话', + title: t('新对话', 'New conversation'), created_at: new Date().toISOString(), updated_at: new Date().toISOString(), message_count: 0, diff --git a/frontend/src/stores/editor.ts b/frontend/src/stores/editor.ts index e4e3d9a..306d02b 100644 --- a/frontend/src/stores/editor.ts +++ b/frontend/src/stores/editor.ts @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { SaveStatus } from '@/contracts' import * as workspaceService from '@/services/workspaceService' +import { t } from '@/i18n' export const useEditorStore = defineStore('editor', () => { const mode = ref<'wysiwyg' | 'source'>('wysiwyg') @@ -76,12 +77,12 @@ export const useEditorStore = defineStore('editor', () => { saveTimer = null } if (saveStatus.value === 'conflict') { - throw new Error('当前文件存在编辑冲突,请处理后再切换文件。') + throw new Error(t('当前文件存在编辑冲突,请处理后再切换文件。', 'The current file has an editing conflict. Resolve it before switching files.')) } if (pendingSave) await pendingSave if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') await save() if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') { - throw new Error('当前文件保存失败,已阻止切换以避免内容丢失。') + throw new Error(t('当前文件保存失败,已阻止切换以避免内容丢失。', 'The current file could not be saved. Switching was blocked to prevent data loss.')) } // 版本号使较慢的旧读取不能覆盖用户后选择的新文件。 const version = ++loadVersion diff --git a/frontend/src/stores/plugin.ts b/frontend/src/stores/plugin.ts index 49f94ce..13d84f4 100644 --- a/frontend/src/stores/plugin.ts +++ b/frontend/src/stores/plugin.ts @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { Plugin } from '@/contracts' import * as pluginService from '@/services/pluginService' +import { t } from '@/i18n' export const usePluginStore = defineStore('plugin', () => { const plugins = ref([]) @@ -23,7 +24,7 @@ export const usePluginStore = defineStore('plugin', () => { plugins.value = await pluginService.listPlugins() error.value = null } catch (reason) { - error.value = reason instanceof Error ? reason.message : 'Plugin 加载失败' + error.value = reason instanceof Error ? reason.message : t('Plugin 加载失败', 'Failed to load Plugins') } finally { isLoading.value = false } diff --git a/frontend/src/stores/provider.ts b/frontend/src/stores/provider.ts index a7f8276..d7ed422 100644 --- a/frontend/src/stores/provider.ts +++ b/frontend/src/stores/provider.ts @@ -3,6 +3,7 @@ import { ref, computed } from 'vue' import type { ProviderConfig, ModelInfo, ProviderPreset } from '@/contracts' import { createProvider, deleteProvider as deleteProviderRequest, getCredentialStatus, listModels, listProviderPresets, listProviders, putCredential, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService' import { ApiErrorClass } from '@/services/apiClient' +import { t } from '@/i18n' export const useProviderStore = defineStore('provider', () => { const providers = ref([]) @@ -29,7 +30,7 @@ export const useProviderStore = defineStore('provider', () => { } error.value = null } catch (reason) { - error.value = reason instanceof Error ? reason.message : 'Provider 加载失败' + error.value = reason instanceof Error ? reason.message : t('Provider 加载失败', 'Failed to load Providers') } finally { isLoading.value = false } @@ -39,7 +40,7 @@ export const useProviderStore = defineStore('provider', () => { try { presets.value = await listProviderPresets() } catch (reason) { - error.value = reason instanceof Error ? reason.message : 'Provider 预设加载失败' + error.value = reason instanceof Error ? reason.message : t('Provider 预设加载失败', 'Failed to load Provider presets') } } @@ -55,11 +56,11 @@ export const useProviderStore = defineStore('provider', () => { } catch (reason) { const provider = providers.value.find((item) => item.provider_id === providerId) const credentialId = provider?.credential_id - let message = reason instanceof Error ? reason.message : '模型列表获取失败' + let message = reason instanceof Error ? reason.message : t('模型列表获取失败', 'Failed to load the model list') if (reason instanceof ApiErrorClass && reason.code === 'PROVIDER_CREDENTIAL_MISSING') { - message = '尚未配置 API Key,请编辑该 Provider 后填写并保存。' + message = t('尚未配置 API Key,请编辑该 Provider 后填写并保存。', 'No API key is configured. Edit this Provider, enter a key, and save it.') } else if (reason instanceof ApiErrorClass && reason.code === 'PROVIDER_AUTH_FAILED') { - message = `鉴权失败,请检查凭据“${credentialId || '未设置'}”对应的 API Key 是否有效。` + message = t(`鉴权失败,请检查凭据“${credentialId || '未设置'}”对应的 API Key 是否有效。`, `Authentication failed. Check the API key for credential “${credentialId || 'not set'}”.`) } modelErrorsByProvider.value[providerId] = message throw reason diff --git a/frontend/src/stores/search.ts b/frontend/src/stores/search.ts index c879a1e..d46be34 100644 --- a/frontend/src/stores/search.ts +++ b/frontend/src/stores/search.ts @@ -3,6 +3,7 @@ import { ref } from 'vue' import type { SearchResult, SearchRequest } from '@/contracts' import * as searchService from '@/services/searchService' import { ApiErrorClass } from '@/services/apiClient' +import { t } from '@/i18n' const VECTOR_ERROR_CODES = new Set([ 'SEMANTIC_INDEX_UNAVAILABLE', @@ -28,7 +29,7 @@ export const useSearchStore = defineStore('search', () => { if (version !== historyVersion) return recentQueries.value = response.queries historyError.value = '' - } catch { if (version === historyVersion) historyError.value = '无法读取应用搜索记录,请检查后端连接。' } + } catch { if (version === historyVersion) historyError.value = t('无法读取应用搜索记录,请检查后端连接。', 'Could not load search history. Check the backend connection.') } } async function clearHistory() { const version = ++historyVersion @@ -36,7 +37,7 @@ export const useSearchStore = defineStore('search', () => { await searchService.clearHistory() if (version !== historyVersion) return recentQueries.value = []; historyError.value = '' - } catch { if (version === historyVersion) historyError.value = '清空搜索记录失败,请重试。' } + } catch { if (version === historyVersion) historyError.value = t('清空搜索记录失败,请重试。', 'Failed to clear search history. Please retry.') } } const error = ref(null) const vectorUnavailable = ref(false) @@ -71,12 +72,12 @@ export const useSearchStore = defineStore('search', () => { selectedIndex.value = 0 } catch (fallbackError) { if (version !== searchVersion) return - error.value = fallbackError instanceof Error ? fallbackError.message : '全文检索降级失败' + error.value = fallbackError instanceof Error ? fallbackError.message : t('全文检索降级失败', 'Full-text search fallback failed') results.value = [] total.value = 0 } } else { - error.value = reason instanceof Error ? reason.message : '搜索失败' + error.value = reason instanceof Error ? reason.message : t('搜索失败', 'Search failed') results.value = [] total.value = 0 } diff --git a/frontend/src/stores/settings.ts b/frontend/src/stores/settings.ts index 3a81f60..0b51cf7 100644 --- a/frontend/src/stores/settings.ts +++ b/frontend/src/stores/settings.ts @@ -5,7 +5,7 @@ import { resolveApiUrl } from '@/services/apiClient' import packageInfo from '../../package.json' import * as indexService from '@/services/indexService' import * as systemService from '@/services/systemService' -import { appLocale } from '@/i18n' +import { appLocale, t } from '@/i18n' export const useSettingsStore = defineStore('settings', () => { const saved = (() => { @@ -17,7 +17,7 @@ export const useSettingsStore = defineStore('settings', () => { const autoSaveInterval = ref(typeof saved.autoSaveInterval === 'number' ? saved.autoSaveInterval : 1500) const language = appLocale const appVersion = ref(packageInfo.version) - const aiCoreVersion = ref('未获取') + const aiCoreVersion = ref('—') // Editor const defaultEditorMode = ref<'wysiwyg' | 'source'>(saved.defaultEditorMode === 'source' ? 'source' : 'wysiwyg') @@ -48,10 +48,10 @@ export const useSettingsStore = defineStore('settings', () => { ]) const [health, status, index, policy] = results aiCoreStatus.value = health.status === 'fulfilled' && health.value.status === 'ok' ? 'running' : 'error' - aiCoreVersion.value = status.status === 'fulfilled' ? status.value.version : '未获取' + aiCoreVersion.value = status.status === 'fulfilled' ? status.value.version : '—' indexStatus.value = index.status === 'fulfilled' ? index.value : emptyIndex() permissionPolicy.value = policy.status === 'fulfilled' ? policy.value : {} - diagnosticsError.value = results.filter(item => item.status === 'rejected').map(item => item.reason instanceof Error ? item.reason.message : '后端请求失败').join(';') || null + diagnosticsError.value = results.filter(item => item.status === 'rejected').map(item => item.reason instanceof Error ? item.reason.message : t('后端请求失败', 'Backend request failed')).join(t(';', '; ')) || null } function setAutoSaveInterval(ms: number) { @@ -69,7 +69,7 @@ export const useSettingsStore = defineStore('settings', () => { indexStatus.value = await indexService.getIndexStatus() } catch (reason) { indexStatus.value.status = 'error' - indexStatus.value.error = reason instanceof Error ? reason.message : '索引重建失败' + indexStatus.value.error = reason instanceof Error ? reason.message : t('索引重建失败', 'Index rebuild failed') } } diff --git a/frontend/src/stores/skill.ts b/frontend/src/stores/skill.ts index 5f712b3..5802011 100644 --- a/frontend/src/stores/skill.ts +++ b/frontend/src/stores/skill.ts @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { Skill } from '@/contracts' import * as skillService from '@/services/skillService' +import { t } from '@/i18n' export const useSkillStore = defineStore('skill', () => { const skills = ref([]) @@ -23,7 +24,7 @@ export const useSkillStore = defineStore('skill', () => { skills.value = await skillService.listSkills() error.value = null } catch (reason) { - error.value = reason instanceof Error ? reason.message : 'Skill 加载失败' + error.value = reason instanceof Error ? reason.message : t('Skill 加载失败', 'Failed to load Skills') } finally { isLoading.value = false } diff --git a/frontend/src/stores/task.ts b/frontend/src/stores/task.ts index 3f20c15..b802dd9 100644 --- a/frontend/src/stores/task.ts +++ b/frontend/src/stores/task.ts @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { TaskItem, TaskStatus, TaskPriority, TaskSource } from '@/contracts' import { createTask as createTaskRequest, deleteTask as deleteTaskRequest, listTasks, updateTask as updateTaskRequest } from '@/services/taskService' +import { t } from '@/i18n' export const useTaskStore = defineStore('task', () => { const tasks = ref([]) @@ -31,7 +32,7 @@ export const useTaskStore = defineStore('task', () => { tasks.value = resp.items error.value = null } catch (reason) { - error.value = reason instanceof Error ? reason.message : '任务加载失败' + error.value = reason instanceof Error ? reason.message : t('任务加载失败', 'Failed to load tasks') } finally { isLoading.value = false }