diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 4570aa0895..e4930fdc14 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -311,7 +311,7 @@ "nonTriviaTokens": 92 }, "src/renderer/app-shell-chat-actions.ts": { - "importDeclarations": 9, + "importDeclarations": 8, "bridgePaths": { "window.maka.newTasks.create": 1, "window.maka.sessions.remove": 1, @@ -337,11 +337,11 @@ "@maka/core/session-name": 1, "@maka/ui": 1 }, - "importSpecifiers": 16, + "importSpecifiers": 14, "nonTriviaTokens": 4086 }, "src/renderer/app-shell-chrome-actions.tsx": { - "importDeclarations": 5, + "importDeclarations": 4, "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { @@ -357,11 +357,11 @@ "@maka/ui": 1, "@maka/ui/icons": 1 }, - "importSpecifiers": 8, + "importSpecifiers": 7, "nonTriviaTokens": 408 }, "src/renderer/app-shell-command-actions.ts": { - "importDeclarations": 7, + "importDeclarations": 5, "bridgePaths": { "window.maka.connections.setDefault": 1, "window.maka.connections.test": 1, @@ -388,11 +388,11 @@ "./locales/shell-copy.js": 1, "react": 1 }, - "importSpecifiers": 11, + "importSpecifiers": 9, "nonTriviaTokens": 2307 }, "src/renderer/app-shell-context-compaction.ts": { - "importDeclarations": 1, + "importDeclarations": 0, "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": {}, @@ -402,11 +402,11 @@ "dependencyPaths": { "./locales/shell-copy.js": 1 }, - "importSpecifiers": 1, + "importSpecifiers": 0, "nonTriviaTokens": 612 }, "src/renderer/app-shell-copy.ts": { - "importDeclarations": 2, + "importDeclarations": 1, "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": {}, @@ -417,7 +417,7 @@ "./locales/shell-copy.js": 1, "@maka/core/redaction": 1 }, - "importSpecifiers": 2, + "importSpecifiers": 1, "nonTriviaTokens": 504 }, "src/renderer/app-shell-detail-panel.tsx": { @@ -453,7 +453,7 @@ "nonTriviaTokens": 672 }, "src/renderer/app-shell-effects.ts": { - "importDeclarations": 12, + "importDeclarations": 11, "bridgePaths": { "window.maka.app.info": 1, "window.maka.appWindow.subscribeCommand": 1, @@ -502,11 +502,11 @@ "@maka/core/session-event-health": 1, "react": 1 }, - "importSpecifiers": 20, + "importSpecifiers": 19, "nonTriviaTokens": 3816 }, "src/renderer/app-shell-overlays.tsx": { - "importDeclarations": 8, + "importDeclarations": 7, "bridgePaths": {}, "environmentCapabilities": { "window": 2, @@ -534,11 +534,11 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 12, + "importSpecifiers": 11, "nonTriviaTokens": 977 }, "src/renderer/app-shell-project-actions.ts": { - "importDeclarations": 5, + "importDeclarations": 4, "bridgePaths": { "window.maka.app.openPath": 4, "window.maka.app.resolveProjectGitInfo": 1, @@ -564,11 +564,11 @@ "./open-path": 1, "./session-workspace-errors": 1 }, - "importSpecifiers": 9, + "importSpecifiers": 7, "nonTriviaTokens": 2284 }, "src/renderer/app-shell-revision-actions.ts": { - "importDeclarations": 6, + "importDeclarations": 4, "bridgePaths": { "window.maka.sessions.abandonSessionCopy": 2, "window.maka.sessions.reviseBeforeTurn": 1 @@ -588,11 +588,11 @@ "./session-workspace-errors.js": 1, "@maka/core/session": 1 }, - "importSpecifiers": 10, + "importSpecifiers": 8, "nonTriviaTokens": 2316 }, "src/renderer/app-shell-session-events.ts": { - "importDeclarations": 3, + "importDeclarations": 2, "bridgePaths": {}, "environmentCapabilities": { "requestAnimationFrame": 1, @@ -610,11 +610,11 @@ "./model-connection-errors.js": 1, "@maka/ui": 1 }, - "importSpecifiers": 12, + "importSpecifiers": 11, "nonTriviaTokens": 2974 }, "src/renderer/app-shell-session-start-actions.ts": { - "importDeclarations": 3, + "importDeclarations": 2, "bridgePaths": { "window.maka.newTasks.create": 1, "window.maka.onboarding.setMilestone": 1 @@ -631,7 +631,7 @@ "./model-connection-errors.js": 1, "./session-workspace-errors.js": 1 }, - "importSpecifiers": 7, + "importSpecifiers": 5, "nonTriviaTokens": 650 }, "src/renderer/app-shell-session-ui-state.ts": { @@ -649,7 +649,7 @@ "nonTriviaTokens": 7 }, "src/renderer/app-shell-stop-action.ts": { - "importDeclarations": 2, + "importDeclarations": 0, "bridgePaths": { "window.maka.sessions.stop": 1 }, @@ -664,11 +664,11 @@ "./locales/conversation-copy.js": 1, "./locales/shell-copy.js": 1 }, - "importSpecifiers": 2, + "importSpecifiers": 0, "nonTriviaTokens": 302 }, "src/renderer/app-shell-turn-actions.ts": { - "importDeclarations": 4, + "importDeclarations": 2, "bridgePaths": { "window.maka.sessions.branchFromTurn": 1, "window.maka.sessions.regenerateTurn": 1 @@ -686,7 +686,7 @@ "./session-copy-attempt.js": 1, "./session-workspace-errors.js": 1 }, - "importSpecifiers": 5, + "importSpecifiers": 3, "nonTriviaTokens": 650 }, "src/renderer/app-shell-turn-view-model.ts": { @@ -713,7 +713,7 @@ "nonTriviaTokens": 1408 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 92, + "importDeclarations": 81, "bridgePaths": { "window.maka.app.installUpdate": 1, "window.maka.app.retryUpdateDownload": 1, @@ -895,7 +895,7 @@ "@maka/ui/icons": 1, "react": 1 }, - "importSpecifiers": 147, + "importSpecifiers": 124, "nonTriviaTokens": 15588 }, "src/renderer/use-app-shell-composer-quotes.ts": { @@ -916,7 +916,7 @@ "nonTriviaTokens": 360 }, "src/renderer/use-app-shell-session-list.ts": { - "importDeclarations": 10, + "importDeclarations": 8, "bridgePaths": { "window.maka.sessions.list": 1 }, @@ -941,7 +941,7 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 13, + "importSpecifiers": 11, "nonTriviaTokens": 581 }, "src/renderer/use-app-shell-session-ui-reads.ts": { @@ -1620,7 +1620,9 @@ "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], - "dependencyPaths": {} + "dependencyPaths": { + "@maka/core/ui-locale": 1 + } }, "src/renderer/locales/settings-daily-review-copy.ts": { "bridgePaths": {}, diff --git a/apps/desktop/scripts/check-renderer-architecture.mjs b/apps/desktop/scripts/check-renderer-architecture.mjs index 606952781f..f83be21cb5 100644 --- a/apps/desktop/scripts/check-renderer-architecture.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.mjs @@ -1249,6 +1249,8 @@ export function analyzeRendererSource(source, file = 'fixture.ts') { const moduleReexports = []; let importDeclarations = 0; let importSpecifiers = 0; + const importDeclarationsBySource = {}; + const importSpecifiersBySource = {}; let unresolvedDependencies = 0; function recordBridgePath(path) { @@ -1261,9 +1263,14 @@ export function analyzeRendererSource(source, file = 'fixture.ts') { function visit(node, parent) { if (node.type === 'ImportDeclaration' && !typeOnlySourceDependency(node)) { + const specifierCount = node.specifiers.filter((specifier) => specifier.importKind !== 'type').length; importDeclarations += 1; - importSpecifiers += node.specifiers.filter((specifier) => specifier.importKind !== 'type').length; + importSpecifiers += specifierCount; const source = staticString(node.source); + if (source !== undefined) { + importDeclarationsBySource[source] = (importDeclarationsBySource[source] ?? 0) + 1; + importSpecifiersBySource[source] = (importSpecifiersBySource[source] ?? 0) + specifierCount; + } if (source !== undefined && node.importKind !== 'type') { for (const specifier of node.specifiers) { if (specifier.importKind === 'type') continue; @@ -1544,7 +1551,9 @@ export function analyzeRendererSource(source, file = 'fixture.ts') { environmentCapabilities: sortedObject(environmentCapabilities), hookCalls: sortedObject(hookCalls), importDeclarations, + importDeclarationsBySource: sortedObject(importDeclarationsBySource), importSpecifiers, + importSpecifiersBySource: sortedObject(importSpecifiersBySource), lifecycleMethods: sortedObject(lifecycleMethods), moduleImports: moduleImports.map((entry, index) => ({ ...entry, @@ -1651,11 +1660,20 @@ function capabilityDebtMetrics(analysis) { }; } -function debtMetrics(analysis) { +// Imports from a sanctioned target (a validated copy catalog, or for AppShell +// files a shell / public application / public feature module) are the edges +// the migration wants a legacy file to take on; they cost no import debt, so +// a legacy file is never pushed to inline a helper it could import. +function debtMetrics(analysis, isSanctionedSource = () => false) { + const unsanctioned = (bySource) => + Object.entries(bySource).reduce( + (total, [source, count]) => (isSanctionedSource(source) ? total : total + count), + 0, + ); return { - importDeclarations: analysis.importDeclarations, + importDeclarations: unsanctioned(analysis.importDeclarationsBySource), ...capabilityDebtMetrics(analysis), - importSpecifiers: analysis.importSpecifiers, + importSpecifiers: unsanctioned(analysis.importSpecifiersBySource), nonTriviaTokens: analysis.nonTriviaTokens, }; } @@ -2247,15 +2265,15 @@ function validateMetric(path, metric, actual, expected, violations) { } } -function validateDebtFile(desktopRoot, path, expected, violations, metrics = ROOT_DEBT_METRICS) { - const absolutePath = resolve(desktopRoot, path); - if (!existsSync(absolutePath)) { +function validateDebtFile(desktopRoot, path, expected, violations, section) { + if (!existsSync(resolve(desktopRoot, path))) { violations.push(`${path}: debt ledger entry points to a missing file`); return; } - const analysis = analyzeRendererSource(readFileSync(absolutePath, 'utf8'), path); - for (const metric of metrics) { - validateMetric(path, metric, analysis[metric], expected[metric], violations); + const rootSection = section === 'legacyAppShell' || section === 'rootDebt'; + const actual = rootSection ? debtForPath(desktopRoot, path, section) : capabilityDebtForPath(desktopRoot, path); + for (const metric of rootSection ? ROOT_DEBT_METRICS : CAPABILITY_DEBT_METRICS) { + validateMetric(path, metric, actual[metric], expected[metric], violations); } } @@ -2282,7 +2300,7 @@ function validateLegacyLedger(desktopRoot, config, violations) { } for (const [path, expected] of Object.entries(config.legacyAppShell.files)) { - validateDebtFile(desktopRoot, path, expected, violations); + validateDebtFile(desktopRoot, path, expected, violations, 'legacyAppShell'); } const actualClosure = collectRootDependencyClosure(desktopRoot, expectedFiles, violations, 'AppShell'); const expectedClosure = Object.keys(config.legacyAppShell.closure).sort(); @@ -2299,10 +2317,10 @@ function validateLegacyLedger(desktopRoot, config, violations) { if (!isRootClosureDebtSource(path) || DECLARATION_FILE.test(path)) { violations.push(`${path}: AppShell closure debt must point to a non-owner Desktop source`); } - validateDebtFile(desktopRoot, path, expected, violations, CAPABILITY_DEBT_METRICS); + validateDebtFile(desktopRoot, path, expected, violations, 'legacyAppShellClosure'); } for (const [path, expected] of Object.entries(config.rootDebt)) { - validateDebtFile(desktopRoot, path, expected, violations); + validateDebtFile(desktopRoot, path, expected, violations, 'rootDebt'); } const appShellDebtPaths = new Set([...expectedFiles, ...expectedClosure]); const rootDebtPaths = Object.keys(config.rootDebt).sort(); @@ -2322,7 +2340,7 @@ function validateLegacyLedger(desktopRoot, config, violations) { if (!isRootClosureDebtSource(path) || DECLARATION_FILE.test(path)) { violations.push(`${path}: renderer root closure debt must point to a non-owner Desktop source`); } - validateDebtFile(desktopRoot, path, expected, violations, CAPABILITY_DEBT_METRICS); + validateDebtFile(desktopRoot, path, expected, violations, 'rootDebtClosure'); } const ownedPaths = new Map(); @@ -2981,7 +2999,7 @@ function validateCopyCatalog(desktopRoot, relativePath) { if (metricTotal(value) > 0) return `catalog carries ${metric} (${describeMetric(value)})`; } const forbidden = inspection.runtimeDependencies.find( - (dependency) => dependency.startsWith('.') || dependency.startsWith(DESKTOP_SELF_PREFIX), + (dependency) => !isBarePackageSpecifier(dependency), ); if (forbidden !== undefined) { return `runtime import ${forbidden} is not a bare package specifier`; @@ -3008,9 +3026,17 @@ function validateCopyCatalogFiles(desktopRoot, violations) { } } +function isBarePackageSpecifier(dependency) { + return !dependency.startsWith('.') && !dependency.startsWith(DESKTOP_SELF_PREFIX); +} + function withoutSanctionedDependencies(desktopRoot, section, importerPath, dependencyPaths) { + // A validated catalog is already restricted to bare package runtime imports; + // pricing them again would push copy helpers back inline into the catalog. + const importerIsCatalog = isValidatedCopyCatalog(desktopRoot, importerPath); const filtered = {}; for (const [dependency, count] of Object.entries(dependencyPaths)) { + if (importerIsCatalog && isBarePackageSpecifier(dependency)) continue; if (isSanctionedDependencyTarget(desktopRoot, section, importerPath, dependency)) continue; filtered[dependency] = count; } @@ -3043,8 +3069,11 @@ function allowsMigrationDependency({ base, current, dependency, desktopRoot, pat return swapZones.includes(zoneFor(normalizePath(relative(desktopRoot, target))).kind); } -function debtForPath(desktopRoot, path) { - return debtMetrics(analyzeRendererSource(readFileSync(resolve(desktopRoot, path), 'utf8'), path)); +function debtForPath(desktopRoot, path, section) { + return debtMetrics( + analyzeRendererSource(readFileSync(resolve(desktopRoot, path), 'utf8'), path), + (source) => isSanctionedDependencyTarget(desktopRoot, section, path, source), + ); } function capabilityDebtForPath(desktopRoot, path) { @@ -3091,7 +3120,7 @@ export function generateArchitectureConfig(desktopRoot, config) { const imports = collectLegacyImportEdges(desktopRoot); const rootDebt = {}; for (const path of Object.keys(config.rootDebt ?? {}).sort()) { - if (existsSync(resolve(desktopRoot, path))) rootDebt[path] = debtForPath(desktopRoot, path); + if (existsSync(resolve(desktopRoot, path))) rootDebt[path] = debtForPath(desktopRoot, path, 'rootDebt'); } const appShellDebtPaths = new Set([...appShellFiles, ...closureFiles]); const rootDebtClosureFiles = collectRootDependencyClosure( @@ -3111,7 +3140,7 @@ export function generateArchitectureConfig(desktopRoot, config) { legacyPlatformImports: imports.platform, controllerOwners: controllerOwnersOf(config), legacyAppShell: { - files: Object.fromEntries(appShellFiles.map((path) => [path, debtForPath(desktopRoot, path)])), + files: Object.fromEntries(appShellFiles.map((path) => [path, debtForPath(desktopRoot, path, 'legacyAppShell')])), closure: Object.fromEntries(closureFiles.map((path) => [path, capabilityDebtForPath(desktopRoot, path)])), }, rootDebt, diff --git a/apps/desktop/scripts/check-renderer-architecture.test.mjs b/apps/desktop/scripts/check-renderer-architecture.test.mjs index 46d6e81b7c..db2c11d8cf 100644 --- a/apps/desktop/scripts/check-renderer-architecture.test.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.test.mjs @@ -2229,9 +2229,15 @@ describe('renderer architecture checker fixtures', () => { return ; } `; - const currentDebt = debtForSource(appShellSource, appShellPath); + const currentDebt = { + ...debtForSource(appShellSource, appShellPath), + importDeclarations: 0, + importSpecifiers: 0, + }; const baseDebt = { ...currentDebt, + importDeclarations: 2, + importSpecifiers: 2, dependencyPaths: { './legacy-alpha-owner.js': 1, './legacy-session-owner.js': 1, @@ -2271,6 +2277,37 @@ describe('renderer architecture checker fixtures', () => { ); }); + for (const [targetPath, pricing] of [ + ['src/renderer/application/contracts/fixture-diagnostics.ts', 'free'], + ['src/renderer/application/sessions/fixture-service.ts', 'priced'], + ]) { + it(`${pricing === 'free' ? 'exempts' : 'prices'} AppShell import specifiers from ${targetPath}`, async () => { + const specifier = `./${targetPath.slice('src/renderer/'.length).replace(/\.ts$/u, '.js')}`; + await withDesktopFixture( + { + [TRANSITIVE_APP_SHELL_PATH]: ` + import { reportFixture } from '${specifier}'; + export const AppShell = reportFixture('shell'); + `, + [targetPath]: `export function reportFixture(scope: string): string { return scope; }`, + }, + (desktopRoot) => { + const currentConfig = generateArchitectureConfig(desktopRoot, transitiveAppShellSeedConfig()); + const baseConfig = structuredClone(currentConfig); + Object.assign(baseConfig.legacyAppShell.files[TRANSITIVE_APP_SHELL_PATH], { + importDeclarations: 0, + importSpecifiers: 0, + dependencyPaths: {}, + }); + const priced = violationsFor(desktopRoot, currentConfig, baseConfig).some((violation) => + violation.startsWith(`${TRANSITIVE_APP_SHELL_PATH}: importSpecifiers debt increased`), + ); + assert.equal(priced, pricing === 'priced'); + }, + ); + }); + } + it('rejects replacing legacy AppShell debt with a feature private import', async () => { const appShellPath = 'src/renderer/app-shell.tsx'; const appShellSource = ` @@ -2831,6 +2868,29 @@ describe('validated copy catalog dependencies', () => { ); }); + it('does not price a catalog\'s own bare package runtime import', async () => { + await withDesktopFixture( + transitiveAppShellFiles( + ` + import { FIXTURE_COPY } from './locales/fixture-copy.js'; + export const legacySessionHelper = FIXTURE_COPY.en.notice; + `, + { + [CATALOG_PATH]: catalogSource(` + import { lookupCopy } from '@maka/core/ui-locale'; + export const noticeFor = (code: string) => lookupCopy(FIXTURE_COPY.en, code); + `), + }, + ), + (desktopRoot) => { + const currentConfig = generateArchitectureConfig(desktopRoot, catalogSeedConfig()); + const baseConfig = structuredClone(currentConfig); + baseConfig.legacyAppShell.closure[CATALOG_PATH].dependencyPaths = {}; + assert.deepEqual(violationsFor(desktopRoot, currentConfig, baseConfig), []); + }, + ); + }); + const INVALID_CATALOGS = [ ['a hook call', catalogSource(` import { useState } from 'react'; diff --git a/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts b/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts index 83d5744dc2..97c3035534 100644 --- a/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts +++ b/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts @@ -351,8 +351,8 @@ describe('BotOnboardingService', () => { test.advance(5_000); const connected = await test.service.poll(started.sessionId); assert.equal(connected.state, 'connected'); - assert.match(connected.warning ?? '', /凭据已保存,但连接未建立/); - assert.match(connected.warning ?? '', /鉴权失败/); + assert.equal(connected.warningCode, 'saved_not_connected'); + assert.match(connected.warningDetail ?? '', /鉴权失败/); assert.equal(JSON.stringify(connected).includes('private-client-secret'), false); }); @@ -376,7 +376,7 @@ describe('BotOnboardingService', () => { test.advance(5_000); const connected = await test.service.poll(started.sessionId); assert.equal(connected.state, 'connected'); - assert.equal(connected.warning, undefined); + assert.equal(connected.warningCode, undefined); }); it('invalidates an older session when the same provider starts again', async () => { diff --git a/apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts b/apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts index f905cba983..390c5d8001 100644 --- a/apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts +++ b/apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts @@ -101,7 +101,7 @@ describe("settings IPC helpers", () => { const result = toSettingsTestResult("telegram", { ok: true, identity: { id: "42", username: "maka_bot", displayName: "Maka" }, - hint: "ready", + hintCode: "telegram_send_start", }); assert.equal(result.ok, true); @@ -115,7 +115,7 @@ describe("settings IPC helpers", () => { username: "maka_bot", displayName: "Maka", }); - assert.equal(result.details?.hint, "ready"); + assert.equal(result.details?.hintCode, "telegram_send_start"); }); test("redacts and generalizes bot test errors before returning SettingsTestResult", () => { diff --git a/apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts b/apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts index 9f86548727..8dc5c859f5 100644 --- a/apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts +++ b/apps/desktop/src/main/__tests__/settings-test-result-copy.test.ts @@ -20,6 +20,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { settingsTestResultMessage } from "../../renderer/locales/settings-test-result-copy.js"; +import { toSettingsTestResult } from "../settings-ipc-helpers.js"; test("missing proxy credentials have actionable bilingual copy", () => { const result = { @@ -37,3 +38,17 @@ test("missing proxy credentials have actionable bilingual copy", () => { "Proxy authentication is enabled. Enter a proxy password before testing.", ); }); + + +test("renders a bot-test error code per locale without content sniffing", () => { + const result = toSettingsTestResult("dingtalk", { + ok: false, + errorCode: "dingtalk_credentials_missing", + }); + assert.equal(result.code, "bot_app_credentials_missing"); + assert.equal(settingsTestResultMessage(result, "zh-CN"), "请填写 App ID 和 App Secret 后再测试。"); + assert.equal( + settingsTestResultMessage(result, "en"), + "Enter an App ID and App Secret before testing the connection.", + ); +}); diff --git a/apps/desktop/src/main/bot-incoming-main.ts b/apps/desktop/src/main/bot-incoming-main.ts index 545518a885..2b1a521f73 100644 --- a/apps/desktop/src/main/bot-incoming-main.ts +++ b/apps/desktop/src/main/bot-incoming-main.ts @@ -183,6 +183,7 @@ export function createBotIncomingMainService(deps: BotIncomingMainServiceDeps): } } + // bot-channel notices follow the bot audience language; localization tracked under #2672 async function sendTransientBotNotice(message: BotIncomingMessage, text: string, ttlMs: number): Promise { if (closed) return; await deps.botRegistry.sendMessage( @@ -424,6 +425,7 @@ export function createBotIncomingMainService(deps: BotIncomingMainServiceDeps): return { handleBotIncomingMessage, invalidateSessionBindings, close }; } +// bot-channel notices follow the bot audience language; localization tracked under #2672 function botReply(result: BotSessionTurnResult): string { if (result.kind === 'suspended') { return '这条请求需要在 Maka 桌面端审批后才能继续。'; diff --git a/apps/desktop/src/main/bot-onboarding-main.ts b/apps/desktop/src/main/bot-onboarding-main.ts index 6ed3ff7535..6d75d2c821 100644 --- a/apps/desktop/src/main/bot-onboarding-main.ts +++ b/apps/desktop/src/main/bot-onboarding-main.ts @@ -23,12 +23,17 @@ import type { AppSettings, UpdateAppSettingsInput } from '@maka/core/settings'; import type { BotChannelSettings } from '@maka/core/bot-chat-settings'; import type { BotOnboardingBrand, + BotOnboardingErrorCode, BotOnboardingProvider, BotOnboardingSnapshot, BotOnboardingStartInput, BotOnboardingState, } from '@maka/core/bot-onboarding'; -import { generalizedErrorMessageChinese, redactSecrets } from '@maka/core/redaction'; +import { + classifyGeneralizedError, + generalizedErrorMessageForLocale, + redactSecrets, +} from '@maka/core/redaction'; import { isBotOnboardingBrand, isBotOnboardingProvider } from '@maka/core/bot-onboarding'; import type { BotRegistry } from '@maka/runtime/bots'; import { proxiedFetch } from '@maka/runtime/bots'; @@ -101,7 +106,9 @@ interface BotOnboardingSession { pollFailures: number; identity?: { id?: string; displayName?: string }; error?: string; - warning?: string; + errorCode?: BotOnboardingErrorCode; + warningCode?: 'saved_not_connected'; + warningDetail?: string; } export interface BotOnboardingServiceDeps { @@ -195,6 +202,7 @@ export class BotOnboardingService { } session.state = 'error'; session.error = safeProviderError(error); + session.errorCode = providerErrorCode(error); throw new Error(session.error); } } @@ -279,7 +287,9 @@ export class BotOnboardingService { // bridge is not running, surface an honest warning instead of lying // about a healthy connection. Onboarding still succeeds — the user can // retry the connection later from settings. - session.warning = this.connectionWarning(session.provider); + const warning = this.connectionWarning(session.provider); + session.warningCode = warning?.code; + session.warningDetail = warning?.detail; break; } return this.snapshot(session); @@ -304,6 +314,7 @@ export class BotOnboardingService { } session.state = 'error'; session.error = safeProviderError(error); + session.errorCode = providerErrorCode(error); return this.snapshot(session); } } @@ -386,13 +397,13 @@ export class BotOnboardingService { * honest, secret-free notice when the credentials were saved but the bridge * is not actually running, or `undefined` when the connection is healthy. */ - private connectionWarning(provider: BotOnboardingProvider): string | undefined { + private connectionWarning( + provider: BotOnboardingProvider, + ): { code: 'saved_not_connected'; detail?: string } | undefined { const status = this.readChannelStatus(provider); if (status.running) return undefined; - const reason = connectionFailureReason(status.reason); - return reason - ? `凭据已保存,但连接未建立:${reason},可稍后在设置中重试。` - : '凭据已保存,但连接未建立,可稍后在设置中重试。'; + const detail = connectionFailureReason(status.reason); + return { code: 'saved_not_connected', ...(detail ? { detail } : {}) }; } private getSession(rawSessionId: unknown): BotOnboardingSession { @@ -451,7 +462,9 @@ export class BotOnboardingService { canOpenInBrowser: Boolean(session.verificationUrl), ...(session.identity ? { identity: { ...session.identity } } : {}), ...(session.error ? { error: session.error } : {}), - ...(session.warning ? { warning: session.warning } : {}), + ...(session.errorCode ? { errorCode: session.errorCode } : {}), + ...(session.warningCode ? { warningCode: session.warningCode } : {}), + ...(session.warningDetail ? { warningDetail: session.warningDetail } : {}), }; } } @@ -478,12 +491,17 @@ function clampPollInterval(value: number): number { return Math.min(Math.max(Math.round(value), 1_000), MAX_POLL_INTERVAL_MS); } +function providerErrorCode(error: unknown): BotOnboardingErrorCode { + if (error instanceof Error && error.name === 'AbortError') return 'cancelled'; + return classifyGeneralizedError(error) ?? 'unavailable'; +} + function safeProviderError(error: unknown): string { if (error instanceof Error && error.name === 'AbortError') return '扫码接入已取消。'; // PR1197 review (P2-11): route through the shared categorizer so 超时 / 鉴权失败 / // 网络错误 survive as specific Chinese copy instead of collapsing to one generic // line. The helper redacts secrets before returning. - return generalizedErrorMessageChinese(error, '扫码接入暂时不可用,请稍后重试。'); + return generalizedErrorMessageForLocale(error, '扫码接入暂时不可用,请稍后重试。', 'zh-CN'); } /** diff --git a/apps/desktop/src/main/settings-bots-ipc-main.ts b/apps/desktop/src/main/settings-bots-ipc-main.ts index 5910c3b117..acc1cc6768 100644 --- a/apps/desktop/src/main/settings-bots-ipc-main.ts +++ b/apps/desktop/src/main/settings-bots-ipc-main.ts @@ -90,12 +90,12 @@ export function registerSettingsBotsIpc( : 'configured') as BotReadinessState, readinessReason: result.ok ? undefined - : botTestErrorMessage(provider, result.error), + : botTestErrorMessage(provider, result), readinessUpdatedAt: Date.now(), lastTestAt: Date.now(), lastError: result.ok ? undefined - : botTestErrorMessage(provider, result.error), + : botTestErrorMessage(provider, result), }; await deps.settingsStore.update({ botChat: { channels: { [provider]: channelPatch } }, diff --git a/apps/desktop/src/main/settings-ipc-helpers.ts b/apps/desktop/src/main/settings-ipc-helpers.ts index df8952d38b..a2b27d05da 100644 --- a/apps/desktop/src/main/settings-ipc-helpers.ts +++ b/apps/desktop/src/main/settings-ipc-helpers.ts @@ -33,7 +33,7 @@ import { maskSensitive, type TestProxyResult, } from "@maka/core/settings/network-settings"; -import type { BotTestResult } from '@maka/runtime/bots'; +import type { BotTestErrorCode, BotTestResult } from '@maka/runtime/bots'; import { collectPersonalizationWarnings } from '@maka/runtime/system-prompt/personalization-prompt'; import { getTavilyCredentialSource } from "./web-search/credentials.js"; @@ -195,9 +195,7 @@ export function toSettingsTestResult( provider: BotProvider, result: BotTestResult, ): SettingsTestResult { - const failure = result.ok - ? undefined - : botTestFailure(provider, result.error); + const failure = result.ok ? undefined : botTestFailure(provider, result); return { ok: result.ok, code: result.ok ? "bot_credentials_valid" : failure?.code, @@ -208,50 +206,54 @@ export function toSettingsTestResult( details: { ...(result.identity ? { identity: result.identity } : {}), ...(result.capabilities ? { capabilities: result.capabilities } : {}), - ...(result.hint ? { hint: result.hint } : {}), + ...(result.hintCode ? { hintCode: result.hintCode } : {}), }, }; } export function botTestErrorMessage( provider: BotProvider, - error: unknown, + result: Pick, ): string { - return botTestFailure(provider, error).message; + return botTestFailure(provider, result).message; } +const MISSING_CREDENTIAL_CODES: ReadonlySet = new Set([ + "slack_tokens_missing", + "feishu_credentials_missing", + "wecom_credentials_missing", + "dingtalk_credentials_missing", + "qq_credentials_missing", + "wechat_ilink_credentials_incomplete", +]); + function botTestFailure( provider: BotProvider, - error: unknown, + result: Pick, ): { code: SettingsTestResultCode; message: string } { const label = botDisplayLabel(provider); - const raw = redactSecrets( - error instanceof Error ? error.message : String(error ?? ""), - ).trim(); - const lower = raw.toLowerCase(); - - if (lower.includes("bot token is required")) { + if (result.errorCode === "token_missing") { return { code: "bot_token_missing", message: `${label} requires a Bot Token.`, }; } - if (lower.includes("invalid bot token")) { + if (result.errorCode === "token_invalid") { return { code: "bot_token_invalid", message: `${label} rejected the Bot Token.`, }; } - if ( - provider === "feishu" && - /appid|app_id|appsecret|app_secret|required/.test(lower) - ) { + if (result.errorCode && MISSING_CREDENTIAL_CODES.has(result.errorCode)) { return { code: "bot_app_credentials_missing", - message: "Feishu requires an App ID and App Secret.", + message: `${label} requires an App ID and App Secret.`, }; } + const raw = redactSecrets( + typeof result.error === "string" ? result.error : "", + ).trim(); const classified = generalizedErrorMessage(raw, ""); return { code: "bot_connection_failed", diff --git a/apps/desktop/src/renderer/locales/settings-bot-copy.ts b/apps/desktop/src/renderer/locales/settings-bot-copy.ts index b34d2fbca1..0e68f6b4f0 100644 --- a/apps/desktop/src/renderer/locales/settings-bot-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-bot-copy.ts @@ -20,7 +20,7 @@ import type { StatusSemantic } from '@maka/ui'; import type { BotProvider, BotReadinessState } from '@maka/core/bot-chat-settings'; -import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +import { type UiCatalog, type UiLocale, lookupCopy } from '@maka/core/ui-locale'; type WidenCopy = T extends string ? string @@ -54,6 +54,36 @@ const zhCopy = { unavailable: '该平台当前不可作为远程接入渠道', stopped: '监听已停止', detailsInLogs: '运行态详情请见日志', polling: '长轮询', gateway: '事件通道', webhook: 'Webhook', none: '无', }, + testHints: { + slack_socket_ready: '凭据有效,Socket Mode 连接可用。', + wechat_mp_callback_required: '凭据有效;消息收发还需要公众号服务器配置和回调验证。', + telegram_send_start: '发送 /start 给机器人后可在运行态接收消息。', + wecom_runtime_authority: '已保存凭据;企业微信 AI 机器人的连接状态以运行态长连接为准。', + dingtalk_stream_required: '凭据有效;接收消息需要 outgoing 机器人或 Stream 模式配置。', + qq_gateway_required: '凭据有效;接收消息需要 QQ Gateway WebSocket 接入。', + wechat_bridge_local_only: '微信本地桥接只允许访问本机 wechat-bridge,不能指向远端 URL。', + wechat_bridge_start_required: '先在本机启动 wechat-bridge,并确认 WeChat 已登录;发送能力需要 wxp_act_ 激活码。', + wechat_ilink_login_required: '请先完成微信扫码登录,保存 iLink bot token 与 base URL。', + wechat_ilink_polling: '扫码登录凭据已保存;运行态会通过 iLink 长轮询接收消息。', + wechat_bridge_remote_url: '微信扫码登录只允许访问本机 wechat-bridge,不能指向远端 URL。', + wechat_bridge_unreachable: '先启动本机 wechat-bridge,并确认它暴露了 iLink 兼容的 /api/weixin/qrcode 或 /qrcode 接口。', + }, + statusReasons: { + codes: { + 'rate-limited': '发送被节流(429);上一条回复可能截断,可以请用户再发一次', + 'polling-timeout': '事件轮询超时;可能是网络抖动或代理失效', + 'send-failed': '上一次发送失败,详细原因 Telegram 没有返回', + 'get-me-failed': '凭据探测失败;请检查 Bot Token', + }, + withCode: { + gatewayBot: (code: string) => `获取 Gateway 失败(HTTP ${code})`, + gatewayClosed: (code: string) => `Gateway 连接关闭(${code});正在重连`, + connectionsOpen: (code: string) => `Stream 订阅打开失败(HTTP ${code})`, + streamClosed: (code: string) => `Stream 连接关闭(${code});正在重连`, + sendFailed: (code: string) => `发送失败(HTTP ${code})`, + getAppAccessToken: (code: string) => `获取 access_token 失败(HTTP ${code})`, + }, + }, overview: { loadFailed: '远程接入状态载入失败', reload: '重新载入', active: '正在使用', sortHint: '按需要处理、最近活动排序', empty: '还没有正在使用的渠道', emptyHelp: '从下方选择一个消息平台开始配置。', more: '接入更多渠道', choose: '选择平台开始配置', @@ -88,7 +118,7 @@ const zhCopy = { wecomSecretPlaceholder: 'AI 应用 Secret', wecomSecretAria: '企业微信 Secret', qqId: 'QQ 应用编号', allowedUsersLabel: (count: number, max: number) => `允许的用户 ID(${count} / ${max})`, allowedUsersPlaceholder: '每行一个用户 ID,留空表示不限\n例如:123456789', allowedUsersHelp: 'Telegram 用户 ID 是 64 位整数;填入后只接收列表里这些 ID 的来信,其它人发的消息会被静默忽略(不会回弹任何提示)。', - limitReached: '(已达到上限)', invalidUsers: (values: string) => `下列不是数字 ID,可能是用户名之类的输入,匹配不到任何人:${values}`, moreInvalid: (count: number) => ` 等 ${count} 项`, + limitReached: '(已达到上限)', invalidUsers: (values: string) => `下列不是数字 ID,可能是用户名之类的输入,匹配不到任何人:${values}`, moreInvalid: (count: number) => ` 等 ${count} 项`, idSeparator: '、', }, onboarding: { providers: { @@ -103,6 +133,12 @@ const zhCopy = { generatingAria: '正在生成二维码', privacy: '凭据仅保存在本机,不会传给 renderer 或 Maka 云端。', openBrowser: '无法扫码?在浏览器中打开', done: '完成', regenerate: '重新生成', refreshQr: '刷新二维码', cancel: '取消', generating: '正在生成安全二维码…', connecting: '授权完成,正在保存凭据并启动连接…', connected: (name: string) => `${name} 已连接`, connectedWarning: '凭据已保存,但连接尚未成功启动。', expired: '二维码已过期,请重新生成', denied: '授权已取消,请重新生成二维码', cancelled: '扫码接入已取消', failed: '扫码接入失败,请重试', preparing: '准备扫码接入…', + savedNotConnected: '凭据已保存,但连接未建立,可稍后在设置中重试。', + savedNotConnectedDetail: (detail: string) => `凭据已保存,但连接未建立:${detail},可稍后在设置中重试。`, + errors: { + cancelled: '扫码接入已取消。', timeout: '请求超时', rate_limited: '触发模型速率限制', auth_failed: '鉴权失败', + provider_error: '模型服务返回错误', network_error: '网络错误', unavailable: '扫码接入暂时不可用,请稍后重试。', + }, }, wechat: { token: '微信 Bot Token', tokenPlaceholder: '本机 wechat-bridge Bearer Token', collapseAdvanced: '收起高级设置', expandAdvanced: '高级设置(公众号 / 本机 bridge 地址)', @@ -140,6 +176,36 @@ const zhTwCopy = { unavailable: '該平台目前不可作為遠端串接管道', stopped: '監聽已停止', detailsInLogs: '執行狀態詳情請見記錄', polling: '長輪詢', gateway: '事件通道', webhook: 'Webhook', none: '無', }, + testHints: { + slack_socket_ready: '憑證有效,Socket Mode 連線可用。', + wechat_mp_callback_required: '憑證有效;訊息收發還需要公眾號伺服器設定和回呼驗證。', + telegram_send_start: '傳送 /start 給機器人後可在執行狀態接收訊息。', + wecom_runtime_authority: '已儲存憑證;企業微信 AI 機器人的連線狀態以執行狀態長連線為準。', + dingtalk_stream_required: '憑證有效;接收訊息需要 outgoing 機器人或 Stream 模式設定。', + qq_gateway_required: '憑證有效;接收訊息需要 QQ Gateway WebSocket 接入。', + wechat_bridge_local_only: '微信本機橋接只允許存取本機 wechat-bridge,不能指向遠端 URL。', + wechat_bridge_start_required: '先在本機啟動 wechat-bridge,並確認 WeChat 已登入;傳送能力需要 wxp_act_ 啟用碼。', + wechat_ilink_login_required: '請先完成微信掃碼登入,儲存 iLink bot token 與 base URL。', + wechat_ilink_polling: '掃碼登入憑證已儲存;執行狀態會透過 iLink 長輪詢接收訊息。', + wechat_bridge_remote_url: '微信掃碼登入只允許存取本機 wechat-bridge,不能指向遠端 URL。', + wechat_bridge_unreachable: '先啟動本機 wechat-bridge,並確認它暴露了 iLink 相容的 /api/weixin/qrcode 或 /qrcode 介面。', + }, + statusReasons: { + codes: { + 'rate-limited': '傳送被節流(429);上一則回覆可能截斷,可以請使用者再發一次', + 'polling-timeout': '事件輪詢逾時;可能是網路抖動或代理失效', + 'send-failed': '上一次傳送失敗,詳細原因 Telegram 沒有回傳', + 'get-me-failed': '憑證探測失敗;請檢查 Bot Token', + }, + withCode: { + gatewayBot: (code: string) => `取得 Gateway 失敗(HTTP ${code})`, + gatewayClosed: (code: string) => `Gateway 連線關閉(${code});正在重連`, + connectionsOpen: (code: string) => `Stream 訂閱開啟失敗(HTTP ${code})`, + streamClosed: (code: string) => `Stream 連線關閉(${code});正在重連`, + sendFailed: (code: string) => `傳送失敗(HTTP ${code})`, + getAppAccessToken: (code: string) => `取得 access_token 失敗(HTTP ${code})`, + }, + }, overview: { loadFailed: '遠端串接狀態載入失敗', reload: '重新載入', active: '正在使用', sortHint: '按需要處理、最近活動排序', empty: '還沒有正在使用的管道', emptyHelp: '從下方選擇一個訊息平台開始設定。', more: '串接更多管道', choose: '選擇平台開始設定', @@ -174,7 +240,7 @@ const zhTwCopy = { wecomSecretPlaceholder: 'AI 應用 Secret', wecomSecretAria: '企業微信 Secret', qqId: 'QQ 應用編號', allowedUsersLabel: (count: number, max: number) => `允許的使用者 ID(${count} / ${max})`, allowedUsersPlaceholder: '每行一個使用者 ID,留空表示不限\n例如:123456789', allowedUsersHelp: 'Telegram 使用者 ID 是 64 位整數;填入後只接收列表裡這些 ID 的來信,其它人發的訊息會被靜默忽略(不會回彈任何提示)。', - limitReached: '(已達到上限)', invalidUsers: (values: string) => `下列不是數字 ID,可能是使用者名稱之類的輸入,符合不到任何人:${values}`, moreInvalid: (count: number) => ` 等 ${count} 項`, + limitReached: '(已達到上限)', invalidUsers: (values: string) => `下列不是數字 ID,可能是使用者名稱之類的輸入,符合不到任何人:${values}`, moreInvalid: (count: number) => ` 等 ${count} 項`, idSeparator: '、', }, onboarding: { providers: { @@ -189,6 +255,12 @@ const zhTwCopy = { generatingAria: '正在生成二維碼', privacy: '憑證僅儲存在本機,不會傳給 renderer 或 Maka 雲端。', openBrowser: '無法掃碼?在瀏覽器中開啟', done: '完成', regenerate: '重新生成', refreshQr: '重新整理二維碼', cancel: '取消', generating: '正在生成安全二維碼…', connecting: '授權完成,正在儲存憑證並啟動連線…', connected: (name: string) => `${name} 已連線`, connectedWarning: '憑證已儲存,但連線尚未成功啟動。', expired: '二維碼已過期,請重新生成', denied: '授權已取消,請重新生成二維碼', cancelled: '掃碼串接已取消', failed: '掃碼串接失敗,請重試', preparing: '準備掃碼串接…', + savedNotConnected: '憑證已儲存,但連線未建立,可稍後在設定中重試。', + savedNotConnectedDetail: (detail: string) => `憑證已儲存,但連線未建立:${detail},可稍後在設定中重試。`, + errors: { + cancelled: '掃碼串接已取消。', timeout: '請求逾時', rate_limited: '已達模型速率限制', auth_failed: '驗證失敗', + provider_error: '模型服務傳回錯誤', network_error: '網路錯誤', unavailable: '掃碼串接暫時無法使用,請稍後重試。', + }, }, wechat: { token: '微信 Bot Token', tokenPlaceholder: '本機 wechat-bridge Bearer Token', collapseAdvanced: '收起進階設定', expandAdvanced: '進階設定(公眾號 / 本機 bridge 地址)', @@ -217,14 +289,50 @@ const enCopy: BotSettingsCopy = { }, planned: { label: 'Unavailable', detail: 'This platform is not saved as a remote-access channel or scheduled-task delivery target.', tone: 'neutral' }, status: { disabled: 'Turned off', noToken: 'Waiting for Bot Token', missingFeishuCredentials: 'Waiting for Feishu App ID or App Secret', feishuDomainRequired: 'Feishu credentials are valid; add the event subscription domain', feishuEventsNotConnected: 'Feishu credentials are valid; connect the event callback', unavailable: 'This platform cannot currently be used for remote access', stopped: 'Listener stopped', detailsInLogs: 'See logs for runtime details', polling: 'Long polling', gateway: 'Event channel', webhook: 'Webhook', none: 'None' }, + testHints: { + slack_socket_ready: 'Credentials are valid and the Socket Mode connection is available.', + wechat_mp_callback_required: 'Credentials are valid; messaging still needs the Official Account server configuration and callback verification.', + telegram_send_start: 'Send /start to the bot to receive messages at runtime.', + wecom_runtime_authority: 'Credentials saved; the WeCom AI bot connection state follows the live persistent connection.', + dingtalk_stream_required: 'Credentials are valid; receiving messages needs an outgoing bot or Stream mode configuration.', + qq_gateway_required: 'Credentials are valid; receiving messages needs the QQ Gateway WebSocket.', + wechat_bridge_local_only: 'The local WeChat bridge only accepts the local wechat-bridge, not a remote URL.', + wechat_bridge_start_required: 'Start the local wechat-bridge and make sure WeChat is signed in; sending needs a wxp_act_ activation code.', + wechat_ilink_login_required: 'Complete WeChat QR sign-in first to save the iLink bot token and base URL.', + wechat_ilink_polling: 'QR sign-in credentials saved; the runtime receives messages over iLink long polling.', + wechat_bridge_remote_url: 'WeChat QR sign-in only accepts the local wechat-bridge, not a remote URL.', + wechat_bridge_unreachable: 'Start the local wechat-bridge first and make sure it exposes an iLink-compatible /api/weixin/qrcode or /qrcode endpoint.', + }, + statusReasons: { + codes: { + 'rate-limited': 'Sending was throttled (429); the last reply may be truncated, so ask the user to resend', + 'polling-timeout': 'Event polling timed out; the network or proxy may be unstable', + 'send-failed': 'The last send failed; Telegram returned no details', + 'get-me-failed': 'Credential probe failed; check the Bot Token', + }, + withCode: { + gatewayBot: (code) => `Failed to fetch the Gateway (HTTP ${code})`, + gatewayClosed: (code) => `Gateway connection closed (${code}); reconnecting`, + connectionsOpen: (code) => `Failed to open the Stream subscription (HTTP ${code})`, + streamClosed: (code) => `Stream connection closed (${code}); reconnecting`, + sendFailed: (code) => `Send failed (HTTP ${code})`, + getAppAccessToken: (code) => `Failed to fetch access_token (HTTP ${code})`, + }, + }, overview: { loadFailed: 'Failed to load remote-access status', reload: 'Reload', active: 'In use', sortHint: 'Sorted by attention needed and recent activity', empty: 'No channels are in use', emptyHelp: 'Choose a messaging platform below to begin setup.', more: 'Connect more channels', choose: 'Choose a platform to begin setup', listening: 'Listening', manageAria: (name, status) => `Manage ${name}, ${status}`, connectAria: (name) => `Connect ${name}` }, page: { saveFailed: (name) => `Failed to save ${name}`, loadFailed: 'Failed to load remote-access status', refreshFailed: 'Failed to refresh remote-access status', credentialVerified: (name) => `${name} credentials verified`, credentialVerifiedDetail: 'The credential check passed.', credentialTestFailed: (name) => `${name} credential test failed`, credentialTestFailedDetail: 'Check the credentials and network settings, then try again.', testError: (name) => `${name} test error`, listening: (name) => `${name} is listening`, notListening: (name) => `${name} did not start listening`, startFailed: (name) => `Failed to start ${name}`, disconnectTitle: 'Disconnect WeChat?', disconnectDescription: 'This clears the saved local QR sign-in credentials. You will need to scan again to keep using WeChat.', disconnect: 'Disconnect', cancel: 'Cancel', disconnected: 'WeChat disconnected', credentialsCleared: 'Local linked-session credentials cleared.' }, detail: { - unavailableHint: 'This platform is not available and cannot be enabled.', scanFirstHint: 'Scan to connect before enabling this channel.', testFirstHint: 'Test and connect before enabling this channel.', back: 'Back to Remote access', configDocs: 'View setup guide', enableAria: (name) => `Enable ${name} channel`, listening: 'Listening for new messages', healthy: 'Connection healthy. No action needed.', actionsAria: (name) => `${name} channel actions`, quickBind: 'Quick connect', scanLogin: 'Scan to sign in', scanConnect: 'Scan to connect', disconnecting: 'Disconnecting…', disconnectWechat: 'Disconnect WeChat', bridgeQr: 'Local bridge QR code', testing: 'Testing…', test: 'Test connection', connecting: 'Connecting…', testAndConnect: 'Test and connect', restarting: 'Restarting…', restart: 'Restart listener', runtimeAria: (name) => `${name} runtime status`, identity: 'Identity', unknownIdentity: 'Unavailable', connectionType: 'Connection type', lastEvent: 'Last event', noneYet: 'None', lastTest: 'Last test', neverTested: 'Never tested', statusRefreshFailed: 'Failed to refresh runtime status', latestFailure: 'Latest failure', latestFailureDetail: 'Check the configuration, network, and runtime logs, then try again.', savedButNotConnected: 'Credentials were saved, but the connection did not start.', setupMethod: 'Connection method', connectionSettings: 'Connection settings', localCredentials: 'Credentials stay on this device', autosave: 'Saved automatically', setupAria: (name) => `${name} connection method`, quickRecommended: 'Quick setup (recommended)', manual: 'Manual setup', quickAria: (name) => `${name} quick setup`, quickWecomTitle: 'Scan to create and connect a bot', quickTitle: 'Scan to create an app and bot', quickWecomDetail: 'After an administrator confirms the scan, Maka saves the Bot ID and Secret and starts the persistent connection.', quickQqTitle: 'Scan with mobile QQ to create and bind a bot', quickQqDetail: 'After confirmation, QQ securely returns the AppID and AppSecret; Maka stores them locally and starts the Gateway.', telegramOfficialFlow: 'Telegram officially requires a Bot Token from @BotFather and does not provide an API that creates a bot by QR scan and returns its token.', quickDetail: 'After confirmation, Maka stores credentials in the main process and starts the message connection.', feishuRegionAria: 'Choose Feishu account region', feishu: 'Feishu', beginQuickBind: 'Start quick connect', scanWith: (name) => `Scan with ${name}`, planned: 'This platform is shown in the catalog only. It will not become an active channel or a scheduled-task delivery target.', credentialsSaved: (name) => `${name} credentials saved`, scanComplete: (name) => `${name} QR setup complete`, savedAndConnected: 'Credentials saved securely and connection started', proxy: 'Proxy URL', chinaRequired: '(required on networks in mainland China)', authOnly: '(Bot authentication only)', telegramProxyAria: 'Telegram proxy URL', telegramNotice: 'Enable TUN mode in your network tool and restart the app to complete Telegram Bot setup.', feishuCredentialId: 'Feishu credential ID', feishuSecret: 'Feishu App Secret', feishuDomain: 'Feishu domain', feishuOption: 'Feishu (feishu.cn)', discordProxyAria: 'Discord proxy URL', discordNotice: 'For Discord access from mainland China, the proxy above covers Bot authentication only. Message WebSockets require a system-level proxy. Enable TUN mode and restart the app.', dingtalkId: 'DingTalk app key', dingtalkSecret: 'DingTalk Client Secret', wecomBotPlaceholder: 'WeCom AI app Bot ID', wecomBotAria: 'WeCom Bot ID', wecomSecretPlaceholder: 'AI app Secret', wecomSecretAria: 'WeCom Secret', qqId: 'QQ app ID', allowedUsersLabel: (count, max) => `Allowed user IDs (${count} / ${max})`, allowedUsersPlaceholder: 'One user ID per line; leave empty to allow everyone\nExample: 123456789', allowedUsersHelp: 'Telegram user IDs are 64-bit integers. When set, only messages from these IDs are accepted; all others are silently ignored.', limitReached: '(limit reached)', invalidUsers: (values) => `These entries are not numeric IDs and may be usernames, so they will not match anyone: ${values}`, moreInvalid: (count) => ` and ${count} more`, + unavailableHint: 'This platform is not available and cannot be enabled.', scanFirstHint: 'Scan to connect before enabling this channel.', testFirstHint: 'Test and connect before enabling this channel.', back: 'Back to Remote access', configDocs: 'View setup guide', enableAria: (name) => `Enable ${name} channel`, listening: 'Listening for new messages', healthy: 'Connection healthy. No action needed.', actionsAria: (name) => `${name} channel actions`, quickBind: 'Quick connect', scanLogin: 'Scan to sign in', scanConnect: 'Scan to connect', disconnecting: 'Disconnecting…', disconnectWechat: 'Disconnect WeChat', bridgeQr: 'Local bridge QR code', testing: 'Testing…', test: 'Test connection', connecting: 'Connecting…', testAndConnect: 'Test and connect', restarting: 'Restarting…', restart: 'Restart listener', runtimeAria: (name) => `${name} runtime status`, identity: 'Identity', unknownIdentity: 'Unavailable', connectionType: 'Connection type', lastEvent: 'Last event', noneYet: 'None', lastTest: 'Last test', neverTested: 'Never tested', statusRefreshFailed: 'Failed to refresh runtime status', latestFailure: 'Latest failure', latestFailureDetail: 'Check the configuration, network, and runtime logs, then try again.', savedButNotConnected: 'Credentials were saved, but the connection did not start.', setupMethod: 'Connection method', connectionSettings: 'Connection settings', localCredentials: 'Credentials stay on this device', autosave: 'Saved automatically', setupAria: (name) => `${name} connection method`, quickRecommended: 'Quick setup (recommended)', manual: 'Manual setup', quickAria: (name) => `${name} quick setup`, quickWecomTitle: 'Scan to create and connect a bot', quickTitle: 'Scan to create an app and bot', quickWecomDetail: 'After an administrator confirms the scan, Maka saves the Bot ID and Secret and starts the persistent connection.', quickQqTitle: 'Scan with mobile QQ to create and bind a bot', quickQqDetail: 'After confirmation, QQ securely returns the AppID and AppSecret; Maka stores them locally and starts the Gateway.', telegramOfficialFlow: 'Telegram officially requires a Bot Token from @BotFather and does not provide an API that creates a bot by QR scan and returns its token.', quickDetail: 'After confirmation, Maka stores credentials in the main process and starts the message connection.', feishuRegionAria: 'Choose Feishu account region', feishu: 'Feishu', beginQuickBind: 'Start quick connect', scanWith: (name) => `Scan with ${name}`, planned: 'This platform is shown in the catalog only. It will not become an active channel or a scheduled-task delivery target.', credentialsSaved: (name) => `${name} credentials saved`, scanComplete: (name) => `${name} QR setup complete`, savedAndConnected: 'Credentials saved securely and connection started', proxy: 'Proxy URL', chinaRequired: '(required on networks in mainland China)', authOnly: '(Bot authentication only)', telegramProxyAria: 'Telegram proxy URL', telegramNotice: 'Enable TUN mode in your network tool and restart the app to complete Telegram Bot setup.', feishuCredentialId: 'Feishu credential ID', feishuSecret: 'Feishu App Secret', feishuDomain: 'Feishu domain', feishuOption: 'Feishu (feishu.cn)', discordProxyAria: 'Discord proxy URL', discordNotice: 'For Discord access from mainland China, the proxy above covers Bot authentication only. Message WebSockets require a system-level proxy. Enable TUN mode and restart the app.', dingtalkId: 'DingTalk app key', dingtalkSecret: 'DingTalk Client Secret', wecomBotPlaceholder: 'WeCom AI app Bot ID', wecomBotAria: 'WeCom Bot ID', wecomSecretPlaceholder: 'AI app Secret', wecomSecretAria: 'WeCom Secret', qqId: 'QQ app ID', allowedUsersLabel: (count, max) => `Allowed user IDs (${count} / ${max})`, allowedUsersPlaceholder: 'One user ID per line; leave empty to allow everyone\nExample: 123456789', allowedUsersHelp: 'Telegram user IDs are 64-bit integers. When set, only messages from these IDs are accepted; all others are silently ignored.', limitReached: '(limit reached)', invalidUsers: (values) => `These entries are not numeric IDs and may be usernames, so they will not match anyone: ${values}`, moreInvalid: (count) => ` and ${count} more`, idSeparator: ', ', }, onboarding: { providers: { dingtalk: { title: 'Set up DingTalk', ariaLabel: 'Set up DingTalk with a QR code', qrAlt: 'DingTalk setup QR code', subtitle: 'Scan in DingTalk to register the app', waiting: 'Scan with DingTalk and confirm authorization', scanned: 'Scanned. Complete confirmation in DingTalk.' }, feishu: { title: 'Set up Feishu', ariaLabel: 'Set up Feishu with a QR code', qrAlt: 'Feishu setup QR code', subtitle: 'Scan with Feishu to create and configure the bot', waiting: 'Scan with Feishu and confirm creation', scanned: 'Scanned. Complete confirmation in Feishu.' }, wecom: { title: 'Set up WeCom', ariaLabel: 'Set up WeCom with a QR code', qrAlt: 'WeCom setup QR code', subtitle: 'Quick setup creates and connects a WeCom bot', waiting: 'Open WeCom and scan to create the bot', scanned: 'Scanned. Complete confirmation in WeCom.' }, wechat: { title: 'Scan to sign in', ariaLabel: 'WeChat QR sign-in', qrAlt: 'WeChat sign-in QR code', subtitle: 'Scan with WeChat to connect', waiting: 'Scan with WeChat and confirm on your phone', scanned: 'Scanned. Complete confirmation in WeChat.' }, qq: { title: 'Set up QQ', ariaLabel: 'Set up QQ with a QR code', qrAlt: 'QQ setup QR code', subtitle: 'Scan with mobile QQ to create and bind a bot', waiting: 'Scan with mobile QQ and confirm binding', scanned: 'Scanned. Complete confirmation in QQ.' } }, lark: { title: 'Set up Lark', ariaLabel: 'Set up Lark with a QR code', qrAlt: 'Lark setup QR code', subtitle: 'Scan with Lark to create and configure the bot', waiting: 'Scan with Lark and confirm creation', scanned: 'Scanned. Complete confirmation in Lark.' }, connectedRefreshFailed: (message) => `Connected, but status refresh failed: ${message}`, close: (title) => `Close ${title}`, generatingAria: 'Generating QR code', privacy: 'Credentials stay on this device and are never sent to the renderer or Maka cloud.', openBrowser: 'Cannot scan? Open in browser', done: 'Done', regenerate: 'Generate again', refreshQr: 'Refresh QR code', cancel: 'Cancel', generating: 'Generating a secure QR code…', connecting: 'Authorization complete. Saving credentials and starting connection…', connected: (name) => `${name} connected`, connectedWarning: 'Credentials were saved, but the connection did not start.', expired: 'QR code expired. Generate a new one.', denied: 'Authorization cancelled. Generate a new QR code.', cancelled: 'QR setup cancelled', failed: 'QR setup failed. Try again.', preparing: 'Preparing QR setup…', + savedNotConnected: 'Credentials were saved, but the connection did not start. Retry from settings later.', + savedNotConnectedDetail: (detail) => `Credentials were saved, but the connection did not start: ${detail}. Retry from settings later.`, + errors: { + cancelled: 'QR setup was cancelled.', timeout: 'Request timed out', rate_limited: 'Rate limit exceeded', auth_failed: 'Authentication failed', + provider_error: 'Provider returned an error', network_error: 'Network error', unavailable: 'QR setup is temporarily unavailable. Try again later.', + }, }, wechat: { token: 'WeChat Bot Token', tokenPlaceholder: 'Local wechat-bridge Bearer Token', collapseAdvanced: 'Hide advanced settings', expandAdvanced: 'Advanced settings (Official Account / local bridge URL)', bridgeAddress: 'Local bridge URL', appId: 'Official Account App ID', appIdPlaceholder: 'WeChat Official Account App ID', appSecret: 'Official Account App Secret', appSecretPlaceholder: 'WeChat Official Account App Secret', advancedNotice: 'The local bridge defaults to http://127.0.0.1:18400. Official Account App ID and App Secret are used only for Official Account messaging; personal WeChat QR sign-in uses the local bridge.', readQrFailed: 'Could not read a QR code from the local wechat-bridge. Make sure the bridge is running.', title: 'WeChat QR sign-in', subtitle: 'Scan the QR code with WeChat and confirm signing in to the local wechat-bridge on your phone.', close: 'Close WeChat QR sign-in', generating: 'Generating QR code…', loggedIn: 'WeChat is signed in. Return to test the connection or restart the listener.', expired: 'QR code expired', expiredHint: 'Refresh the QR code and scan again to continue signing in.', refreshing: 'Refreshing…', refresh: 'Refresh QR code', qrAlt: 'WeChat sign-in QR code', waiting: 'Waiting for confirmation… Sign-in status refreshes every 3 seconds.', retrying: 'Retrying…', retry: 'Retry', bridgeGenerating: 'The bridge is generating a QR code', bridgeGeneratingHint: 'The QR code appears automatically once ready; you can also fetch it again.', fetching: 'Fetching…', fetchAgain: 'Fetch again' }, }; @@ -238,3 +346,33 @@ const BOT_SETTINGS_COPY = { export function getBotSettingsCopy(locale: UiLocale): BotSettingsCopy { return BOT_SETTINGS_COPY[locale]; } + +const BOT_STATUS_REASON_PATTERNS: ReadonlyArray<{ + pattern: RegExp; + key: keyof BotSettingsCopy['statusReasons']['withCode']; +}> = [ + { pattern: /^gateway-bot-(\d+)$/, key: 'gatewayBot' }, + { pattern: /^gateway-closed-(\d+)$/, key: 'gatewayClosed' }, + { pattern: /^connections-open-(\d+)$/, key: 'connectionsOpen' }, + { pattern: /^stream-closed-(\d+)$/, key: 'streamClosed' }, + { pattern: /^send-failed-(\d+)$/, key: 'sendFailed' }, + { pattern: /^getAppAccessToken-(\d+)$/, key: 'getAppAccessToken' }, +]; + +/** Localize a machine-readable bridge status reason such as `gateway-closed-4004`. */ +export function botStatusReasonMessage( + reason: string | undefined, + locale: UiLocale, +): string | undefined { + if (!reason) return undefined; + const copy = BOT_SETTINGS_COPY[locale].statusReasons; + const fixed = lookupCopy(copy.codes, reason); + if (fixed) return fixed; + for (const { pattern, key } of BOT_STATUS_REASON_PATTERNS) { + const match = pattern.exec(reason); + if (match) return copy.withCode[key](match[1]); + } + // Platform-supplied description ("Bad Request: chat not found", …): external + // error text passes through verbatim rather than being classified by content. + return reason; +} diff --git a/apps/desktop/src/renderer/settings/bot-chat-detail.tsx b/apps/desktop/src/renderer/settings/bot-chat-detail.tsx index ae9ee1aeaf..b78c67432a 100644 --- a/apps/desktop/src/renderer/settings/bot-chat-detail.tsx +++ b/apps/desktop/src/renderer/settings/bot-chat-detail.tsx @@ -53,7 +53,7 @@ import { botStatusDetail, type BotPendingActionName, } from './bot-chat-shared'; -import { getBotSettingsCopy, type BotSettingsCopy } from '../locales/settings-bot-copy'; +import { botStatusReasonMessage, getBotSettingsCopy, type BotSettingsCopy } from '../locales/settings-bot-copy'; import { SettingsPage, SettingsSection } from './settings-section'; import { dotForStatus } from '@maka/ui'; @@ -305,7 +305,7 @@ export function BotChatChannelDetail(props: { title={detailCopy.latestFailure} description={( - {locale === 'zh-CN' ? viewState.currentError : detailCopy.latestFailureDetail} + {botStatusReasonMessage(viewState.currentError, locale) ?? detailCopy.latestFailureDetail} )} /> )} @@ -412,10 +412,13 @@ export function BotChatChannelDetail(props: { // PR1197 review (P0-3): the bridge may have failed to start even // though credentials saved. Reflect that honestly instead of a // success toast that overstates the connection. - if (snapshot.warning) { + if (snapshot.warningCode) { + const onboardingCopy = getBotSettingsCopy(locale).onboarding; toast.warning( detailCopy.credentialsSaved(providerPresentation.label), - locale === 'zh-CN' ? snapshot.warning : detailCopy.savedButNotConnected, + snapshot.warningDetail + ? onboardingCopy.savedNotConnectedDetail(snapshot.warningDetail) + : onboardingCopy.savedNotConnected, ); return; } @@ -635,7 +638,7 @@ function BotAllowedUserIdsField(props: { if (!same) props.onChange(next); }; const warning = invalidEntries.length > 0 - ? `${copy.invalidUsers(invalidEntries.slice(0, 3).join(locale !== 'en' ? '、' : ', '))}${invalidEntries.length > 3 ? copy.moreInvalid(invalidEntries.length) : ''}` + ? `${copy.invalidUsers(invalidEntries.slice(0, 3).join(copy.idSeparator))}${invalidEntries.length > 3 ? copy.moreInvalid(invalidEntries.length) : ''}` : undefined; return ( diff --git a/apps/desktop/src/renderer/settings/bot-chat-overview.tsx b/apps/desktop/src/renderer/settings/bot-chat-overview.tsx index b2e248a006..249c17de72 100644 --- a/apps/desktop/src/renderer/settings/bot-chat-overview.tsx +++ b/apps/desktop/src/renderer/settings/bot-chat-overview.tsx @@ -26,7 +26,7 @@ import { EmptyState, Item, StatusDot } from '@astryxdesign/core'; import { Button, RelativeTime, useUiLocale, Banner } from '@maka/ui'; import { deriveBotChannelViewState } from './bot-settings-view-model'; import { BOT_LABELS, BotBrandLogo, botReadinessCopyForSupport, botStatusDetail } from './bot-chat-shared'; -import { getBotSettingsCopy } from '../locales/settings-bot-copy'; +import { botStatusReasonMessage, getBotSettingsCopy } from '../locales/settings-bot-copy'; import { SettingsPage, SettingsSection } from './settings-section'; import { dotForStatus } from '@maka/ui'; @@ -157,7 +157,7 @@ function botOverviewDetail( ); } - if (currentError) return locale === 'zh-CN' ? currentError : fallback; + if (currentError) return botStatusReasonMessage(currentError, locale) ?? fallback; if (status?.reason) return botStatusDetail(status, locale); return fallback; } diff --git a/apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx b/apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx index 90496c3070..036e3e50d1 100644 --- a/apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx +++ b/apps/desktop/src/renderer/settings/bot-onboarding-modal.tsx @@ -190,7 +190,7 @@ export function BotOnboardingModal(props: { ) : starting || snapshot?.state === 'connecting' ? ( ) : snapshot?.state === 'connected' ? ( - snapshot.warning ? ( + snapshot.warningCode ? ( @@ -262,13 +262,15 @@ function statusCopy( case 'connecting': return shared.connecting; // PR1197 review (P0-3): honour the honest "saved but not connected" notice // instead of claiming a healthy connection. - case 'connected': return snapshot.warning - ? (locale === 'zh-CN' ? snapshot.warning : shared.connectedWarning) + case 'connected': return snapshot.warningCode + ? (snapshot.warningDetail + ? shared.savedNotConnectedDetail(snapshot.warningDetail) + : shared.savedNotConnected) : shared.connected(getBotSettingsCopy(locale).providers[snapshot.provider].label); case 'expired': return shared.expired; case 'denied': return shared.denied; case 'cancelled': return shared.cancelled; - case 'error': return locale === 'zh-CN' ? (snapshot.error ?? shared.failed) : shared.failed; + case 'error': return snapshot.errorCode ? shared.errors[snapshot.errorCode] : shared.failed; default: return shared.preparing; } } diff --git a/apps/desktop/src/renderer/settings/bot-settings-view-model.ts b/apps/desktop/src/renderer/settings/bot-settings-view-model.ts index f75c27860a..f3aea677f6 100644 --- a/apps/desktop/src/renderer/settings/bot-settings-view-model.ts +++ b/apps/desktop/src/renderer/settings/bot-settings-view-model.ts @@ -17,7 +17,7 @@ * under the License. */ -import { humanizeBotStatusReason } from '@maka/core/bot-events'; +import { botStatusErrorReason } from '@maka/core/bot-events'; import { type BotChannelSettings, type BotReadinessState } from '@maka/core/bot-chat-settings'; import type { BotStatus } from '@maka/runtime/bots'; @@ -43,7 +43,7 @@ export function deriveBotChannelViewState(input: { || isConfiguredReadiness(readiness); const liveOperational = status?.running === true && readiness === 'operational'; const liveError = readiness === 'degraded' - ? humanizeBotStatusReason(status?.reason) + ? botStatusErrorReason(status?.reason) : undefined; const currentError = liveOperational ? undefined : liveError ?? channel.lastError; const needsAttention = configured && ( diff --git a/apps/desktop/src/renderer/settings/bot-wechat-login.tsx b/apps/desktop/src/renderer/settings/bot-wechat-login.tsx index 796b6b20ac..fc372fb7ce 100644 --- a/apps/desktop/src/renderer/settings/bot-wechat-login.tsx +++ b/apps/desktop/src/renderer/settings/bot-wechat-login.tsx @@ -99,7 +99,8 @@ export function WechatQrLoginModal(props: { onRefreshStatuses(): void | Promise; }) { const locale = useUiLocale(); - const copy = getBotSettingsCopy(locale).wechat; + const botCopy = getBotSettingsCopy(locale); + const copy = botCopy.wechat; const [result, setResult] = useState(null); const [loading, setLoading] = useState(true); const [reloadNonce, setReloadNonce] = useState(0); @@ -131,7 +132,6 @@ export function WechatQrLoginModal(props: { setResult({ ok: false, error: settingsActionErrorMessage(error, locale), - hint: copy.readQrFailed, }); }) .finally(() => { @@ -222,7 +222,7 @@ export function WechatQrLoginModal(props: { headingLevel={4} icon={} title={error.error} - description={error.hint} + description={error.hintCode ? botCopy.testHints[error.hintCode] : copy.readQrFailed} actions={