diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 2d7de72919..ed2ce94bc1 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -63,6 +63,7 @@ "src/renderer/locales/agent-graph-copy.ts", "src/renderer/locales/artifact-copy.ts", "src/renderer/locales/browser-copy.ts", + "src/renderer/locales/capability-reason-copy.ts", "src/renderer/locales/conversation-copy.ts", "src/renderer/locales/external-session-import-copy.ts", "src/renderer/locales/mcp-copy.ts", @@ -387,12 +388,13 @@ "./command-palette-commands.js": 1, "./conversation-markdown.js": 1, "./default-runtime-host-operation.js": 1, + "./locales/settings-memory-copy.js": 1, "./locales/settings-test-result-copy.js": 1, "./locales/shell-copy.js": 1, "react": 1 }, "importSpecifiers": 9, - "nonTriviaTokens": 2307 + "nonTriviaTokens": 2299 }, "src/renderer/app-shell-context-compaction.ts": { "importDeclarations": 0, @@ -1548,6 +1550,17 @@ "actionFactories": [], "dependencyPaths": {} }, + "src/renderer/locales/capability-reason-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/ui-locale": 1 + } + }, "src/renderer/locales/conversation-copy.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -1665,7 +1678,9 @@ "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], - "dependencyPaths": {} + "dependencyPaths": { + "@maka/core/ui-locale": 1 + } }, "src/renderer/locales/settings-navigation-copy.ts": { "bridgePaths": {}, @@ -2687,6 +2702,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "../locales/capability-reason-copy": 1, "../locales/settings-bot-copy": 1, "../locales/settings-health-copy": 1, "./runtime-host-settings-target.js": 1, @@ -2895,6 +2911,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "../locales/capability-reason-copy": 1, "../locales/permission-center-copy": 1, "../locales/settings-bot-copy": 1, "./runtime-host-settings-target.js": 1, diff --git a/apps/desktop/src/main/__tests__/computer-use-host.test.ts b/apps/desktop/src/main/__tests__/computer-use-host.test.ts index 51c89b636e..3fe5bd3a1e 100644 --- a/apps/desktop/src/main/__tests__/computer-use-host.test.ts +++ b/apps/desktop/src/main/__tests__/computer-use-host.test.ts @@ -36,7 +36,7 @@ describe('Computer Use host health', () => { it('does not report a binary-only executor as healthy before first use', () => { assert.deepEqual(computerUseServiceHealth('maka-cu', snapshot('idle')), { state: 'not_run', - reason: 'maka-cu 已可用,将在首次调用时启动。', + reason: 'cu_executor_lazy_start', }); }); @@ -44,7 +44,7 @@ describe('Computer Use host health', () => { assert.equal(computerUseServiceHealth('maka-cu', snapshot('ready')).state, 'healthy'); assert.equal( computerUseServiceHealth('maka-cu', snapshot('backing_off')).reason, - 'maka-cu executor 正在启动或恢复。', + 'cu_executor_recovering', ); assert.equal( computerUseServiceHealth('maka-cu', snapshot('starting')).state, @@ -52,11 +52,11 @@ describe('Computer Use host health', () => { ); assert.deepEqual(computerUseServiceHealth('maka-cu', snapshot('unavailable')), { state: 'not_available', - reason: 'maka-cu executor 启动失败或已退出。', + reason: 'cu_executor_start_failed', }); assert.deepEqual(computerUseServiceHealth('maka-cu', snapshot('disposed')), { state: 'not_available', - reason: 'maka-cu executor 已停止。', + reason: 'cu_executor_stopped', }); }); diff --git a/apps/desktop/src/main/__tests__/github-copilot-local-credential.test.ts b/apps/desktop/src/main/__tests__/github-copilot-local-credential.test.ts index a034d7cd1d..f2ab2c82f2 100644 --- a/apps/desktop/src/main/__tests__/github-copilot-local-credential.test.ts +++ b/apps/desktop/src/main/__tests__/github-copilot-local-credential.test.ts @@ -71,8 +71,8 @@ describe('importGitHubCopilotLocalCredential', () => { assert.equal(imported.result.ok, false); if (!imported.result.ok) { assert.equal(imported.result.reason, 'token_exchange_failed'); - assert.match(imported.result.message, /不支持 classic PAT/); - assert.equal(imported.result.message.includes('ghp_classic_pat'), false); + assert.equal(imported.result.code, 'copilot_classic_pat_unsupported'); + assert.equal(imported.result.message, undefined); } assert.equal(imported.secret, undefined); }); @@ -83,7 +83,7 @@ describe('importGitHubCopilotLocalCredential', () => { }); assert.equal(imported.result.ok, false); - if (!imported.result.ok) assert.match(imported.result.message, /凭据类型不受支持/); + if (!imported.result.ok) assert.equal(imported.result.code, 'copilot_credential_type_unsupported'); assert.equal(imported.secret, undefined); }); @@ -95,7 +95,7 @@ describe('importGitHubCopilotLocalCredential', () => { }); assert.equal(imported.result.ok, false); - if (!imported.result.ok) assert.match(imported.result.message, /未找到可导入/); + if (!imported.result.ok) assert.equal(imported.result.code, 'copilot_local_credential_missing'); assert.equal(imported.secret, undefined); }); }); 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 b9d7d1d06f..5cf03c20ae 100644 --- a/apps/desktop/src/main/__tests__/health-center-copy.test.ts +++ b/apps/desktop/src/main/__tests__/health-center-copy.test.ts @@ -19,8 +19,11 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; +import { CAPABILITY_REASON_CODES } from '@maka/core/capabilities'; +import { UI_LOCALES, type UiCatalog } from '@maka/core/ui-locale'; import { connectionLastTestMessageDisplay } from '../../renderer/features/connection-settings/index.js'; import { getHealthCenterCopy } from '../../renderer/locales/settings-health-copy.js'; +import { getCapabilityReasonCopy } from '../../renderer/locales/capability-reason-copy.js'; test('labels blocker counts as global across filtered health views', () => { assert.equal( @@ -96,23 +99,25 @@ test('renders runtime probe details from structured params, not string parsing', ); }); -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('capability reason codes have copy in every locale, unknown reasons keep the fallback', () => { + const fallback = { + 'zh-CN': '状态详情请见对应设置页。', + 'zh-TW': '狀態詳細資料請參閱對應的設定頁。', + en: 'See the corresponding settings page for details.', + } satisfies UiCatalog; + assert.equal(getCapabilityReasonCopy('zh-CN').platform_credentials_missing, '未配置平台凭据'); + for (const locale of UI_LOCALES) { + const reasons = getCapabilityReasonCopy(locale); + for (const code of CAPABILITY_REASON_CODES) { + assert.equal(typeof reasons[code], 'string'); + assert.notEqual(reasons[code], code); + } + const render = (reason: string) => + getHealthCenterCopy(locale).signalDetail(signal({ detail: { kind: 'capability_reason', reason } })); + for (const reason of ['future_code', '未配置平台凭据', 'Slack requires a Bot Token.', 'toString', '__proto__']) { + assert.equal(render(reason), fallback[locale]); + } + } }); test('maps connection test error classes without exposing machine tokens', () => { diff --git a/apps/desktop/src/main/__tests__/oauth-result-copy.test.ts b/apps/desktop/src/main/__tests__/oauth-result-copy.test.ts new file mode 100644 index 0000000000..5d10b16b42 --- /dev/null +++ b/apps/desktop/src/main/__tests__/oauth-result-copy.test.ts @@ -0,0 +1,71 @@ +/* + * 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 assert from "node:assert/strict"; +import test from "node:test"; +// Lives under main/__tests__ on purpose: desktop main tests run from dist via +// `node --test "dist/main/**/*.test.js"`, and settings-provider-copy is a pure +// copy module (no react), so it is safe to exercise from node. Same precedent +// as permission-center-copy.test.ts. +import { + connectionTestFailureMessage, + subscriptionResultMessage, +} from "../../renderer/features/connection-settings/index.js"; + +test("renders a coded Copilot import failure per locale, ignoring its machine message", () => { + const result = { code: "copilot_subscription_unavailable", message: "copilot_subscription_unavailable" }; + assert.equal(subscriptionResultMessage(result, "fallback", "zh-CN"), "当前 GitHub 账号没有可用的 Copilot 订阅权限。"); + assert.equal(subscriptionResultMessage(result, "fallback", "en"), "This GitHub account has no usable Copilot subscription."); +}); + +test("renders the typed experimental_disabled reason per locale", () => { + const result = { reason: "experimental_disabled", message: "enrollment is disabled for this provider" }; + assert.equal(subscriptionResultMessage(result, "fallback", "zh-CN"), "本机未启用该账号登录方式;可改用导入兼容凭据,或由管理员启用后重试。"); + assert.equal(subscriptionResultMessage(result, "fallback", "en"), "This sign-in is not enabled on this install. Import a compatible credential instead, or ask an operator to enable it."); +}); + +test("falls back to catalog copy for an unknown code instead of the raw message", () => { + const result = { code: "not_a_known_code", message: "内部错误" }; + assert.equal(subscriptionResultMessage(result, "fallback", "en"), "fallback"); + assert.equal(subscriptionResultMessage(result, "fallback", "zh-CN"), "fallback"); +}); + +test("renders provider rate limits consistently from the stable status code", () => { + const result = { ok: false, statusCode: 429, errorClass: "provider_unavailable" } as const; + const troubleshooting = { auth: "auth", recheck: "recheck" }; + assert.equal( + connectionTestFailureMessage(result, troubleshooting, "zh-CN"), + "当前账号或模型服务触发速率限制,请稍后重试。", + ); + assert.equal( + connectionTestFailureMessage(result, troubleshooting, "en"), + "This account or model service is rate-limited. Try again later.", + ); +}); + +test("rejects raw backend prose for Traditional Chinese subscription errors", () => { + assert.equal( + subscriptionResultMessage("GitHub Copilot 登录已过期。", "請重新登入。", "zh-TW"), + "請重新登入。", + ); + assert.equal( + subscriptionResultMessage("network unreachable", "請重新登入。", "zh-TW"), + "網路錯誤", + ); +}); diff --git a/apps/desktop/src/main/__tests__/permission-center-bot-reason.test.ts b/apps/desktop/src/main/__tests__/permission-center-bot-reason.test.ts index c540f55f2a..35b53270f9 100644 --- a/apps/desktop/src/main/__tests__/permission-center-bot-reason.test.ts +++ b/apps/desktop/src/main/__tests__/permission-center-bot-reason.test.ts @@ -26,6 +26,7 @@ import { build } from 'esbuild'; import { UI_LOCALES, type UiLocale } from '@maka/core/ui-locale'; import type { HealthSignal } from '@maka/core/health'; import { botStatusReasonMessage, getBotSettingsCopy } from '../../renderer/locales/settings-bot-copy.js'; +import { getCapabilityReasonCopy } from '../../renderer/locales/capability-reason-copy.js'; import { getHealthCenterCopy, type HealthCenterCopy } from '../../renderer/locales/settings-health-copy.js'; const REPO_ROOT = resolve(import.meta.dirname, '../../../../..'); @@ -99,8 +100,8 @@ test('health center renders localized bot capability reasons in all locales', () } }); -test('health center keeps the interim CJK passthrough for non-bot capability reasons', () => { - const signal: HealthSignal = { +test('health center resolves non-bot capability codes through the shared catalog', () => { + const signal = (reason: string): HealthSignal => ({ id: 'capability:computer_use', label: 'Computer Use', scope: 'capability', @@ -109,9 +110,18 @@ test('health center keeps the interim CJK passthrough for non-bot capability rea source: 'capability_snapshot', checkedAt: 1, message: 'capability_degraded', - detail: { kind: 'capability_reason', reason: 'maka-cu service 启动失败、已退出或已停止。' }, + detail: { kind: 'capability_reason', reason }, relatedCapabilityId: 'computer_use', - }; - assert.equal(localizedSignalDetail(signal, getHealthCenterCopy('zh-CN'), 'zh-CN'), 'maka-cu service 启动失败、已退出或已停止。'); - assert.equal(localizedSignalDetail(signal, getHealthCenterCopy('en'), 'en'), 'See the corresponding settings page for details.'); + }); + for (const locale of UI_LOCALES) { + const copy = getHealthCenterCopy(locale); + assert.equal( + localizedSignalDetail(signal('cu_executor_start_failed'), copy, locale), + getCapabilityReasonCopy(locale).cu_executor_start_failed, + ); + assert.equal( + localizedSignalDetail(signal('maka-cu service 启动失败、已退出或已停止。'), copy, locale), + copy.signalDetail(signal('future_code')), + ); + } }); \ No newline at end of file diff --git a/apps/desktop/src/main/__tests__/permission-center-copy.test.ts b/apps/desktop/src/main/__tests__/permission-center-copy.test.ts index 318b15a877..6e6aa7344a 100644 --- a/apps/desktop/src/main/__tests__/permission-center-copy.test.ts +++ b/apps/desktop/src/main/__tests__/permission-center-copy.test.ts @@ -20,8 +20,35 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { getPermissionCenterCopy } from '../../renderer/locales/permission-center-copy.js'; +import { getCapabilityReasonCopy } from '../../renderer/locales/capability-reason-copy.js'; test('presents a granted OS permission as a verified success', () => { assert.equal(getPermissionCenterCopy('zh-CN').osStates.granted.tone, 'success'); assert.equal(getPermissionCenterCopy('en').osStates.granted.tone, 'success'); }); + +test('renders capability reason codes per locale', () => { + const zh = getCapabilityReasonCopy('zh-CN'); + const en = getCapabilityReasonCopy('en'); + assert.equal(zh.platform_credentials_missing, '未配置平台凭据'); + assert.equal(en.platform_credentials_missing, 'Platform credentials are not configured'); + assert.equal(zh.cu_executor_recovering, 'maka-cu executor 正在启动或恢复。'); + assert.equal(en.cu_executor_recovering, 'The maka-cu executor is starting or recovering.'); +}); + +test('composes the computer-use backend status from snapshot facts per locale', () => { + const zh = getPermissionCenterCopy('zh-CN'); + const en = getPermissionCenterCopy('en'); + assert.equal( + zh.cuBackendStatus(['辅助功能', '屏幕录制'], 'healthy'), + 'maka-cu artifact 已通过本地完整性检查。等待辅助功能、屏幕录制权限。操作与截图 service 已就绪;按目标与动作类别授权后可操作本机应用。', + ); + assert.equal( + zh.cuBackendStatus([], 'not_run'), + 'maka-cu artifact 已通过本地完整性检查。service 将在首次调用时启动;按目标与动作类别授权后可操作本机应用。', + ); + assert.equal( + en.cuBackendStatus(['Accessibility'], 'degraded'), + 'The maka-cu artifact passed the local integrity check. Waiting for Accessibility permission. The maka-cu service is starting or recovering.', + ); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts index 436f3aa40b..77f5186af0 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts @@ -79,6 +79,7 @@ for (const [fault, reason] of [ } syncBuiltinESMExports(); registerRuntimeHostArtifactsIpc({ + uiLocale: () => 'zh-CN' as const, ipcMain: { handle: (channel, handler) => handlers.set(channel, handler as Handler) }, client: { hostEpoch: "host-1", @@ -138,6 +139,7 @@ function attachmentReadHandler( ): Handler { const handlers = new Map(); registerRuntimeHostArtifactsIpc({ + uiLocale: () => 'zh-CN' as const, ipcMain: { handle: (channel, handler) => handlers.set(channel, handler as Handler), }, @@ -205,6 +207,7 @@ test("Runtime Host Artifact IPC preserves previews and streams complete exports" try { registerRuntimeHostArtifactsIpc({ + uiLocale: () => 'zh-CN' as const, ipcMain: { handle: (channel, handler) => handlers.set(channel, handler as Handler), }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-memory-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-memory-ipc-main.test.ts index 17391c12fc..b47b7df9c9 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-memory-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-memory-ipc-main.test.ts @@ -166,17 +166,11 @@ test('does not project or open remote Runtime Host file paths', async () => { path: string; backups: Array<{ path: string }>; }; - const opened = (await handlers.get('memory:openFile')?.({})) as { - ok: boolean; - message: string; - }; + const opened = (await handlers.get('memory:openFile')?.({})) as { ok: boolean; code: string }; assert.equal(projected.path, ''); assert.deepEqual(projected.backups.map(({ path }) => path), ['']); - assert.deepEqual(opened, { - ok: false, - message: 'Memory files are owned by the remote Runtime Host', - }); + assert.deepEqual(opened, { ok: false, code: 'remote_host_owned' }); }); function revision(value: string): `sha256:${string}` { diff --git a/apps/desktop/src/main/__tests__/settings-memory-copy.test.ts b/apps/desktop/src/main/__tests__/settings-memory-copy.test.ts new file mode 100644 index 0000000000..d18566f593 --- /dev/null +++ b/apps/desktop/src/main/__tests__/settings-memory-copy.test.ts @@ -0,0 +1,195 @@ +/* + * 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 assert from 'node:assert/strict'; +import test from 'node:test'; +import { UI_LOCALES, type UiCatalog } from '@maka/core/ui-locale'; +import { + getMemorySettingsCopy, + memoryResultMessage, + type MemoryResultCode, +} from '../../renderer/locales/settings-memory-copy.js'; + +const rejectionCopy = { + invalid_content: ['MEMORY.md content is invalid. Check its format and try again.', 'MEMORY.md 内容无效,请检查格式后重试。'], + invalid_scope: ['The memory operation has an invalid scope.', '当前记忆操作的作用域无效。'], + not_found: ['The memory entry was not found.', '找不到对应的记忆条目。'], + not_pending: ['The memory entry is not pending review.', '对应的记忆条目不在待确认状态。'], + upload_not_found: ['The memory upload session does not exist or has expired.', '记忆上传会话不存在或已过期。'], + upload_incomplete: ['The memory content has not finished uploading.', '记忆内容尚未上传完整。'], + upload_conflict: ['Another memory upload is in progress. Try again.', '另一个记忆上传正在进行,请重试。'], +} satisfies Partial>; + +test('renders Runtime Host memory rejection codes per locale', () => { + for (const [code, [en, zh]] of Object.entries(rejectionCopy)) { + assert.equal(memoryResultMessage({ code }, getMemorySettingsCopy('en'), 'fallback'), en); + assert.equal(memoryResultMessage({ code }, getMemorySettingsCopy('zh-CN'), 'fallback'), zh); + } +}); + +test('falls back for an unknown Runtime Host memory rejection code', () => { + for (const locale of UI_LOCALES) { + for (const code of [undefined, '', 'future_host_code', 'toString', 'constructor', '__proto__']) { + assert.equal(memoryResultMessage({ code }, getMemorySettingsCopy(locale), 'fallback'), 'fallback'); + } + } +}); + +const formattedCopy = { + 'zh-CN': { + counts: [ + ['0 条生效', '草稿 0 条生效', '0 条已归档', '草稿 0 条已归档', '0 条记忆'], + ['1 条生效', '草稿 1 条生效', '1 条已归档', '草稿 1 条已归档', '1 条记忆'], + ['2 条生效', '草稿 2 条生效', '2 条已归档', '草稿 2 条已归档', '2 条记忆'], + ], + summaries: [ + ['当前 0 条生效;已保留上一版备份。', '0 条生效'], + ['当前 1 条生效;已保留上一版备份。', '1 条生效'], + ['当前 2 条生效;已保留上一版备份。', '2 条生效'], + ['当前 0 条生效 / 2 条已归档;已保留上一版备份。', '0 条生效 / 2 条已归档'], + ['当前 1 条生效 / 1 条已归档;已保留上一版备份。', '1 条生效 / 1 条已归档'], + ['当前 1 条生效 / 2 条已归档;已保留上一版备份。', '1 条生效 / 2 条已归档'], + ['当前 2 条生效 / 1 条已归档;已保留上一版备份。', '2 条生效 / 1 条已归档'], + ['当前 2 条生效 / 2 条已归档;已保留上一版备份。', '2 条生效 / 2 条已归档'], + ], + redacted: [ + '写入前已替换疑似 token、API key 或密码;当前 1 条生效;已保留上一版备份。', + '写入前已替换疑似 token、API key 或密码;当前 1 条生效 / 2 条已归档;已保留上一版备份。', + ], + backupFailures: ['打开重置前备份失败', '打开恢复前备份失败', '打开保存前备份失败'], + preview: ['预览已按 8,000 字符上限截断', '预览 1,234 / 8,000 字符', 'prompt 上限 8,000 字符'], + labels: [ + '0 / 0 条匹配', '1 / 2 条匹配', 'Memory #2列表', 'Memory #2 记忆操作', + '归档:Memory #2', '打开备份候选 Backup #2', '恢复备份候选 Backup #2', + '复制备份候选引用 Backup #2', '归档到草稿,保存前不会写入 MEMORY.md', + '恢复到草稿,保存前不会写入 MEMORY.md', + '会先备份当前 MEMORY.md,再用最近一次备份覆盖当前文件。将恢复:Backup #2', + '会先备份当前 MEMORY.md,再用选中的备份覆盖当前文件。将恢复:Backup #2', + ], + }, + 'zh-TW': { + counts: [ + ['0 則生效', '草稿 0 則生效', '0 則已歸檔', '草稿 0 則已歸檔', '0 則記憶'], + ['1 則生效', '草稿 1 則生效', '1 則已歸檔', '草稿 1 則已歸檔', '1 則記憶'], + ['2 則生效', '草稿 2 則生效', '2 則已歸檔', '草稿 2 則已歸檔', '2 則記憶'], + ], + summaries: [ + ['目前 0 則生效;已保留上一版備份。', '0 則生效'], + ['目前 1 則生效;已保留上一版備份。', '1 則生效'], + ['目前 2 則生效;已保留上一版備份。', '2 則生效'], + ['目前 0 則生效 / 2 則已歸檔;已保留上一版備份。', '0 則生效 / 2 則已歸檔'], + ['目前 1 則生效 / 1 則已歸檔;已保留上一版備份。', '1 則生效 / 1 則已歸檔'], + ['目前 1 則生效 / 2 則已歸檔;已保留上一版備份。', '1 則生效 / 2 則已歸檔'], + ['目前 2 則生效 / 1 則已歸檔;已保留上一版備份。', '2 則生效 / 1 則已歸檔'], + ['目前 2 則生效 / 2 則已歸檔;已保留上一版備份。', '2 則生效 / 2 則已歸檔'], + ], + redacted: [ + '寫入前已遮蔽疑似 token、API key 或密碼;目前 1 則生效;已保留上一版備份。', + '寫入前已遮蔽疑似 token、API key 或密碼;目前 1 則生效 / 2 則已歸檔;已保留上一版備份。', + ], + backupFailures: ['開啟重置前備份失敗', '開啟恢復前備份失敗', '開啟儲存前備份失敗'], + preview: ['預覽已依 8,000 字元上限截斷', '預覽 1,234 / 8,000 字元', 'prompt 上限 8,000 字元'], + labels: [ + '0 / 0 則符合', '1 / 2 則符合', 'Memory #2清單', 'Memory #2 記憶操作', + '歸檔:Memory #2', '開啟備份候選 Backup #2', '還原備份候選 Backup #2', + '複製備份候選引用 Backup #2', '歸檔到草稿,儲存前不會寫入 MEMORY.md', + '恢復到草稿,儲存前不會寫入 MEMORY.md', + '會先備份目前的 MEMORY.md,再以最近一次備份覆蓋目前檔案。將還原:Backup #2', + '會先備份目前的 MEMORY.md,再以選取的備份覆蓋目前檔案。將還原:Backup #2', + ], + }, + en: { + counts: [ + ['0 active entries', 'Draft · 0 active entries', '0 archived entries', 'Draft · 0 archived entries', '0 memories'], + ['1 active entry', 'Draft · 1 active entry', '1 archived entry', 'Draft · 1 archived entry', '1 memory'], + ['2 active entries', 'Draft · 2 active entries', '2 archived entries', 'Draft · 2 archived entries', '2 memories'], + ], + summaries: [ + ['0 active entries; the previous version was backed up.', '0 active entries'], + ['1 active entry; the previous version was backed up.', '1 active entry'], + ['2 active entries; the previous version was backed up.', '2 active entries'], + ['0 active entries / 2 archived entries; the previous version was backed up.', '0 active entries / 2 archived entries'], + ['1 active entry / 1 archived entry; the previous version was backed up.', '1 active entry / 1 archived entry'], + ['1 active entry / 2 archived entries; the previous version was backed up.', '1 active entry / 2 archived entries'], + ['2 active entries / 1 archived entry; the previous version was backed up.', '2 active entries / 1 archived entry'], + ['2 active entries / 2 archived entries; the previous version was backed up.', '2 active entries / 2 archived entries'], + ], + redacted: [ + 'Suspected tokens, API keys, or passwords were redacted before writing; 1 active entry; the previous version was backed up.', + 'Suspected tokens, API keys, or passwords were redacted before writing; 1 active entry / 2 archived entries; the previous version was backed up.', + ], + backupFailures: ['Failed to open Before reset', 'Failed to open Before restore', 'Failed to open Before save'], + preview: ['Preview truncated at the 8,000-character limit', 'Preview 1,234 / 8,000 characters', 'Prompt limit: 8,000 characters'], + labels: [ + '0 / 0 matching', '1 / 2 matching', 'Memory #2 list', 'Memory #2 memory actions', + 'Archive: Memory #2', 'Open backup candidate Backup #2', 'Restore backup candidate Backup #2', + 'Copy backup candidate reference Backup #2', 'Archive in draft; MEMORY.md is not written until you save', + 'Restore to draft; MEMORY.md is not written until you save', + 'The current MEMORY.md will be backed up before the latest backup replaces it. Restore: Backup #2', + 'The current MEMORY.md will be backed up before the selected backup replaces it. Restore: Backup #2', + ], + }, +} satisfies UiCatalog<{ + counts: string[][]; + summaries: string[][]; + redacted: string[]; + backupFailures: string[]; + preview: string[]; + labels: string[]; +}>; + +for (const locale of UI_LOCALES) { + const expected = formattedCopy[locale]; + const copy = getMemorySettingsCopy(locale); + + test(`renders complete memory counts and summaries in ${locale}`, () => { + for (const count of [0, 1, 2]) { + assert.deepEqual([ + copy.countActive(count), copy.countActive(count, true), + copy.countArchived(count), copy.countArchived(count, true), copy.countEntries(count), + ], expected.counts[count]); + assert.equal(copy.countActive(count, false), expected.counts[count][0]); + assert.equal(copy.countArchived(count, false), expected.counts[count][2]); + } + const summaryCounts = [[0, 0], [1, 0], [2, 0], [0, 2], [1, 1], [1, 2], [2, 1], [2, 2]] as const; + assert.deepEqual(summaryCounts.map(([active, archived]) => [ + copy.saveSummary(active, archived), copy.backupSummary(active, archived), + ]), expected.summaries); + }); + + test(`renders complete redaction, backup failure, and preview messages in ${locale}`, () => { + assert.deepEqual([0, 2].map((archived) => copy.redactedDetail(copy.saveSummary(1, archived))), expected.redacted); + assert.deepEqual((['reset', 'restore', 'save'] as const).map((kind) => ( + copy.openBackupFailed(copy.backupKinds[kind]) + )), expected.backupFailures); + assert.deepEqual([ + copy.previewTruncated('8,000'), copy.previewUsage('1,234', '8,000'), copy.previewLimit('8,000'), + ], expected.preview); + }); + + test(`renders complete memory action and restore descriptions in ${locale}`, () => { + assert.deepEqual([ + copy.countMatches(0, 0), copy.countMatches(1, 2), copy.listAria('Memory #2'), + copy.entryActionsAria('Memory #2'), copy.entryActionAria(copy.text.archiveAction, 'Memory #2'), + copy.openBackupAria('Backup #2'), copy.restoreBackupAria('Backup #2'), copy.copyBackupAria('Backup #2'), + copy.draftStatusAria(copy.text.archiveDraftAction), copy.draftStatusAria(copy.text.restoreDraftAction), + copy.restoreLatestDescription('Backup #2'), copy.restoreCandidateDescription('Backup #2'), + ], expected.labels); + }); +} diff --git a/apps/desktop/src/main/__tests__/subscription-result-message.test.ts b/apps/desktop/src/main/__tests__/subscription-result-message.test.ts deleted file mode 100644 index 843717e9f0..0000000000 --- a/apps/desktop/src/main/__tests__/subscription-result-message.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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 assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { subscriptionResultMessage } from '../../renderer/features/connection-settings/index.js'; - -test('Traditional Chinese subscription errors reject raw Simplified backend copy', () => { - assert.equal( - subscriptionResultMessage('GitHub Copilot 登录已过期。', '請重新登入。', 'zh-TW'), - '請重新登入。', - ); - assert.equal( - subscriptionResultMessage('network unreachable', '請重新登入。', 'zh-TW'), - '網路錯誤', - ); -}); diff --git a/apps/desktop/src/main/browser-message-box.ts b/apps/desktop/src/main/browser-message-box.ts index 7ab4302cd7..0c5519dc96 100644 --- a/apps/desktop/src/main/browser-message-box.ts +++ b/apps/desktop/src/main/browser-message-box.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { UiCatalog } from '@maka/core/ui-locale'; import { randomUUID } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -348,7 +349,7 @@ export function buildBrowserMessageBoxHtml( function renderBrowserMessageBoxHtml(input: BrowserMessageBoxPresentation): string { const nonce = randomUUID().replaceAll('-', ''); - const closeLabel = input.locale === 'zh-CN' ? '关闭' : input.locale === 'zh-TW' ? '關閉' : 'Close'; + const closeLabel = CLOSE_LABEL[input.locale]; const closeButton = ``; @@ -638,3 +639,5 @@ function escapeHtml(value: string): string { return entities[character] ?? character; }); } + +const CLOSE_LABEL = { 'zh-CN': '关闭', 'zh-TW': '關閉', en: 'Close' } satisfies UiCatalog; diff --git a/apps/desktop/src/main/capability-snapshot.ts b/apps/desktop/src/main/capability-snapshot.ts index 8c430d9b00..3fed88bb2f 100644 --- a/apps/desktop/src/main/capability-snapshot.ts +++ b/apps/desktop/src/main/capability-snapshot.ts @@ -27,6 +27,7 @@ import { type CapabilityFeatureSignal, type CapabilityMemoryAcceptanceSignal, type CapabilityPermissionRequirement, + type CapabilityReasonCode, type CapabilityRuntimeProbeSignal, type CapabilitySnapshot, type CapabilitySnapshotCollection, @@ -80,7 +81,7 @@ export function buildCapabilitySnapshotCollection(input: { feature: { state: 'partial', source: 'runtime', - reason: 'Daily Review 已聚合本地任务 / 工具 / 模型活动;当前不包含屏幕与应用级录制', + reason: 'activity_recorder_partial', }, requiredPermissions: [ { id: 'screen_recording', required: false, status: permissions.screen_recording.status }, @@ -90,7 +91,7 @@ export function buildCapabilitySnapshotCollection(input: { runtimeProbe: { state: 'not_run', source: 'runtime_probe', - reason: '打开 Daily Review 可查看本地活动聚合结果', + reason: 'activity_recorder_probe_hint', }, }), staticCapability({ @@ -100,7 +101,7 @@ export function buildCapabilitySnapshotCollection(input: { feature: { state: 'partial', source: 'runtime', - reason: '本地 MEMORY.md 已可见;自动抽取/写入仍需用户确认', + reason: 'memory_partial', }, requiredPermissions: [], actionApproval: { state: 'not_required', source: 'not_applicable' }, @@ -108,7 +109,7 @@ export function buildCapabilitySnapshotCollection(input: { runtimeProbe: { state: 'not_run', source: 'runtime_probe', - reason: '透明本地记忆为文件读写能力,不做后台探测', + reason: 'memory_no_probe', }, }), ...BOT_PROVIDERS.map((provider) => @@ -138,7 +139,7 @@ function computerUseCapability( feature: { state: artifactAvailable ? 'enabled' : 'not_available', source: 'runtime', - reason: computerUseCapabilityReason(input, permissions), + reason: input === undefined || input.backendId === 'none' ? 'cu_artifact_missing' : 'cu_backend_status', }, requiredPermissions: [ { id: 'accessibility', required: true, status: permissions.accessibility.status }, @@ -153,47 +154,11 @@ function computerUseCapability( state: input?.health.state ?? 'not_available', source: 'runtime_probe', lastCheckedAt: now, - reason: input?.health.reason ?? 'Computer Use 后端当前不可用。', + reason: input?.health.reason ?? 'cu_backend_unavailable', }, }); } -function computerUseCapabilityReason( - input: { - backendId: CuBackendId | 'none'; - health: ReturnType; - } | undefined, - permissions: PermissionSnapshot['permissions'], -): string { - if (input === undefined || input.backendId === 'none') { - return '未找到通过完整性检查的 Computer Use 执行器 artifact。'; - } - - const reasons = [`${input.backendId} artifact 已通过本地完整性检查。`]; - const missingPermissions = [ - ['辅助功能', permissions.accessibility.status], - ['屏幕录制', permissions.screen_recording.status], - ].filter((entry) => entry[1] !== 'granted').map((entry) => entry[0]); - if (missingPermissions.length > 0) { - reasons.push(`等待${missingPermissions.join('、')}权限。`); - } - switch (input.health.state) { - case 'not_available': - reasons.push(`${input.backendId} service 启动失败、已退出或已停止。`); - break; - case 'degraded': - reasons.push(`${input.backendId} service 正在启动或恢复。`); - break; - case 'healthy': - reasons.push('操作与截图 service 已就绪;按目标与动作类别授权后可操作本机应用。'); - break; - case 'not_run': - reasons.push('service 将在首次调用时启动;按目标与动作类别授权后可操作本机应用。'); - break; - } - return reasons.join(''); -} - function staticCapability(input: { id: CapabilitySnapshot['id']; label: string; @@ -203,7 +168,6 @@ function staticCapability(input: { actionApproval: CapabilityActionApprovalSignal; memoryAcceptance: CapabilityMemoryAcceptanceSignal; runtimeProbe: CapabilityRuntimeProbeSignal; - guidance?: string[]; }): CapabilitySnapshot { const configuration: CapabilityConfigurationSignal = { state: 'not_required', source: 'not_applicable' }; return { @@ -223,7 +187,6 @@ function staticCapability(input: { runtimeProbe: input.runtimeProbe, canRevoke: false, canPause: input.feature.state === 'enabled', - guidance: input.guidance ?? [], auditEvents: [], updatedAt: input.now, }; @@ -243,7 +206,7 @@ function botCapability( }; const configuration: CapabilityConfigurationSignal = hasConfig ? { state: 'present', source: 'settings' } - : { state: 'missing', source: 'settings', reason: '未配置平台凭据' }; + : { state: 'missing', source: 'settings', reason: 'platform_credentials_missing' }; const runtimeProbe = runtimeProbeFromBotReadiness( status.readiness, channel.readinessUpdatedAt, @@ -267,14 +230,13 @@ function botCapability( runtimeProbe, canRevoke: channel.enabled || hasConfig, canPause: channel.enabled, - guidance: [], auditEvents: [], updatedAt: now, }; } function accessibilitySnapshot(now: number, platform: NodeJS.Platform): OsPermissionSnapshot { - if (platform !== 'darwin') return unsupportedPermission('accessibility', now, '仅 macOS TCC 权限适用'); + if (platform !== 'darwin') return unsupportedPermission('accessibility', now, 'macos_tcc_only'); try { const granted = systemPreferences.isTrustedAccessibilityClient(false); return { @@ -282,12 +244,15 @@ function accessibilitySnapshot(now: number, platform: NodeJS.Platform): OsPermis status: granted ? 'granted' : 'not_determined', source: 'electron', checkedAt: now, - reason: granted ? undefined : 'macOS 不区分辅助功能权限是未授权还是未申请', + reason: granted ? undefined : 'accessibility_status_ambiguous', canOpenSettings: true, canRequest: false, }; } catch (error) { - return unknownPermission('accessibility', now, generalizedReason(error), true); + // `permission_probe_failed` stays a closed code for the page; the raw + // cause is diagnostic-only, so the probe error is not silently swallowed. + console.warn('[capability] accessibility probe failed:', error instanceof Error ? error.message : error); + return unknownPermission('accessibility', now, true); } } @@ -298,11 +263,7 @@ function mediaPermissionSnapshot( platform: NodeJS.Platform, ): OsPermissionSnapshot { if (!supportsMediaPermissionProbe(id, platform)) { - return unsupportedPermission( - id, - now, - '屏幕录制权限状态仅能在 macOS 上读取', - ); + return unsupportedPermission(id, now, 'screen_recording_status_mac_only'); } try { const status = mapMediaAccessStatus(systemPreferences.getMediaAccessStatus(mediaType)); @@ -315,7 +276,8 @@ function mediaPermissionSnapshot( ...actions, }; } catch (error) { - return unknownPermission(id, now, generalizedReason(error), platform === 'darwin'); + console.warn('[capability] media probe failed:', error instanceof Error ? error.message : error); + return unknownPermission(id, now, platform === 'darwin'); } } @@ -328,9 +290,9 @@ function notificationSnapshot(now: number, platform: NodeJS.Platform): OsPermiss checkedAt: now, reason: supported ? platform === 'darwin' - ? 'Electron 无法可靠读取 macOS 通知授权状态,请在系统设置中确认' - : 'Electron 无法可靠读取当前系统的通知授权状态' - : 'Electron 通知能力不可用', + ? 'notifications_status_unreadable_macos' + : 'notifications_status_unreadable' + : 'notifications_unsupported', canOpenSettings: platform === 'darwin', // Showing a Notification is not an authorization API and does not report // whether macOS delivered or suppressed it. Never present that probe as a @@ -340,19 +302,23 @@ function notificationSnapshot(now: number, platform: NodeJS.Platform): OsPermiss } function automationSnapshot(now: number, platform: NodeJS.Platform): OsPermissionSnapshot { - if (platform !== 'darwin') return unsupportedPermission('automation', now, '仅 macOS TCC 权限适用'); + if (platform !== 'darwin') return unsupportedPermission('automation', now, 'macos_tcc_only'); return { id: 'automation', status: 'unknown', source: 'static', checkedAt: now, - reason: 'Electron 暂不支持读取逐 App 的 Apple Events 授权状态', + reason: 'apple_events_tcc_status_unavailable', canOpenSettings: true, canRequest: false, }; } -function unsupportedPermission(id: OsPermissionId, now: number, reason: string): OsPermissionSnapshot { +function unsupportedPermission( + id: OsPermissionId, + now: number, + reason: CapabilityReasonCode, +): OsPermissionSnapshot { return { id, status: 'unsupported', @@ -367,7 +333,6 @@ function unsupportedPermission(id: OsPermissionId, now: number, reason: string): function unknownPermission( id: OsPermissionId, now: number, - reason: string, canOpenSettings: boolean, ): OsPermissionSnapshot { return { @@ -375,12 +340,8 @@ function unknownPermission( status: 'unknown', source: 'electron', checkedAt: now, - reason, + reason: 'permission_probe_failed', canOpenSettings, canRequest: false, }; } - -function generalizedReason(error: unknown): string { - return error instanceof Error ? error.message : 'permission probe failed'; -} diff --git a/apps/desktop/src/main/computer-use-host.ts b/apps/desktop/src/main/computer-use-host.ts index 5b5c964b74..aefef19cec 100644 --- a/apps/desktop/src/main/computer-use-host.ts +++ b/apps/desktop/src/main/computer-use-host.ts @@ -34,6 +34,7 @@ import { selectComputerUseBackend, type SelectedComputerUseBackend, } from '@maka/computer-use'; +import type { CapabilityReasonCode } from '@maka/core/capabilities'; import type { CuOverlayHook } from '@maka/runtime/computer-use-types'; export interface ComputerUseHostState { @@ -147,25 +148,22 @@ export function computerUseServiceHealth( state: MakaCuServiceSnapshot | undefined, ): { state: 'not_available' | 'not_run' | 'healthy' | 'degraded'; - reason: string; + reason: CapabilityReasonCode; } { if (backendId === 'none' || !state) { - return { - state: 'not_available', - reason: '未找到通过完整性检查且可分发的 maka-cu executor。', - }; + return { state: 'not_available', reason: 'cu_executor_undistributable' }; } switch (state.state) { case 'disposed': - return { state: 'not_available', reason: 'maka-cu executor 已停止。' }; + return { state: 'not_available', reason: 'cu_executor_stopped' }; case 'unavailable': - return { state: 'not_available', reason: 'maka-cu executor 启动失败或已退出。' }; + return { state: 'not_available', reason: 'cu_executor_start_failed' }; case 'starting': case 'backing_off': - return { state: 'degraded', reason: 'maka-cu executor 正在启动或恢复。' }; + return { state: 'degraded', reason: 'cu_executor_recovering' }; case 'ready': - return { state: 'healthy', reason: 'maka-cu executor 已就绪。' }; + return { state: 'healthy', reason: 'cu_executor_ready' }; default: - return { state: 'not_run', reason: 'maka-cu 已可用,将在首次调用时启动。' }; + return { state: 'not_run', reason: 'cu_executor_lazy_start' }; } } diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 565583503f..cad8498460 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -17,7 +17,7 @@ * under the License. */ -import { resolveSystemUiLocale } from '@maka/core/ui-locale'; +import { resolveSystemUiLocale, type UiCatalog } from '@maka/core/ui-locale'; import { DEV_LOSER_EXIT_CODE, developmentLaunchResultFile, @@ -105,27 +105,14 @@ if (!app.requestSingleInstanceLock()) { .whenReady() .then(() => { const locale = resolveSystemUiLocale(app.getPreferredSystemLanguages()); - const isSimplifiedChinese = locale === 'zh-CN'; - const isTraditionalChinese = locale === 'zh-TW'; + const copy = DEV_SINGLETON_COPY[locale]; return showBrowserMessageBox( { type: 'warning', - title: isSimplifiedChinese - ? 'Maka Dev 已在运行' - : isTraditionalChinese - ? 'Maka Dev 已在執行' - : 'Maka Dev is already running', - message: isSimplifiedChinese - ? '另一个 Maka Dev 实例正在使用此开发配置。' - : isTraditionalChinese - ? '另一個 Maka Dev 執行個體正在使用此開發設定。' - : 'Another Maka Dev instance is using this development profile.', - detail: isSimplifiedChinese - ? `开发配置:${profilePath}\n\n请先退出正在运行的实例,然后重试。` - : isTraditionalChinese - ? `開發設定:${profilePath}\n\n請先退出正在執行的執行個體,然後重試。` - : `Development profile: ${profilePath}\n\nQuit the running instance, then retry.`, - buttons: [isSimplifiedChinese ? '退出' : isTraditionalChinese ? '退出' : 'Exit'], + title: copy.title, + message: copy.message, + detail: copy.detail(profilePath), + buttons: [copy.exit], defaultId: 0, cancelId: 0, }, @@ -246,3 +233,29 @@ if (!app.requestSingleInstanceLock()) { } }); } + +const DEV_SINGLETON_COPY = { + 'zh-CN': { + title: 'Maka Dev 已在运行', + message: '另一个 Maka Dev 实例正在使用此开发配置。', + detail: (profilePath: string) => `开发配置:${profilePath}\n\n请先退出正在运行的实例,然后重试。`, + exit: '退出', + }, + 'zh-TW': { + title: 'Maka Dev 已在執行', + message: '另一個 Maka Dev 執行個體正在使用此開發設定。', + detail: (profilePath: string) => `開發設定:${profilePath}\n\n請先退出正在執行的執行個體,然後重試。`, + exit: '退出', + }, + en: { + title: 'Maka Dev is already running', + message: 'Another Maka Dev instance is using this development profile.', + detail: (profilePath: string) => `Development profile: ${profilePath}\n\nQuit the running instance, then retry.`, + exit: 'Exit', + }, +} satisfies UiCatalog<{ + title: string; + message: string; + detail(profilePath: string): string; + exit: string; +}>; diff --git a/apps/desktop/src/main/oauth/github-copilot-local-credential.ts b/apps/desktop/src/main/oauth/github-copilot-local-credential.ts index 2a39fed564..8030379261 100644 --- a/apps/desktop/src/main/oauth/github-copilot-local-credential.ts +++ b/apps/desktop/src/main/oauth/github-copilot-local-credential.ts @@ -56,8 +56,7 @@ export async function importGitHubCopilotLocalCredential( result: { ok: false, reason: 'token_exchange_failed', - message: - 'GitHub Copilot 不支持 classic PAT;请使用兼容 OAuth 登录或具有 Copilot Requests 权限的 fine-grained PAT。', + code: 'copilot_classic_pat_unsupported', }, }; } @@ -66,7 +65,7 @@ export async function importGitHubCopilotLocalCredential( result: { ok: false, reason: 'token_exchange_failed', - message: '当前 GitHub 凭据类型不受支持;请使用兼容 OAuth 登录或 fine-grained PAT。', + code: 'copilot_credential_type_unsupported', }, }; } @@ -79,7 +78,7 @@ export async function importGitHubCopilotLocalCredential( result: { ok: false, reason: 'token_exchange_failed', - message: '未找到可导入的 GitHub 凭据;请先使用 gh 登录或配置兼容凭据。', + code: 'copilot_local_credential_missing', }, }; } diff --git a/apps/desktop/src/main/permission-snapshot-e2e-fixture.ts b/apps/desktop/src/main/permission-snapshot-e2e-fixture.ts index 436962739c..0ac5fe418b 100644 --- a/apps/desktop/src/main/permission-snapshot-e2e-fixture.ts +++ b/apps/desktop/src/main/permission-snapshot-e2e-fixture.ts @@ -91,7 +91,7 @@ export function permissionSnapshotE2eFixture(now: number): PermissionSnapshot | id: 'automation', status: 'unsupported', now, - reason: '当前系统版本不暴露自动化授权状态。', + reason: 'apple_events_tcc_status_unavailable', canOpenSettings: false, }), }, diff --git a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts index 32c22cb991..6b136f81ba 100644 --- a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; import { randomUUID } from "node:crypto"; import { open, mkdir, rename, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -36,6 +37,7 @@ import type { createMainWindowController } from "./main-window.js"; import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; interface RuntimeHostArtifactsIpcDeps { + uiLocale(): UiLocale; readonly ipcMain: ReconnectableReadIpcMain; readonly client: DesktopRuntimeHostClient; readonly mainWindowController: ReturnType; @@ -111,7 +113,7 @@ export function registerRuntimeHostArtifactsIpc( const artifact = await deps.client.getArtifact(sessionId, artifactId); if (!artifact) return { ok: false, reason: "not_found" }; const result = await deps.mainWindowController.showSaveDialog({ - title: `另存为 ${artifact.name}`, + title: ARTIFACT_DIALOG_COPY[deps.uiLocale()].saveAs(artifact.name), defaultPath: artifact.name, }); if (result.canceled || !result.filePath) { @@ -253,3 +255,9 @@ class ArtifactMaterializationError extends Error { this.name = "ArtifactMaterializationError"; } } + +const ARTIFACT_DIALOG_COPY = { + 'zh-CN': { saveAs: (name: string) => `另存为 ${name}` }, + 'zh-TW': { saveAs: (name: string) => `另存為 ${name}` }, + en: { saveAs: (name: string) => `Save ${name} as` }, +} satisfies UiCatalog<{ saveAs(name: string): string }>; diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index ef0005c406..b1530e81be 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1064,6 +1064,7 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( const chatId = requireScheduledTaskEffectString(input.chatId, "chatId"); const title = requireScheduledTaskEffectString(input.title, "title"); const body = typeof input.body === "string" ? input.body.trim() : ""; + // Bot-channel notices follow the bot audience language; localization tracked under #2672 const text = [`【定时任务】${title}`, ...(body ? ["", body] : [])].join("\n"); const sent = await botRegistry.sendMessage(platform, chatId, text); if (!sent) throw new Error("ScheduledTask bot channel is unavailable"); @@ -1495,6 +1496,7 @@ function registerHostClientIpc( }); registerRuntimeHostRendererIpc({ ipcMain: scopedIpc, client }); registerRuntimeHostArtifactsIpc({ + uiLocale: () => desktopLocale.current(), ipcMain: scopedIpc, client, mainWindowController, @@ -1530,6 +1532,7 @@ function registerHostClientIpc( module: runtimeHostSettings, }); registerRuntimeHostConfigIpc({ + uiLocale: () => desktopLocale.current(), ipcMain: scopedIpc, client, mainWindowController, diff --git a/apps/desktop/src/main/runtime-host-config-ipc-main.ts b/apps/desktop/src/main/runtime-host-config-ipc-main.ts index 073283c34b..a4458df2b7 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; import { readFile, writeFile } from 'node:fs/promises'; import { isDeepStrictEqual } from 'node:util'; import type { IpcMain } from 'electron'; @@ -67,6 +68,7 @@ import { } from '@maka/storage/config-transfer'; interface RuntimeHostConfigIpcDeps { + uiLocale(): UiLocale; readonly ipcMain: Pick; readonly client: DesktopRuntimeHostClient; readonly mainWindowController: ReturnType; @@ -100,7 +102,7 @@ export function registerRuntimeHostConfigIpc( } const today = new Date().toISOString().slice(0, 10); const result = await deps.mainWindowController.showSaveDialog({ - title: '导出 Maka 配置', + title: CONFIG_DIALOG_COPY[deps.uiLocale()].exportTitle, defaultPath: `maka-config-${today}.json`, filters: [{ name: 'Maka Config', extensions: ['json'] }], }); @@ -129,7 +131,7 @@ export function registerRuntimeHostConfigIpc( 'config:import', async (_event, input: { strategy?: unknown } = {}) => { const result = await deps.mainWindowController.showOpenDialog({ - title: '导入 Maka 配置', + title: CONFIG_DIALOG_COPY[deps.uiLocale()].importTitle, properties: ['openFile'], filters: [{ name: 'Maka Config', extensions: ['json'] }], }); @@ -656,3 +658,9 @@ function sanitizeCategories(value: unknown): ConfigCategory[] { function sanitizeStrategy(value: unknown): ConnectionConflictStrategy { return value === 'overwrite' ? 'overwrite' : 'skip'; } + +const CONFIG_DIALOG_COPY = { + 'zh-CN': { exportTitle: '导出 Maka 配置', importTitle: '导入 Maka 配置' }, + 'zh-TW': { exportTitle: '匯出 Maka 設定', importTitle: '匯入 Maka 設定' }, + en: { exportTitle: 'Export Maka configuration', importTitle: 'Import Maka configuration' }, +} satisfies UiCatalog<{ exportTitle: string; importTitle: string }>; diff --git a/apps/desktop/src/main/runtime-host-github-copilot-ipc-main.ts b/apps/desktop/src/main/runtime-host-github-copilot-ipc-main.ts index d463637e23..badfb70080 100644 --- a/apps/desktop/src/main/runtime-host-github-copilot-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-github-copilot-ipc-main.ts @@ -21,6 +21,7 @@ import { importGitHubCopilotLocalCredential, type ImportedGitHubCopilotCredential, } from './oauth/github-copilot-local-credential.js'; +import type { SubscriptionActionCode } from '@maka/core/oauth-subscription'; import type { ReconnectableReadIpcMain } from './ipc-reconnect-policy.js'; import { findRuntimeHostAccountConnection, @@ -53,7 +54,7 @@ export function registerRuntimeHostGitHubCopilotIpc(deps: RuntimeHostGitHubCopil deps.ipcMain.handle('github-copilot:connect-existing-login', async () => { const imported = await importExistingLogin(); if (!imported.result.ok) return imported.result; - if (!imported.secret) return storageFailure('GitHub Copilot login produced no credential'); + if (!imported.secret) return storageFailure('copilot_import_no_credential'); try { const before = await deps.client.loadConnectionCatalog(); @@ -69,23 +70,23 @@ export function registerRuntimeHostGitHubCopilotIpc(deps: RuntimeHostGitHubCopil }); if (adopted.kind === 'rejected') { if (adopted.reason === 'superseded') { - return storageFailure('GitHub Copilot 账号在导入期间发生变化,请重试。'); + return storageFailure('copilot_import_superseded'); } return adopted.reason === 'model_unavailable' - ? actionFailure('当前 GitHub 账号没有可用的 Copilot 订阅权限。') - : actionFailure('当前 GitHub 凭据无法导入,请检查凭据后重试。'); + ? actionFailure('copilot_subscription_unavailable') + : actionFailure('copilot_credential_import_rejected'); } if (adopted.kind === 'failed') { return adopted.errorClass === 'auth' - ? actionFailure('当前 GitHub 账号没有可用的 Copilot 订阅权限。') - : actionFailure('暂时无法验证 GitHub Copilot 订阅状态,请稍后重试。'); + ? actionFailure('copilot_subscription_unavailable') + : actionFailure('copilot_subscription_check_failed'); } await selectAccountDefaultIfMissing(deps.client, adopted.connection.connectionId); deps.emitConnectionListChanged(); return { ok: true as const }; } catch { - return storageFailure('GitHub Copilot login could not be committed to Runtime Host'); + return storageFailure('copilot_import_commit_failed'); } }); } @@ -108,10 +109,10 @@ async function selectAccountDefaultIfMissing( } } -function actionFailure(message: string) { - return { ok: false as const, reason: 'token_exchange_failed' as const, message }; +function actionFailure(code: SubscriptionActionCode) { + return { ok: false as const, reason: 'token_exchange_failed' as const, code }; } -function storageFailure(message: string) { - return { ok: false as const, reason: 'storage_failed' as const, message }; +function storageFailure(code: SubscriptionActionCode) { + return { ok: false as const, reason: 'storage_failed' as const, code }; } diff --git a/apps/desktop/src/main/runtime-host-memory-ipc-main.ts b/apps/desktop/src/main/runtime-host-memory-ipc-main.ts index 1004f497e3..30b2cb83f4 100644 --- a/apps/desktop/src/main/runtime-host-memory-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-memory-ipc-main.ts @@ -23,6 +23,7 @@ import { join } from "node:path"; import type { LocalMemoryBackupInfo, LocalMemoryEntryPreview, + LocalMemoryOperationCode, LocalMemoryState, } from '@maka/core/local-memory'; import { isPathInside } from '@maka/runtime/path-containment'; @@ -34,6 +35,7 @@ import { type MemoryEntriesView, type MemoryMutateInput, type MemoryMutateResult, + type MemoryMutationRejectionReason, type MemoryRevision, type MemoryStateProjection, } from "@maka/runtime-host/protocol"; @@ -43,6 +45,8 @@ import { type ReconnectableReadIpcMain, } from "./ipc-reconnect-policy.js"; +type LocalMemoryResultCode = LocalMemoryOperationCode | MemoryMutationRejectionReason; + type LocalMemoryMutationResult = | { readonly ok: true; @@ -53,8 +57,7 @@ type LocalMemoryMutationResult = | { readonly ok: false; readonly state: LocalMemoryState; - readonly reason: string; - readonly message: string; + readonly code: LocalMemoryResultCode; }; const MAX_REVISION_ATTEMPTS = 3; @@ -99,7 +102,7 @@ export function registerRuntimeHostMemoryIpc( return { ok: false as const, state, - message: "No Memory backup is available", + code: "no_backup", }; return restoreBackup(deps, backup.kind); }); @@ -108,7 +111,7 @@ export function registerRuntimeHostMemoryIpc( return { ok: false as const, state: await getMemoryState(deps), - message: "Invalid Memory backup kind", + code: "invalid_backup_kind", }; } return restoreBackup(deps, kind); @@ -140,12 +143,18 @@ export function registerRuntimeHostMemoryIpc( deps, BACKUP_FILES[state.latestBackup.kind], ) - : { ok: false as const, message: "No Memory backup is available" }; + : { + ok: false as const, + code: "no_backup", + }; }); deps.ipcMain.handle("memory:openBackup", async (_event, kind: unknown) => { return isBackupKind(kind) ? openMemoryPath(deps, BACKUP_FILES[kind]) - : { ok: false as const, message: "Invalid Memory backup kind" }; + : { + ok: false as const, + code: "invalid_backup_kind", + }; }); } @@ -479,14 +488,14 @@ async function restoreBackup( kind: MemoryBackupKind, ): Promise< | { ok: true; state: LocalMemoryState } - | { ok: false; state: LocalMemoryState; message: string } + | { ok: false; state: LocalMemoryState; code: LocalMemoryResultCode } > { const state = await deps.client.queryMemory({ kind: "state" }); if (state.kind !== "state") { return { ok: false, state: await getMemoryState(deps), - message: "Memory is unavailable", + code: "memory_unavailable", }; } const backup = state.backups.find((candidate) => candidate.kind === kind); @@ -494,7 +503,7 @@ async function restoreBackup( return { ok: false, state: await getMemoryState(deps), - message: "Memory backup not found", + code: "backup_not_found", }; } const result = await mutateMemory(deps, (expectedRevision) => ({ @@ -505,7 +514,7 @@ async function restoreBackup( })); return result.ok ? { ok: true, state: result.state } - : { ok: false, state: result.state, message: result.message }; + : { ok: false, state: result.state, code: result.code }; } async function openMemoryPath( @@ -514,11 +523,11 @@ async function openMemoryPath( "workspaceRoot" | "allowLocalPaths" | "openPath" >, fileName: string, -): Promise<{ ok: true } | { ok: false; message: string }> { +): Promise<{ ok: true } | { ok: false; code: LocalMemoryResultCode }> { if (deps.allowLocalPaths === false) { return { ok: false, - message: "Memory files are owned by the remote Runtime Host", + code: "remote_host_owned", }; } try { @@ -529,15 +538,18 @@ async function openMemoryPath( if (!isPathInside(directory, path) || !(await lstat(path)).isFile()) { return { ok: false, - message: "Memory path is not an allowed regular file", + code: "not_regular_file", }; } const error = await deps.openPath(path); return error - ? { ok: false, message: "The system could not open the Memory file" } + ? { + ok: false, + code: "open_failed", + } : { ok: true }; } catch { - return { ok: false, message: "Memory file not found" }; + return { ok: false, code: "file_not_found" }; } } @@ -594,13 +606,12 @@ function isBackupKind(value: unknown): value is MemoryBackupKind { async function mutationFailure( deps: RuntimeHostMemoryIpcDeps, - reason: string, + reason: LocalMemoryResultCode, ): Promise { return { ok: false, state: await getMemoryState(deps), - reason, - message: memoryReasonMessage(reason), + code: reason, }; } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index b4169fc59d..effb1338c4 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1473,13 +1473,13 @@ export interface MakaBridge { getState(sessionId?: string, host?: DesktopRuntimeHostRef): Promise; save(content: string, host?: DesktopRuntimeHostRef): Promise; reset(host?: DesktopRuntimeHostRef): Promise; - restoreLatestBackup(host?: DesktopRuntimeHostRef): Promise<{ ok: true; state: LocalMemoryState } | { ok: false; state: LocalMemoryState; message: string }>; - restoreBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true; state: LocalMemoryState } | { ok: false; state: LocalMemoryState; message: string }>; + restoreLatestBackup(host?: DesktopRuntimeHostRef): Promise<{ ok: true; state: LocalMemoryState } | { ok: false; state: LocalMemoryState; code: string }>; + restoreBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true; state: LocalMemoryState } | { ok: false; state: LocalMemoryState; code: string }>; setEnabled(enabled: boolean, host?: DesktopRuntimeHostRef): Promise; setAgentReadEnabled(enabled: boolean, host?: DesktopRuntimeHostRef): Promise; - openFile(host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; message: string }>; - openLatestBackup(host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; message: string }>; - openBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; message: string }>; + openFile(host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; code: string }>; + openLatestBackup(host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; code: string }>; + openBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; code: string }>; }; attachments: { pickDirectory(): Promise<{ ok: true; reference: import('@maka/core/events').DirectoryReference } | { ok: false; reason: 'cancelled' }>; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index fdcd3e4337..208f661b56 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2949,10 +2949,10 @@ const makaBridge = { reset(host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'memory:reset'); }, - restoreLatestBackup(host?: DesktopRuntimeHostRef): Promise<{ ok: true; state: LocalMemoryState } | { ok: false; state: LocalMemoryState; message: string }> { + restoreLatestBackup(host?: DesktopRuntimeHostRef): Promise<{ ok: true; state: LocalMemoryState } | { ok: false; state: LocalMemoryState; code: string }> { return invokeSelectedRuntimeHost(host, 'memory:restoreLatestBackup'); }, - restoreBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true; state: LocalMemoryState } | { ok: false; state: LocalMemoryState; message: string }> { + restoreBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true; state: LocalMemoryState } | { ok: false; state: LocalMemoryState; code: string }> { return invokeSelectedRuntimeHost(host, 'memory:restoreBackup', kind); }, setEnabled(enabled: boolean, host?: DesktopRuntimeHostRef): Promise { @@ -2961,13 +2961,13 @@ const makaBridge = { setAgentReadEnabled(enabled: boolean, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'memory:setAgentReadEnabled', enabled); }, - openFile(host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; message: string }> { + openFile(host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; code: string }> { return invokeSelectedRuntimeHost(host, 'memory:openFile'); }, - openLatestBackup(host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; message: string }> { + openLatestBackup(host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; code: string }> { return invokeSelectedRuntimeHost(host, 'memory:openLatestBackup'); }, - openBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; message: string }> { + openBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; code: string }> { return invokeSelectedRuntimeHost(host, 'memory:openBackup', kind); }, }, diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index 61a60dc34d..6e91cca05c 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -41,6 +41,7 @@ import { commandPaletteConnectionTestFailureMessage, } from "./app-shell-copy.js"; import { getShellCopy } from "./locales/shell-copy.js"; +import { memoryOpenFailureMessage } from "./locales/settings-memory-copy.js"; import { settingsTestResultMessage } from "./locales/settings-test-result-copy.js"; type ToastApi = { @@ -127,10 +128,11 @@ export function buildAppShellCommandList( // still acts on current data (same stable-ref pattern as // openSessionInChatRef in app-shell.tsx). const options = optionsRef.current; - const copy = getShellCopy(options.uiLocale).commandActions; + const locale = options.uiLocale; + const copy = getShellCopy(locale).commandActions; return buildCommandList({ - locale: options.uiLocale, + locale, activeSessionId: options.activeId, themePref: options.themePref, connections: options.connections, @@ -170,7 +172,7 @@ export function buildAppShellCommandList( copy.connectionTestFailed(name), commandPaletteConnectionTestFailureMessage( result, - options.uiLocale, + locale, ), undefined, diagnosticTarget, @@ -183,7 +185,7 @@ export function buildAppShellCommandList( commandPaletteActionErrorMessage( err, copy.connectionUnavailable, - options.uiLocale, + locale, ), undefined, defaultRuntimeHostDiagnosticTarget(err), @@ -205,7 +207,7 @@ export function buildAppShellCommandList( commandPaletteActionErrorMessage( err, copy.setDefaultFallback, - options.uiLocale, + locale, ), undefined, defaultRuntimeHostDiagnosticTarget(err), @@ -231,7 +233,7 @@ export function buildAppShellCommandList( const markdown = renderConversationMarkdown( session?.name ?? copy.newConversation, messages, - options.uiLocale, + locale, ); try { await navigator.clipboard.writeText(markdown); @@ -251,7 +253,7 @@ export function buildAppShellCommandList( const markdown = renderConversationMarkdown( sessionName, messages, - options.uiLocale, + locale, ); const now = new Date(); const yyyy = now.getFullYear(); @@ -287,7 +289,7 @@ export function buildAppShellCommandList( commandPaletteActionErrorMessage( err, copy.exportFallback, - options.uiLocale, + locale, ), ); } @@ -299,7 +301,12 @@ export function buildAppShellCommandList( window.maka.memory.openFile(host), ); if (!result.ok) { - toastApi.error(copy.memoryOpenFailedTitle, result.message, undefined, diagnosticTarget); + toastApi.error( + copy.memoryOpenFailedTitle, + memoryOpenFailureMessage(result, locale), + undefined, + diagnosticTarget, + ); } } catch (err) { toastApi.error( @@ -307,7 +314,7 @@ export function buildAppShellCommandList( commandPaletteActionErrorMessage( err, copy.memoryOpenFallback, - options.uiLocale, + locale, ), undefined, defaultRuntimeHostDiagnosticTarget(err), @@ -350,7 +357,7 @@ export function buildAppShellCommandList( commandPaletteActionErrorMessage( err, copy.clipboardDenied, - options.uiLocale, + locale, ), undefined, target, @@ -368,7 +375,7 @@ export function buildAppShellCommandList( const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => window.maka.settings.testNetworkProxy(undefined, host), ); - const message = settingsTestResultMessage(result, options.uiLocale); + const message = settingsTestResultMessage(result, locale); if (result.ok) { const latency = result.latencyMs ? ` · ${result.latencyMs}ms` : ""; toastApi.success(copy.networkPassedTitle, `${message}${latency}`); @@ -381,7 +388,7 @@ export function buildAppShellCommandList( commandPaletteActionErrorMessage( err, copy.networkTestFallback, - options.uiLocale, + locale, ), undefined, defaultRuntimeHostDiagnosticTarget(err), diff --git a/apps/desktop/src/renderer/features/connection-settings/index.ts b/apps/desktop/src/renderer/features/connection-settings/index.ts index cf1c5ddb5f..109e3d3824 100644 --- a/apps/desktop/src/renderer/features/connection-settings/index.ts +++ b/apps/desktop/src/renderer/features/connection-settings/index.ts @@ -37,8 +37,7 @@ export { providerPanelActionErrorMessage, } from './provider-panel-shared.js'; export { OnboardingStepForm } from './onboarding-step-form.js'; -export { getProviderSettingsCopy } from './settings-provider-copy.js'; -export { subscriptionResultMessage } from './subscription-result-message.js'; +export { getProviderSettingsCopy, subscriptionActionErrorMessage, subscriptionResultMessage } from './settings-provider-copy.js'; export type { ProviderSettingsCopy } from './settings-provider-copy.js'; export type { CredentialPresenceStatus, 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 39f4017f4d..13b6193d02 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 @@ -17,7 +17,11 @@ * under the License. */ -import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +import { generalizedErrorMessageForLocale, redactSecrets } from '@maka/core/redaction'; +import type { SubscriptionActionCode, SubscriptionActionFailureReason } from '@maka/core/oauth-subscription'; +import { type UiCatalog, type UiLocale, lookupCopy } from '@maka/core/ui-locale'; + +type SubscriptionResultCode = SubscriptionActionCode | Extract; type WidenCopy = T extends string ? string @@ -245,6 +249,20 @@ const zhCopy = { loggedOut: '已退出登录', credentialsCleared: '本地凭据已清除。', logoutFailed: '退出失败', logoutFailedRetry: '退出登录失败,请稍后重试。', serviceUnavailable: '登录服务暂时不可用,请检查网络后重试。', logoutTitle: (name: string) => `退出 ${name} 登录?`, + loginConflict: '上一轮浏览器登录仍在进行或已切换,请再点一次登录,或稍后再试。', + browserPresentFailed: '无法打开系统浏览器完成登录,请检查是否拦截了弹窗后重试。', + resultCodes: { + copilot_classic_pat_unsupported: 'GitHub Copilot 不支持 classic PAT;请使用兼容 OAuth 登录或具有 Copilot Requests 权限的 fine-grained PAT。', + copilot_credential_type_unsupported: '当前 GitHub 凭据类型不受支持;请使用兼容 OAuth 登录或 fine-grained PAT。', + copilot_local_credential_missing: '未找到可导入的 GitHub 凭据;请先使用 gh 登录或配置兼容凭据。', + copilot_import_no_credential: 'GitHub Copilot 登录没有产生可用凭据。', + copilot_import_superseded: 'GitHub Copilot 账号在导入期间发生变化,请重试。', + copilot_subscription_unavailable: '当前 GitHub 账号没有可用的 Copilot 订阅权限。', + copilot_credential_import_rejected: '当前 GitHub 凭据无法导入,请检查凭据后重试。', + copilot_subscription_check_failed: '暂时无法验证 GitHub Copilot 订阅状态,请稍后重试。', + copilot_import_commit_failed: 'GitHub Copilot 登录未能写入 Runtime Host。', + experimental_disabled: '本机未启用该账号登录方式;可改用导入兼容凭据,或由管理员启用后重试。', + } satisfies Record, }, oauthSection: { signedIn: '已登录', codexDescription: '使用 ChatGPT Plus / Pro 账号添加连接。', xaiDescription: '使用 SuperGrok / X Premium 账号添加连接。', @@ -409,6 +427,20 @@ const zhTwCopy = { logoutDescription: '將刪除本機儲存的訂閱憑據,之後需要重新登入才能繼續使用這些 OAuth 模型。', logout: '退出登入', cancel: '取消', loggedOut: '已退出登入', credentialsCleared: '本地憑據已清除。', logoutFailed: '退出失敗', logoutFailedRetry: '退出登入失敗,請稍後重試。', serviceUnavailable: '登入服務暫時不可用,請檢查網路後重試。', + loginConflict: '上一輪瀏覽器登入仍在進行或已切換,請再按一次登入,或稍後再試。', + browserPresentFailed: '無法開啟系統瀏覽器完成登入,請檢查是否封鎖了彈出式視窗後再試。', + resultCodes: { + copilot_classic_pat_unsupported: 'GitHub Copilot 不支援 classic PAT;請使用相容 OAuth 登入或具有 Copilot Requests 權限的 fine-grained PAT。', + copilot_credential_type_unsupported: '目前的 GitHub 憑據類型不受支援;請使用相容 OAuth 登入或 fine-grained PAT。', + copilot_local_credential_missing: '找不到可匯入的 GitHub 憑據;請先使用 gh 登入或設定相容憑據。', + copilot_import_no_credential: 'GitHub Copilot 登入沒有產生可用憑據。', + copilot_import_superseded: 'GitHub Copilot 帳號在匯入期間發生變化,請重試。', + copilot_subscription_unavailable: '目前的 GitHub 帳號沒有可用的 Copilot 訂閱權限。', + copilot_credential_import_rejected: '目前的 GitHub 憑據無法匯入,請檢查憑據後重試。', + copilot_subscription_check_failed: '暫時無法驗證 GitHub Copilot 訂閱狀態,請稍後重試。', + copilot_import_commit_failed: 'GitHub Copilot 登入未能寫入 Runtime Host。', + experimental_disabled: '本機未啟用該帳號登入方式;可改用匯入相容憑據,或由管理員啟用後重試。', + }, logoutTitle: (name: string) => `退出 ${name} 登入?`, }, oauthSection: { @@ -576,6 +608,20 @@ const enCopy: ProviderSettingsCopy = { loggedOut: 'Signed out', credentialsCleared: 'Local credentials cleared.', logoutFailed: 'Sign-out failed', logoutFailedRetry: 'Sign-out failed. Try again later.', serviceUnavailable: 'The sign-in service is temporarily unavailable. Check the network and try again.', logoutTitle: (name: string) => `Sign out of ${name}?`, + loginConflict: 'A previous browser login is still running or was superseded. Try logging in again shortly.', + browserPresentFailed: 'Could not open the system browser for login. Check popup blockers and try again.', + resultCodes: { + copilot_classic_pat_unsupported: 'GitHub Copilot does not accept classic PATs. Use a compatible OAuth login or a fine-grained PAT with the Copilot Requests permission.', + copilot_credential_type_unsupported: 'This GitHub credential type is not supported. Use a compatible OAuth login or a fine-grained PAT.', + copilot_local_credential_missing: 'No importable GitHub credential was found. Sign in with gh or configure a compatible credential first.', + copilot_import_no_credential: 'The GitHub Copilot login produced no usable credential.', + copilot_import_superseded: 'The GitHub Copilot account changed during import. Try again.', + copilot_subscription_unavailable: 'This GitHub account has no usable Copilot subscription.', + copilot_credential_import_rejected: 'This GitHub credential could not be imported. Check it and try again.', + copilot_subscription_check_failed: 'Could not verify the GitHub Copilot subscription right now. Try again later.', + copilot_import_commit_failed: 'The GitHub Copilot login could not be committed to Runtime Host.', + experimental_disabled: 'This sign-in is not enabled on this install. Import a compatible credential instead, or ask an operator to enable it.', + }, }, oauthSection: { signedIn: 'Signed in', codexDescription: 'Use a ChatGPT Plus / Pro account to add a connection.', xaiDescription: 'Use a SuperGrok or X Premium account to add a connection.', @@ -606,3 +652,35 @@ const PROVIDER_SETTINGS_COPY = { export function getProviderSettingsCopy(locale: UiLocale): ProviderSettingsCopy { return PROVIDER_SETTINGS_COPY[locale]; } + +export function subscriptionActionErrorMessage(error: unknown, locale: UiLocale): string { + const message = error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : ''; + return subscriptionResultMessage(message, getProviderSettingsCopy(locale).oauthFlow.serviceUnavailable, locale); +} + +export type SubscriptionResultInput = + // `code`/`reason` stay `string` on the wire: a newer host may send a code + // this client does not know yet, and the guard below maps only known codes. + | string + | undefined + | { readonly code?: string; readonly reason?: string; readonly message?: string }; + +export function subscriptionResultMessage(input: SubscriptionResultInput, fallback: string, locale: UiLocale): string { + const { code, reason, message } = typeof input === 'object' && input !== null ? input : { message: input }; + const copy = getProviderSettingsCopy(locale).oauthFlow; + const mapped = lookupCopy(copy.resultCodes, code) ?? lookupCopy(copy.resultCodes, reason); + if (mapped) return mapped; + const raw = redactSecrets(message ?? '').trim(); + if (!raw) return fallback; + // Stable Host messages, matched before the coarse keyword classifier turns + // "authorization" into a generic auth failure that does not tell the user what to do. + if (/enrollment is disabled for this provider/i.test(raw)) return copy.resultCodes.experimental_disabled; + if (/already in progress|superseded by a new attempt/i.test(raw)) return copy.loginConflict; + if (/did not present OAuth|no matching OAuth presentation/i.test(raw)) return copy.browserPresentFailed; + const classified = generalizedErrorMessageForLocale(new Error(raw), '', locale); + return classified || fallback; +} diff --git a/apps/desktop/src/renderer/features/connection-settings/subscription-result-message.ts b/apps/desktop/src/renderer/features/connection-settings/subscription-result-message.ts deleted file mode 100644 index 125e073d68..0000000000 --- a/apps/desktop/src/renderer/features/connection-settings/subscription-result-message.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * 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 { generalizedErrorMessageForLocale, redactSecrets } from '@maka/core/redaction'; -import type { UiLocale } from '@maka/core/ui-locale'; - -export function subscriptionResultMessage( - message: string | undefined, - fallback: string, - locale: UiLocale = 'zh-CN', - reason?: string, -): string { - const raw = redactSecrets(message ?? '').trim(); - // The Host refuses an enrollment this install has not opted into and says so - // with a typed reason. Read the reason, not the English message: a reworded - // string or an added locale must not silently disable this branch. The - // message match stays only as a fallback for callers without a typed reason. - if (reason === 'experimental_disabled' || /enrollment is disabled for this provider/i.test(raw)) { - if (locale === 'zh-CN') return '本机未启用该账号登录方式;可改用导入兼容凭据,或由管理员启用后重试。'; - if (locale === 'zh-TW') return '本機未啟用該帳號登入方式;可改用匯入相容憑據,或由管理員啟用後重試。'; - return 'This sign-in is not enabled on this install. Import a compatible credential instead, or ask an operator to enable it.'; - } - if (!raw) return fallback; - if (/already in progress|superseded by a new attempt/i.test(raw)) { - if (locale === 'zh-CN') return '上一轮浏览器登录仍在进行或已切换,请再点一次登录,或稍后再试。'; - if (locale === 'zh-TW') return '上一輪瀏覽器登入仍在進行或已切換,請再按一次登入,或稍後再試。'; - return 'A previous browser login is still running or was superseded. Try logging in again shortly.'; - } - if (/did not present OAuth|no matching OAuth presentation/i.test(raw)) { - if (locale === 'zh-CN') return '无法打开系统浏览器完成登录,请检查是否拦截了弹窗后重试。'; - if (locale === 'zh-TW') return '無法開啟系統瀏覽器完成登入,請檢查是否封鎖了彈出式視窗後再試。'; - return 'Could not open the system browser for login. Check popup blockers and try again.'; - } - const classified = generalizedErrorMessageForLocale(new Error(raw), '', locale); - if (classified) return classified; - return locale === 'zh-CN' || !/[\u4e00-\u9fff]/.test(raw) ? raw : fallback; -} diff --git a/apps/desktop/src/renderer/locales/capability-reason-copy.ts b/apps/desktop/src/renderer/locales/capability-reason-copy.ts new file mode 100644 index 0000000000..1178ebb03d --- /dev/null +++ b/apps/desktop/src/renderer/locales/capability-reason-copy.ts @@ -0,0 +1,109 @@ +/* + * 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 type { CapabilityReasonCode } from '@maka/core/capabilities'; +import { type UiCatalog, type UiLocale, lookupCopy } from '@maka/core/ui-locale'; + +export type CapabilityReasonCopy = Record; + +const CAPABILITY_REASON_COPY = { + 'zh-CN': { + disabled: '该能力当前已关闭。', + platform_credentials_missing: '未配置平台凭据', + macos_tcc_only: '仅 macOS TCC 权限适用', + apple_events_tcc_status_unavailable: 'Electron 暂不支持读取逐 App 的 Apple Events 授权状态', + cu_artifact_missing: '未找到通过完整性检查的 Computer Use 执行器 artifact。', + cu_backend_status: 'maka-cu artifact 已通过本地完整性检查。', + cu_backend_unavailable: 'Computer Use 后端当前不可用。', + cu_executor_undistributable: '未找到通过完整性检查且可分发的 maka-cu executor。', + cu_executor_stopped: 'maka-cu executor 已停止。', + cu_executor_start_failed: 'maka-cu executor 启动失败或已退出。', + cu_executor_recovering: 'maka-cu executor 正在启动或恢复。', + cu_executor_ready: 'maka-cu executor 已就绪。', + cu_executor_lazy_start: 'maka-cu 已可用,将在首次调用时启动。', + activity_recorder_partial: 'Daily Review 已聚合本地任务 / 工具 / 模型活动;当前不包含屏幕与应用级录制', + activity_recorder_probe_hint: '打开 Daily Review 可查看本地活动聚合结果', + memory_partial: '本地 MEMORY.md 已可见;自动抽取/写入仍需用户确认', + memory_no_probe: '透明本地记忆为文件读写能力,不做后台探测', + accessibility_status_ambiguous: 'macOS 不区分辅助功能权限是未授权还是未申请', + screen_recording_status_mac_only: '屏幕录制权限状态仅能在 macOS 上读取', + notifications_status_unreadable_macos: 'Electron 无法可靠读取 macOS 通知授权状态,请在系统设置中确认', + notifications_status_unreadable: 'Electron 无法可靠读取当前系统的通知授权状态', + notifications_unsupported: 'Electron 通知能力不可用', + permission_probe_failed: '权限探测失败', + }, + 'zh-TW': { + disabled: '此能力目前已關閉。', + platform_credentials_missing: '未設定平台憑據', + macos_tcc_only: '僅 macOS TCC 權限適用', + apple_events_tcc_status_unavailable: 'Electron 暫不支援讀取逐 App 的 Apple Events 授權狀態', + cu_artifact_missing: '找不到通過完整性檢查的 Computer Use 執行器 artifact。', + cu_backend_status: 'maka-cu artifact 已通過本機完整性檢查。', + cu_backend_unavailable: 'Computer Use 後端目前無法使用。', + cu_executor_undistributable: '找不到通過完整性檢查且可分發的 maka-cu executor。', + cu_executor_stopped: 'maka-cu executor 已停止。', + cu_executor_start_failed: 'maka-cu executor 啟動失敗或已退出。', + cu_executor_recovering: 'maka-cu executor 正在啟動或恢復。', + cu_executor_ready: 'maka-cu executor 已就緒。', + cu_executor_lazy_start: 'maka-cu 已可用,將在首次呼叫時啟動。', + activity_recorder_partial: 'Daily Review 已彙整本機任務 / 工具 / 模型活動;目前不包含螢幕與應用程式層級錄製', + activity_recorder_probe_hint: '開啟 Daily Review 可檢視本機活動彙整結果', + memory_partial: '本機 MEMORY.md 已可見;自動擷取/寫入仍需使用者確認', + memory_no_probe: '透明本機記憶為檔案讀寫能力,不做背景探測', + accessibility_status_ambiguous: 'macOS 不區分輔助使用權限是未授權還是未申請', + screen_recording_status_mac_only: '螢幕錄製權限狀態僅能在 macOS 上讀取', + notifications_status_unreadable_macos: 'Electron 無法可靠讀取 macOS 通知授權狀態,請在系統設定中確認', + notifications_status_unreadable: 'Electron 無法可靠讀取目前系統的通知授權狀態', + notifications_unsupported: 'Electron 通知能力無法使用', + permission_probe_failed: '權限探測失敗', + }, + en: { + disabled: 'This capability is turned off.', + platform_credentials_missing: 'Platform credentials are not configured', + macos_tcc_only: 'Only macOS TCC permissions apply', + apple_events_tcc_status_unavailable: 'Electron cannot read per-app Apple Events authorization status', + cu_artifact_missing: 'No Computer Use executor artifact passed the integrity check.', + cu_backend_status: 'The maka-cu artifact passed the local integrity check.', + cu_backend_unavailable: 'The Computer Use backend is currently unavailable.', + cu_executor_undistributable: 'No distributable maka-cu executor passed the integrity check.', + cu_executor_stopped: 'The maka-cu executor has stopped.', + cu_executor_start_failed: 'The maka-cu executor failed to start or has exited.', + cu_executor_recovering: 'The maka-cu executor is starting or recovering.', + cu_executor_ready: 'The maka-cu executor is ready.', + cu_executor_lazy_start: 'maka-cu is available and starts on first use.', + activity_recorder_partial: 'Daily Review aggregates local task, tool, and model activity; screen and app-level recording is not included.', + activity_recorder_probe_hint: 'Open Daily Review to see the local activity summary.', + memory_partial: 'The local MEMORY.md is visible; automatic extraction and writes still require confirmation.', + memory_no_probe: 'Transparent local memory is plain file access, so no background probe runs.', + accessibility_status_ambiguous: 'macOS does not distinguish denied from never-requested Accessibility permission', + screen_recording_status_mac_only: 'Screen Recording permission status can only be read on macOS', + notifications_status_unreadable_macos: 'Electron cannot reliably read macOS notification authorization; check System Settings', + notifications_status_unreadable: 'Electron cannot reliably read notification authorization on this system', + notifications_unsupported: 'Electron notifications are unavailable', + permission_probe_failed: 'Permission probe failed', + }, +} satisfies UiCatalog; + +export function getCapabilityReasonCopy(locale: UiLocale): CapabilityReasonCopy { + return CAPABILITY_REASON_COPY[locale]; +} + +export function capabilityReasonMessage(reason: string | undefined, locale: UiLocale): string | undefined { + return lookupCopy(CAPABILITY_REASON_COPY[locale], reason); +} diff --git a/apps/desktop/src/renderer/locales/permission-center-copy.ts b/apps/desktop/src/renderer/locales/permission-center-copy.ts index 91df593854..e2398a71eb 100644 --- a/apps/desktop/src/renderer/locales/permission-center-copy.ts +++ b/apps/desktop/src/renderer/locales/permission-center-copy.ts @@ -20,9 +20,11 @@ import type { StatusSemantic } from '@maka/ui'; import type { CapabilityReadinessState, + CapabilityReasonCode, CapabilitySnapshot, OsPermissionId, OsPermissionState, + RuntimeProbeState, } from '@maka/core/capabilities'; import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; @@ -81,8 +83,6 @@ export type PermissionCenterCopy = { }; requiredPermissions: string; requiredPermissionsAria(label: string): string; - guidance: string; - guidanceAria(label: string): string; auditSection: string; noAudit: string; auditAria(label: string): string; @@ -94,6 +94,10 @@ export type PermissionCenterCopy = { /** macOS drag-to-grant onboarding (accessibility / screen recording). */ dragGrant: string; dragGranting: string; + // Single-backend assumption: CU_BACKEND_IDS is ['maka-cu'], so the backend + // name stays a literal in copy. Revisit when a second backend lands. + cuBackendStatus(missingPermissionLabels: readonly string[], health: RuntimeProbeState): string; + reasonFallback: string; }; const PERMISSION_CENTER_COPY = { @@ -140,9 +144,19 @@ const PERMISSION_CENTER_COPY = { memoryStates: { not_applicable: '不涉及记忆写入', disabled: '记忆写入已关闭', draft_required: '需要先草拟 memory 协议', accepted: '记忆写入已接受' }, runtimeStates: { not_available: '尚无运行态探测', not_run: '探测未运行', healthy: '探测通过', degraded: '探测降级' }, }, - requiredPermissions: '所需系统权限', requiredPermissionsAria: (label) => `${label}所需系统权限列表`, guidance: '处理建议', guidanceAria: (label) => `${label}处理建议列表`, + requiredPermissions: '所需系统权限', requiredPermissionsAria: (label) => `${label}所需系统权限列表`, auditSection: '审计记录', noAudit: '暂无审计记录', auditAria: (label) => `${label}审计记录列表`, impact: '影响功能', opening: '打开中…', openSettings: '前往系统设置', requesting: '请求中…', request: '请求授权', dragGrant: '引导授权', dragGranting: '引导中…', + cuBackendStatus: (missing, health) => + 'maka-cu artifact 已通过本地完整性检查。' + + (missing.length > 0 ? `等待${missing.join('、')}权限。` : '') + + ({ + not_available: 'maka-cu service 启动失败、已退出或已停止。', + degraded: 'maka-cu service 正在启动或恢复。', + healthy: '操作与截图 service 已就绪;按目标与动作类别授权后可操作本机应用。', + not_run: 'service 将在首次调用时启动;按目标与动作类别授权后可操作本机应用。', + } satisfies Record)[health], + reasonFallback: '状态详情请查看运行日志。', }, 'zh-TW': { readiness: { @@ -187,9 +201,19 @@ const PERMISSION_CENTER_COPY = { memoryStates: { not_applicable: '不涉及記憶寫入', disabled: '記憶寫入已關閉', draft_required: '需要先草擬 memory 協議', accepted: '記憶寫入已接受' }, runtimeStates: { not_available: '尚無執行態探測', not_run: '探測未執行', healthy: '探測透過', degraded: '探測降級' }, }, - requiredPermissions: '所需系統權限', requiredPermissionsAria: (label) => `${label}所需系統權限列表`, guidance: '處理建議', guidanceAria: (label) => `${label}處理建議列表`, + requiredPermissions: '所需系統權限', requiredPermissionsAria: (label) => `${label}所需系統權限列表`, auditSection: '審計記錄', noAudit: '暫無審計記錄', auditAria: (label) => `${label}審計記錄列表`, impact: '影響功能', opening: '開啟中…', openSettings: '前往系統設定', requesting: '請求中…', request: '請求授權', dragGrant: '引導授權', dragGranting: '引導中…', + cuBackendStatus: (missing, health) => + 'maka-cu artifact 已通過本機完整性檢查。' + + (missing.length > 0 ? `等待${missing.join('、')}權限。` : '') + + ({ + not_available: 'maka-cu service 啟動失敗、已退出或已停止。', + degraded: 'maka-cu service 正在啟動或恢復。', + healthy: '操作與截圖 service 已就緒;依目標與動作類別授權後可操作本機應用程式。', + not_run: 'service 將在首次呼叫時啟動;依目標與動作類別授權後可操作本機應用程式。', + } satisfies Record)[health], + reasonFallback: '狀態詳情請查看執行日誌。', }, en: { readiness: { @@ -234,9 +258,19 @@ const PERMISSION_CENTER_COPY = { memoryStates: { not_applicable: 'No memory writes', disabled: 'Memory writes disabled', draft_required: 'Draft a memory protocol first', accepted: 'Memory writes accepted' }, runtimeStates: { not_available: 'No runtime probe available', not_run: 'Probe not run', healthy: 'Probe passed', degraded: 'Probe degraded' }, }, - requiredPermissions: 'Required system permissions', requiredPermissionsAria: (label) => `${label} required system permissions`, guidance: 'Suggested actions', guidanceAria: (label) => `${label} suggested actions`, + requiredPermissions: 'Required system permissions', requiredPermissionsAria: (label) => `${label} required system permissions`, auditSection: 'Audit records', noAudit: 'No audit records', auditAria: (label) => `${label} audit records`, impact: 'Affects', opening: 'Opening…', openSettings: 'Open System Settings', requesting: 'Requesting…', request: 'Request permission', dragGrant: 'Guide me', dragGranting: 'Opening…', + cuBackendStatus: (missing, health) => + 'The maka-cu artifact passed the local integrity check. ' + + (missing.length > 0 ? `Waiting for ${missing.join(', ')} permission. ` : '') + + ({ + not_available: 'The maka-cu service failed to start, exited, or was stopped.', + degraded: 'The maka-cu service is starting or recovering.', + healthy: 'The action and screenshot service is ready; grant by target and action category to operate local apps.', + not_run: 'The service starts on first use; grant by target and action category to operate local apps.', + } satisfies Record)[health], + reasonFallback: 'See the runtime logs for details.', }, } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/locales/settings-data-copy.ts b/apps/desktop/src/renderer/locales/settings-data-copy.ts index 9ad0c79a31..b8bce4bc15 100644 --- a/apps/desktop/src/renderer/locales/settings-data-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-data-copy.ts @@ -28,7 +28,8 @@ export type DataSettingsCopy = { }; loadFailed: string; openFailed(label: string): string; pathCopied: string; copyFailed: string; copyFailedDetail: string; historyCleared: string; historyClearedDetail: string; selectCategory: string; exported: string; exportedDetail(items: readonly string[]): string; - exportFailed: string; noCategories: string; tryAgain: string; imported: string; importFailed: string; invalidFile: string; + exportFailed: string; noCategories: string; tryAgain: string; imported: string; importFailed: string; + importFailures: Record<'not_json' | 'malformed' | 'unsupported_version', string>; rows: { workspace: string; workspaceDetail: string; loadValueFailed: string; loading: string; history: string; historyDetail: string; @@ -55,7 +56,8 @@ const SETTINGS_DATA_COPY = { loadFailed: '载入数据目录失败', openFailed: (label) => `无法打开${label}`, pathCopied: '已复制工作区路径', copyFailed: '复制失败', copyFailedDetail: '剪贴板不可用或被系统拒绝。', historyCleared: '已清空输入历史', historyClearedDetail: '已发送的提示词记录已从本机移除。', selectCategory: '请至少选择一个类别', exported: '已导出配置', exportedDetail: (items) => `包含:${items.join('、')}`, exportFailed: '导出失败', noCategories: '未选择任何类别', tryAgain: '请稍后重试', - imported: '已导入配置', importFailed: '导入失败', invalidFile: '文件无效或版本不受支持。', + imported: '已导入配置', importFailed: '导入失败', + importFailures: { not_json: '文件不是有效的 JSON。', malformed: '配置文件结构无效。', unsupported_version: '配置文件版本不受支持。' }, rows: { workspace: '工作区路径', workspaceDetail: '任务、设置、凭据和技能文件都存在这个目录下。', loadValueFailed: '载入失败', loading: '正在加载…', history: '输入历史', historyDetail: '上箭头 / 下箭头调出的已发送提示词记录,保存在本机、重启后仍在。清空后无法恢复。', @@ -82,7 +84,8 @@ const SETTINGS_DATA_COPY = { loadFailed: '載入資料目錄失敗', openFailed: (label) => `無法開啟${label}`, pathCopied: '已複製工作區路徑', copyFailed: '複製失敗', copyFailedDetail: '剪貼簿不可用或被系統拒絕。', historyCleared: '已清空輸入歷史', historyClearedDetail: '已傳送的提示詞記錄已從本機移除。', selectCategory: '請至少選擇一個類別', exported: '已匯出設定', exportedDetail: (items) => `包含:${items.join('、')}`, exportFailed: '匯出失敗', noCategories: '未選擇任何類別', tryAgain: '請稍後重試', - imported: '已匯入設定', importFailed: '匯入失敗', invalidFile: '檔案無效或版本不受支援。', + imported: '已匯入設定', importFailed: '匯入失敗', + importFailures: { not_json: '檔案不是有效的 JSON。', malformed: '設定檔結構無效。', unsupported_version: '設定檔版本不受支援。' }, rows: { workspace: '工作區路徑', workspaceDetail: '任務、設定、憑據和技能檔案都存在這個目錄下。', loadValueFailed: '載入失敗', loading: '正在載入…', history: '輸入歷史', historyDetail: '上箭頭 / 下箭頭調出的已傳送提示詞記錄,儲存在本機、重啟後仍在。清空後無法恢復。', @@ -109,7 +112,8 @@ const SETTINGS_DATA_COPY = { loadFailed: 'Failed to load data directory', openFailed: (label) => `Could not open ${label}`, pathCopied: 'Workspace path copied', copyFailed: 'Copy failed', copyFailedDetail: 'The clipboard is unavailable or access was denied by the system.', historyCleared: 'Input history cleared', historyClearedDetail: 'Sent prompt history was removed from this device.', selectCategory: 'Select at least one category', exported: 'Configuration exported', exportedDetail: (items) => `Included: ${items.join(', ')}`, exportFailed: 'Export failed', noCategories: 'No categories selected', tryAgain: 'Try again later', - imported: 'Configuration imported', importFailed: 'Import failed', invalidFile: 'The file is invalid or its version is unsupported.', + imported: 'Configuration imported', importFailed: 'Import failed', + importFailures: { not_json: 'The file is not valid JSON.', malformed: 'The config bundle is malformed.', unsupported_version: 'The config file version is unsupported.' }, rows: { workspace: 'Workspace path', workspaceDetail: 'Tasks, settings, credentials, and skill files are stored in this directory.', loadValueFailed: 'Failed to load', loading: 'Loading…', history: 'Input history', historyDetail: 'Previously sent prompts recalled with the Up and Down arrows are kept on this machine and persist across restarts. Clearing them cannot be undone.', diff --git a/apps/desktop/src/renderer/locales/settings-health-copy.ts b/apps/desktop/src/renderer/locales/settings-health-copy.ts index acf0339e99..b4f9229b28 100644 --- a/apps/desktop/src/renderer/locales/settings-health-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-health-copy.ts @@ -123,7 +123,7 @@ const SETTINGS_HEALTH_COPY = { source: '来源:', blocksSend: '阻塞发送', blocksCapability: '阻塞能力', signalLabel: (signal) => (signal.id.endsWith(':runtime') ? `${signal.label} 运行态` : signal.label), signalMessage: (signal) => signalMessagesZh[signal.message], - signalDetail: (signal) => signalDetailZh(signal), + signalDetail: (signal) => signalDetailZh(signal.detail), }, 'zh-TW': { loading: '正在載入健康快照', readFailed: '無法讀取健康快照', noData: '健康服務未返回資料。', readAgain: '重新讀取', @@ -142,7 +142,7 @@ const SETTINGS_HEALTH_COPY = { source: '來源:', blocksSend: '阻塞傳送', blocksCapability: '阻塞能力', signalLabel: (signal) => (signal.id.endsWith(':runtime') ? `${signal.label} 執行狀態` : signal.label), signalMessage: (signal) => signalMessagesZhTw[signal.message], - signalDetail: (signal) => signalDetailZhTw(signal), + 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', @@ -161,7 +161,7 @@ const SETTINGS_HEALTH_COPY = { source: 'Source: ', blocksSend: 'Blocks sending', blocksCapability: 'Blocks capability', signalLabel: (signal) => (signal.id.endsWith(':runtime') ? `${signal.label} runtime` : signal.label), signalMessage: (signal) => signalMessagesEn[signal.message], - signalDetail: (signal) => signalDetailEn(signal), + signalDetail: (signal) => signalDetailEn(signal.detail), }, } satisfies UiCatalog; @@ -253,8 +253,7 @@ const connectionTestErrorMessages = { }, } satisfies UiCatalog>; -function signalDetailZh(signal: HealthSignal): string | undefined { - const detail = signal.detail; +function signalDetailZh(detail: HealthSignalDetail | undefined): string | undefined { if (!detail) return undefined; switch (detail.kind) { case 'validation_scope_note': @@ -272,10 +271,7 @@ function signalDetailZh(signal: HealthSignal): string | undefined { ...(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. Bot reasons pre-resolve at the - // page layer (copy catalogs may not runtime-import each other). - return /[\u3400-\u9fff]/u.test(detail.reason) ? detail.reason : '状态详情请见对应设置页。'; + return '状态详情请见对应设置页。'; case 'last_test_error_class': return connectionTestErrorMessages['zh-CN'][detail.errorClass]; case 'last_test_message': @@ -285,8 +281,7 @@ function signalDetailZh(signal: HealthSignal): string | undefined { } } -function signalDetailZhTw(signal: HealthSignal): string | undefined { - const detail = signal.detail; +function signalDetailZhTw(detail: HealthSignalDetail | undefined): string | undefined { if (!detail) return undefined; switch (detail.kind) { case 'validation_scope_note': @@ -314,8 +309,7 @@ function signalDetailZhTw(signal: HealthSignal): string | undefined { } } -function signalDetailEn(signal: HealthSignal): string | undefined { - const detail = signal.detail; +function signalDetailEn(detail: HealthSignalDetail | undefined): string | undefined { if (!detail) return undefined; switch (detail.kind) { case 'validation_scope_note': diff --git a/apps/desktop/src/renderer/locales/settings-memory-copy.ts b/apps/desktop/src/renderer/locales/settings-memory-copy.ts index 39dfd21856..aeaa911bb6 100644 --- a/apps/desktop/src/renderer/locales/settings-memory-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-memory-copy.ts @@ -17,9 +17,10 @@ * under the License. */ -import type { LocalMemoryState } from '@maka/core/local-memory'; +import type { LocalMemoryOperationCode, LocalMemoryState } from '@maka/core/local-memory'; +import type { MemoryMutationRejectionReason } from '@maka/runtime-host/protocol'; -import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +import { type UiCatalog, type UiLocale, lookupCopy } from '@maka/core/ui-locale'; type MemoryTextKey = | 'localFile' | 'localFileHelp' | 'enableLocalFile' | 'agentReadable' | 'agentReadableHelp' | 'enableAgentRead' @@ -45,9 +46,12 @@ type MemoryTextKey = | 'entryRestoreFailed' | 'promptCopied' | 'promptCopiedDetail' | 'restoreDraftAction' | 'archiveDraftAction' | 'restoreAction' | 'archiveAction'; +export type MemoryResultCode = LocalMemoryOperationCode | MemoryMutationRejectionReason; + export type MemorySettingsCopy = { intlLocale: string; text: Record; + results: Record; origins: Record['origin'], string>; entryStatuses: Record; backupKinds: Record['kind'], string>; @@ -91,43 +95,152 @@ const enText = { } satisfies Record; const SETTINGS_MEMORY_COPY = { - 'zh-CN': makeCopy('zh-CN', zhText, { + 'zh-CN': { + intlLocale: 'zh-CN', + text: zhText, + countActive: (count, draft) => draft ? `草稿 ${count} 条生效` : `${count} 条生效`, + countArchived: (count, draft) => draft ? `草稿 ${count} 条已归档` : `${count} 条已归档`, + saveSummary: (active, archived) => archived > 0 + ? `当前 ${active} 条生效 / ${archived} 条已归档;已保留上一版备份。` + : `当前 ${active} 条生效;已保留上一版备份。`, + backupSummary: (active, archived) => archived > 0 + ? `${active} 条生效 / ${archived} 条已归档` + : `${active} 条生效`, + countEntries: (count) => `${count} 条记忆`, + countMatches: (filtered, total) => `${filtered} / ${total} 条匹配`, + listAria: (title) => `${title}列表`, + entryActionsAria: (title) => `${title} 记忆操作`, + entryActionAria: (action, identity) => `${action}:${identity}`, + openBackupAria: (label) => `打开备份候选 ${label}`, + restoreBackupAria: (label) => `恢复备份候选 ${label}`, + copyBackupAria: (label) => `复制备份候选引用 ${label}`, + draftStatusAria: (action) => `${action},保存前不会写入 MEMORY.md`, + restoreLatestDescription: (label) => `会先备份当前 MEMORY.md,再用最近一次备份覆盖当前文件。将恢复:${label}`, + restoreCandidateDescription: (label) => `会先备份当前 MEMORY.md,再用选中的备份覆盖当前文件。将恢复:${label}`, + redactedDetail: (summary) => `写入前已替换疑似 token、API key 或密码;${summary}`, + openBackupFailed: (kind) => `打开${kind}失败`, + previewTruncated: (limit) => `预览已按 ${limit} 字符上限截断`, + previewUsage: (length, limit) => `预览 ${length} / ${limit} 字符`, + previewLimit: (limit) => `prompt 上限 ${limit} 字符`, + results: { + no_backup: '当前没有可用的 MEMORY.md 备份。', invalid_backup_kind: '备份类型无法识别。', + memory_unavailable: '本地记忆服务当前不可用。', backup_not_found: '找不到对应的备份文件。', + remote_host_owned: '记忆文件由远程 Runtime Host 管理,无法在本机打开。', not_regular_file: '记忆路径不是允许打开的常规文件。', + open_failed: '系统无法打开记忆文件。', file_not_found: '找不到记忆文件。', + disabled: '本地记忆已关闭。', incognito_active: '隐身模式下不可用。', + safe_mode: 'MEMORY.md 内容过大,已进入安全模式。', oversize: 'MEMORY.md 超出安全上限,请先删减旧内容。', + revision_conflict: '记忆内容刚被其他操作修改,请重试。', backup_revision_conflict: '备份内容刚被其他操作修改,请重试。', + invalid_state: 'Runtime Host 返回了无效的记忆状态。', + invalid_content: 'MEMORY.md 内容无效,请检查格式后重试。', invalid_scope: '当前记忆操作的作用域无效。', + not_found: '找不到对应的记忆条目。', not_pending: '对应的记忆条目不在待确认状态。', + upload_not_found: '记忆上传会话不存在或已过期。', upload_incomplete: '记忆内容尚未上传完整。', + upload_conflict: '另一个记忆上传正在进行,请重试。', + }, origins: { manual: '手动记录', imported: '导入记录', extracted: '确认提取', unknown: '手写条目' }, entryStatuses: { draft: '草稿', review_required: '待确认', active: '生效', archived: '已归档', rejected: '已拒绝', unknown: '未识别' }, backupKinds: { reset: '重置前备份', restore: '恢复前备份', save: '保存前备份' }, memoryStatuses: { ok: '本地文件已就绪', disabled: '已关闭', safe_mode: '安全模式', incognito_blocked: '隐身禁用', error: '读取失败' }, promptBlocked: { disabled: '本地记忆已关闭。', incognito: '隐身模式下不会提供本地记忆。', safeMode: 'MEMORY.md 过大,当前不会提供。', agentRead: '模型上下文读取未开启。' }, backupOversize: '备份过大,无法预览条目', previewOversize: '草稿过大,条目预览已暂停;保存前请先删减 MEMORY.md 内容。', previewTruncationMarker: '[本地记忆已按长度截断]', - }), - 'zh-TW': makeCopy('zh-TW', zhTwText, { + }, + 'zh-TW': { + intlLocale: 'zh-TW', + text: zhTwText, + countActive: (count, draft) => draft ? `草稿 ${count} 則生效` : `${count} 則生效`, + countArchived: (count, draft) => draft ? `草稿 ${count} 則已歸檔` : `${count} 則已歸檔`, + saveSummary: (active, archived) => archived > 0 + ? `目前 ${active} 則生效 / ${archived} 則已歸檔;已保留上一版備份。` + : `目前 ${active} 則生效;已保留上一版備份。`, + backupSummary: (active, archived) => archived > 0 + ? `${active} 則生效 / ${archived} 則已歸檔` + : `${active} 則生效`, + countEntries: (count) => `${count} 則記憶`, + countMatches: (filtered, total) => `${filtered} / ${total} 則符合`, + listAria: (title) => `${title}清單`, + entryActionsAria: (title) => `${title} 記憶操作`, + entryActionAria: (action, identity) => `${action}:${identity}`, + openBackupAria: (label) => `開啟備份候選 ${label}`, + restoreBackupAria: (label) => `還原備份候選 ${label}`, + copyBackupAria: (label) => `複製備份候選引用 ${label}`, + draftStatusAria: (action) => `${action},儲存前不會寫入 MEMORY.md`, + restoreLatestDescription: (label) => `會先備份目前的 MEMORY.md,再以最近一次備份覆蓋目前檔案。將還原:${label}`, + restoreCandidateDescription: (label) => `會先備份目前的 MEMORY.md,再以選取的備份覆蓋目前檔案。將還原:${label}`, + redactedDetail: (summary) => `寫入前已遮蔽疑似 token、API key 或密碼;${summary}`, + openBackupFailed: (kind) => `開啟${kind}失敗`, + previewTruncated: (limit) => `預覽已依 ${limit} 字元上限截斷`, + previewUsage: (length, limit) => `預覽 ${length} / ${limit} 字元`, + previewLimit: (limit) => `prompt 上限 ${limit} 字元`, + results: { + no_backup: '目前沒有可用的 MEMORY.md 備份。', invalid_backup_kind: '備份類型無法識別。', + memory_unavailable: '本機記憶服務目前無法使用。', backup_not_found: '找不到對應的備份檔案。', + remote_host_owned: '記憶檔案由遠端 Runtime Host 管理,無法在本機開啟。', not_regular_file: '記憶路徑不是允許開啟的一般檔案。', + open_failed: '系統無法開啟記憶檔案。', file_not_found: '找不到記憶檔案。', + disabled: '本機記憶已關閉。', incognito_active: '隱身模式下無法使用。', + safe_mode: 'MEMORY.md 內容過大,已進入安全模式。', oversize: 'MEMORY.md 超出安全上限,請先刪減舊內容。', + revision_conflict: '記憶內容剛被其他操作修改,請重試。', backup_revision_conflict: '備份內容剛被其他操作修改,請重試。', + invalid_state: 'Runtime Host 回傳了無效的記憶狀態。', + invalid_content: 'MEMORY.md 內容無效,請檢查格式後重試。', invalid_scope: '目前記憶操作的作用域無效。', + not_found: '找不到對應的記憶條目。', not_pending: '對應的記憶條目不在待確認狀態。', + upload_not_found: '記憶上傳工作階段不存在或已過期。', upload_incomplete: '記憶內容尚未上傳完整。', + upload_conflict: '另一個記憶上傳正在進行,請重試。', + }, origins: { manual: '手動記錄', imported: '匯入記錄', extracted: '確認提取', unknown: '手寫條目' }, entryStatuses: { draft: '草稿', review_required: '待確認', active: '生效', archived: '已歸檔', rejected: '已拒絕', unknown: '未識別' }, backupKinds: { reset: '重置前備份', restore: '恢復前備份', save: '儲存前備份' }, memoryStatuses: { ok: '本地檔案已就緒', disabled: '已關閉', safe_mode: '安全模式', incognito_blocked: '隱身停用', error: '讀取失敗' }, promptBlocked: { disabled: '本地記憶已關閉。', incognito: '隱身模式下不會提供本地記憶。', safeMode: 'MEMORY.md 過大,目前不會提供。', agentRead: '模型上下文讀取未開啟。' }, backupOversize: '備份過大,無法預覽條目', previewOversize: '草稿過大,條目預覽已暫停;儲存前請先刪減 MEMORY.md 內容。', previewTruncationMarker: '[本地記憶已按長度截斷]', - }), - en: makeCopy('en-US', enText, { + }, + en: { + intlLocale: 'en-US', + text: enText, + countActive: (count, draft) => draft + ? `Draft · ${count} active ${count === 1 ? 'entry' : 'entries'}` + : `${count} active ${count === 1 ? 'entry' : 'entries'}`, + countArchived: (count, draft) => draft + ? `Draft · ${count} archived ${count === 1 ? 'entry' : 'entries'}` + : `${count} archived ${count === 1 ? 'entry' : 'entries'}`, + saveSummary: (active, archived) => archived > 0 + ? `${active} active ${active === 1 ? 'entry' : 'entries'} / ${archived} archived ${archived === 1 ? 'entry' : 'entries'}; the previous version was backed up.` + : `${active} active ${active === 1 ? 'entry' : 'entries'}; the previous version was backed up.`, + backupSummary: (active, archived) => archived > 0 + ? `${active} active ${active === 1 ? 'entry' : 'entries'} / ${archived} archived ${archived === 1 ? 'entry' : 'entries'}` + : `${active} active ${active === 1 ? 'entry' : 'entries'}`, + countEntries: (count) => count === 1 ? `${count} memory` : `${count} memories`, + countMatches: (filtered, total) => `${filtered} / ${total} matching`, + listAria: (title) => `${title} list`, + entryActionsAria: (title) => `${title} memory actions`, + entryActionAria: (action, identity) => `${action}: ${identity}`, + openBackupAria: (label) => `Open backup candidate ${label}`, + restoreBackupAria: (label) => `Restore backup candidate ${label}`, + copyBackupAria: (label) => `Copy backup candidate reference ${label}`, + draftStatusAria: (action) => `${action}; MEMORY.md is not written until you save`, + restoreLatestDescription: (label) => `The current MEMORY.md will be backed up before the latest backup replaces it. Restore: ${label}`, + restoreCandidateDescription: (label) => `The current MEMORY.md will be backed up before the selected backup replaces it. Restore: ${label}`, + redactedDetail: (summary) => `Suspected tokens, API keys, or passwords were redacted before writing; ${summary}`, + openBackupFailed: (kind) => `Failed to open ${kind}`, + previewTruncated: (limit) => `Preview truncated at the ${limit}-character limit`, + previewUsage: (length, limit) => `Preview ${length} / ${limit} characters`, + previewLimit: (limit) => `Prompt limit: ${limit} characters`, + results: { + no_backup: 'No MEMORY.md backup is available.', invalid_backup_kind: 'Unrecognized backup kind.', + memory_unavailable: 'Local memory is currently unavailable.', backup_not_found: 'The backup file was not found.', + remote_host_owned: 'Memory files are owned by the remote Runtime Host and cannot be opened locally.', not_regular_file: 'The memory path is not an allowed regular file.', + open_failed: 'The system could not open the memory file.', file_not_found: 'The memory file was not found.', + disabled: 'Local memory is disabled.', incognito_active: 'Unavailable in incognito mode.', + safe_mode: 'MEMORY.md is too large and entered safe mode.', oversize: 'MEMORY.md exceeds the safety limit. Remove older content first.', + revision_conflict: 'Memory was just changed by another operation. Try again.', backup_revision_conflict: 'The backup was just changed by another operation. Try again.', + invalid_state: 'The Runtime Host returned an invalid memory state.', + invalid_content: 'MEMORY.md content is invalid. Check its format and try again.', invalid_scope: 'The memory operation has an invalid scope.', + not_found: 'The memory entry was not found.', not_pending: 'The memory entry is not pending review.', + upload_not_found: 'The memory upload session does not exist or has expired.', upload_incomplete: 'The memory content has not finished uploading.', + upload_conflict: 'Another memory upload is in progress. Try again.', + }, origins: { manual: 'Manual entry', imported: 'Imported entry', extracted: 'Confirmed extraction', unknown: 'Handwritten entry' }, entryStatuses: { draft: 'Draft', review_required: 'Needs review', active: 'Active', archived: 'Archived', rejected: 'Rejected', unknown: 'Unrecognized' }, backupKinds: { reset: 'Before reset', restore: 'Before restore', save: 'Before save' }, memoryStatuses: { ok: 'Local file ready', disabled: 'Off', safe_mode: 'Safe mode', incognito_blocked: 'Disabled in incognito', error: 'Read failed' }, promptBlocked: { disabled: 'Local memory is disabled.', incognito: 'Local memory is never added in incognito mode.', safeMode: 'MEMORY.md is too large and will not be added.', agentRead: 'Model context access is disabled.' }, backupOversize: 'Backup is too large to preview entries', previewOversize: 'The draft is too large, so entry preview is paused. Reduce MEMORY.md before saving.', previewTruncationMarker: '[Local memory truncated to the length limit]', - }), + }, } satisfies UiCatalog; export function getMemorySettingsCopy(locale: UiLocale): MemorySettingsCopy { return SETTINGS_MEMORY_COPY[locale]; } -function makeCopy(intlLocale: string, text: Record, values: Pick): MemorySettingsCopy { - const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`; - const isZh = intlLocale !== 'en-US'; - const isZhTw = intlLocale === 'zh-TW'; - const zh = (simplified: string, traditional: string) => isZhTw ? traditional : simplified; - return { - intlLocale, text, ...values, - countActive: (count, draft) => isZh ? `${draft ? '草稿 ' : ''}${count} ${zh('条生效', '則生效')}` : `${draft ? 'Draft · ' : ''}${plural(count, 'active entry', 'active entries')}`, - countArchived: (count, draft) => isZh ? `${draft ? '草稿 ' : ''}${count} ${zh('条已归档', '則已歸檔')}` : `${draft ? 'Draft · ' : ''}${plural(count, 'archived entry', 'archived entries')}`, - saveSummary: (active, archived) => isZh ? `${zh('当前', '目前')} ${active} ${zh('条生效', '則生效')}${archived > 0 ? ` / ${archived} ${zh('条已归档', '則已歸檔')}` : ''};${zh('已保留上一版备份。', '已保留上一版備份。')}` : `${plural(active, 'active entry', 'active entries')}${archived > 0 ? ` / ${plural(archived, 'archived entry', 'archived entries')}` : ''}; the previous version was backed up.`, - backupSummary: (active, archived) => isZh ? `${active} ${zh('条生效', '則生效')}${archived > 0 ? ` / ${archived} ${zh('条已归档', '則已歸檔')}` : ''}` : `${plural(active, 'active entry', 'active entries')}${archived > 0 ? ` / ${plural(archived, 'archived entry', 'archived entries')}` : ''}`, - countEntries: (count) => isZh ? `${count} ${zh('条记忆', '則記憶')}` : plural(count, 'memory', 'memories'), - countMatches: (filtered, total) => isZh ? `${filtered} / ${total} ${zh('条匹配', '則符合')}` : `${filtered} / ${total} matching`, - listAria: (title) => isZh ? `${title}${zh('列表', '清單')}` : `${title} list`, - entryActionsAria: (title) => isZh ? `${title} ${zh('记忆操作', '記憶操作')}` : `${title} memory actions`, - entryActionAria: (action, identity) => isZh ? `${action}:${identity}` : `${action}: ${identity}`, - openBackupAria: (label) => isZh ? `${zh('打开备份候选', '開啟備份候選')} ${label}` : `Open backup candidate ${label}`, restoreBackupAria: (label) => isZh ? `${zh('恢复备份候选', '還原備份候選')} ${label}` : `Restore backup candidate ${label}`, copyBackupAria: (label) => isZh ? `${zh('复制备份候选引用', '複製備份候選引用')} ${label}` : `Copy backup candidate reference ${label}`, - draftStatusAria: (action) => isZh ? `${action},${zh('保存前不会写入', '儲存前不會寫入')} MEMORY.md` : `${action}; MEMORY.md is not written until you save`, - restoreLatestDescription: (label) => isZh ? `${zh('会先备份当前 MEMORY.md,再用最近一次备份覆盖当前文件。将恢复', '會先備份目前的 MEMORY.md,再以最近一次備份覆蓋目前檔案。將還原')}:${label}` : `The current MEMORY.md will be backed up before the latest backup replaces it. Restore: ${label}`, - restoreCandidateDescription: (label) => isZh ? `${zh('会先备份当前 MEMORY.md,再用选中的备份覆盖当前文件。将恢复', '會先備份目前的 MEMORY.md,再以選取的備份覆蓋目前檔案。將還原')}:${label}` : `The current MEMORY.md will be backed up before the selected backup replaces it. Restore: ${label}`, - redactedDetail: (summary) => isZh ? `${zh('写入前已替换疑似', '寫入前已遮蔽疑似')} token、API key ${zh('或密码', '或密碼')};${summary}` : `Suspected tokens, API keys, or passwords were redacted before writing; ${summary}`, - openBackupFailed: (kind) => isZh ? `${zh('打开', '開啟')}${kind}${zh('失败', '失敗')}` : `Failed to open ${kind}`, - previewTruncated: (limit) => isZh ? `${zh('预览已按', '預覽已依')} ${limit} ${zh('字符上限截断', '字元上限截斷')}` : `Preview truncated at the ${limit}-character limit`, - previewUsage: (length, limit) => isZh ? `${zh('预览', '預覽')} ${length} / ${limit} ${zh('字符', '字元')}` : `Preview ${length} / ${limit} characters`, - previewLimit: (limit) => isZh ? `prompt ${zh('上限', '上限')} ${limit} ${zh('字符', '字元')}` : `Prompt limit: ${limit} characters`, - }; +export function memoryResultMessage( + result: { code?: string }, + copy: MemorySettingsCopy, + fallback: string, +): string { + return lookupCopy(copy.results, result.code) || fallback; +} + +export function memoryOpenFailureMessage(result: { code?: string }, locale: UiLocale): string { + const copy = getMemorySettingsCopy(locale); + return memoryResultMessage(result, copy, copy.text.openFailed); } diff --git a/apps/desktop/src/renderer/settings/data-settings-page.tsx b/apps/desktop/src/renderer/settings/data-settings-page.tsx index 7ab341d52d..ecf86f43e9 100644 --- a/apps/desktop/src/renderer/settings/data-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/data-settings-page.tsx @@ -220,10 +220,7 @@ export function DataSettingsPage(props: { if (res.ok) { toast.success(copy.imported, summarizeImportResult(res.result, copy)); } else if (res.reason !== 'canceled') { - const detail = res.message && (locale === 'zh-CN' || !/[\u3400-\u9fff]/u.test(res.message)) - ? res.message - : copy.invalidFile; - toast.error(copy.importFailed, detail, undefined, diagnosticTarget); + toast.error(copy.importFailed, copy.importFailures[res.reason], undefined, diagnosticTarget); } } catch (error) { toast.error( diff --git a/apps/desktop/src/renderer/settings/health-center-page.tsx b/apps/desktop/src/renderer/settings/health-center-page.tsx index eef3f3fe36..f52158751f 100644 --- a/apps/desktop/src/renderer/settings/health-center-page.tsx +++ b/apps/desktop/src/renderer/settings/health-center-page.tsx @@ -28,6 +28,7 @@ import { HEALTH_SIGNAL_LAYERS } from '@maka/core/health'; import type { UiLocale } from '@maka/core/ui-locale'; import { Text, VStack } from '@astryxdesign/core'; import { Button, RelativeTime, StatusDot, useUiLocale, Banner } from '@maka/ui'; +import { capabilityReasonMessage } from '../locales/capability-reason-copy'; import { getHealthCenterCopy, type HealthCenterCopy } from '../locales/settings-health-copy'; import { botStatusReasonCopy } from '../locales/settings-bot-copy'; import { settingsActionErrorMessage } from './settings-error-copy'; @@ -201,12 +202,9 @@ export function HealthCenterPage() { } function HealthSignalRow(props: { signal: HealthSignal; copy: HealthCenterCopy; locale: UiLocale }) { - const { signal, copy } = props; + const { signal, copy, locale } = props; const statusCopy = copy.statuses[signal.status]; - // Copy catalogs may not runtime-import each other, so bot capability reasons - // (machine codes from the bridge) pre-resolve here; the catalog still owns - // the per-locale fallback sentences for every other producer. - const detail = localizedSignalDetail(signal, copy, props.locale); + const detail = localizedSignalDetail(signal, copy, locale); return ( { diff --git a/apps/desktop/src/renderer/settings/permission-center-page.tsx b/apps/desktop/src/renderer/settings/permission-center-page.tsx index 6c6ede74e2..10fb401926 100644 --- a/apps/desktop/src/renderer/settings/permission-center-page.tsx +++ b/apps/desktop/src/renderer/settings/permission-center-page.tsx @@ -36,7 +36,11 @@ import type { PermissionSnapshot, } from '@maka/core/capabilities'; import type { UiLocale } from '@maka/core/ui-locale'; -import { isDragGrantPermissionId, OS_PERMISSION_IDS } from '@maka/core/capabilities'; +import { + isCapabilityReasonCode, + isDragGrantPermissionId, + OS_PERMISSION_IDS, +} from '@maka/core/capabilities'; import { Banner, Button, @@ -52,9 +56,10 @@ import { } from '@astryxdesign/core'; import { RelativeTime, StatusDot, useMountedRef, useUiLocale } from '@maka/ui'; import { SettingsPage, SettingsSection } from './settings-section'; +import { getCapabilityReasonCopy } from '../locales/capability-reason-copy'; import { getPermissionCenterCopy, type PermissionCenterCopy } from '../locales/permission-center-copy'; -import { settingsActionErrorMessage } from './settings-error-copy'; import { botStatusReasonCopy } from '../locales/settings-bot-copy'; +import { settingsActionErrorMessage } from './settings-error-copy'; import { useRuntimeHostSettingsErrorReporter, useRuntimeHostSettingsTarget, @@ -403,7 +408,7 @@ function permissionActionFailureCopy(reason: string, message: string | undefined * One capability row — a Collapsible whose trigger is the row and whose content * is that capability's diagnostics. * - * The four-layer breakdown, the required-permission list and the guidance list + * The four-layer breakdown and the required-permission list * used to be a `
` and two `
    `s with ~180 lines of CSS giving them label * columns, tone colors and spacing. They are all "label → value" readouts, so * they are Astryx `MetadataList` now. @@ -422,10 +427,9 @@ function CapabilityRow(props: { const { copy, locale } = props; const readinessCopy = copy.readiness[capability.readiness]; const capabilityLabel = localizedCapabilityLabel(capability, locale); - const featureReason = capabilityReasonText(capability.feature.reason, capability.id, locale); - const configurationReason = capabilityReasonText(capability.configuration.reason, capability.id, locale); - const runtimeReason = capabilityReasonText(capability.runtimeProbe.reason, capability.id, locale); - const guidance = localizedCapabilityGuidance(capability, locale); + const featureReason = capabilityReasonText(capability.feature.reason, capability, copy, locale); + const configurationReason = capabilityReasonText(capability.configuration.reason, capability, copy, locale); + const runtimeReason = capabilityReasonText(capability.runtimeProbe.reason, capability, copy, locale); const layers: Array<{ label: string; value: string; reason?: string }> = [ { @@ -525,19 +529,6 @@ function CapabilityRow(props: { ))} )} - {guidance.length > 0 && ( - - {copy.guidance} - - {guidance.map((item, index) => ( - {item}} - /> - ))} - - - )} {/* PR-UX-POLISH-1 commit 2 (yuejing UX audit + xuan ROADMAP-SURFACE-0 + kenji boundary 1): unavailable pause/revoke @@ -590,7 +581,7 @@ function OsPermissionRow(props: { const purpose = permissionCopy?.purpose ?? ''; const impact = permissionCopy?.impact ?? ''; const stateCopy = props.copy.osStates[snapshot.status]; - const reason = localizedSnapshotText(snapshot.reason, props.locale); + const reason = osPermissionReasonText(snapshot, props.copy, props.locale); const showRequest = snapshot.canRequest && snapshot.status !== 'granted'; const showOpenSettings = snapshot.canOpenSettings && snapshot.status !== 'granted'; @@ -705,31 +696,38 @@ function localizedCapabilityLabel(capability: CapabilitySnapshot, locale: UiLoca return capability.label; } -// Bot capabilities carry bridge status codes; other producers still emit prose -// until the capability snapshot codes them. function capabilityReasonText( - value: string | undefined, - capabilityId: CapabilityId, + reason: string | undefined, + capability: CapabilitySnapshot, + copy: PermissionCenterCopy, locale: UiLocale, ): string | undefined { - if (!value) return undefined; - if (capabilityId.startsWith('bot:')) { - const botReason = botStatusReasonCopy(value, locale); - if (botReason) return botReason; + if (!reason) return undefined; + if (reason === 'cu_backend_status') { + const missing = capability.osPermissions + .filter((permission) => permission.required && permission.status !== 'granted') + .map((permission) => copy.osPermissions[permission.id]?.label ?? permission.id); + return copy.cuBackendStatus(missing, capability.runtimeProbe.state); } - return localizedSnapshotText(value, locale); -} - -function localizedSnapshotText(value: string | undefined, locale: UiLocale): string | undefined { - if (!value || (locale !== 'zh-CN' && /[\u3400-\u9fff]/u.test(value))) return undefined; - return value; + if (isCapabilityReasonCode(reason)) { + return getCapabilityReasonCopy(locale)[reason]; + } + if (capability.id.startsWith('bot:')) { + return botStatusReasonCopy(reason, locale) ?? copy.reasonFallback; + } + return copy.reasonFallback; } -function localizedCapabilityGuidance( - capability: CapabilitySnapshot, +function osPermissionReasonText( + snapshot: OsPermissionSnapshot, + copy: PermissionCenterCopy, locale: UiLocale, -): readonly string[] { - return capability.guidance.filter((item) => locale === 'zh-CN' || !/[\u3400-\u9fff]/u.test(item)); +): string | undefined { + return snapshot.reason + ? isCapabilityReasonCode(snapshot.reason) + ? getCapabilityReasonCopy(locale)[snapshot.reason] + : copy.reasonFallback + : undefined; } function featureTone(state: CapabilitySnapshot['feature']['state']): StatusSemantic { diff --git a/apps/desktop/src/renderer/settings/provider-oauth-section.tsx b/apps/desktop/src/renderer/settings/provider-oauth-section.tsx index 3f144ded6b..4bc1ac26a7 100644 --- a/apps/desktop/src/renderer/settings/provider-oauth-section.tsx +++ b/apps/desktop/src/renderer/settings/provider-oauth-section.tsx @@ -29,6 +29,7 @@ import { } from '@maka/ui'; import { getProviderSettingsCopy, + subscriptionActionErrorMessage, subscriptionResultMessage, type ConnectionOAuthProviderBridge, type ConnectionsBridge, @@ -36,7 +37,6 @@ import { } from '../features/connection-settings'; import { useOAuthLoginFlow, - subscriptionActionErrorMessage, type OAuthAuthorizationFlowBridge, type OAuthConnectionIdentity, type SubscriptionSnapshot, @@ -256,7 +256,7 @@ function GitHubCopilotLoginPanel(props: { if (!result.ok) { flow.reportError( copy.copilotActionFailed, - subscriptionResultMessage(result.message, copy.copilotActionFailed, locale, result.reason), + subscriptionResultMessage(result, copy.copilotActionFailed, locale), ); return; } diff --git a/apps/desktop/src/renderer/settings/settings-error-copy.ts b/apps/desktop/src/renderer/settings/settings-error-copy.ts index 37fa24949e..c6137b3be7 100644 --- a/apps/desktop/src/renderer/settings/settings-error-copy.ts +++ b/apps/desktop/src/renderer/settings/settings-error-copy.ts @@ -30,7 +30,6 @@ export function settingsActionErrorMessage(error: unknown, locale: UiLocale): st : ''; const classified = generalizedErrorMessageForLocale(new Error(raw), '', locale); if (classified) return classified; - const redacted = redactSecrets(raw).trim(); - if (locale === 'zh-CN' && redacted && /[\u4E00-\u9FFF]/.test(redacted)) return redacted; + if (raw) console.error('[settings] operation failed:', redactSecrets(raw)); return getSettingsSharedCopy(locale).unknownError; } diff --git a/apps/desktop/src/renderer/settings/use-memory-settings-controller.ts b/apps/desktop/src/renderer/settings/use-memory-settings-controller.ts index 2bfa9a4732..cb08daac78 100644 --- a/apps/desktop/src/renderer/settings/use-memory-settings-controller.ts +++ b/apps/desktop/src/renderer/settings/use-memory-settings-controller.ts @@ -40,7 +40,7 @@ import { } from './memory-settings-labels'; import { deriveMemorySettingsViewModel } from './memory-settings-view-model'; import { useKeyedActionGuard } from './use-action-guard'; -import { getMemorySettingsCopy } from '../locales/settings-memory-copy'; +import { getMemorySettingsCopy, memoryResultMessage } from '../locales/settings-memory-copy'; import { readScrollMotionBehavior } from '../scroll-motion-policy'; import { useRuntimeHostSettingsErrorReporter, @@ -287,7 +287,7 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps } else { reportHostError( copy.text.restoreFailed, - memoryResultMessage(result.message, locale, copy.text.restoreFailed), + memoryResultMessage(result, copy, copy.text.restoreFailed), ); } }); @@ -324,7 +324,7 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps } else { reportHostError( copy.text.restoreFailed, - memoryResultMessage(result.message, locale, copy.text.restoreFailed), + memoryResultMessage(result, copy, copy.text.restoreFailed), ); } }); @@ -345,7 +345,7 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps if (!result.ok) { reportHostError( copy.text.openFailed, - memoryResultMessage(result.message, locale, copy.text.openFailed), + memoryResultMessage(result, copy, copy.text.openFailed), ); } } catch (error) { @@ -364,7 +364,7 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps if (!result.ok) { reportHostError( copy.text.openPreviousFailed, - memoryResultMessage(result.message, locale, copy.text.openPreviousFailed), + memoryResultMessage(result, copy, copy.text.openPreviousFailed), ); } } catch (error) { @@ -386,7 +386,7 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps if (!result.ok) { reportHostError( copy.openBackupFailed(localMemoryBackupKindLabel(backup.kind, copy)), - memoryResultMessage(result.message, locale, copy.text.openFailed), + memoryResultMessage(result, copy, copy.text.openFailed), ); } } catch (error) { @@ -687,7 +687,3 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps copyLocalMemoryPromptPreview, }; } - -function memoryResultMessage(message: string, locale: UiLocale, fallback: string): string { - return locale === 'zh-CN' || !/[\u3400-\u9fff]/u.test(message) ? message : fallback; -} diff --git a/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts b/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts index f237429d4d..c9c35bc186 100644 --- a/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts +++ b/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts @@ -25,7 +25,7 @@ import { useUiLocale, } from '@maka/ui'; import { createOneShotActionGuard, teardownPendingAuthorization } from './oauth-login-flow-guard'; -import { getProviderSettingsCopy, subscriptionResultMessage } from '../features/connection-settings'; +import { getProviderSettingsCopy, subscriptionActionErrorMessage, subscriptionResultMessage } from '../features/connection-settings'; import { useRuntimeHostSettingsErrorReporter } from './runtime-host-settings-target.js'; // Shared browser-assisted OAuth login-flow controller (device-code polling). @@ -64,12 +64,12 @@ export interface OAuthConnectionIdentity { export interface OAuthAuthorizationFlowBridge { getAuthUrl(): Promise< { authRequestId: string; stateHint: string; connection: OAuthConnectionIdentity } - | { ok: boolean; reason?: string; message: string } + | { ok: boolean; reason?: string; message?: string } >; - openAuthUrl(authRequestId: string): Promise<{ ok: true } | { ok: false; reason: string; message: string }>; + openAuthUrl(authRequestId: string): Promise<{ ok: true } | { ok: false; reason: string; message?: string }>; completeAuthorization(authRequestId: string): Promise< { ok: true; connection: OAuthConnectionIdentity } - | { ok: false; reason: string; message: string } + | { ok: false; reason: string; message?: string } >; cancelAuthorization(authRequestId?: string): Promise<{ ok: true }>; // The selected Host's answer to whether this provider may enrol at all. @@ -78,7 +78,7 @@ export interface OAuthAuthorizationFlowBridge { export interface OAuthAccountFlowBridge { getAccountState(): Promise; - logout(): Promise<{ ok: true } | { ok: false; reason: string; message: string }>; + logout(): Promise<{ ok: true } | { ok: false; reason: string; message?: string }>; } export interface OAuthLoginFlowDisplay { @@ -210,7 +210,7 @@ export function useOAuthLoginFlow(params: OAuthLoginFlowParams): OAuthLoginFlowC const payload = await authorizationBridge.getAuthUrl(); if ('ok' in payload) { if (!oauthLoginFlowMountedRef.current) return; - const failureMessage = payload.ok ? copy.retry : subscriptionResultMessage(payload.message, copy.startFailedRetry, locale, payload.reason); + const failureMessage = payload.ok ? copy.retry : subscriptionResultMessage(payload, copy.startFailedRetry, locale); reportHostError(copy.startFailed, failureMessage); setErrorMessage(failureMessage); return; @@ -226,7 +226,7 @@ export function useOAuthLoginFlow(params: OAuthLoginFlowParams): OAuthLoginFlowC const opened = await authorizationBridge.openAuthUrl(payload.authRequestId); if (!oauthLoginFlowMountedRef.current) return; if (!opened.ok) { - const message = subscriptionResultMessage(opened.message, copy.openFailedRetry, locale, opened.reason); + const message = subscriptionResultMessage(opened, copy.openFailedRetry, locale); reportHostError(copy.openFailed, message); setErrorMessage(message); void authorizationBridge.cancelAuthorization(payload.authRequestId); @@ -249,7 +249,7 @@ export function useOAuthLoginFlow(params: OAuthLoginFlowParams): OAuthLoginFlowC if (!oauthLoginFlowMountedRef.current) return; if (params.onLoginSuccess) await params.onLoginSuccess(result.connection); } else { - const message = subscriptionResultMessage(result.message, copy.incompleteRetry, locale, result.reason); + const message = subscriptionResultMessage(result, copy.incompleteRetry, locale); reportHostError(copy.incomplete, message); setErrorMessage(message); } @@ -293,7 +293,7 @@ export function useOAuthLoginFlow(params: OAuthLoginFlowParams): OAuthLoginFlowC } else { reportHostError( copy.logoutFailed, - subscriptionResultMessage(result.message, copy.logoutFailedRetry, locale), + subscriptionResultMessage(result, copy.logoutFailedRetry, locale), ); } } catch (error) { @@ -327,12 +327,3 @@ export function useOAuthLoginFlow(params: OAuthLoginFlowParams): OAuthLoginFlowC refresh, }; } - -export function subscriptionActionErrorMessage(error: unknown, locale: UiLocale = 'zh-CN'): string { - const message = error instanceof Error - ? error.message - : typeof error === 'string' - ? error - : ''; - return subscriptionResultMessage(message, getProviderSettingsCopy(locale).oauthFlow.serviceUnavailable, locale); -} diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index eb1d56b488..72e7dffd34 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -75,6 +75,7 @@ import type { ConnectionsBridge } from '../../src/renderer/settings/providers-pa import type { ProjectRecord } from '@maka/core/project'; import type { ArchivedTasksBridge } from '../../src/renderer/settings/tasks-settings-page'; import type { + DesktopLocalRuntimeHostRemoteAccessSnapshot, DesktopRuntimeHostProfileChangedEvent, DesktopRuntimeHostProfileSnapshot, DesktopSessionSummary, @@ -556,7 +557,6 @@ function makeCapability(input: Partial & Pick runtimeHostProfiles, subscribeChanges: () => () => undefined, }, + localRuntimeHostRemoteAccess: { + getSnapshot: async (): Promise => ({ state: 'off' }), + }, // Projects always mounts the Runtime Host management dialog shell, even // before a remote profile is selected. Keep the shared Settings fixture in // sync with the full preload surface so mount-time subscriptions stay real. @@ -2774,8 +2776,8 @@ export const ModelsCatalogIntentDuringWarmRevalidation: Story = { }; /** * The expanded state, not the collapsed one the page opens in: the capability layers grid - * and the guidance block are hidden until diagnostics are expanded, so the collapsed story - * gives those layouts no baseline at all — which is exactly where the remaining overflow + * is hidden until diagnostics are expanded, so the collapsed story + * gives that layout no baseline at all — which is exactly where the remaining overflow * was hiding. Everything the collapsed story shows is still on screen here. * * The disclosure is per-row now (a CollapsibleGroup, one open at a time) rather than one diff --git a/packages/core/src/__tests__/health.test.ts b/packages/core/src/__tests__/health.test.ts index 583c116b2c..ae269bb579 100644 --- a/packages/core/src/__tests__/health.test.ts +++ b/packages/core/src/__tests__/health.test.ts @@ -298,7 +298,6 @@ function capability( }, canRevoke: false, canPause: false, - guidance: [], auditEvents: [], updatedAt: 1, ...patch, diff --git a/packages/core/src/__tests__/redaction.test.ts b/packages/core/src/__tests__/redaction.test.ts index 6689071875..a97cc71e01 100644 --- a/packages/core/src/__tests__/redaction.test.ts +++ b/packages/core/src/__tests__/redaction.test.ts @@ -306,6 +306,25 @@ describe('generalizedErrorMessageForLocale', () => { '操作失败', ); }); + + test('classifies Chromium network stack error codes as network errors', () => { + for (const raw of [ + 'net::ERR_CONNECTION_RESET', + 'net::ERR_NAME_NOT_RESOLVED', + 'net::ERR_CONNECTION_REFUSED', + 'net::ERR_INTERNET_DISCONNECTED', + ]) { + assert.equal(generalizedErrorMessage(new Error(raw)), 'Network error'); + assert.equal( + generalizedErrorMessageForLocale(new Error(raw), 'fallback', 'zh-CN'), + '网络错误', + ); + assert.equal( + generalizedErrorMessageForLocale(new Error(raw), 'fallback', 'zh-TW'), + '網路錯誤', + ); + } + }); }); describe('generalizedErrorMessage', () => { diff --git a/packages/core/src/capabilities.ts b/packages/core/src/capabilities.ts index bdc33a1fd1..7fea001d47 100644 --- a/packages/core/src/capabilities.ts +++ b/packages/core/src/capabilities.ts @@ -103,6 +103,47 @@ export type CapabilityId = | 'memory_write' | `bot:${BotProvider}`; +/** + * Stable machine codes for capability and OS-permission reasons. Producers + * emit these instead of locale-bound prose; presenters own the code→copy map + * per locale. Bot capabilities pass their bridge status reasons through as-is + * (`rate-limited`, `gateway-closed-4004`, …), so signal `reason` fields stay + * `string` — this union types the desktop producers and the presenter maps. + */ +export const CAPABILITY_REASON_CODES = [ + 'disabled', + 'platform_credentials_missing', + 'macos_tcc_only', + 'apple_events_tcc_status_unavailable', + 'cu_artifact_missing', + 'cu_backend_status', + 'cu_backend_unavailable', + 'cu_executor_undistributable', + 'cu_executor_stopped', + 'cu_executor_start_failed', + 'cu_executor_recovering', + 'cu_executor_ready', + 'cu_executor_lazy_start', + 'activity_recorder_partial', + 'activity_recorder_probe_hint', + 'memory_partial', + 'memory_no_probe', + 'accessibility_status_ambiguous', + 'screen_recording_status_mac_only', + 'notifications_status_unreadable_macos', + 'notifications_status_unreadable', + 'notifications_unsupported', + 'permission_probe_failed', +] as const; + +export type CapabilityReasonCode = (typeof CAPABILITY_REASON_CODES)[number]; + +export function isCapabilityReasonCode(value: unknown): value is CapabilityReasonCode { + return ( + typeof value === 'string' && (CAPABILITY_REASON_CODES as readonly string[]).includes(value) + ); +} + export interface OsPermissionSnapshot { id: OsPermissionId; status: OsPermissionState; @@ -166,7 +207,6 @@ export interface CapabilitySnapshot { runtimeProbe: CapabilityRuntimeProbeSignal; canRevoke: boolean; canPause: boolean; - guidance: string[]; auditEvents: string[]; updatedAt: number; } diff --git a/packages/core/src/local-memory.ts b/packages/core/src/local-memory.ts index 943ec2ff08..c017007eb7 100644 --- a/packages/core/src/local-memory.ts +++ b/packages/core/src/local-memory.ts @@ -1079,6 +1079,18 @@ function slugId(title: string): string { return slug.length > 0 ? slug : 'memory-entry'; } +export type LocalMemoryOperationCode = + | 'no_backup' + | 'invalid_backup_kind' + | 'memory_unavailable' + | 'backup_not_found' + | 'remote_host_owned' + | 'not_regular_file' + | 'open_failed' + | 'file_not_found' + | 'revision_conflict' + | 'backup_revision_conflict'; + function hexSha256(sha256: Sha256Digest, input: string): string { return Array.from(sha256.digest(input), (byte) => byte.toString(16).padStart(2, '0')).join(''); } diff --git a/packages/core/src/oauth-subscription.ts b/packages/core/src/oauth-subscription.ts index c8f271b92d..1675882e30 100644 --- a/packages/core/src/oauth-subscription.ts +++ b/packages/core/src/oauth-subscription.ts @@ -38,7 +38,23 @@ */ export type SubscriptionActionResult = | { ok: true } - | { ok: false; reason: SubscriptionActionFailureReason; message: string }; + | { + ok: false; + reason: SubscriptionActionFailureReason; + code?: SubscriptionActionCode; + message?: string; + }; + +export type SubscriptionActionCode = + | 'copilot_classic_pat_unsupported' + | 'copilot_credential_type_unsupported' + | 'copilot_local_credential_missing' + | 'copilot_import_no_credential' + | 'copilot_import_superseded' + | 'copilot_subscription_unavailable' + | 'copilot_credential_import_rejected' + | 'copilot_subscription_check_failed' + | 'copilot_import_commit_failed'; export type SubscriptionActionFailureReason = | 'authorization_pending' // no startAuthorization called yet diff --git a/packages/core/src/redaction.ts b/packages/core/src/redaction.ts index 0144baead4..39cd4e1123 100644 --- a/packages/core/src/redaction.ts +++ b/packages/core/src/redaction.ts @@ -230,6 +230,10 @@ export function classifyGeneralizedError(error: unknown): GeneralizedErrorClass if ( lower.includes('network') || lower.includes('fetch') || + // Chromium network stack error codes (`net::ERR_CONNECTION_RESET`, + // `net::ERR_NAME_NOT_RESOLVED`, ...) never match the Node errno + // spellings below. + lower.includes('net::err') || lower.includes('econn') || lower.includes('enotfound') ) diff --git a/packages/runtime/src/test-connection.ts b/packages/runtime/src/test-connection.ts index 3c95c80fe4..875bea65cc 100644 --- a/packages/runtime/src/test-connection.ts +++ b/packages/runtime/src/test-connection.ts @@ -508,8 +508,6 @@ async function httpFailure(r: ConnectionEffectResponse, t0: number): Promise { - if (message.type() === 'error') browserFailures.push(`console.error: ${message.text()}`); + if (message.type() === 'error' && !isExpectedConsoleError(job.storyId, message.text())) { + browserFailures.push(`console.error: ${message.text()}`); + } }; const onPageError = (error) => { browserFailures.push(`uncaught page error: ${describeBrowserValue(error)}`); diff --git a/scripts/storybook-visual-smoke.test.mjs b/scripts/storybook-visual-smoke.test.mjs index eca595657a..783ca373ec 100644 --- a/scripts/storybook-visual-smoke.test.mjs +++ b/scripts/storybook-visual-smoke.test.mjs @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { catalogJobs, storyUrl } from './storybook-visual-smoke.mjs'; +import { catalogJobs, isExpectedConsoleError, storyUrl } from './storybook-visual-smoke.mjs'; const REFERENCE_STORY_ID = 'product-shell-official-appshell--native-conversation'; const THEME_PALETTES = [ @@ -135,3 +135,34 @@ test('story URLs encode the selected colour scheme and palette', () => { assert.equal(url.searchParams.get('viewMode'), 'story'); assert.equal(url.searchParams.get('globals'), 'colorScheme:dark;palette:tokyo-night'); }); + +const errorStory = 'product-settings-pages--general-host-settings-error'; +const expectedError = + '[settings] operation failed: Runtime Host settings read failed in this story.'; + +test('allows the intentional settings read failure only in its error story', () => { + assert.equal(isExpectedConsoleError(errorStory, expectedError), true); + assert.equal(isExpectedConsoleError('product-settings-pages--general', expectedError), false); + assert.equal( + isExpectedConsoleError( + 'product-settings-pages--projects-cached-host-revalidation', + expectedError, + ), + false, + ); +}); + +test('keeps unexpected settings errors fatal, including errors in the error story', () => { + const missingBridgeError = + "[settings] operation failed: Cannot read properties of undefined (reading 'getSnapshot')"; + assert.equal(isExpectedConsoleError(errorStory, missingBridgeError), false); + assert.equal( + isExpectedConsoleError( + 'product-settings-pages--projects-cached-host-revalidation', + missingBridgeError, + ), + false, + ); + assert.equal(isExpectedConsoleError(errorStory, `${expectedError} unexpected detail`), false); + assert.equal(isExpectedConsoleError(errorStory, 'unexpected render failure'), false); +});