diff --git a/apps/desktop/src/main/__tests__/assistant-stream.test.ts b/apps/desktop/src/main/__tests__/assistant-stream.test.ts index e0db00c5d5..71868b43fe 100644 --- a/apps/desktop/src/main/__tests__/assistant-stream.test.ts +++ b/apps/desktop/src/main/__tests__/assistant-stream.test.ts @@ -22,9 +22,14 @@ import { describe, it } from 'node:test'; import { ASSISTANT_MAX_DELTA_CHARS, ASSISTANT_MAX_TOTAL_CHARS, - applyAssistantComplete, - applyAssistantDelta, + applyAssistantComplete as applyAssistantCompleteWithLocale, + applyAssistantDelta as applyAssistantDeltaWithLocale, } from '@maka/ui/assistant-stream'; +// Tests exercise stream mechanics, not copy; pin zh so markers stay verbatim. +const applyAssistantDelta = (prev: string, delta: string, options?: Partial[2]>) => + applyAssistantDeltaWithLocale(prev, delta, { locale: 'zh-CN', ...options }); +const applyAssistantComplete = (text: string, options?: Partial[1]>) => + applyAssistantCompleteWithLocale(text, { locale: 'zh-CN', ...options }); function visibleDeltaResult(result: ReturnType) { return { diff --git a/apps/desktop/src/main/__tests__/health-center-copy.test.ts b/apps/desktop/src/main/__tests__/health-center-copy.test.ts index 6ab57b2855..b9d7d1d06f 100644 --- a/apps/desktop/src/main/__tests__/health-center-copy.test.ts +++ b/apps/desktop/src/main/__tests__/health-center-copy.test.ts @@ -19,6 +19,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; +import { connectionLastTestMessageDisplay } from '../../renderer/features/connection-settings/index.js'; import { getHealthCenterCopy } from '../../renderer/locales/settings-health-copy.js'; test('labels blocker counts as global across filtered health views', () => { @@ -35,15 +36,15 @@ test('labels blocker counts as global across filtered health views', () => { test('Traditional Chinese health copy localizes structured signal text', () => { const copy = getHealthCenterCopy('zh-TW'); const signal = { - id: 'connection:test', - label: '測試 运行态', + id: 'connection:test:runtime', + label: '測試', scope: 'llm_connection' as const, layer: 'validation' as const, status: 'ok' as const, source: 'connection_test' as const, checkedAt: 1, - message: '凭据与端点验证已通过。', - detail: '这是连接验证结果,不代表发送、流式输出或中断通路已经运行通过。', + message: 'validation_passed' as const, + detail: { kind: 'validation_scope_note' as const }, blocksSend: false, }; assert.equal(copy.layers.configuration.label, '設定'); @@ -51,3 +52,92 @@ test('Traditional Chinese health copy localizes structured signal text', () => { assert.equal(copy.signalMessage(signal), '憑證與端點驗證已通過。'); assert.match(copy.signalDetail(signal) ?? '', /串流輸出/); }); + +const signal = (overrides: Partial): import('@maka/core/health').HealthSignal => ({ + id: 'connection:demo', + label: 'Demo', + scope: 'llm_connection', + layer: 'configuration', + status: 'info', + source: 'settings', + checkedAt: 0, + message: 'not_default_source', + ...overrides, +}); + +test('renders configuration message codes distinctly in both locales', () => { + const zh = getHealthCenterCopy('zh-CN'); + const en = getHealthCenterCopy('en'); + assert.equal(zh.signalMessage(signal({ message: 'not_default_source' })), '不是工作区的默认模型来源。'); + assert.equal(en.signalMessage(signal({ message: 'not_default_source' })), 'Not the workspace default model source.'); + assert.equal(zh.signalMessage(signal({ message: 'no_models_enabled' })), '没有启用任何模型。'); + assert.equal(en.signalMessage(signal({ message: 'no_models_enabled' })), 'No models are enabled on this connection.'); +}); + +test('renders runtime probe details from structured params, not string parsing', () => { + const detail = { kind: 'runtime_probe_result', modelId: 'claude-sonnet-5', latencyMs: 812, errorClass: 'timeout' } as const; + assert.equal( + getHealthCenterCopy('zh-CN').signalDetail(signal({ detail })), + '模型=claude-sonnet-5 · 延迟=812ms · 错误类型=请求超时', + ); + assert.equal( + getHealthCenterCopy('en').signalDetail(signal({ detail })), + 'Model=claude-sonnet-5 · Latency=812ms · Error type=Request timed out', + ); + + const rateLimited = { ...detail, errorClass: 'rate_limit' }; + assert.equal( + getHealthCenterCopy('zh-CN').signalDetail(signal({ detail: rateLimited })), + '模型=claude-sonnet-5 · 延迟=812ms · 错误类型=rate_limit', + ); + assert.equal( + getHealthCenterCopy('en').signalDetail(signal({ detail: rateLimited })), + 'Model=claude-sonnet-5 · Latency=812ms · Error type=rate_limit', + ); +}); + +test('keeps zh capability reasons and hides wrong-locale ones', () => { + const zhReason = { kind: 'capability_reason', reason: '未配置平台凭据' } as const; + assert.equal(getHealthCenterCopy('zh-CN').signalDetail(signal({ detail: zhReason })), '未配置平台凭据'); + assert.equal( + getHealthCenterCopy('en').signalDetail(signal({ detail: zhReason })), + 'See the corresponding settings page for details.', + ); + + const enReason = { kind: 'capability_reason', reason: 'Slack requires a Bot Token.' } as const; + assert.equal( + getHealthCenterCopy('zh-CN').signalDetail(signal({ detail: enReason })), + '状态详情请见对应设置页。', + ); + assert.equal( + getHealthCenterCopy('en').signalDetail(signal({ detail: enReason })), + 'See the corresponding settings page for details.', + ); +}); + +test('maps connection test error classes without exposing machine tokens', () => { + const auth = { kind: 'last_test_error_class', errorClass: 'auth' } as const; + assert.equal(getHealthCenterCopy('zh-CN').signalDetail(signal({ detail: auth })), '鉴权失败'); + assert.equal(getHealthCenterCopy('en').signalDetail(signal({ detail: auth })), 'Authentication failed'); + + const unknown = { kind: 'last_test_message' } as const; + assert.equal( + getHealthCenterCopy('zh-CN').signalDetail(signal({ detail: unknown })), + '连接测试状态暂时无法显示,请重新测试。', + ); + assert.equal( + getHealthCenterCopy('en').signalDetail(signal({ detail: unknown })), + 'The connection test status is temporarily unavailable. Test again.', + ); +}); + +test('maps connection test error classes in connection details', () => { + assert.equal(connectionLastTestMessageDisplay('auth', 'zh-CN'), '鉴权失败'); + assert.equal(connectionLastTestMessageDisplay('auth', 'en'), 'Authentication failed'); +}); + +test('suffixes runtime signal labels per locale from the id, not the producer', () => { + const runtime = signal({ id: 'connection:demo:runtime', label: 'Demo' }); + assert.equal(getHealthCenterCopy('zh-CN').signalLabel(runtime), 'Demo 运行态'); + assert.equal(getHealthCenterCopy('en').signalLabel(runtime), 'Demo runtime'); +}); diff --git a/apps/desktop/src/main/__tests__/thinking-stream.test.ts b/apps/desktop/src/main/__tests__/thinking-stream.test.ts index 8d33f1df80..a219bfc2ed 100644 --- a/apps/desktop/src/main/__tests__/thinking-stream.test.ts +++ b/apps/desktop/src/main/__tests__/thinking-stream.test.ts @@ -21,7 +21,12 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import { applyThinkingComplete, applyThinkingDelta } from '@maka/ui'; +import { applyThinkingComplete as applyThinkingCompleteWithLocale, applyThinkingDelta as applyThinkingDeltaWithLocale } from '@maka/ui'; +// Tests exercise stream mechanics, not copy; pin zh so markers stay verbatim. +const applyThinkingDelta = (prev: string, delta: string, options?: Partial[2]>) => + applyThinkingDeltaWithLocale(prev, delta, { locale: 'zh-CN', ...options }); +const applyThinkingComplete = (text: string, options?: Partial[1]>) => + applyThinkingCompleteWithLocale(text, { locale: 'zh-CN', ...options }); describe('applyThinkingDelta — secondary redaction', () => { it('masks raw `Authorization: Bearer ...` text before storing', () => { diff --git a/apps/desktop/src/main/__tests__/tool-output-stream.test.ts b/apps/desktop/src/main/__tests__/tool-output-stream.test.ts index ca66c39051..ce5ef38a8a 100644 --- a/apps/desktop/src/main/__tests__/tool-output-stream.test.ts +++ b/apps/desktop/src/main/__tests__/tool-output-stream.test.ts @@ -48,9 +48,12 @@ import { TOOL_STREAM_MAX_CHUNKS, TOOL_STREAM_MAX_CHUNK_CHARS, TOOL_STREAM_MAX_TOTAL_CHARS, - applyToolOutputChunk, + applyToolOutputChunk as applyToolOutputChunkWithLocale, type ToolOutputChunk, } from '@maka/ui'; +// Tests exercise stream mechanics, not copy; pin zh so markers stay verbatim. +const applyToolOutputChunk = (prev: Parameters[0], chunk: Parameters[1], options?: Partial[2]>) => + applyToolOutputChunkWithLocale(prev, chunk, { locale: 'zh-CN', ...options }); function chunk(seq: number, text: string, stream: 'stdout' | 'stderr' = 'stdout', redacted = false): ToolOutputChunk { return { seq, text, stream, redacted, createdAt: 1_700_000_000_000 + seq }; @@ -77,7 +80,6 @@ describe('applyToolOutputChunk — secondary redaction (defense in depth)', () = assert.equal(result.chunks[0]!.redacted, true); }); - }); describe('applyToolOutputChunk — per-chunk cap', () => { @@ -106,7 +108,6 @@ describe('applyToolOutputChunk — per-chunk cap', () => { ); }); - }); describe('applyToolOutputChunk — per-tool caps (count + total chars)', () => { diff --git a/apps/desktop/src/main/__tests__/traditional-chinese-peer-mesh-copy.test.ts b/apps/desktop/src/main/__tests__/traditional-chinese-peer-mesh-copy.test.ts index 67af56d965..ca0261e1d1 100644 --- a/apps/desktop/src/main/__tests__/traditional-chinese-peer-mesh-copy.test.ts +++ b/apps/desktop/src/main/__tests__/traditional-chinese-peer-mesh-copy.test.ts @@ -46,8 +46,7 @@ test('Traditional Chinese connection copy uses 回傳, 回應, and 發送', () = assert.match(conversation.turnError.provider, /模型服務回傳錯誤/); const provider = getProviderSettingsCopy('zh-TW'); - assert.equal(provider.shared.lastTest['模型服务返回错误'], '模型服務回傳錯誤'); - assert.equal(provider.shared.lastTest['provider returned an error'], '模型服務回傳錯誤'); + assert.equal(provider.shared.lastTest.provider_unavailable, '模型服務回傳錯誤'); assert.equal( settingsTestResultMessage( diff --git a/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts b/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts index 273473fb7a..47222efd96 100644 --- a/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts +++ b/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts @@ -19,7 +19,7 @@ import { generalizedErrorMessageForLocale, redactSecrets } from '@maka/core/redaction'; import { type ConnectionTestResult } from '@maka/core/llm-connections'; -import { type UiLocale } from '@maka/core/ui-locale'; +import { type UiLocale, lookupCopy } from '@maka/core/ui-locale'; import { getProviderSettingsCopy } from './settings-provider-copy.js'; import { cleanErrorMessage } from '../../application/contracts/connection-error-cleaner.js'; @@ -32,8 +32,6 @@ export function providerPanelActionErrorMessage(error: unknown, locale: UiLocale // wrapper — channel names like 'connections:fetchModels' contain "fetch", // which the keyword classifier reads as a network error. const cleaned = redactSecrets(cleanErrorMessage(error)).trim(); - const known = (shared.lastTest as Readonly>)[cleaned.toLowerCase()]; - if (known) return known; // Main-process handlers throw display-ready Chinese copy; keep it instead // of flattening it into a coarser classification or the generic fallback. if (locale === 'zh-CN' && /[\u3400-\u9fff]/.test(cleaned)) return cleaned; @@ -88,10 +86,9 @@ export function connectionLastTestMessageDisplay(message: string | undefined, lo if (!message) return undefined; const trimmed = message.trim(); if (!trimmed) return undefined; - const normalized = trimmed.toLowerCase(); const copy = getProviderSettingsCopy(locale).shared; - const known = (copy.lastTest as Readonly>)[normalized]; - if (known) return known; - const classified = generalizedErrorMessageForLocale(new Error(trimmed), '', locale); - return classified || copy.statusUnavailable; + return ( + lookupCopy(copy.lastTest, trimmed) ?? + (generalizedErrorMessageForLocale(new Error(trimmed), '', locale) || copy.statusUnavailable) + ); } diff --git a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts index da6a223f72..2eea3f9b7b 100644 --- a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts @@ -191,12 +191,7 @@ const zhCopy = { filterMatches: (count: number) => (count === 0 ? '没有匹配的结果' : `${count} 个匹配结果`), connectionStatuses: { retired: '已停用 · 请删除', reauth: '需要重新登录', disabledFailed: '暂不可用 · 上次连接失败', disabled: '暂不可用', failed: '上次连接失败' }, lastTest: { - '连接已验证': '连接已验证', '鉴权失败': '鉴权失败', '请求超时': '请求超时', '网络错误': '网络错误', '模型服务返回错误': '模型服务返回错误', '连接测试失败': '连接测试失败', - 'connection verified': '连接已验证', 'authentication failed': '鉴权失败', 'request timed out': '请求超时', 'network error': '网络错误', 'provider returned an error': '模型服务返回错误', 'connection test failed': '连接测试失败', - 'claude oauth 未登录。': 'Claude OAuth 未登录。', 'claude oauth 本地凭据读取失败。': 'Claude OAuth 本地凭据读取失败。', 'claude oauth 需要重新登录。': 'Claude OAuth 需要重新登录。', 'claude oauth 已登录。': 'Claude OAuth 已登录。', 'claude oauth 已退出登录。': 'Claude OAuth 已退出登录。', - 'codex oauth 未登录。': 'Codex OAuth 未登录。', 'codex oauth 本地凭据读取失败。': 'Codex OAuth 本地凭据读取失败。', 'codex oauth 需要重新登录。': 'Codex OAuth 需要重新登录。', 'codex oauth 已登录。': 'Codex OAuth 已登录。', 'codex oauth 已退出登录。': 'Codex OAuth 已退出登录。', - '当前账号无可用 codex 模型。': '当前账号无可用 Codex 模型。', 'codex 模型列表获取失败。': 'Codex 模型列表获取失败。', - 'github copilot 需要重新导入 github cli 登录。': 'GitHub Copilot 需要重新导入 GitHub CLI 登录。', 'github copilot 无法读取当前账号可用模型,请重新验证登录。': 'GitHub Copilot 无法读取当前账号可用模型,请重新验证登录。', 'github copilot 登录已导入。': 'GitHub Copilot 登录已导入。', 'github copilot 连接未能保存,请重新导入登录。': 'GitHub Copilot 连接未能保存,请重新导入登录。', 'github copilot 已移除本地登录。': 'GitHub Copilot 已移除本地登录。', + auth: '鉴权失败', timeout: '请求超时', provider_unavailable: '模型服务返回错误', network: '网络错误', invalid_response: '模型服务返回错误', unknown: '连接测试失败', }, }, panel: { @@ -360,12 +355,7 @@ const zhTwCopy = { filterMatches: (count: number) => (count === 0 ? '沒有符合的結果' : `${count} 個符合結果`), connectionStatuses: { retired: '已停用 · 請刪除', reauth: '需要重新登入', disabledFailed: '暫不可用 · 上次連線失敗', disabled: '暫不可用', failed: '上次連線失敗' }, lastTest: { - '连接已验证': '連線已驗證', '鉴权失败': '鑑權失敗', '请求超时': '請求超時', '网络错误': '網路錯誤', '模型服务返回错误': '模型服務回傳錯誤', '连接测试失败': '連線測試失敗', - 'connection verified': '連線已驗證', 'authentication failed': '鑑權失敗', 'request timed out': '請求超時', 'network error': '網路錯誤', 'provider returned an error': '模型服務回傳錯誤', 'connection test failed': '連線測試失敗', - 'claude oauth 未登录。': 'Claude OAuth 未登入。', 'claude oauth 本地凭据读取失败。': 'Claude OAuth 本地憑據讀取失敗。', 'claude oauth 需要重新登录。': 'Claude OAuth 需要重新登入。', 'claude oauth 已登录。': 'Claude OAuth 已登入。', 'claude oauth 已退出登录。': 'Claude OAuth 已退出登入。', - 'codex oauth 未登录。': 'Codex OAuth 未登入。', 'codex oauth 本地凭据读取失败。': 'Codex OAuth 本地憑據讀取失敗。', 'codex oauth 需要重新登录。': 'Codex OAuth 需要重新登入。', 'codex oauth 已登录。': 'Codex OAuth 已登入。', 'codex oauth 已退出登录。': 'Codex OAuth 已退出登入。', - '当前账号无可用 codex 模型。': '目前帳號無可用 Codex 模型。', 'codex 模型列表获取失败。': 'Codex 模型列表取得失敗。', - 'github copilot 需要重新导入 github cli 登录。': 'GitHub Copilot 需要重新匯入 GitHub CLI 登入。', 'github copilot 无法读取当前账号可用模型,请重新验证登录。': 'GitHub Copilot 無法讀取目前帳號可用模型,請重新驗證登入。', 'github copilot 登录已导入。': 'GitHub Copilot 登入已匯入。', 'github copilot 连接未能保存,请重新导入登录。': 'GitHub Copilot 連線未能儲存,請重新匯入登入。', 'github copilot 已移除本地登录。': 'GitHub Copilot 已移除本地登入。', + auth: '鑑權失敗', timeout: '請求超時', provider_unavailable: '模型服務回傳錯誤', network: '網路錯誤', invalid_response: '模型服務回傳錯誤', unknown: '連線測試失敗', }, }, panel: { @@ -530,12 +520,7 @@ const enCopy: ProviderSettingsCopy = { filterMatches: (count: number) => (count === 0 ? 'No matches' : count === 1 ? '1 match' : `${count} matches`), connectionStatuses: { retired: 'Retired · delete it', reauth: 'Sign-in required', disabledFailed: 'Unavailable · last connection failed', disabled: 'Unavailable', failed: 'Last connection failed' }, lastTest: { - '连接已验证': 'Connection verified', '鉴权失败': 'Authentication failed', '请求超时': 'Request timed out', '网络错误': 'Network error', '模型服务返回错误': 'Model service returned an error', '连接测试失败': 'Connection test failed', - 'connection verified': 'Connection verified', 'authentication failed': 'Authentication failed', 'request timed out': 'Request timed out', 'network error': 'Network error', 'provider returned an error': 'Model service returned an error', 'connection test failed': 'Connection test failed', - 'claude oauth 未登录。': 'Claude OAuth is signed out.', 'claude oauth 本地凭据读取失败。': 'Could not read local Claude OAuth credentials.', 'claude oauth 需要重新登录。': 'Claude OAuth requires sign-in.', 'claude oauth 已登录。': 'Claude OAuth is signed in.', 'claude oauth 已退出登录。': 'Claude OAuth signed out.', - 'codex oauth 未登录。': 'Codex OAuth is signed out.', 'codex oauth 本地凭据读取失败。': 'Could not read local Codex OAuth credentials.', 'codex oauth 需要重新登录。': 'Codex OAuth requires sign-in.', 'codex oauth 已登录。': 'Codex OAuth is signed in.', 'codex oauth 已退出登录。': 'Codex OAuth signed out.', - '当前账号无可用 codex 模型。': 'No Codex models are available for this account.', 'codex 模型列表获取失败。': 'Failed to fetch the Codex model list.', - 'github copilot 需要重新导入 github cli 登录。': 'GitHub Copilot requires the GitHub CLI sign-in to be imported again.', 'github copilot 无法读取当前账号可用模型,请重新验证登录。': 'GitHub Copilot could not read models available to this account. Verify sign-in again.', 'github copilot 登录已导入。': 'GitHub Copilot sign-in imported.', 'github copilot 连接未能保存,请重新导入登录。': 'The GitHub Copilot connection could not be saved. Import sign-in again.', 'github copilot 已移除本地登录。': 'Local GitHub Copilot sign-in removed.', + auth: 'Authentication failed', timeout: 'Request timed out', provider_unavailable: 'Model service returned an error', network: 'Network error', invalid_response: 'Model service returned an error', unknown: 'Connection test failed', }, }, panel: { diff --git a/apps/desktop/src/renderer/locales/settings-health-copy.ts b/apps/desktop/src/renderer/locales/settings-health-copy.ts index f1b9ed34f0..bca1c83724 100644 --- a/apps/desktop/src/renderer/locales/settings-health-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-health-copy.ts @@ -18,7 +18,15 @@ */ import type { StatusSemantic } from '@maka/ui'; -import type { HealthSignal, HealthSignalLayer, HealthSignalSource, HealthSignalStatus } from '@maka/core/health'; +import type { + HealthConnectionTestErrorClass, + HealthSignal, + HealthSignalDetail, + HealthSignalLayer, + HealthSignalMessageCode, + HealthSignalSource, + HealthSignalStatus, +} from '@maka/core/health'; import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; @@ -86,60 +94,6 @@ const layersZhTw: HealthCenterCopy['layers'] = { storage: { label: '儲存空間', description: '工作區檔案、JSONL、SQLite 和其他本機儲存空間的健康狀態。' }, }; -const healthMessageZhTw: Readonly> = { - '连接已关闭。': '連線已關閉。', - '等待选择默认模型。': '等待選擇預設模型。', - '凭据与端点验证已通过。': '憑證與端點驗證已通過。', - '连接需要重新修复认证。': '連線需要重新完成驗證。', - '上次连接验证失败。': '上次連線驗證失敗。', - '没有启用任何模型。': '尚未啟用任何模型。', - '不是工作区的默认模型来源。': '不是工作區的預設模型來源。', - '等待验证连接。': '等待驗證連線。', - '等待完成发送运行态探测。': '等待完成傳送執行狀態探測。', - '能力门禁已满足。': '能力門檻已滿足。', - '能力已关闭或暂停。': '能力已關閉或暫停。', - '等待补齐能力配置。': '等待完成能力設定。', - '能力被必要系统权限阻塞。': '能力受到必要系統權限阻擋。', - '能力运行态探测处于降级状态。': '能力執行狀態探測目前處於降級狀態。', - '最近一次发送已完成。': '最近一次傳送已完成。', - '最近一次发送已由用户停止。': '最近一次傳送已由使用者停止。', - '最近一次发送失败。': '最近一次傳送失敗。', -}; - -const healthDetailZhTw: Readonly> = { - '这是连接验证结果,不代表发送、流式输出或中断通路已经运行通过。': '這是連線驗證結果,不代表傳送、串流輸出或中斷路徑已實際執行成功。', - '在 设置 · 模型 的连接详情里启用至少一个模型后才能使用该连接。': '請在「設定・模型」的連線詳細資料中啟用至少一個模型,才能使用此連線。', - '在任务中显式选择该连接的模型即可正常使用;新对话的默认模型在 设置 · 通用 配置。': '在任務中明確選擇此連線的模型即可使用;新對話的預設模型可在「設定・一般」中設定。', - '凭据验证与真实发送、流式输出、中断通路是两层健康信号。': '憑證驗證與實際傳送、串流輸出、中斷路徑是兩層不同的健康訊號。', - '该能力当前已关闭。': '此能力目前已關閉。', - '等待填写平台凭据。': '等待填寫平台憑證。', - '仅 macOS 系统权限可探测。': '只能探測 macOS 系統權限。', - '系统未提供可直接读取的授权状态。': '系統未提供可直接讀取的授權狀態。', - '状态详情请见对应设置页。': '狀態詳細資料請參閱對應的設定頁。', -}; - -function healthSignalLabelZhTw(signal: HealthSignal): string { - return signal.label.endsWith(' 运行态') - ? `${signal.label.slice(0, -' 运行态'.length)} 執行狀態` - : signal.label; -} - -function healthSignalMessageZhTw(signal: HealthSignal): string { - return healthMessageZhTw[signal.message] - ?? (/[\u3400-\u9fff]/u.test(signal.message) ? '健康狀態已更新。' : signal.message); -} - -function healthSignalDetailZhTw(signal: HealthSignal): string | undefined { - if (!signal.detail) return undefined; - const runtimeDetail = /^模型=(.*?) · 延迟=(\d+)ms(?: · 错误类型=(.*))?$/u.exec(signal.detail); - if (runtimeDetail) { - const [, model, latency, errorClass] = runtimeDetail; - return `模型=${model} · 延遲=${latency}ms${errorClass ? ` · 錯誤類型=${errorClass}` : ''}`; - } - return healthDetailZhTw[signal.detail] - ?? (/[\u3400-\u9fff]/u.test(signal.detail) ? '詳細資料請參閱對應的設定頁。' : signal.detail); -} - const layersEn: HealthCenterCopy['layers'] = { configuration: { label: 'Configuration', description: 'Whether required settings are complete.' }, validation: { label: 'Validation', description: 'Credential and endpoint connectivity results. A passing validation does not prove the send path works.' }, @@ -164,12 +118,12 @@ const SETTINGS_HEALTH_COPY = { footnote: '本页不直接执行测试、修复或权限变更;它只汇总当前已记录的健康信号。需要处理问题时,请进入对应设置页或重新触发相关功能。', layers: layersZh, statuses: { ok: { label: '正常', tone: 'neutral' }, info: { label: '提示', tone: 'neutral' }, warning: { label: '警告', tone: 'attention' }, error: { label: '错误', tone: 'error' }, unknown: { label: '未知', tone: 'neutral' } }, - scopes: { app: '应用', llm_connection: 'LLM 连接', bot: '机器人', capability: '能力', storage: '存储' }, - sources: { connection_test: '连接测试', capability_snapshot: '能力快照', permission_snapshot: '权限快照', runtime_probe: '运行态探测', settings: '设置', storage: '本地存储' }, + scopes: { llm_connection: 'LLM 连接', bot: '机器人', capability: '能力' }, + sources: { connection_test: '连接测试', capability_snapshot: '能力快照', permission_snapshot: '权限快照', runtime_probe: '运行态探测', settings: '设置' }, source: '来源:', blocksSend: '阻塞发送', blocksCapability: '阻塞能力', - signalLabel: (signal) => signal.label, - signalMessage: (signal) => signal.message, - signalDetail: (signal) => signal.detail, + signalLabel: (signal) => (signal.id.endsWith(':runtime') ? `${signal.label} 运行态` : signal.label), + signalMessage: (signal) => signalMessagesZh[signal.message], + signalDetail: (signal) => signalDetailZh(signal.detail), }, 'zh-TW': { loading: '正在載入健康快照', readFailed: '無法讀取健康快照', noData: '健康服務未返回資料。', readAgain: '重新讀取', @@ -183,12 +137,12 @@ const SETTINGS_HEALTH_COPY = { footnote: '本頁不直接執行測試、修復或權限變更;它只彙總目前已記錄的健康訊號。需要處理問題時,請進入對應設定頁或重新觸發相關功能。', layers: layersZhTw, statuses: { ok: { label: '正常', tone: 'neutral' }, info: { label: '提示', tone: 'neutral' }, warning: { label: '警告', tone: 'attention' }, error: { label: '錯誤', tone: 'error' }, unknown: { label: '未知', tone: 'neutral' } }, - scopes: { app: '應用', llm_connection: 'LLM 連線', bot: '機器人', capability: '能力', storage: '儲存' }, - sources: { connection_test: '連線測試', capability_snapshot: '能力快照', permission_snapshot: '權限快照', runtime_probe: '執行態探測', settings: '設定', storage: '本地儲存' }, + scopes: { llm_connection: 'LLM 連線', bot: '機器人', capability: '能力' }, + sources: { connection_test: '連線測試', capability_snapshot: '能力快照', permission_snapshot: '權限快照', runtime_probe: '執行態探測', settings: '設定' }, source: '來源:', blocksSend: '阻塞傳送', blocksCapability: '阻塞能力', - signalLabel: healthSignalLabelZhTw, - signalMessage: healthSignalMessageZhTw, - signalDetail: healthSignalDetailZhTw, + signalLabel: (signal) => (signal.id.endsWith(':runtime') ? `${signal.label} 執行狀態` : signal.label), + signalMessage: (signal) => signalMessagesZhTw[signal.message], + signalDetail: (signal) => signalDetailZhTw(signal.detail), }, en: { loading: 'Loading health snapshot', readFailed: 'Could not read health snapshot', noData: 'The health service returned no data.', readAgain: 'Read again', @@ -202,12 +156,12 @@ const SETTINGS_HEALTH_COPY = { footnote: 'This page does not run tests, repairs, or permission changes. It only summarizes recorded health signals. Open the relevant settings page or retry the related feature to address an issue.', layers: layersEn, statuses: { ok: { label: 'Healthy', tone: 'neutral' }, info: { label: 'Info', tone: 'neutral' }, warning: { label: 'Warning', tone: 'attention' }, error: { label: 'Error', tone: 'error' }, unknown: { label: 'Unknown', tone: 'neutral' } }, - scopes: { app: 'App', llm_connection: 'LLM connection', bot: 'Bot', capability: 'Capability', storage: 'Storage' }, - sources: { connection_test: 'Connection test', capability_snapshot: 'Capability snapshot', permission_snapshot: 'Permission snapshot', runtime_probe: 'Runtime probe', settings: 'Settings', storage: 'Local storage' }, + scopes: { llm_connection: 'LLM connection', bot: 'Bot', capability: 'Capability' }, + sources: { connection_test: 'Connection test', capability_snapshot: 'Capability snapshot', permission_snapshot: 'Permission snapshot', runtime_probe: 'Runtime probe', settings: 'Settings' }, source: 'Source: ', blocksSend: 'Blocks sending', blocksCapability: 'Blocks capability', - signalLabel: englishSignalLabel, - signalMessage: englishSignalMessage, - signalDetail: englishSignalDetail, + signalLabel: (signal) => (signal.id.endsWith(':runtime') ? `${signal.label} runtime` : signal.label), + signalMessage: (signal) => signalMessagesEn[signal.message], + signalDetail: (signal) => signalDetailEn(signal.detail), }, } satisfies UiCatalog; @@ -215,57 +169,191 @@ export function getHealthCenterCopy(locale: UiLocale): HealthCenterCopy { return SETTINGS_HEALTH_COPY[locale]; } -function englishSignalLabel(signal: HealthSignal): string { - if (signal.id.endsWith(':runtime')) return `${signal.label.replace(/\s*运行态$/, '')} runtime`; - return signal.label; -} +const signalMessagesZh: Record = { + connection_disabled: '连接已关闭。', + awaiting_default_model: '等待选择默认模型。', + validation_passed: '凭据与端点验证已通过。', + needs_reauth: '连接需要重新修复认证。', + validation_failed: '上次连接验证失败。', + no_models_enabled: '没有启用任何模型。', + not_default_source: '不是工作区的默认模型来源。', + awaiting_validation: '等待验证连接。', + runtime_probe_pending: '等待完成发送运行态探测。', + send_completed: '最近一次发送已完成。', + send_aborted: '最近一次发送已由用户停止。', + send_failed: '最近一次发送失败。', + capability_ok: '能力门禁已满足。', + capability_paused: '能力已关闭或暂停。', + capability_not_configured: '等待补齐能力配置。', + capability_denied: '能力被必要系统权限阻塞。', + capability_degraded: '能力运行态探测处于降级状态。', +}; -function englishSignalMessage(signal: HealthSignal): string { - if (signal.scope === 'llm_connection') { - if (signal.layer === 'configuration') { - // Three-way split matching the producer's configuration states - // (packages/core/src/health.ts) — the message string is the anchor, - // the same way the runtime_probe branch below parses the producer's - // detail. Falling back on status alone described an enabled - // non-default connection as disabled. - if (signal.message === '不是工作区的默认模型来源。') { - return 'Not the workspace default model source.'; - } - if (signal.message === '没有启用任何模型。') { - return 'No models are enabled on this connection.'; - } - return signal.status === 'info' ? 'Connection is disabled.' : 'Select a default model.'; - } - if (signal.layer === 'runtime_probe') { - return { ok: 'The latest send completed.', info: 'The latest send was stopped by the user.', warning: 'The latest send failed.', error: 'The latest send failed.', unknown: 'Waiting for a send-path runtime probe.' }[signal.status]; - } - return { ok: 'Credentials and endpoint validation passed.', info: 'Connection validation needs attention.', warning: 'The latest connection validation failed.', error: 'The connection needs authentication repair.', unknown: 'Waiting to validate the connection.' }[signal.status]; - } - if (signal.scope === 'capability' || signal.scope === 'bot') { - return { ok: 'Capability requirements are satisfied.', info: 'The capability is disabled or paused.', warning: 'Capability configuration is incomplete.', error: 'The capability is blocked or degraded.', unknown: 'Capability state is unknown.' }[signal.status]; +const signalMessagesZhTw: Record = { + connection_disabled: '連線已關閉。', + awaiting_default_model: '等待選擇預設模型。', + validation_passed: '憑證與端點驗證已通過。', + needs_reauth: '連線需要重新完成驗證。', + validation_failed: '上次連線驗證失敗。', + no_models_enabled: '尚未啟用任何模型。', + not_default_source: '不是工作區的預設模型來源。', + awaiting_validation: '等待驗證連線。', + runtime_probe_pending: '等待完成傳送執行狀態探測。', + send_completed: '最近一次傳送已完成。', + send_aborted: '最近一次傳送已由使用者停止。', + send_failed: '最近一次傳送失敗。', + capability_ok: '能力門檻已滿足。', + capability_paused: '能力已關閉或暫停。', + capability_not_configured: '等待完成能力設定。', + capability_denied: '能力受到必要系統權限阻擋。', + capability_degraded: '能力執行狀態探測目前處於降級狀態。', +}; + +const signalMessagesEn: Record = { + connection_disabled: 'Connection is disabled.', + awaiting_default_model: 'Select a default model.', + validation_passed: 'Credentials and endpoint validation passed.', + needs_reauth: 'The connection needs authentication repair.', + validation_failed: 'The latest connection validation failed.', + no_models_enabled: 'No models are enabled on this connection.', + not_default_source: 'Not the workspace default model source.', + awaiting_validation: 'Waiting to validate the connection.', + runtime_probe_pending: 'Waiting for a send-path runtime probe.', + send_completed: 'The latest send completed.', + send_aborted: 'The latest send was stopped by the user.', + send_failed: 'The latest send failed.', + capability_ok: 'Capability requirements are satisfied.', + capability_paused: 'The capability is disabled or paused.', + capability_not_configured: 'Capability configuration is incomplete.', + capability_denied: 'The capability is blocked by a required system permission.', + capability_degraded: 'The capability runtime probe is degraded.', +}; + +const connectionTestErrorMessages = { + 'zh-CN': { + auth: '鉴权失败', + timeout: '请求超时', + provider_unavailable: '模型服务返回错误', + network: '网络错误', + unknown: '连接测试失败', + }, + 'zh-TW': { + auth: '驗證失敗', + timeout: '請求逾時', + provider_unavailable: '模型服務傳回錯誤', + network: '網路錯誤', + unknown: '連線測試失敗', + }, + en: { + auth: 'Authentication failed', + timeout: 'Request timed out', + provider_unavailable: 'Model service returned an error', + network: 'Network error', + unknown: 'Connection test failed', + }, +} satisfies UiCatalog>; + +function signalDetailZh(detail: HealthSignalDetail | undefined): string | undefined { + if (!detail) return undefined; + switch (detail.kind) { + case 'validation_scope_note': + return '这是连接验证结果,不代表发送、流式输出或中断通路已经运行通过。'; + case 'no_models_enabled_hint': + return '在 设置 · 模型 的连接详情里启用至少一个模型后才能使用该连接。'; + case 'not_default_source_hint': + return '在任务中显式选择该连接的模型即可正常使用;新对话的默认模型在 设置 · 通用 配置。'; + case 'runtime_probe_layers_note': + return '凭据验证与真实发送、流式输出、中断通路是两层健康信号。'; + case 'runtime_probe_result': + return [ + `模型=${detail.modelId}`, + `延迟=${detail.latencyMs}ms`, + ...(detail.errorClass ? [`错误类型=${localizedRuntimeErrorClass(detail.errorClass, 'zh-CN')}`] : []), + ].join(' · '); + case 'capability_reason': + // Interim: capability-snapshot still emits zh-CN prose; code it as a + // CapabilityReasonCode to drop this sniff. + return /[\u3400-\u9fff]/u.test(detail.reason) ? detail.reason : '状态详情请见对应设置页。'; + case 'last_test_error_class': + return connectionTestErrorMessages['zh-CN'][detail.errorClass]; + case 'last_test_message': + return '连接测试状态暂时无法显示,请重新测试。'; + default: + return unhandledDetail(detail); } - return { ok: 'The health check passed.', info: 'Review this health signal.', warning: 'This health signal needs attention.', error: 'This health signal reports an error.', unknown: 'Health state is unknown.' }[signal.status]; } -function englishSignalDetail(signal: HealthSignal): string | undefined { - if (!signal.detail) return undefined; - if (signal.scope === 'llm_connection' && signal.layer === 'validation' && signal.status === 'ok') { - return 'This validates the connection only; it does not prove send, streaming, or interruption paths have run successfully.'; +function signalDetailZhTw(detail: HealthSignalDetail | undefined): string | undefined { + if (!detail) return undefined; + switch (detail.kind) { + case 'validation_scope_note': + return '這是連線驗證結果,不代表傳送、串流輸出或中斷路徑已實際執行成功。'; + case 'no_models_enabled_hint': + return '請在「設定・模型」的連線詳細資料中啟用至少一個模型,才能使用此連線。'; + case 'not_default_source_hint': + return '在任務中明確選擇此連線的模型即可使用;新對話的預設模型可在「設定・一般」中設定。'; + case 'runtime_probe_layers_note': + return '憑證驗證與實際傳送、串流輸出、中斷路徑是兩層不同的健康訊號。'; + case 'runtime_probe_result': + return [ + `模型=${detail.modelId}`, + `延遲=${detail.latencyMs}ms`, + ...(detail.errorClass ? [`錯誤類型=${localizedRuntimeErrorClass(detail.errorClass, 'zh-TW')}`] : []), + ].join(' · '); + case 'capability_reason': + return '狀態詳細資料請參閱對應的設定頁。'; + case 'last_test_error_class': + return connectionTestErrorMessages['zh-TW'][detail.errorClass]; + case 'last_test_message': + return '連線測試狀態暫時無法顯示,請重新測試。'; + default: + return unhandledDetail(detail); } - if (signal.scope === 'llm_connection' && signal.layer === 'runtime_probe') { - const model = signal.detail.match(/模型=([^·]+)/)?.[1]?.trim(); - const latency = signal.detail.match(/延迟=([^·]+)/)?.[1]?.trim(); - const errorClass = signal.detail.match(/错误类型=([^·]+)/)?.[1]?.trim(); - const parts = [model && `Model=${model}`, latency && `Latency=${latency}`, errorClass && `Error type=${errorClass}`].filter(Boolean); - return parts.length > 0 ? parts.join(' · ') : 'Runtime details are available in Usage settings.'; - } - if (signal.scope === 'llm_connection' && signal.layer === 'configuration') { - if (signal.message === '不是工作区的默认模型来源。') { - return 'Models on this connection stay usable when selected explicitly in a task; the default model for new chats lives in Settings · General.'; - } - if (signal.message === '没有启用任何模型。') { +} + +function signalDetailEn(detail: HealthSignalDetail | undefined): string | undefined { + if (!detail) return undefined; + switch (detail.kind) { + case 'validation_scope_note': + return 'This validates the connection only; it does not prove send, streaming, or interruption paths have run successfully.'; + case 'no_models_enabled_hint': return "Enable at least one model in this connection's detail view under Settings · Models."; - } + case 'not_default_source_hint': + return 'Models on this connection stay usable when selected explicitly in a task; the default model for new chats lives in Settings · General.'; + case 'runtime_probe_layers_note': + return 'Credential validation and real send, streaming, and interruption paths are two separate health layers.'; + case 'runtime_probe_result': + return [ + `Model=${detail.modelId}`, + `Latency=${detail.latencyMs}ms`, + ...(detail.errorClass ? [`Error type=${localizedRuntimeErrorClass(detail.errorClass, 'en')}`] : []), + ].join(' · '); + case 'capability_reason': + return 'See the corresponding settings page for details.'; + case 'last_test_error_class': + return connectionTestErrorMessages.en[detail.errorClass]; + case 'last_test_message': + return 'The connection test status is temporarily unavailable. Test again.'; + default: + return unhandledDetail(detail); } - return 'See the corresponding settings page for details.'; +} + +const unknownRuntimeErrorClass = { + 'zh-CN': '未知错误', + 'zh-TW': '未知錯誤', + en: 'Unknown error', +} satisfies UiCatalog; + +// Runtime probes carry the turn's failure class (rate_limit, context_overflow, +// …), a wider vocabulary than connection tests; unmapped classes stay visible. +function localizedRuntimeErrorClass(errorClass: string, locale: UiLocale): string { + const messages: Readonly> = connectionTestErrorMessages[locale]; + const normalized = errorClass.toLowerCase(); + if (normalized === 'unknown') return unknownRuntimeErrorClass[locale]; + return messages[normalized] ?? errorClass; +} + +function unhandledDetail(_detail: never): undefined { + return undefined; } diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 032e326f80..f18c6c3fe8 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -589,16 +589,6 @@ const capabilitySnapshot: CapabilitySnapshotCollection = { }; const healthSignals: HealthSignal[] = [ - { - id: 'app:config', - label: '应用配置', - scope: 'app', - layer: 'configuration', - status: 'ok', - source: 'settings', - checkedAt: NOW - 60_000, - message: '配置文件可读写,schema 版本为最新。', - }, { id: 'conn:zai-live', label: 'Z.AI Live', @@ -607,8 +597,8 @@ const healthSignals: HealthSignal[] = [ status: 'ok', source: 'connection_test', checkedAt: NOW - 12 * 60_000, - message: '连接测试通过,延迟 210ms。', - detail: '验证通过只代表凭据可用,实际可用性仍需运行态探测确认。', + message: 'validation_passed', + detail: { kind: 'validation_scope_note' }, }, { id: 'conn:openai-review', @@ -618,8 +608,8 @@ const healthSignals: HealthSignal[] = [ status: 'error', source: 'connection_test', checkedAt: NOW - 3 * 60_000, - message: '连接测试失败:HTTP 401 invalid_api_key。', - detail: '凭据已失效或被吊销,请在「模型」页重新填写 API Key 后再次测试。', + message: 'needs_reauth', + detail: { kind: 'last_test_message' }, blocksSend: true, }, { @@ -630,7 +620,7 @@ const healthSignals: HealthSignal[] = [ status: 'info', source: 'capability_snapshot', checkedAt: NOW - 60_000, - message: '功能已开启,但仍以逐次审批模式运行。', + message: 'capability_paused', relatedCapabilityId: 'computer_use', }, { @@ -641,21 +631,11 @@ const healthSignals: HealthSignal[] = [ status: 'warning', source: 'runtime_probe', checkedAt: NOW - 5 * 60_000, - message: '探测超时,已回落到只读观察模式。', - detail: 'maka-cu 未在 3000ms 内完成握手;下一次探测会在功能被调用时自动触发。', + message: 'capability_degraded', + detail: { kind: 'capability_reason', reason: 'maka-cu service 启动失败、已退出或已停止。' }, relatedCapabilityId: 'computer_use', blocksCapability: true, }, - { - id: 'storage:sessions', - label: '会话存储', - scope: 'storage', - layer: 'storage', - status: 'ok', - source: 'storage', - checkedAt: NOW - 60_000, - message: 'SQLite 库可写,WAL 检查点正常。', - }, ]; const healthSnapshot: HealthSnapshot = buildHealthSnapshot(NOW - 45_000, healthSignals); @@ -2625,8 +2605,8 @@ export const HealthCenter: Story = { expect(errorFilter).toHaveAttribute('aria-pressed', 'true'); expect(canvas.getByText('OpenAI Review')).toBeInTheDocument(); expect(canvas.queryByText('Z.AI Live')).not.toBeInTheDocument(); - expect(canvas.getByText('全部健康信号中,1/6 条会阻塞发送')).toBeInTheDocument(); - expect(canvas.getByText('全部健康信号中,1/6 条会阻塞能力')).toBeInTheDocument(); + expect(canvas.getByText('全部健康信号中,1/4 条会阻塞发送')).toBeInTheDocument(); + expect(canvas.getByText('全部健康信号中,1/4 条会阻塞能力')).toBeInTheDocument(); }); await userEvent.click(errorFilter); await waitFor(() => { diff --git a/packages/core/src/__tests__/health.test.ts b/packages/core/src/__tests__/health.test.ts index 0e751851a0..583c116b2c 100644 --- a/packages/core/src/__tests__/health.test.ts +++ b/packages/core/src/__tests__/health.test.ts @@ -44,6 +44,20 @@ describe('HealthSignal contract', () => { assert.strictEqual(result.source, 'connection_test'); }); + test('separates connection test error classes from legacy diagnostics', () => { + const coded = healthSignalFromConnection( + connection({ lastTestStatus: 'needs_reauth', lastTestMessage: 'auth' }), + 20, + ); + assert.deepStrictEqual(coded.detail, { kind: 'last_test_error_class', errorClass: 'auth' }); + + const legacy = healthSignalFromConnection( + connection({ lastTestStatus: 'error', lastTestMessage: 'HTTP 502 upstream failure' }), + 20, + ); + assert.deepStrictEqual(legacy.detail, { kind: 'last_test_message' }); + }); + test('a missing default model warns only when the workspace has no default target', () => { // The catalog projects `defaultModel` onto exactly one connection (the // default target). With a default configured elsewhere, an enabled @@ -244,6 +258,10 @@ describe('HealthSignal contract', () => { assert.strictEqual(partial.status, 'warning'); assert.strictEqual(partial.layer, 'feature'); assert.strictEqual(partial.blocksCapability, false); + assert.deepStrictEqual(partial.detail, { + kind: 'capability_reason', + reason: '打开 Daily Review 可查看本地活动聚合结果', + }); }); }); diff --git a/packages/core/src/health.ts b/packages/core/src/health.ts index e88582cc56..3c3596a07a 100644 --- a/packages/core/src/health.ts +++ b/packages/core/src/health.ts @@ -18,7 +18,11 @@ */ import type { CapabilityId, CapabilityReadinessState, CapabilitySnapshot } from './capabilities.js'; -import { connectionEnabledModelIds, type LlmConnection } from './llm-connections.js'; +import { + connectionEnabledModelIds, + type ConnectionTestErrorClass, + type LlmConnection, +} from './llm-connections.js'; import type { UsageLogRow } from './usage-stats/types.js'; export const HEALTH_SIGNAL_STATUSES = ['ok', 'info', 'warning', 'error', 'unknown'] as const; @@ -36,15 +40,45 @@ export const HEALTH_SIGNAL_LAYERS = [ ] as const; export type HealthSignalLayer = (typeof HEALTH_SIGNAL_LAYERS)[number]; -export type HealthSignalScope = 'app' | 'llm_connection' | 'bot' | 'capability' | 'storage'; +export type HealthSignalScope = 'llm_connection' | 'bot' | 'capability'; export type HealthSignalSource = | 'connection_test' | 'capability_snapshot' | 'permission_snapshot' | 'runtime_probe' - | 'settings' - | 'storage'; + | 'settings'; + +export type HealthSignalMessageCode = + | 'connection_disabled' + | 'awaiting_default_model' + | 'validation_passed' + | 'needs_reauth' + | 'validation_failed' + | 'no_models_enabled' + | 'not_default_source' + | 'awaiting_validation' + | 'runtime_probe_pending' + | 'send_completed' + | 'send_aborted' + | 'send_failed' + | 'capability_ok' + | 'capability_paused' + | 'capability_not_configured' + | 'capability_denied' + | 'capability_degraded'; + +export type HealthConnectionTestErrorClass = ConnectionTestErrorClass; + +export type HealthSignalDetail = + | { kind: 'validation_scope_note' } + | { kind: 'no_models_enabled_hint' } + | { kind: 'not_default_source_hint' } + | { kind: 'runtime_probe_layers_note' } + | { kind: 'runtime_probe_result'; modelId: string; latencyMs: number; errorClass?: string } + | { kind: 'capability_reason'; reason: string } + | { kind: 'last_test_error_class'; errorClass: HealthConnectionTestErrorClass } + | { kind: 'last_test_message' }; export interface HealthSignal { id: string; @@ -54,8 +88,8 @@ export interface HealthSignal { status: HealthSignalStatus; source: HealthSignalSource; checkedAt: number; - message: string; - detail?: string; + message: HealthSignalMessageCode; + detail?: HealthSignalDetail; relatedCapabilityId?: CapabilityId; blocksSend?: boolean; blocksCapability?: boolean; @@ -151,7 +185,7 @@ export function healthSignalFromConnection( status: 'info', source: 'settings', checkedAt, - message: '连接已关闭。', + message: 'connection_disabled', blocksSend: false, }; } @@ -165,7 +199,7 @@ export function healthSignalFromConnection( status: 'warning', source: 'settings', checkedAt, - message: '等待选择默认模型。', + message: 'awaiting_default_model', blocksSend: true, }; } @@ -179,8 +213,8 @@ export function healthSignalFromConnection( status: 'ok', source: 'connection_test', checkedAt: timeFromIso(connection.lastTestAt) ?? checkedAt, - message: '凭据与端点验证已通过。', - detail: '这是连接验证结果,不代表发送、流式输出或中断通路已经运行通过。', + message: 'validation_passed', + detail: { kind: 'validation_scope_note' }, blocksSend: false, }; } @@ -194,8 +228,10 @@ export function healthSignalFromConnection( status: 'error', source: 'connection_test', checkedAt: timeFromIso(connection.lastTestAt) ?? checkedAt, - message: '连接需要重新修复认证。', - detail: connection.lastTestMessage, + message: 'needs_reauth', + ...(connection.lastTestMessage + ? { detail: connectionLastTestDetail(connection.lastTestMessage) } + : {}), blocksSend: true, }; } @@ -209,8 +245,10 @@ export function healthSignalFromConnection( status: 'warning', source: 'connection_test', checkedAt: timeFromIso(connection.lastTestAt) ?? checkedAt, - message: '上次连接验证失败。', - detail: connection.lastTestMessage, + message: 'validation_failed', + ...(connection.lastTestMessage + ? { detail: connectionLastTestDetail(connection.lastTestMessage) } + : {}), blocksSend: true, }; } @@ -230,8 +268,8 @@ export function healthSignalFromConnection( status: 'warning', source: 'settings', checkedAt, - message: '没有启用任何模型。', - detail: '在 设置 · 模型 的连接详情里启用至少一个模型后才能使用该连接。', + message: 'no_models_enabled', + detail: { kind: 'no_models_enabled_hint' }, blocksSend: false, }; } @@ -243,8 +281,8 @@ export function healthSignalFromConnection( status: 'info', source: 'settings', checkedAt, - message: '不是工作区的默认模型来源。', - detail: '在任务中显式选择该连接的模型即可正常使用;新对话的默认模型在 设置 · 通用 配置。', + message: 'not_default_source', + detail: { kind: 'not_default_source_hint' }, blocksSend: false, }; } @@ -257,7 +295,7 @@ export function healthSignalFromConnection( status: 'unknown', source: 'connection_test', checkedAt, - message: '等待验证连接。', + message: 'awaiting_validation', blocksSend: false, }; } @@ -272,14 +310,14 @@ export function healthSignalFromConnectionRuntime( if (!latestRuntimeProbe) { return { id: `connection:${connection.slug}:runtime`, - label: `${connection.name} 运行态`, + label: connection.name, scope: 'llm_connection', layer: 'runtime_probe', status: 'unknown', source: 'runtime_probe', checkedAt, - message: '等待完成发送运行态探测。', - detail: '凭据验证与真实发送、流式输出、中断通路是两层健康信号。', + message: 'runtime_probe_pending', + detail: { kind: 'runtime_probe_layers_note' }, blocksSend: false, }; } @@ -287,7 +325,7 @@ export function healthSignalFromConnectionRuntime( const status = runtimeStatusToHealth(latestRuntimeProbe.status); return { id: `connection:${connection.slug}:runtime`, - label: `${connection.name} 运行态`, + label: connection.name, scope: 'llm_connection', layer: 'runtime_probe', status, @@ -342,41 +380,41 @@ function healthLayerFromCapability(capability: CapabilitySnapshot): HealthSignal return 'feature'; } -function capabilityMessage(readiness: CapabilityReadinessState): string { +function capabilityMessage(readiness: CapabilityReadinessState): HealthSignalMessageCode { switch (readiness) { case 'enabled': - return '能力门禁已满足。'; + return 'capability_ok'; case 'paused': - return '能力已关闭或暂停。'; + return 'capability_paused'; case 'not_configured': - return '等待补齐能力配置。'; + return 'capability_not_configured'; case 'denied': - return '能力被必要系统权限阻塞。'; + return 'capability_denied'; case 'degraded': - return '能力运行态探测处于降级状态。'; + return 'capability_degraded'; } } -function capabilityDetail(capability: CapabilitySnapshot): string | undefined { - return userVisibleCapabilityReason( - capability.runtimeProbe.reason ?? capability.feature.reason ?? capability.configuration.reason, - ); +function capabilityDetail(capability: CapabilitySnapshot): HealthSignalDetail | undefined { + const reason = ( + capability.runtimeProbe.reason ?? + capability.feature.reason ?? + capability.configuration.reason + )?.trim(); + return reason ? { kind: 'capability_reason', reason } : undefined; } -function userVisibleCapabilityReason(reason: string | undefined): string | undefined { - const raw = reason?.trim(); - if (!raw) return undefined; - switch (raw) { - case 'disabled': - return '该能力当前已关闭。'; - case 'missing platform credentials': - return '等待填写平台凭据。'; - case 'macOS TCC only': - return '仅 macOS 系统权限可探测。'; - case 'no Electron API for per-target Apple Events TCC status': - return '系统未提供可直接读取的授权状态。'; +function connectionLastTestDetail(message: string): HealthSignalDetail { + const normalized = message.trim().toLowerCase(); + switch (normalized) { + case 'auth': + case 'timeout': + case 'provider_unavailable': + case 'network': + case 'unknown': + return { kind: 'last_test_error_class', errorClass: normalized }; default: - return /[\u3400-\u9fff]/.test(raw) ? raw : '状态详情请见对应设置页。'; + return { kind: 'last_test_message' }; } } @@ -397,19 +435,22 @@ function runtimeStatusToHealth(status: UsageLogRow['status']): HealthSignalStatu } } -function runtimeProbeMessage(status: UsageLogRow['status']): string { +function runtimeProbeMessage(status: UsageLogRow['status']): HealthSignalMessageCode { switch (status) { case 'success': - return '最近一次发送已完成。'; + return 'send_completed'; case 'aborted': - return '最近一次发送已由用户停止。'; + return 'send_aborted'; case 'error': - return '最近一次发送失败。'; + return 'send_failed'; } } -function runtimeProbeDetail(row: UsageLogRow): string { - const parts = [`模型=${row.modelId}`, `延迟=${row.latencyMs}ms`]; - if (row.errorClass) parts.push(`错误类型=${row.errorClass}`); - return parts.join(' · '); +function runtimeProbeDetail(row: UsageLogRow): HealthSignalDetail { + return { + kind: 'runtime_probe_result', + modelId: row.modelId, + latencyMs: row.latencyMs, + ...(row.errorClass ? { errorClass: row.errorClass } : {}), + }; } diff --git a/packages/core/src/ui-locale.ts b/packages/core/src/ui-locale.ts index 069254891c..7c3ee8cd88 100644 --- a/packages/core/src/ui-locale.ts +++ b/packages/core/src/ui-locale.ts @@ -169,3 +169,11 @@ export function resolveUiLocale( export function uiLocaleToIntlLocale(locale: UiLocale): UiLocale { return locale; } + +/** Copy for a wire code, or undefined when a newer producer sent one this catalog does not know. */ +export function lookupCopy( + map: Readonly>, + code: string | undefined, +): string | undefined { + return code !== undefined && Object.hasOwn(map, code) ? map[code] : undefined; +} diff --git a/packages/ui/src/__tests__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts index a196486f51..a78c95c49b 100644 --- a/packages/ui/src/__tests__/live-turn-projection.test.ts +++ b/packages/ui/src/__tests__/live-turn-projection.test.ts @@ -20,8 +20,8 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { encodeToolStepProgress } from '@maka/core/events'; +import { applyLiveTurnEvent } from './live-turn-zh.js'; import { - applyLiveTurnEvent, armLiveTurn, confirmLiveTurn, reconcileTerminalLiveTurn, diff --git a/packages/ui/src/__tests__/live-turn-zh.ts b/packages/ui/src/__tests__/live-turn-zh.ts new file mode 100644 index 0000000000..9ec0d85915 --- /dev/null +++ b/packages/ui/src/__tests__/live-turn-zh.ts @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { applyLiveTurnEvent as applyLiveTurnEventWithLocale } from '../live-turn-projection.js'; + +import type { LiveTurnProjection } from '../live-turn-projection.js'; +import type { SessionEvent } from '@maka/core/events'; + +type LiveTurnContentEvent = Extract; + +// Tests exercise projection logic, not copy; pin zh so markers stay verbatim. +export function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: LiveTurnContentEvent, +): LiveTurnProjection; +export function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: SessionEvent, +): LiveTurnProjection | undefined; +export function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: SessionEvent, +): LiveTurnProjection | undefined { + return applyLiveTurnEventWithLocale(current, event, 'zh-CN'); +} diff --git a/packages/ui/src/__tests__/materialize.test.ts b/packages/ui/src/__tests__/materialize.test.ts index 68e2a12f8f..4755a516d5 100644 --- a/packages/ui/src/__tests__/materialize.test.ts +++ b/packages/ui/src/__tests__/materialize.test.ts @@ -27,10 +27,8 @@ import { overlayLiveTurn, type TurnTimelineItem, } from "../materialize.js"; -import { - applyLiveTurnEvent, - armLiveTurn, -} from "../live-turn-projection.js"; +import { applyLiveTurnEvent } from './live-turn-zh.js'; +import { armLiveTurn } from "../live-turn-projection.js"; const originalUser = { type: "user" as const, diff --git a/packages/ui/src/__tests__/streaming-display-redaction.test.ts b/packages/ui/src/__tests__/streaming-display-redaction.test.ts index 3f9ebc1ffd..216d860966 100644 --- a/packages/ui/src/__tests__/streaming-display-redaction.test.ts +++ b/packages/ui/src/__tests__/streaming-display-redaction.test.ts @@ -22,7 +22,7 @@ import { describe, it } from 'node:test'; import { redactSecrets } from '../redact.js'; import { applyAssistantComplete, applyAssistantDelta } from '../assistant-stream.js'; import { applyThinkingComplete, applyThinkingDelta } from '../thinking-stream.js'; -import { applyLiveTurnEvent } from '../live-turn-projection.js'; +import { applyLiveTurnEvent } from './live-turn-zh.js'; import { appendStreamingDisplayRedaction, createStreamingDisplayRedactionState, @@ -164,12 +164,14 @@ describe('streaming display redaction', () => { for (const apply of [applyAssistantDelta, applyThinkingDelta]) { const initialState = createStreamingDisplayRedactionState(); const opener = apply('', 'Authorization:', { + locale: 'zh-CN' as const, maxDeltaChars: 128, maxTotalChars: 512, redactionState: initialState, }); const secret = `Bearer ${'s'.repeat(5_000)}`; const truncated = apply(opener.text, secret, { + locale: 'zh-CN' as const, maxDeltaChars: 128, maxTotalChars: 512, redactionState: opener.redactionState, @@ -183,6 +185,7 @@ describe('streaming display redaction', () => { ); const total = apply('', 'safe '.repeat(200), { + locale: 'zh-CN' as const, maxDeltaChars: 2_000, maxTotalChars: 128, redactionState: createStreamingDisplayRedactionState(), diff --git a/packages/ui/src/assistant-stream.ts b/packages/ui/src/assistant-stream.ts index 38474c5484..2cb10ab8e9 100644 --- a/packages/ui/src/assistant-stream.ts +++ b/packages/ui/src/assistant-stream.ts @@ -56,7 +56,7 @@ export const ASSISTANT_MAX_TOTAL_CHARS = 256 * 1024; export interface ApplyAssistantOptions extends ApplyStreamOptions { /** Resolved UI locale for user-visible truncation markers. */ - locale?: UiLocale; + locale: UiLocale; } export type ApplyAssistantResult = ApplyStreamResult; @@ -65,9 +65,9 @@ export type ApplyAssistantResult = ApplyStreamResult; export function applyAssistantDelta( prev: string, rawDelta: string, - options: ApplyAssistantOptions = {}, + options: ApplyAssistantOptions, ): ApplyAssistantResult { - const copy = getSharedUiCopy(options.locale ?? 'zh-CN').stream; + const copy = getSharedUiCopy(options.locale).stream; return applyStreamDelta(prev, rawDelta, { maxDeltaChars: options.maxDeltaChars ?? ASSISTANT_MAX_DELTA_CHARS, maxTotalChars: options.maxTotalChars ?? ASSISTANT_MAX_TOTAL_CHARS, @@ -83,11 +83,11 @@ export function applyAssistantDelta( /** Apply a `text_complete` final payload (replace, total cap only). */ export function applyAssistantComplete( rawText: string, - options: Pick = {}, + options: Pick, ): ApplyAssistantResult { return applyStreamComplete(rawText, { maxTotalChars: options.maxTotalChars ?? ASSISTANT_MAX_TOTAL_CHARS, recovery: 'head', - totalMarker: getSharedUiCopy(options.locale ?? 'zh-CN').stream.assistantTailTruncated, + totalMarker: getSharedUiCopy(options.locale).stream.assistantTailTruncated, }); } diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 0e2e146298..da53472516 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -965,11 +965,11 @@ export function ChatView(props: { export function DeepResearchProgressPanel({ run, onContinue, - copy = getConversationCopy('zh-CN').chat.deepResearchProgress, + copy, }: { run: DeepResearchClientProgress; onContinue?: (run: DeepResearchClientProgress) => void; - copy?: ReturnType['chat']['deepResearchProgress']; + copy: ReturnType['chat']['deepResearchProgress']; }) { const completedItems = run.checklist.filter( (item) => item.status === 'completed' || item.status === 'skipped', diff --git a/packages/ui/src/live-turn-projection.ts b/packages/ui/src/live-turn-projection.ts index 504dbcb106..f926290b3e 100644 --- a/packages/ui/src/live-turn-projection.ts +++ b/packages/ui/src/live-turn-projection.ts @@ -192,17 +192,17 @@ export function confirmLiveTurn( export function applyLiveTurnEvent( current: LiveTurnProjection | undefined, event: LiveTurnContentEvent, - locale?: UiLocale, + locale: UiLocale, ): LiveTurnProjection; export function applyLiveTurnEvent( current: LiveTurnProjection | undefined, event: SessionEvent, - locale?: UiLocale, + locale: UiLocale, ): LiveTurnProjection | undefined; export function applyLiveTurnEvent( current: LiveTurnProjection | undefined, event: SessionEvent, - locale: UiLocale = 'zh-CN', + locale: UiLocale, ): LiveTurnProjection | undefined { if (event.type === 'steering_message') { const prior = current?.turnId === event.turnId diff --git a/packages/ui/src/thinking-stream.ts b/packages/ui/src/thinking-stream.ts index de98f7a65f..9c29e8a47e 100644 --- a/packages/ui/src/thinking-stream.ts +++ b/packages/ui/src/thinking-stream.ts @@ -65,7 +65,7 @@ export const THINKING_MAX_TOTAL_CHARS = 32 * 1024; export interface ApplyThinkingOptions extends ApplyStreamOptions { /** Resolved UI locale for user-visible truncation markers. */ - locale?: UiLocale; + locale: UiLocale; } export type ApplyThinkingResult = ApplyStreamResult; @@ -74,9 +74,9 @@ export type ApplyThinkingResult = ApplyStreamResult; export function applyThinkingDelta( prev: string, rawDelta: string, - options: ApplyThinkingOptions = {}, + options: ApplyThinkingOptions, ): ApplyThinkingResult { - const copy = getSharedUiCopy(options.locale ?? 'zh-CN').stream; + const copy = getSharedUiCopy(options.locale).stream; return applyStreamDelta(prev, rawDelta, { maxDeltaChars: options.maxDeltaChars ?? THINKING_MAX_DELTA_CHARS, maxTotalChars: options.maxTotalChars ?? THINKING_MAX_TOTAL_CHARS, @@ -96,11 +96,11 @@ export function applyThinkingDelta( */ export function applyThinkingComplete( rawText: string, - options: ApplyThinkingOptions = {}, + options: ApplyThinkingOptions, ): ApplyThinkingResult { return applyStreamComplete(rawText, { maxTotalChars: options.maxTotalChars ?? THINKING_MAX_TOTAL_CHARS, recovery: 'tail', - totalMarker: getSharedUiCopy(options.locale ?? 'zh-CN').stream.thinkingHeadTruncated, + totalMarker: getSharedUiCopy(options.locale).stream.thinkingHeadTruncated, }); } diff --git a/packages/ui/src/tool-activity/copy.ts b/packages/ui/src/tool-activity/copy.ts index 5f73a5e3a2..b3f3929fe4 100644 --- a/packages/ui/src/tool-activity/copy.ts +++ b/packages/ui/src/tool-activity/copy.ts @@ -110,6 +110,9 @@ export interface ToolActivityCopy { genericAction: string; genericTitle: string; genericDescription: string; + fallbackLabel: string; + namedAction: (label: string) => string; + namedTitle: (label: string) => string; count: (count: number) => string; technicalDetails: string; groupId: string; @@ -255,6 +258,9 @@ const TOOL_ACTIVITY_COPY = { genericAction: '启用工具能力', genericTitle: '工具能力已启用', genericDescription: '现在可以使用这组工具。', + fallbackLabel: '工具', + namedAction: (label) => `启用 ${label}`, + namedTitle: (label) => `${label} 已启用`, count: (n) => `${n} 项能力可用`, technicalDetails: '技术详情', groupId: '工具组', @@ -357,6 +363,9 @@ const TOOL_ACTIVITY_COPY = { loadTools: { displayName: '啟用能力', genericAction: '啟用工具能力', + fallbackLabel: '工具', + namedAction: (label) => `啟用 ${label}`, + namedTitle: (label) => `${label} 已啟用`, genericTitle: '工具能力已啟用', genericDescription: '現在可以使用這組工具。', count: (n) => `${n} 項能力可用`, @@ -460,6 +469,9 @@ const TOOL_ACTIVITY_COPY = { genericAction: 'Enable tool capabilities', genericTitle: 'Tool capabilities enabled', genericDescription: 'This tool group is ready to use.', + fallbackLabel: 'Tools', + namedAction: (label) => `Enable ${label}`, + namedTitle: (label) => `${label} enabled`, count: (n) => `${n} ${n === 1 ? 'capability' : 'capabilities'} available`, technicalDetails: 'Technical details', groupId: 'Group', diff --git a/packages/ui/src/tool-activity/preview-utils.ts b/packages/ui/src/tool-activity/preview-utils.ts index 73efe24e39..61a1db266f 100644 --- a/packages/ui/src/tool-activity/preview-utils.ts +++ b/packages/ui/src/tool-activity/preview-utils.ts @@ -47,7 +47,7 @@ export function formatDuration(ms: number | undefined): string | null { return `${minutes}m ${seconds}s`; } -export function formatUserVisibleToolText(text: string, locale: UiLocale = 'zh-CN'): string { +export function formatUserVisibleToolText(text: string, locale: UiLocale): string { return text.replace(/\bUser denied permission(?: request)?\b|用户已拒绝权限请求/g, getToolActivityCopy(locale).permissionDenied); } diff --git a/packages/ui/src/tool-activity/result-projection.ts b/packages/ui/src/tool-activity/result-projection.ts index 3a3774ebad..7c23c2feee 100644 --- a/packages/ui/src/tool-activity/result-projection.ts +++ b/packages/ui/src/tool-activity/result-projection.ts @@ -114,7 +114,7 @@ function resultHasCapturedStreams(result: ToolActivityItem['result']): boolean { export function withLiveStreamFallback( result: NonNullable, chunks: ToolActivityItem['outputChunks'] | undefined, - options?: { truncated?: boolean; locale?: UiLocale }, + options: { truncated?: boolean; locale: UiLocale }, ): NonNullable { if (result.kind !== 'terminal' && result.kind !== 'shell_run') return result; if (resultHasCapturedStreams(result)) return result; @@ -134,7 +134,7 @@ export function withLiveStreamFallback( else stdout += chunk.text; } const truncated = existing?.mode === 'pipes' && existing.stdoutTruncated === true - || options?.truncated === true; + || options.truncated === true; // Empty redacted/truncated live buffer still carries diagnosis — do not // early-return and drop "已脱敏" / "输出已截断". if (!stdout && !stderr && !anyRedacted && !truncated) return result; @@ -142,7 +142,7 @@ export function withLiveStreamFallback( // Match live stream's "[已脱敏]" marker when a chunk was redacted // (including empty bodies that only suppressed secrets). if (anyRedacted) { - const marker = getToolActivityCopy(options?.locale ?? 'zh-CN').output.redacted; + const marker = getToolActivityCopy(options.locale).output.redacted; if (stdout.length > 0) stdout = `${stdout}${stdout.endsWith('\n') ? '' : '\n'}${marker}`; else if (stderr.length > 0) stderr = `${stderr}${stderr.endsWith('\n') ? '' : '\n'}${marker}`; else stdout = marker; diff --git a/packages/ui/src/tool-format.ts b/packages/ui/src/tool-format.ts index 4d319bd30c..ed40dca398 100644 --- a/packages/ui/src/tool-format.ts +++ b/packages/ui/src/tool-format.ts @@ -88,17 +88,11 @@ export function describeLoadToolResult( }; } - const label = suppliedLabel ?? (locale === 'en' ? 'Tools' : '工具'); - const enableLabel = locale === 'en' ? 'Enable' : locale === 'zh-CN' ? '启用' : '啟用'; - const enabledLabel = locale === 'en' ? 'enabled' : locale === 'zh-CN' ? '已启用' : '已啟用'; + const label = suppliedLabel ?? copy.fallbackLabel; return { kind, - actionLabel: suppliedLabel - ? `${enableLabel} ${suppliedLabel}` - : copy.genericAction, - title: suppliedLabel - ? `${suppliedLabel} ${enabledLabel}` - : copy.genericTitle, + actionLabel: suppliedLabel ? copy.namedAction(suppliedLabel) : copy.genericAction, + title: suppliedLabel ? copy.namedTitle(suppliedLabel) : copy.genericTitle, description: suppliedDescription ?? copy.genericDescription, label, countLabel: copy.count(n), diff --git a/packages/ui/src/tool-output-stream.ts b/packages/ui/src/tool-output-stream.ts index ec7d56e019..c5fb39d9ad 100644 --- a/packages/ui/src/tool-output-stream.ts +++ b/packages/ui/src/tool-output-stream.ts @@ -99,7 +99,7 @@ export interface ApplyToolOutputChunkOptions { maxChunks?: number; maxTotalChars?: number; maxChunkChars?: number; - locale?: UiLocale; + locale: UiLocale; } export interface ApplyToolOutputChunkResult { @@ -142,12 +142,12 @@ export interface ApplyToolOutputChunkResult { export function applyToolOutputChunk( prevChunks: ToolOutputChunk[] | undefined, rawChunk: ToolOutputChunk, - options: ApplyToolOutputChunkOptions = {}, + options: ApplyToolOutputChunkOptions, ): ApplyToolOutputChunkResult { const maxChunks = options.maxChunks ?? TOOL_STREAM_MAX_CHUNKS; const maxTotalChars = options.maxTotalChars ?? TOOL_STREAM_MAX_TOTAL_CHARS; const maxChunkChars = options.maxChunkChars ?? TOOL_STREAM_MAX_CHUNK_CHARS; - const truncatedChunkMarker = getSharedUiCopy(options.locale ?? 'zh-CN').stream.toolChunkTruncated; + const truncatedChunkMarker = getSharedUiCopy(options.locale).stream.toolChunkTruncated; const list = prevChunks ?? [];