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..37d330c738 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,7 +71,7 @@ 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.code, 'copilot_classic_pat_unsupported'); assert.equal(imported.result.message.includes('ghp_classic_pat'), false); } 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__/oauth-result-copy.test.ts b/apps/desktop/src/main/__tests__/oauth-result-copy.test.ts new file mode 100644 index 0000000000..6e1c75e2d7 --- /dev/null +++ b/apps/desktop/src/main/__tests__/oauth-result-copy.test.ts @@ -0,0 +1,60 @@ +/* + * 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.", + ); +}); 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..f68a808474 100644 --- a/apps/desktop/src/main/__tests__/permission-center-copy.test.ts +++ b/apps/desktop/src/main/__tests__/permission-center-copy.test.ts @@ -25,3 +25,29 @@ 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 = getPermissionCenterCopy('zh-CN'); + const en = getPermissionCenterCopy('en'); + assert.equal(zh.reasons['missing platform credentials'], '未配置平台凭据'); + assert.equal(en.reasons['missing platform credentials'], 'Platform credentials are not configured'); + assert.equal(zh.reasons.cu_executor_recovering, 'maka-cu executor 正在启动或恢复。'); + assert.equal(en.reasons.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 6c452d5922..58f7ed0898 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 @@ -52,6 +52,7 @@ function attachmentReadHandler( ): Handler { const handlers = new Map(); registerRuntimeHostArtifactsIpc({ + uiLocale: () => 'zh-CN' as const, ipcMain: { handle: (channel, handler) => handlers.set(channel, handler as Handler), }, @@ -121,6 +122,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..fe7e199457 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 @@ -175,6 +175,7 @@ test('does not project or open remote Runtime Host file paths', async () => { assert.deepEqual(projected.backups.map(({ path }) => path), ['']); assert.deepEqual(opened, { ok: false, + code: 'remote_host_owned', message: 'Memory files are owned by the remote Runtime Host', }); }); 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..48dcb768df --- /dev/null +++ b/apps/desktop/src/main/__tests__/settings-memory-copy.test.ts @@ -0,0 +1,50 @@ +/* + * 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 { + 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', () => { + assert.equal( + memoryResultMessage({ code: 'future_host_code' }, getMemorySettingsCopy('en'), 'fallback'), + 'fallback', + ); +}); 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..4d54a92a78 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; @@ -243,7 +208,7 @@ function botCapability( }; const configuration: CapabilityConfigurationSignal = hasConfig ? { state: 'present', source: 'settings' } - : { state: 'missing', source: 'settings', reason: '未配置平台凭据' }; + : { state: 'missing', source: 'settings', reason: 'missing platform credentials' }; const runtimeProbe = runtimeProbeFromBotReadiness( status.readiness, channel.readinessUpdatedAt, @@ -274,7 +239,7 @@ function botCapability( } 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 +247,12 @@ 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); + } catch { + 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)); @@ -314,8 +275,8 @@ function mediaPermissionSnapshot( checkedAt: now, ...actions, }; - } catch (error) { - return unknownPermission(id, now, generalizedReason(error), platform === 'darwin'); + } catch { + return unknownPermission(id, now, platform === 'darwin'); } } @@ -328,9 +289,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 +301,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: 'no Electron API for per-target Apple Events TCC status', 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 +332,6 @@ function unsupportedPermission(id: OsPermissionId, now: number, reason: string): function unknownPermission( id: OsPermissionId, now: number, - reason: string, canOpenSettings: boolean, ): OsPermissionSnapshot { return { @@ -375,12 +339,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..21bcc4854a 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,8 @@ 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', + message: 'GitHub Copilot does not accept classic PATs.', }, }; } @@ -66,7 +66,8 @@ export async function importGitHubCopilotLocalCredential( result: { ok: false, reason: 'token_exchange_failed', - message: '当前 GitHub 凭据类型不受支持;请使用兼容 OAuth 登录或 fine-grained PAT。', + code: 'copilot_credential_type_unsupported', + message: 'Unsupported GitHub credential type.', }, }; } @@ -79,7 +80,8 @@ export async function importGitHubCopilotLocalCredential( result: { ok: false, reason: 'token_exchange_failed', - message: '未找到可导入的 GitHub 凭据;请先使用 gh 登录或配置兼容凭据。', + code: 'copilot_local_credential_missing', + message: 'No importable GitHub credential found.', }, }; } 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 4032293ef4..b012daa815 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; @@ -130,7 +132,7 @@ export function registerRuntimeHostArtifactsIpc( if (!artifact) return { ok: false, reason: "not_found" }; if (artifact.status === "deleted") return { ok: false, reason: "deleted" }; 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) { @@ -250,3 +252,9 @@ async function materializeArtifact( throw error; } } + +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 7caee1cf13..d30e6e91aa 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1064,6 +1064,8 @@ 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, not the + // desktop UI locale; localization tracked under the locale issue. 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"); @@ -1496,6 +1498,7 @@ function registerHostClientIpc( }); registerRuntimeHostRendererIpc({ ipcMain: scopedIpc, client }); registerRuntimeHostArtifactsIpc({ + uiLocale: () => desktopLocale.current(), ipcMain: scopedIpc, client, mainWindowController, @@ -1532,6 +1535,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..45de4a1174 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 @@ -53,7 +53,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 +69,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 +108,10 @@ async function selectAccountDefaultIfMissing( } } -function actionFailure(message: string) { - return { ok: false as const, reason: 'token_exchange_failed' as const, message }; +function actionFailure(code: string) { + return { ok: false as const, reason: 'token_exchange_failed' as const, code, message: code }; } -function storageFailure(message: string) { - return { ok: false as const, reason: 'storage_failed' as const, message }; +function storageFailure(code: string) { + return { ok: false as const, reason: 'storage_failed' as const, code, message: 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..2474cfa9f7 100644 --- a/apps/desktop/src/main/runtime-host-memory-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-memory-ipc-main.ts @@ -54,6 +54,7 @@ type LocalMemoryMutationResult = readonly ok: false; readonly state: LocalMemoryState; readonly reason: string; + readonly code: string; readonly message: string; }; @@ -99,6 +100,7 @@ export function registerRuntimeHostMemoryIpc( return { ok: false as const, state, + code: "no_backup", message: "No Memory backup is available", }; return restoreBackup(deps, backup.kind); @@ -108,6 +110,7 @@ export function registerRuntimeHostMemoryIpc( return { ok: false as const, state: await getMemoryState(deps), + code: "invalid_backup_kind", message: "Invalid Memory backup kind", }; } @@ -140,12 +143,20 @@ 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", + message: "No Memory backup is available", + }; }); 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", + message: "Invalid Memory backup kind", + }; }); } @@ -479,13 +490,14 @@ async function restoreBackup( kind: MemoryBackupKind, ): Promise< | { ok: true; state: LocalMemoryState } - | { ok: false; state: LocalMemoryState; message: string } + | { ok: false; state: LocalMemoryState; code: string; message: string } > { const state = await deps.client.queryMemory({ kind: "state" }); if (state.kind !== "state") { return { ok: false, state: await getMemoryState(deps), + code: "memory_unavailable", message: "Memory is unavailable", }; } @@ -494,6 +506,7 @@ async function restoreBackup( return { ok: false, state: await getMemoryState(deps), + code: "backup_not_found", message: "Memory backup not found", }; } @@ -505,7 +518,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, message: result.message }; } async function openMemoryPath( @@ -514,10 +527,11 @@ async function openMemoryPath( "workspaceRoot" | "allowLocalPaths" | "openPath" >, fileName: string, -): Promise<{ ok: true } | { ok: false; message: string }> { +): Promise<{ ok: true } | { ok: false; code: string; message: string }> { if (deps.allowLocalPaths === false) { return { ok: false, + code: "remote_host_owned", message: "Memory files are owned by the remote Runtime Host", }; } @@ -529,15 +543,20 @@ async function openMemoryPath( if (!isPathInside(directory, path) || !(await lstat(path)).isFile()) { return { ok: false, + code: "not_regular_file", message: "Memory path is not an allowed 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", + message: "The system could not open the Memory file", + } : { ok: true }; } catch { - return { ok: false, message: "Memory file not found" }; + return { ok: false, code: "file_not_found", message: "Memory file not found" }; } } @@ -600,6 +619,7 @@ async function mutationFailure( ok: false, state: await getMemoryState(deps), reason, + code: reason, message: memoryReasonMessage(reason), }; } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 70b940e6b6..d7761c364f 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1508,13 +1508,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; message: string }>; + restoreBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true; state: LocalMemoryState } | { ok: false; state: LocalMemoryState; code: string; message: 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; message: string }>; + openLatestBackup(host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; code: string; message: string }>; + openBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; code: string; message: 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 bcb2babbf9..225f1fcb46 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2943,10 +2943,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; message: 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; message: string }> { return invokeSelectedRuntimeHost(host, 'memory:restoreBackup', kind); }, setEnabled(enabled: boolean, host?: DesktopRuntimeHostRef): Promise { @@ -2955,13 +2955,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; message: 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; message: 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; message: string }> { return invokeSelectedRuntimeHost(host, 'memory:openBackup', kind); }, }, 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/provider-panel-shared.ts b/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts index b30f2d1a6f..f77888bfd1 100644 --- a/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts +++ b/apps/desktop/src/renderer/features/connection-settings/provider-panel-shared.ts @@ -25,7 +25,7 @@ import { cleanErrorMessage } from '../../application/contracts/connection-error- export type CredentialPresenceStatus = boolean | 'loading' | 'error'; -export function providerPanelActionErrorMessage(error: unknown, locale: UiLocale = 'zh-CN'): string { +export function providerPanelActionErrorMessage(error: unknown, locale: UiLocale): string { const shared = getProviderSettingsCopy(locale).shared; // Electron wraps ipcMain.handle rejections as "Error invoking remote method // '': Error: ". Classify the original message, not the @@ -34,8 +34,10 @@ export function providerPanelActionErrorMessage(error: unknown, locale: UiLocale const cleaned = redactSecrets(cleanErrorMessage(error)).trim(); const known = (shared.lastTest as Readonly>)[cleaned.toLowerCase()]; if (known) return known; - // Main-process handlers throw display-ready Chinese copy; keep it instead - // of flattening it into a coarser classification or the generic fallback. + // Main-process handlers still throw display-ready Chinese copy; keep it for + // zh only instead of flattening it into a coarser classification. en never + // sees raw CJK here — it falls through to the classifier and the fallback. + // Full removal waits on producer code-ification (locale roadmap W2b). if (locale === 'zh-CN' && /[\u3400-\u9fff]/.test(cleaned)) return cleaned; if (/connection_stale|Unable to delete Connection: connection_stale/i.test(cleaned)) { if (locale === 'zh-CN') return '连接状态已更新,请刷新列表后再删除。'; @@ -59,7 +61,7 @@ export interface ConnectionTestTroubleshootingCopy { export function connectionTestFailureFallback( result: ConnectionTestResult, copy: ConnectionTestTroubleshootingCopy, - locale: UiLocale = 'zh-CN', + locale: UiLocale, ): string { const shared = getProviderSettingsCopy(locale).shared; if (result.statusCode === 429) return shared.rateLimit; @@ -77,14 +79,14 @@ export function connectionTestFailureFallback( export function connectionTestFailureMessage( result: ConnectionTestResult, copy: ConnectionTestTroubleshootingCopy, - locale: UiLocale = 'zh-CN', + locale: UiLocale, ): string { const fallback = connectionTestFailureFallback(result, copy, locale); if (!result.errorMessage) return fallback; return generalizedErrorMessageForLocale(new Error(result.errorMessage), fallback, locale); } -export function connectionLastTestMessageDisplay(message: string | undefined, locale: UiLocale = 'zh-CN'): string | undefined { +export function connectionLastTestMessageDisplay(message: string | undefined, locale: UiLocale): string | undefined { if (!message) return undefined; const trimmed = message.trim(); if (!trimmed) return undefined; diff --git a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts index da6a223f72..0a7774b502 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,6 +17,7 @@ * under the License. */ +import { generalizedErrorMessageForLocale, redactSecrets } from '@maka/core/redaction'; import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; type WidenCopy = T extends string @@ -249,6 +250,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: '本机未启用该账号登录方式;可改用导入兼容凭据,或由管理员启用后重试。', + }, }, oauthSection: { signedIn: '已登录', codexDescription: '使用 ChatGPT Plus / Pro 账号添加连接。', xaiDescription: '使用 SuperGrok / X Premium 账号添加连接。', @@ -417,6 +432,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: { @@ -588,6 +617,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.', @@ -618,3 +661,38 @@ 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; + // Catalog uses exact code keys; the index-signature view maps unknown wire + // codes (host version skew) to undefined without a cast. + const codes: Readonly> = copy.resultCodes; + const mapped = (code && codes[code]) || (reason && codes[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/permission-center-copy.ts b/apps/desktop/src/renderer/locales/permission-center-copy.ts index 91df593854..92953a7a25 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'; @@ -94,6 +96,11 @@ export type PermissionCenterCopy = { /** macOS drag-to-grant onboarding (accessibility / screen recording). */ dragGrant: string; dragGranting: string; + reasons: Record, 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 = { @@ -143,6 +150,40 @@ const PERMISSION_CENTER_COPY = { requiredPermissions: '所需系统权限', requiredPermissionsAria: (label) => `${label}所需系统权限列表`, guidance: '处理建议', guidanceAria: (label) => `${label}处理建议列表`, auditSection: '审计记录', noAudit: '暂无审计记录', auditAria: (label) => `${label}审计记录列表`, impact: '影响功能', opening: '打开中…', openSettings: '前往系统设置', requesting: '请求中…', request: '请求授权', dragGrant: '引导授权', dragGranting: '引导中…', + reasons: { + disabled: '该能力当前已关闭。', + 'missing platform credentials': '未配置平台凭据', + 'macOS TCC only': '仅 macOS TCC 权限适用', + 'no Electron API for per-target Apple Events TCC status': 'Electron 暂不支持读取逐 App 的 Apple Events 授权状态', + cu_artifact_missing: '未找到通过完整性检查的 Computer Use 执行器 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: '权限探测失败', + }, + 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: { @@ -190,6 +231,40 @@ const PERMISSION_CENTER_COPY = { requiredPermissions: '所需系統權限', requiredPermissionsAria: (label) => `${label}所需系統權限列表`, guidance: '處理建議', guidanceAria: (label) => `${label}處理建議列表`, auditSection: '審計記錄', noAudit: '暫無審計記錄', auditAria: (label) => `${label}審計記錄列表`, impact: '影響功能', opening: '開啟中…', openSettings: '前往系統設定', requesting: '請求中…', request: '請求授權', dragGrant: '引導授權', dragGranting: '引導中…', + reasons: { + disabled: '此能力目前已關閉。', + 'missing platform credentials': '未設定平台憑據', + 'macOS TCC only': '僅 macOS TCC 權限適用', + 'no Electron API for per-target Apple Events TCC status': 'Electron 暫不支援讀取逐 App 的 Apple Events 授權狀態', + cu_artifact_missing: '找不到通過完整性檢查的 Computer Use 執行器 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: '權限探測失敗', + }, + 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: { @@ -237,6 +312,40 @@ const PERMISSION_CENTER_COPY = { requiredPermissions: 'Required system permissions', requiredPermissionsAria: (label) => `${label} required system permissions`, guidance: 'Suggested actions', guidanceAria: (label) => `${label} suggested actions`, 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…', + reasons: { + disabled: 'This capability is turned off.', + 'missing platform credentials': 'Platform credentials are not configured', + 'macOS TCC only': 'Only macOS TCC permissions apply', + 'no Electron API for per-target Apple Events TCC status': 'Electron cannot read per-app Apple Events authorization status', + cu_artifact_missing: 'No Computer Use executor artifact passed the 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', + }, + 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-memory-copy.ts b/apps/desktop/src/renderer/locales/settings-memory-copy.ts index 39dfd21856..212dec2da7 100644 --- a/apps/desktop/src/renderer/locales/settings-memory-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-memory-copy.ts @@ -45,9 +45,18 @@ type MemoryTextKey = | 'entryRestoreFailed' | 'promptCopied' | 'promptCopiedDetail' | 'restoreDraftAction' | 'archiveDraftAction' | 'restoreAction' | 'archiveAction'; +export type MemoryResultCode = + | 'no_backup' | 'invalid_backup_kind' | 'memory_unavailable' | 'backup_not_found' + | 'remote_host_owned' | 'not_regular_file' | 'open_failed' | 'file_not_found' + | 'disabled' | 'incognito_active' | 'safe_mode' | 'oversize' + | 'revision_conflict' | 'backup_revision_conflict' | 'invalid_state' + | 'invalid_content' | 'invalid_scope' | 'not_found' | 'not_pending' + | 'upload_not_found' | 'upload_incomplete' | 'upload_conflict'; + export type MemorySettingsCopy = { intlLocale: string; text: Record; + results: Record; origins: Record['origin'], string>; entryStatuses: Record; backupKinds: Record['kind'], string>; @@ -92,19 +101,70 @@ const enText = { const SETTINGS_MEMORY_COPY = { 'zh-CN': makeCopy('zh-CN', zhText, { + 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, { + 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, { + 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 { +export function memoryResultMessage( + result: { code?: string }, + copy: MemorySettingsCopy, + fallback: string, +): string { + const results: Readonly> = copy.results; + return (result.code && results[result.code]) || fallback; +} + +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'; diff --git a/apps/desktop/src/renderer/locales/settings-test-result-copy.ts b/apps/desktop/src/renderer/locales/settings-test-result-copy.ts index ee5ce9b90d..5e0f4f005e 100644 --- a/apps/desktop/src/renderer/locales/settings-test-result-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-test-result-copy.ts @@ -163,9 +163,7 @@ export function settingsTestResultMessage( case "bot_connection_failed": return copy.bot.connectionFailed; default: - return locale === "en" && result.message.trim() - ? result.message - : copy.bot.connectionFailed; + return copy.bot.connectionFailed; } } 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/permission-center-page.tsx b/apps/desktop/src/renderer/settings/permission-center-page.tsx index ec4c6517d7..e0cdf1bf2a 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, @@ -421,10 +425,10 @@ function CapabilityRow(props: { const { copy, locale } = props; const readinessCopy = copy.readiness[capability.readiness]; const capabilityLabel = localizedCapabilityLabel(capability, locale); - const featureReason = localizedSnapshotText(capability.feature.reason, locale); - const configurationReason = localizedSnapshotText(capability.configuration.reason, locale); - const runtimeReason = localizedSnapshotText(capability.runtimeProbe.reason, locale); - const guidance = localizedCapabilityGuidance(capability, locale, copy); + const featureReason = capabilityReasonText(capability.feature.reason, capability, copy); + const configurationReason = capabilityReasonText(capability.configuration.reason, capability, copy); + const runtimeReason = capabilityReasonText(capability.runtimeProbe.reason, capability, copy); + const guidance = localizedCapabilityGuidance(capability, copy); const layers: Array<{ label: string; value: string; reason?: string }> = [ { @@ -589,7 +593,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); const showRequest = snapshot.canRequest && snapshot.status !== 'granted'; const showOpenSettings = snapshot.canOpenSettings && snapshot.status !== 'granted'; @@ -704,17 +708,45 @@ function localizedCapabilityLabel(capability: CapabilitySnapshot, locale: UiLoca return capability.label; } -function localizedSnapshotText(value: string | undefined, locale: UiLocale): string | undefined { - if (!value || (locale !== 'zh-CN' && /[\u3400-\u9fff]/u.test(value))) return undefined; - return value; +function capabilityReasonText( + reason: string | undefined, + capability: CapabilitySnapshot, + copy: PermissionCenterCopy, +): string | undefined { + 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); + } + if (isCapabilityReasonCode(reason) && reason !== 'cu_backend_status') { + return copy.reasons[reason]; + } + // Bot capabilities pass bridge status reasons through as machine codes; the + // bot settings page owns their full copy, this summary shows the generic line. + return copy.reasonFallback; } function localizedCapabilityGuidance( capability: CapabilitySnapshot, - locale: UiLocale, copy: PermissionCenterCopy, ): readonly string[] { - return capability.guidance.filter((item) => locale === 'zh-CN' || !/[\u3400-\u9fff]/u.test(item)); + return capability.guidance + .map((code) => capabilityReasonText(code, capability, copy)) + .filter((item): item is string => Boolean(item)); +} + +function osPermissionReasonText( + snapshot: OsPermissionSnapshot, + copy: PermissionCenterCopy, +): string | undefined { + const text = snapshot.reason + ? isCapabilityReasonCode(snapshot.reason) && snapshot.reason !== 'cu_backend_status' + ? copy.reasons[snapshot.reason] + : copy.reasonFallback + : undefined; + return text; } 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/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..d399135b6f 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). @@ -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/packages/core/src/capabilities.ts b/packages/core/src/capabilities.ts index bdc33a1fd1..fbd355e39d 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', + 'missing platform credentials', + 'macOS TCC only', + 'no Electron API for per-target Apple Events TCC status', + '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; diff --git a/packages/core/src/health.ts b/packages/core/src/health.ts index e88582cc56..dca5eb3a7b 100644 --- a/packages/core/src/health.ts +++ b/packages/core/src/health.ts @@ -366,6 +366,11 @@ function capabilityDetail(capability: CapabilitySnapshot): string | undefined { function userVisibleCapabilityReason(reason: string | undefined): string | undefined { const raw = reason?.trim(); if (!raw) return undefined; + // Locale anchor contract: this module is the zh source of truth and + // settings-health-copy maps en from these exact strings (see + // englishSignalMessage/englishSignalDetail). New codes below intentionally + // add zh branches only — en keeps the generic line there until #4524 + // code-ifies the health presenter. Do not reword without updating both. switch (raw) { case 'disabled': return '该能力当前已关闭。'; @@ -375,8 +380,34 @@ function userVisibleCapabilityReason(reason: string | undefined): string | undef return '仅 macOS 系统权限可探测。'; case 'no Electron API for per-target Apple Events TCC status': return '系统未提供可直接读取的授权状态。'; + case 'cu_artifact_missing': + return '未找到通过完整性检查的 Computer Use 执行器 artifact。'; + case 'cu_backend_status': + return 'maka-cu artifact 已通过本地完整性检查。'; + case 'cu_backend_unavailable': + return 'Computer Use 后端当前不可用。'; + case 'cu_executor_undistributable': + return '未找到通过完整性检查且可分发的 maka-cu executor。'; + case 'cu_executor_stopped': + return 'maka-cu executor 已停止。'; + case 'cu_executor_start_failed': + return 'maka-cu executor 启动失败或已退出。'; + case 'cu_executor_recovering': + return 'maka-cu executor 正在启动或恢复。'; + case 'cu_executor_ready': + return 'maka-cu executor 已就绪。'; + case 'cu_executor_lazy_start': + return 'maka-cu 已可用,将在首次调用时启动。'; + case 'activity_recorder_partial': + return 'Daily Review 已聚合本地任务 / 工具 / 模型活动;当前不包含屏幕与应用级录制'; + case 'activity_recorder_probe_hint': + return '打开 Daily Review 可查看本地活动聚合结果'; + case 'memory_partial': + return '本地 MEMORY.md 已可见;自动抽取/写入仍需用户确认'; + case 'memory_no_probe': + return '透明本地记忆为文件读写能力,不做后台探测'; default: - return /[\u3400-\u9fff]/.test(raw) ? raw : '状态详情请见对应设置页。'; + return '状态详情请见对应设置页。'; } } diff --git a/packages/core/src/oauth-subscription.ts b/packages/core/src/oauth-subscription.ts index c8f271b92d..9459597cce 100644 --- a/packages/core/src/oauth-subscription.ts +++ b/packages/core/src/oauth-subscription.ts @@ -38,7 +38,13 @@ */ export type SubscriptionActionResult = | { ok: true } - | { ok: false; reason: SubscriptionActionFailureReason; message: string }; + | { + ok: false; + reason: SubscriptionActionFailureReason; + /** Stable machine code the presenter maps to per-locale copy. */ + code?: string; + message: string; + }; export type SubscriptionActionFailureReason = | 'authorization_pending' // no startAuthorization called yet 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