diff --git a/apps/desktop/src/main/__tests__/assistant-stream.test.ts b/apps/desktop/src/main/__tests__/assistant-stream.test.ts index e0db00c5d5..1a0dc994ef 100644 --- a/apps/desktop/src/main/__tests__/assistant-stream.test.ts +++ b/apps/desktop/src/main/__tests__/assistant-stream.test.ts @@ -22,9 +22,18 @@ 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', ...options }); +const applyAssistantComplete = (text: string, options?: Partial[1]>) => + applyAssistantCompleteWithLocale(text, { locale: 'zh', ...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 e8f7a6aa5f..af4d24bdd2 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', () => { @@ -31,3 +32,85 @@ test('labels blocker counts as global across filtered health views', () => { 'Across all health signals, 1 of 6 blocks sending', ); }); + +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'); + 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').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 unknown = { ...detail, errorClass: 'future_runtime_error' }; + assert.equal( + getHealthCenterCopy('zh').signalDetail(signal({ detail: unknown })), + '模型=claude-sonnet-5 · 延迟=812ms · 错误类型=未知错误', + ); + assert.equal( + getHealthCenterCopy('en').signalDetail(signal({ detail: unknown })), + 'Model=claude-sonnet-5 · Latency=812ms · Error type=Unknown error', + ); +}); + +test('degrades capability reasons safely in both locales', () => { + const unknown = { kind: 'capability_reason' } as const; + assert.equal( + getHealthCenterCopy('zh').signalDetail(signal({ detail: unknown })), + '状态详情请见对应设置页。', + ); + assert.equal( + getHealthCenterCopy('en').signalDetail(signal({ detail: unknown })), + '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').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').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'), '鉴权失败'); + 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').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..78635afa9a 100644 --- a/apps/desktop/src/main/__tests__/thinking-stream.test.ts +++ b/apps/desktop/src/main/__tests__/thinking-stream.test.ts @@ -21,7 +21,16 @@ 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', ...options }); +const applyThinkingComplete = (text: string, options?: Partial[1]>) => + applyThinkingCompleteWithLocale(text, { locale: 'zh', ...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..630a008e83 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,15 @@ 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', ...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 }; 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 28cfe0f7a9..59c5109a62 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 @@ -167,6 +167,7 @@ const zhCopy = { filterMatches: (count: number) => (count === 0 ? '没有匹配的结果' : `${count} 个匹配结果`), connectionStatuses: { retired: '已停用 · 请删除', reauth: '需要重新登录', disabledFailed: '暂不可用 · 上次连接失败', disabled: '暂不可用', failed: '上次连接失败' }, lastTest: { + auth: '鉴权失败', timeout: '请求超时', provider_unavailable: '模型服务返回错误', network: '网络错误', invalid_response: '模型服务返回错误', unknown: '连接测试失败', '连接已验证': '连接已验证', '鉴权失败': '鉴权失败', '请求超时': '请求超时', '网络错误': '网络错误', '模型服务返回错误': '模型服务返回错误', '连接测试失败': '连接测试失败', '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 已退出登录。', @@ -337,6 +338,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: { + 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', '连接已验证': '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.', diff --git a/apps/desktop/src/renderer/locales/settings-health-copy.ts b/apps/desktop/src/renderer/locales/settings-health-copy.ts index 6bb4aebbb9..685c929b73 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'; @@ -102,9 +110,9 @@ const SETTINGS_HEALTH_COPY = { scopes: { app: '应用', llm_connection: 'LLM 连接', bot: '机器人', capability: '能力', storage: '存储' }, sources: { connection_test: '连接测试', capability_snapshot: '能力快照', permission_snapshot: '权限快照', runtime_probe: '运行态探测', settings: '设置', storage: '本地存储' }, 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), }, en: { loading: 'Loading health snapshot', readFailed: 'Could not read health snapshot', noData: 'The health service returned no data.', readAgain: 'Read again', @@ -121,9 +129,9 @@ const SETTINGS_HEALTH_COPY = { 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' }, 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; @@ -131,57 +139,120 @@ 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 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: { + auth: '鉴权失败', + timeout: '请求超时', + provider_unavailable: '模型服务返回错误', + network: '网络错误', + invalid_response: '模型服务返回错误', + unknown: '连接测试失败', + }, + en: { + 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', + }, +} 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')}`] : []), + ].join(' · '); + case 'capability_reason': + return '状态详情请见对应设置页。'; + case 'last_test_error_class': + return connectionTestErrorMessages.zh[detail.errorClass]; + case 'last_test_message': + return '连接测试状态暂时无法显示,请重新测试。'; } - 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.'; - } - 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.'; } - return 'See the corresponding settings page for details.'; +} + +function localizedRuntimeErrorClass(errorClass: string, locale: UiLocale): string { + const messages: Readonly> = connectionTestErrorMessages[locale]; + const normalized = errorClass.toLowerCase(); + if (normalized === 'unknown') return locale === 'zh' ? '未知错误' : 'Unknown error'; + return messages[normalized] ?? (locale === 'zh' ? '未知错误' : 'Unknown error'); } diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index e3be3bb49e..098cf07c63 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' }, 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); @@ -2623,8 +2603,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..9bb9bf8a19 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 diff --git a/packages/core/src/health.ts b/packages/core/src/health.ts index e88582cc56..d36e1d5ab5 100644 --- a/packages/core/src/health.ts +++ b/packages/core/src/health.ts @@ -46,6 +46,43 @@ export type HealthSignalSource = | 'settings' | 'storage'; +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 = + | 'auth' + | 'timeout' + | 'provider_unavailable' + | 'network' + | 'invalid_response' + | 'unknown'; + +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' } + | { kind: 'last_test_error_class'; errorClass: HealthConnectionTestErrorClass } + | { kind: 'last_test_message' }; + export interface HealthSignal { id: string; label: string; @@ -54,8 +91,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 +188,7 @@ export function healthSignalFromConnection( status: 'info', source: 'settings', checkedAt, - message: '连接已关闭。', + message: 'connection_disabled', blocksSend: false, }; } @@ -165,7 +202,7 @@ export function healthSignalFromConnection( status: 'warning', source: 'settings', checkedAt, - message: '等待选择默认模型。', + message: 'awaiting_default_model', blocksSend: true, }; } @@ -179,8 +216,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 +231,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 +248,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 +271,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 +284,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 +298,7 @@ export function healthSignalFromConnection( status: 'unknown', source: 'connection_test', checkedAt, - message: '等待验证连接。', + message: 'awaiting_validation', blocksSend: false, }; } @@ -272,14 +313,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 +328,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 +383,42 @@ 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' } : 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 'invalid_response': + case 'unknown': + return { kind: 'last_test_error_class', errorClass: normalized }; default: - return /[\u3400-\u9fff]/.test(raw) ? raw : '状态详情请见对应设置页。'; + return { kind: 'last_test_message' }; } } @@ -397,19 +439,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/ui/src/__tests__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts index 63d788c85f..0fb91fa557 100644 --- a/packages/ui/src/__tests__/live-turn-projection.test.ts +++ b/packages/ui/src/__tests__/live-turn-projection.test.ts @@ -21,7 +21,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { encodeToolStepProgress } from '@maka/core/events'; import { - applyLiveTurnEvent, + applyLiveTurnEvent as applyLiveTurnEventWithLocale, armLiveTurn, confirmLiveTurn, reconcileTerminalLiveTurn, @@ -32,6 +32,25 @@ import { materializeTurns, overlayLiveTurn, type ToolActivityItem } from '../mat import { redactSecrets } from '../redact.js'; import { getConversationCopy } from '../conversation-copy.js'; +import type { SessionEvent } from '@maka/core/events'; +type LiveTurnContentEvent = Extract; +// Tests exercise projection logic, not copy; pin zh so markers stay verbatim. +function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: LiveTurnContentEvent, +): LiveTurnProjection; +function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: SessionEvent, +): LiveTurnProjection | undefined; +function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: SessionEvent, +): LiveTurnProjection | undefined { + return applyLiveTurnEventWithLocale(current, event, 'zh'); +} + + // A client that just sent cannot read "has my turn started" off session status: // it is the same before the turn starts and after it ends. The arm carries // `unconfirmed` until the authority says something about THAT turn, which is diff --git a/packages/ui/src/__tests__/materialize.test.ts b/packages/ui/src/__tests__/materialize.test.ts index 179be9ea18..f5118dc61d 100644 --- a/packages/ui/src/__tests__/materialize.test.ts +++ b/packages/ui/src/__tests__/materialize.test.ts @@ -28,10 +28,30 @@ import { type TurnTimelineItem, } from "../materialize.js"; import { - applyLiveTurnEvent, + applyLiveTurnEvent as applyLiveTurnEventWithLocale, armLiveTurn, } 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. +function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: LiveTurnContentEvent, +): LiveTurnProjection; +function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: SessionEvent, +): LiveTurnProjection | undefined; +function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: SessionEvent, +): LiveTurnProjection | undefined { + return applyLiveTurnEventWithLocale(current, event, 'zh'); +} + + const originalUser = { type: "user" as const, id: "original", diff --git a/packages/ui/src/__tests__/streaming-display-redaction.test.ts b/packages/ui/src/__tests__/streaming-display-redaction.test.ts index 3f9ebc1ffd..6c4fcd7de9 100644 --- a/packages/ui/src/__tests__/streaming-display-redaction.test.ts +++ b/packages/ui/src/__tests__/streaming-display-redaction.test.ts @@ -22,12 +22,32 @@ 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 as applyLiveTurnEventWithLocale } from '../live-turn-projection.js'; import { appendStreamingDisplayRedaction, createStreamingDisplayRedactionState, } from '../streaming-display-redaction.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. +function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: LiveTurnContentEvent, +): LiveTurnProjection; +function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: SessionEvent, +): LiveTurnProjection | undefined; +function applyLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: SessionEvent, +): LiveTurnProjection | undefined { + return applyLiveTurnEventWithLocale(current, event, 'zh'); +} + + function streamBySizes(input: string, sizes: readonly number[]): void { let projection: ReturnType | undefined; let source = ''; @@ -164,12 +184,14 @@ describe('streaming display redaction', () => { for (const apply of [applyAssistantDelta, applyThinkingDelta]) { const initialState = createStreamingDisplayRedactionState(); const opener = apply('', 'Authorization:', { + locale: 'zh' as const, maxDeltaChars: 128, maxTotalChars: 512, redactionState: initialState, }); const secret = `Bearer ${'s'.repeat(5_000)}`; const truncated = apply(opener.text, secret, { + locale: 'zh' as const, maxDeltaChars: 128, maxTotalChars: 512, redactionState: opener.redactionState, @@ -183,6 +205,7 @@ describe('streaming display redaction', () => { ); const total = apply('', 'safe '.repeat(200), { + locale: 'zh' 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 f2aaca01c0..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').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').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 a4eedf79d6..335f5d2ed4 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -925,11 +925,11 @@ export function ChatView(props: { export function DeepResearchProgressPanel({ run, onContinue, - copy = getConversationCopy('zh').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 fd5f74afb2..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', + 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 05cfcfbe78..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').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').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 bc1fb04be6..7dcbd7f893 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: '工具组', @@ -356,6 +362,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 ce43242173..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'): 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 8f635efcf8..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').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 30c8d44709..ed40dca398 100644 --- a/packages/ui/src/tool-format.ts +++ b/packages/ui/src/tool-format.ts @@ -88,15 +88,11 @@ export function describeLoadToolResult( }; } - const label = suppliedLabel ?? (locale === 'en' ? 'Tools' : '工具'); + const label = suppliedLabel ?? copy.fallbackLabel; return { kind, - actionLabel: suppliedLabel - ? locale === 'en' ? `Enable ${suppliedLabel}` : `启用 ${suppliedLabel}` - : copy.genericAction, - title: suppliedLabel - ? locale === 'en' ? `${suppliedLabel} enabled` : `${suppliedLabel} 已启用` - : 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 062c52de3e..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').stream.toolChunkTruncated; + const truncatedChunkMarker = getSharedUiCopy(options.locale).stream.toolChunkTruncated; const list = prevChunks ?? [];