fix(frontend): 补齐英文失败路径

This commit is contained in:
2026-09-05 09:48:27 +08:00
parent ef961d322b
commit 89e475c0c2
17 changed files with 83 additions and 70 deletions
+30 -29
View File
@@ -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('服务器名称必须为 180 个字符')
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('服务器名称必须为 180 个字符', 'The server name must contain 180 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('密钥值必须为 132768 个字符')
if (!value || value.length > 32768) throw new Error(t('密钥值必须为 132768 个字符', 'Secret values must contain 132768 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,
})