fix(frontend): 补齐英文失败路径
This commit is contained in:
@@ -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 为准。
|
||||
|
||||
## 文档
|
||||
|
||||
|
||||
+3
-1
@@ -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<ServiceStatus> {
|
||||
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<ServiceStatus>
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
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<string, string> {
|
||||
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<string, string>
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
@@ -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<string, Record<string, unknown>> {
|
||||
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 = '' }
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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<string,unknown>}>('/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,
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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<void> {
|
||||
export async function moveFile(sourcePath: string, targetPath: string): Promise<void> {
|
||||
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()
|
||||
|
||||
@@ -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<AgentRun[]>([])
|
||||
@@ -93,7 +94,7 @@ export const useAgentStore = defineStore('agent', () => {
|
||||
tool_name: String(call.name ?? 'unknown'),
|
||||
permission: String(data.permission ?? ''),
|
||||
parameters: (call.arguments ?? {}) as Record<string, unknown>,
|
||||
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)) {
|
||||
|
||||
@@ -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<Conversation[]>([])
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Plugin[]>([])
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<ProviderConfig[]>([])
|
||||
@@ -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
|
||||
|
||||
@@ -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<string | null>(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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Skill[]>([])
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<TaskItem[]>([])
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user