diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index c4724663db..63f010a277 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -62,12 +62,14 @@ "src/renderer/live-turn-reconciler.tsx", "src/renderer/live-turn-snapshot.ts", "src/renderer/local-memory-digest.ts", + "src/renderer/locales/agent-graph-copy.ts", "src/renderer/locales/artifact-copy.ts", "src/renderer/locales/browser-copy.ts", "src/renderer/locales/conversation-copy.ts", "src/renderer/locales/external-session-import-copy.ts", "src/renderer/locales/mcp-copy.ts", "src/renderer/locales/onboarding-copy.ts", + "src/renderer/locales/peer-mesh-copy.ts", "src/renderer/locales/permission-center-copy.ts", "src/renderer/locales/plan-mode-copy.ts", "src/renderer/locales/session-collaboration-copy.ts", @@ -87,6 +89,7 @@ "src/renderer/locales/settings-web-search-copy.ts", "src/renderer/locales/shell-copy.ts", "src/renderer/locales/shell-remaining-copy.ts", + "src/renderer/locales/task-readiness-copy.ts", "src/renderer/main.tsx", "src/renderer/mcp-brand-contrast.ts", "src/renderer/mcp-brand-marks.tsx", @@ -1067,6 +1070,7 @@ "dependencyPaths": { "./agent-graph-panel-visibility.js": 1, "./agent-graph-refresh.js": 1, + "./locales/agent-graph-copy.js": 1, "@astryxdesign/core/Banner": 1, "@astryxdesign/core/Button": 1, "@astryxdesign/core/EmptyState": 1, @@ -1532,6 +1536,15 @@ "actionFactories": [], "dependencyPaths": {} }, + "src/renderer/locales/agent-graph-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, "src/renderer/locales/artifact-copy.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -1586,6 +1599,15 @@ "actionFactories": [], "dependencyPaths": {} }, + "src/renderer/locales/peer-mesh-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, "src/renderer/locales/permission-center-copy.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -1759,6 +1781,15 @@ "actionFactories": [], "dependencyPaths": {} }, + "src/renderer/locales/task-readiness-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, "src/renderer/mcp-brand-contrast.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -3360,6 +3391,7 @@ "dependencyPaths": { "../features/runtime-host-management": 1, "../features/session-collaboration": 1, + "../locales/peer-mesh-copy.js": 1, "../locales/session-collaboration-copy.js": 1, "../locales/settings-projects-copy.js": 1, "./password-input.js": 1, diff --git a/apps/desktop/src/main/__tests__/agent-graph-panel-copy.test.ts b/apps/desktop/src/main/__tests__/agent-graph-panel-copy.test.ts index 0ef254444c..16d5369040 100644 --- a/apps/desktop/src/main/__tests__/agent-graph-panel-copy.test.ts +++ b/apps/desktop/src/main/__tests__/agent-graph-panel-copy.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { getAgentGraphPanelCopy } from '../../renderer/agent-graph-panel.js'; +import { getAgentGraphPanelCopy } from '../../renderer/locales/agent-graph-copy.js'; test('Traditional Chinese Agent Graph copy does not use Simplified fallbacks', () => { const copy = getAgentGraphPanelCopy('zh-TW'); diff --git a/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts b/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts new file mode 100644 index 0000000000..5c0da75522 --- /dev/null +++ b/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts @@ -0,0 +1,359 @@ +/* + * 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 { mkdtemp, rm } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { after, afterEach, before, test } from 'node:test'; +import { pathToFileURL } from 'node:url'; +import { build } from 'esbuild'; +import { parseHTML } from 'linkedom'; +import { act, createElement, type ComponentType, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; +import type { UiLocale } from '@maka/core/ui-locale'; +import type { ProviderType } from '@maka/core/llm-connections'; +import type { PeerMeshQueryResult } from '@maka/runtime-host/protocol'; +import type { RuntimeHostPeerConnectionPath } from '@maka/runtime-host/client'; +import type { DesktopRuntimeHostProfileSnapshot } from '../../preload/bridge-contract.js'; +import type { ConnectionsBridge } from '../../renderer/features/connection-settings/index.js'; +import type { RuntimeHostManagementServices } from '../../renderer/features/runtime-host-management/index.js'; + +// Keep renderer implementations and their asset imports out of the main compilation graph. +interface RenderModules { + AddProviderForm: ComponentType<{ + bridge: ConnectionsBridge; + providerType: ProviderType; + existingSlugs: string[]; + onCancel(): void; + onCreated(slug: string, modelDiscoveryError?: unknown): Promise; + }>; + RuntimeHostProfilesSection: ComponentType<{ + onRemoteHostAdded(profileId: string): void; + }>; + RuntimeHostManagementServicesProvider: ComponentType<{ + services: RuntimeHostManagementServices; + children?: ReactNode; + }>; + RuntimeHostPeerMeshDialog: ComponentType<{ + target: Parameters[0]; + targetName: string; + onClose(): void; + }>; +} + +let components: RenderModules; +let bundleDirectory: string; +let mountedRoot: Root | undefined; +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + HTMLElement: globalThis.HTMLElement, + HTMLIFrameElement: globalThis.HTMLIFrameElement, + Node: globalThis.Node, + Event: globalThis.Event, + CSS: globalThis.CSS, + matchMedia: globalThis.matchMedia, + getComputedStyle: globalThis.getComputedStyle, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; + +before(async () => { + const repoRoot = resolve(import.meta.dirname, '../../../../..'); + bundleDirectory = await mkdtemp(resolve(repoRoot, 'apps/desktop/dist/main/__tests__/connection-locale-')); + const outfile = resolve(bundleDirectory, 'components.mjs'); + // Like password-input.test.ts, bundle extensionless renderer imports without replacing components. + await build({ + stdin: { + contents: [ + "export { AddProviderForm } from './settings/provider-add-form';", + "export { RuntimeHostProfilesSection } from './settings/runtime-host-profiles-section';", + "export { RuntimeHostManagementServicesProvider, RuntimeHostPeerMeshDialog } from './features/runtime-host-management/index';", + ].join('\n'), + resolveDir: resolve(repoRoot, 'apps/desktop/src/renderer'), + }, + outfile, + bundle: true, + packages: 'external', + loader: { '.svg': 'dataurl' }, + platform: 'node', + format: 'esm', + jsx: 'automatic', + target: 'node20', + logLevel: 'silent', + }); + components = await import(pathToFileURL(outfile).href) as RenderModules; +}); + +afterEach(async () => { + try { + if (mountedRoot) await act(() => mountedRoot?.unmount()); + } finally { + mountedRoot = undefined; + Object.assign(globalThis, originalGlobals); + } +}); + +after(async () => { + if (bundleDirectory) await rm(bundleDirectory, { recursive: true, force: true }); +}); + +const localeCases = [ + { + locale: 'zh-CN', direct: '直连', transit: '成员转发', save: '保存供应商', + slugErrors: { + required: '请填写连接标识', + format: '连接标识只能包含小写字母、数字和连字符', + too_long: '连接标识不能超过 64 个字符', + duplicate: '连接标识已存在', + }, + }, + { + locale: 'zh-TW', direct: '直接連線', transit: '成員轉送', save: '儲存供應商', + slugErrors: { + required: '請填寫連線標識', + format: '連線標識只能包含小寫字母、數字和連字號', + too_long: '連線標識不能超過 64 個字元', + duplicate: '連線標識已存在', + }, + }, + { + locale: 'en', direct: 'Direct', transit: 'Member transit', save: 'Save provider', + slugErrors: { + required: 'Enter a connection identifier', + format: 'Connection identifiers use lowercase letters, digits, and hyphens', + too_long: 'Connection identifiers are at most 64 characters', + duplicate: 'Connection identifier already exists', + }, + }, +] as const; + +for (const copy of localeCases) { + test(`${copy.locale}: peer path badges describe the route, while tooltips retain the protocol`, async () => { + const harness = installRenderer(); + const paths: RuntimeHostPeerConnectionPath[] = [ + { kind: 'direct', transport: 'webrtc' }, + { kind: 'direct', transport: 'quic' }, + { kind: 'direct', transport: 'tcp' }, + { kind: 'transit', relayPeerId: 'relay-peer' }, + ]; + const snapshot: DesktopRuntimeHostProfileSnapshot = { + defaultProfileId: 'local', + entries: [{ + profile: { kind: 'local', id: 'local', name: 'Local' }, + enabled: true, isDefault: true, readiness: 'ready', + }, ...paths.map((peerPath, index) => ({ + profile: { + kind: 'remote', id: `remote-${index}`, name: `Remote ${index}`, rootId: 'root', + transport: { + kind: 'libp2p-direct', + reachability: { + lease: { + version: 1, peerId: `peer-${index}`, revision: 1, + issuedAt: 1, expiresAt: 300001, directRoutes: [], coordinationRoutes: [], + }, + publicKey: 'test-public-key', signature: 'test-signature', + }, + }, + }, + enabled: true, isDefault: false, readiness: 'ready', peerPath, + }))], + }; + Object.assign(window, { + maka: { + runtimeHostProfiles: { + getSnapshot: async () => snapshot, + subscribeChanges: () => () => {}, + }, + localRuntimeHostRemoteAccess: { getSnapshot: async () => ({ state: 'off' }) }, + runtimeHostManagement: { subscribeProgress: () => () => {} }, + }, + }); + await harness.render(copy.locale, createElement(components.RuntimeHostProfilesSection, { + onRemoteHostAdded: unexpectedCall, + })); + + const details = [ + `${copy.direct} · WebRTC`, `${copy.direct} · QUIC`, `${copy.direct} · TCP`, + `${copy.transit} · relay-peer`, + ]; + for (const [index, detail] of details.entries()) { + const row = [...harness.document.querySelectorAll('li')].find( + (element) => element.textContent.includes(`Remote ${index}`), + ); + assert.ok(row, `missing Remote ${index}`); + const trigger = [...row.querySelectorAll('span[aria-describedby]')].find( + (element) => describedElements(element).some( + (description) => description.getAttribute('role') === 'tooltip' && description.textContent === detail, + ), + ); + assert.ok(trigger, `missing rendered tooltip: ${detail}`); + assert.equal(trigger.textContent, index === 3 ? copy.transit : copy.direct); + } + }); + + for (const [reason, slug] of Object.entries({ + required: '', format: 'Not A Slug', too_long: 'a'.repeat(65), duplicate: 'taken', + })) { + test(`${copy.locale}: AddProviderForm renders the ${reason} slug error`, async () => { + const harness = installRenderer(); + const calls: string[] = []; + const bridge = { + create: async () => { calls.push('create'); throw new Error('unexpected create'); }, + fetchModels: async () => { calls.push('fetchModels'); throw new Error('unexpected discovery'); }, + } as unknown as ConnectionsBridge; + await harness.render(copy.locale, createElement(components.AddProviderForm, { + bridge, providerType: 'openai-compatible', existingSlugs: ['taken'], + onCancel: unexpectedCall, onCreated: unexpectedCall, + })); + const input = harness.document.querySelector('input[placeholder="my-provider"]'); + assert.ok(input, 'missing connection identifier input'); + await act(async () => { + input.value = slug; + const key = Object.keys(input).find((candidate) => candidate.startsWith('__reactProps$')); + assert.ok(key, 'missing React input props'); + const props = (input as unknown as Record)[key] as { + onChange(event: { target: HTMLInputElement; defaultPrevented: boolean }): void; + }; + props.onChange({ target: input, defaultPrevented: false }); + }); + const submit = [...harness.document.querySelectorAll('button')].find( + (button) => button.textContent === copy.save, + ); + assert.ok(submit, 'missing save button'); + await act(async () => submit.click()); + + assert.equal(input.getAttribute('aria-invalid'), 'true'); + assert.deepEqual(describedElements(input).map((element) => element.textContent), [ + copy.slugErrors[reason as keyof typeof copy.slugErrors], + ]); + assert.deepEqual(calls, [], 'invalid identifiers must not reach the provider bridge'); + }); + } +} + +test('zh-TW: expanded Peer Mesh members render localized route states', async () => { + const harness = installRenderer(); + const states = ['local', 'connecting', 'reachable', 'reconnecting', 'needs_repair'] as const; + const snapshot: PeerMeshQueryResult = { + available: true, localPeerId: 'peer-local', + meshes: [{ + meshId: 'mesh-1', displayName: 'Test Mesh', role: 'authority', + authorityPeerId: 'peer-local', revision: 1, closed: false, pendingInvitationCount: 0, + members: states.map((state) => ({ peerId: `peer-${state}`, state, endpointKind: 'host' })), + }], + }; + const services = managementServices(); + services.peerMesh.execute = async (_target, action) => { + assert.equal(action, 'status'); + return snapshot; + }; + await harness.render('zh-TW', createElement(components.RuntimeHostPeerMeshDialog, { + target: { kind: 'local_host' }, targetName: 'Local Host', onClose: unexpectedCall, + }), services); + const disclosure = harness.document.querySelector('.settingsPeerMeshCardDisclosure'); + assert.ok(disclosure, 'missing mesh disclosure'); + await act(async () => disclosure.click()); + + const expected = [ + '本機 Runtime Host · 管理者', '正在連線', '可連線', '正在恢復連線', '需要新邀請碼修復', + ]; + const members = [...harness.document.querySelectorAll('.settingsPeerMeshMember')]; + assert.equal(members.length, states.length); + for (const [index, member] of members.entries()) { + const heading = member.querySelector('.settingsPeerMeshMemberHeading'); + assert.ok(heading); + assert.equal(heading.nextElementSibling?.textContent, expected[index], states[index]); + } +}); + +function unexpectedCall(): never { + assert.fail('unexpected service call'); +} + +function managementServices(): RuntimeHostManagementServices { + return { + supportsWsl: false, + profilePairing: { retry: unexpectedCall, discard: unexpectedCall }, + connectionCodes: { + create: unexpectedCall, importCode: unexpectedCall, + readClipboardText: unexpectedCall, writeClipboardText: unexpectedCall, + }, + resources: { query: unexpectedCall, schedule: unexpectedCall }, + peerMesh: { + execute: unexpectedCall, cancel: unexpectedCall, + getConnectivityPolicy: unexpectedCall, setConnectivityPolicy: unexpectedCall, + getDirectPeer: unexpectedCall, configureDirectPeer: unexpectedCall, copyText: unexpectedCall, + createOperationId: () => 'status-operation', schedule: () => () => {}, + }, + }; +} + +function describedElements(element: Element): Element[] { + return (element.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean).map((id) => { + const description = element.ownerDocument.getElementById(id); + assert.ok(description, `missing description: ${id}`); + return description; + }); +} + +function installRenderer() { + const { document, window } = parseHTML('
'); + const matchMedia = (media: string) => ({ + matches: false, media, onchange: null, + addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, + dispatchEvent: () => false, + }); + const getComputedStyle = () => ({ + direction: 'ltr', writingMode: 'horizontal-tb', getPropertyValue: () => '', + }) as unknown as CSSStyleDeclaration; + Object.assign(window, { matchMedia, getComputedStyle, scrollTo() {} }); + Object.assign(window.HTMLElement.prototype, { + showModal(this: HTMLElement) { this.setAttribute('open', ''); }, + close(this: HTMLElement) { this.removeAttribute('open'); }, + }); + Object.assign(globalThis, { + document, window, matchMedia, getComputedStyle, + HTMLElement: window.HTMLElement, + HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, + Event: window.Event, Node: window.Node, CSS: { escape: (value: string) => value }, + requestAnimationFrame: () => 0, cancelAnimationFrame: () => {}, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.getElementById('root'); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + return { + document, + async render(locale: UiLocale, children: ReactNode, services = managementServices()) { + await act(async () => root.render(createElement(LocaleProvider, { + locale, + children: createElement(AstryxLocaleProvider, { + children: createElement(ToastProvider, { + children: createElement(components.RuntimeHostManagementServicesProvider, { services, children }), + }), + }), + }))); + }, + }; +} diff --git a/apps/desktop/src/main/__tests__/provider-add-submission.test.ts b/apps/desktop/src/main/__tests__/provider-add-submission.test.ts index 6e5432cc48..b9bd3fbbd3 100644 --- a/apps/desktop/src/main/__tests__/provider-add-submission.test.ts +++ b/apps/desktop/src/main/__tests__/provider-add-submission.test.ts @@ -221,6 +221,18 @@ test('a create failure propagates instead of being reported as a discovery probl ); }); +test('the field gate preserves stable slug validation issues', () => { + for (const [slug, detail] of [ + ['', 'required'], + ['Not A Slug', 'format'], + ['a'.repeat(65), 'too_long'], + ]) { + assert.deepEqual(validateAddProviderDraft(draft({ slug })), { + field: 'slug', reason: 'invalid', detail, + }); + } +}); + test('the field gate still reports the rules that survived', () => { assert.deepEqual(validateAddProviderDraft(draft({ slug: 'Not A Slug' }))?.field, 'slug'); assert.deepEqual(validateAddProviderDraft(draft({ existingSlugs: ['house-relay'] })), { diff --git a/apps/desktop/src/main/__tests__/traditional-chinese-peer-mesh-copy.test.ts b/apps/desktop/src/main/__tests__/traditional-chinese-peer-mesh-copy.test.ts index ca0261e1d1..ed1102fd2c 100644 --- a/apps/desktop/src/main/__tests__/traditional-chinese-peer-mesh-copy.test.ts +++ b/apps/desktop/src/main/__tests__/traditional-chinese-peer-mesh-copy.test.ts @@ -19,24 +19,36 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { getRuntimeHostPeerMeshCopy } from '../../renderer/features/runtime-host-management/index.js'; +import { getPeerMeshCopy } from '../../renderer/locales/peer-mesh-copy.js'; +import { getSettingsProjectsCopy } from '../../renderer/locales/settings-projects-copy.js'; import { getDesktopConversationCopy } from '../../renderer/locales/conversation-copy.js'; import { getProviderSettingsCopy } from '../../renderer/features/connection-settings/index.js'; import { settingsTestResultMessage } from '../../renderer/locales/settings-test-result-copy.js'; test('Traditional Chinese Peer Mesh copy does not use the Simplified Chinese branch', () => { - const copy = getRuntimeHostPeerMeshCopy('zh-TW'); + const copy = getPeerMeshCopy('zh-TW'); + const runtimeHost = getSettingsProjectsCopy('zh-TW').runtimeHost; assert.equal(copy.experimental, '實驗性'); assert.equal(copy.invalidResult, 'Peer Mesh 回傳了無效結果'); assert.equal(copy.copyPeerId('peer-1'), '複製完整 Peer ID:peer-1'); - assert.equal(copy.peerIdCopyFailed, '無法複製 Peer ID'); - assert.equal(copy.peerPathDirect, '直接連線'); - assert.equal(copy.peerPathTransit, '成員轉送'); - assert.equal(copy.peerPathOther, '其他'); + assert.equal(runtimeHost.peerIdCopyFailed, '無法複製 Peer ID'); + assert.equal(runtimeHost.peerPathDirect, '直接連線'); + assert.equal(runtimeHost.peerPathTransit, '成員轉送'); + assert.equal(runtimeHost.peerPathTransportOther, '其他'); assert.equal(copy.joinHint, '貼上另一個 Peer 產生的一次性邀請碼。'); }); +test('Traditional Chinese Peer Mesh route states use exact localized labels', () => { + assert.deepEqual(getPeerMeshCopy('zh-TW').routeState, { + local: '本機', + connecting: '正在連線', + reachable: '可連線', + reconnecting: '正在恢復連線', + needs_repair: '需要新邀請碼修復', + }); +}); + test('Traditional Chinese connection copy uses 回傳, 回應, and 發送', () => { const conversation = getDesktopConversationCopy('zh-TW'); assert.equal(conversation.quoteCompanion.errors.respondFailed, '回應失敗,請稍後重試。'); diff --git a/apps/desktop/src/main/notifications-policy.ts b/apps/desktop/src/main/notifications-policy.ts index 8795aa00d9..32da6335e4 100644 --- a/apps/desktop/src/main/notifications-policy.ts +++ b/apps/desktop/src/main/notifications-policy.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { UiLocale } from '@maka/core/ui-locale'; +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; /** * Pure decision + copy helpers for desktop run-completion notifications. @@ -77,24 +77,26 @@ export interface RunNotificationCopy { * could not supply a session name / reply preview (e.g. an untitled * session or a tool-only turn with no assistant text). */ +const RUN_NOTIFICATION_COPY = { + 'zh-CN': { + errored: { title: '任务出错', body: '本轮回答未能完成,点击查看详情。' }, + completed: { title: '回答已生成', body: 'Maka 已完成本轮回答,点击查看。' }, + }, + 'zh-TW': { + errored: { title: '任務發生錯誤', body: '本次回答未能完成,按一下以檢視詳細資料。' }, + completed: { title: '回答已產生', body: 'Maka 已完成本次回答,按一下以檢視。' }, + }, + en: { + errored: { title: 'Conversation error', body: 'This response did not finish. Click to view details.' }, + completed: { title: 'Response ready', body: 'Maka finished this response. Click to view it.' }, + }, +} satisfies UiCatalog>; + export function runNotificationCopy( kind: RunNotificationKind, locale: UiLocale, ): RunNotificationCopy { - if (locale === 'en') { - return kind === 'errored' - ? { title: 'Conversation error', body: 'This response did not finish. Click to view details.' } - : { title: 'Response ready', body: 'Maka finished this response. Click to view it.' }; - } - if (locale === 'zh-TW') { - return kind === 'errored' - ? { title: '任務發生錯誤', body: '本次回答未能完成,按一下以檢視詳細資料。' } - : { title: '回答已產生', body: 'Maka 已完成本次回答,按一下以檢視。' }; - } - if (kind === 'errored') { - return { title: '任务出错', body: '本轮回答未能完成,点击查看详情。' }; - } - return { title: '回答已生成', body: 'Maka 已完成本轮回答,点击查看。' }; + return RUN_NOTIFICATION_COPY[locale][kind]; } /** Renderer-supplied content for a finished turn. Both fields are diff --git a/apps/desktop/src/renderer/agent-graph-panel.tsx b/apps/desktop/src/renderer/agent-graph-panel.tsx index d2eb5c7d74..7858b401d7 100644 --- a/apps/desktop/src/renderer/agent-graph-panel.tsx +++ b/apps/desktop/src/renderer/agent-graph-panel.tsx @@ -19,10 +19,7 @@ import { useEffect, useId, useMemo, useRef, useState, type JSX } from 'react'; import type { UiLocale } from '@maka/core/ui-locale'; -import type { - AgentGraphClientOperator, - AgentGraphClientSnapshot, -} from '@maka/runtime/stream-graph-read-model'; +import type { AgentGraphClientSnapshot } from '@maka/runtime/stream-graph-read-model'; import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client'; import type { AgentGraphEpochSummary } from '@maka/runtime-host/protocol'; import { IconButton, Selector, type SelectorOptionType } from '@maka/ui'; @@ -43,6 +40,7 @@ import { createAgentGraphRefreshScheduler, type AgentGraphRefreshScheduler, } from './agent-graph-refresh.js'; +import { getAgentGraphPanelCopy } from './locales/agent-graph-copy.js'; const noopAgentGraphRefreshScheduler: AgentGraphRefreshScheduler = { requestRefresh() {}, @@ -51,177 +49,6 @@ const noopAgentGraphRefreshScheduler: AgentGraphRefreshScheduler = { dispose() {}, }; -type GraphPanelCopy = { - title: string; - loading: string; - retry: string; - collapse: string; - expand: string; - dismiss: string; - stop: string; - stopping: string; - stopFailed: string; - loadFailed: string; - openSession: string; - operators: string; - selectedResults: string; - epoch: string; - currentEpoch: string; - historicalEpoch: string; - cappedEpochs(count: number): string; - noOperators: string; - hiddenOperators(count: number): string; - progress(settled: number, total: number, hasOmitted: boolean): string; - status(status: AgentGraphClientSnapshot['status']): string; - operatorStatus(status: AgentGraphClientOperator['status']): string; - wait(operator: AgentGraphClientOperator): string | undefined; -}; - -export function getAgentGraphPanelCopy(locale: UiLocale): GraphPanelCopy { - if (locale === 'zh-CN') { - return { - title: 'Agent Graph', - loading: '正在读取 Graph 状态…', - retry: '重试', - collapse: '收起 Agent Graph', - expand: '展开 Agent Graph', - dismiss: '关闭 Agent Graph', - stop: '停止 Graph', - stopping: '停止中…', - stopFailed: '停止 Graph 失败,请重试。', - loadFailed: 'Graph 状态刷新失败。', - openSession: '打开子任务', - operators: 'Operators', - selectedResults: '已选择结果', - epoch: 'Graph 运行轮次', - currentEpoch: '当前', - historicalEpoch: '历史记录(只读)', - cappedEpochs: (count) => `仅显示最近 ${count} 次运行`, - noOperators: '等待主 Agent 创建 operator…', - hiddenOperators: (count) => `另有 ${count} 个 operator`, - progress: (settled, total, hasOmitted) => - hasOmitted ? `可见 ${settled}/${total} 已结束` : `${settled}/${total} 已结束`, - status: (status) => - ({ - empty: '等待调度', - active: '运行中', - closing: '收尾中', - waiting: '等待中', - stopped: '已停止', - failed: '失败', - completed: '已完成', - })[status], - operatorStatus: (status) => - ({ - not_started: '未启动', - waiting: '等待', - runnable: '可运行', - running: '运行中', - blocked: '受阻', - completed: '完成', - failed: '失败', - aborted: '中止', - cancelled: '取消', - })[status], - wait: waitReasonZh, - }; - } - if (locale === 'zh-TW') { - return { - title: 'Agent Graph', - loading: '正在讀取 Graph 狀態…', - retry: '重試', - collapse: '收起 Agent Graph', - expand: '展開 Agent Graph', - dismiss: '關閉 Agent Graph', - stop: '停止 Graph', - stopping: '停止中…', - stopFailed: '停止 Graph 失敗,請重試。', - loadFailed: 'Graph 狀態重新整理失敗。', - openSession: '開啟子任務', - operators: 'Operators', - selectedResults: '已選取結果', - epoch: 'Graph 執行輪次', - currentEpoch: '目前', - historicalEpoch: '歷史記錄(唯讀)', - cappedEpochs: (count) => `僅顯示最近 ${count} 次執行`, - noOperators: '等待主 Agent 建立 operator…', - hiddenOperators: (count) => `另有 ${count} 個 operator`, - progress: (settled, total, hasOmitted) => - hasOmitted ? `可見項目中 ${settled}/${total} 已結束` : `${settled}/${total} 已結束`, - status: (status) => - ({ - empty: '等待排程', - active: '執行中', - closing: '收尾中', - waiting: '等待中', - stopped: '已停止', - failed: '失敗', - completed: '已完成', - })[status], - operatorStatus: (status) => - ({ - not_started: '尚未啟動', - waiting: '等待', - runnable: '可執行', - running: '執行中', - blocked: '受阻', - completed: '完成', - failed: '失敗', - aborted: '已中止', - cancelled: '已取消', - })[status], - wait: waitReasonZhTw, - }; - } - return { - title: 'Agent Graph', - loading: 'Loading graph state…', - retry: 'Retry', - collapse: 'Collapse Agent Graph', - expand: 'Expand Agent Graph', - dismiss: 'Dismiss Agent Graph', - stop: 'Stop graph', - stopping: 'Stopping…', - stopFailed: 'Could not stop the graph. Try again.', - loadFailed: 'Could not refresh graph state.', - openSession: 'Open child task', - operators: 'Operators', - selectedResults: 'Selected results', - epoch: 'Graph run', - currentEpoch: 'Current', - historicalEpoch: 'History (read-only)', - cappedEpochs: (count) => `Showing the newest ${count} runs`, - noOperators: 'Waiting for the main agent to create an operator…', - hiddenOperators: (count) => `${count} more operator${count === 1 ? '' : 's'}`, - progress: (settled, total, hasOmitted) => - hasOmitted ? `${settled}/${total} visible settled` : `${settled}/${total} settled`, - status: (status) => - ({ - empty: 'Awaiting schedule', - active: 'Running', - closing: 'Finishing', - waiting: 'Waiting', - stopped: 'Stopped', - failed: 'Failed', - completed: 'Completed', - })[status], - operatorStatus: (status) => - ({ - not_started: 'Not started', - waiting: 'Waiting', - runnable: 'Runnable', - running: 'Running', - blocked: 'Blocked', - completed: 'Completed', - failed: 'Failed', - aborted: 'Aborted', - cancelled: 'Cancelled', - })[status], - wait: waitReasonEn, - }; -} - export function AgentGraphPanel(props: { rootSessionId: string; enabled: boolean; @@ -599,43 +426,3 @@ function sameEpochPage( ); }); } - -function firstWait(operator: AgentGraphClientOperator) { - return operator.readiness.find((readiness) => readiness.status === 'waiting')?.waitingFor[0]; -} - -function waitReasonEn(operator: AgentGraphClientOperator): string | undefined { - const wait = firstWait(operator); - if (!wait) return undefined; - if (wait.kind === 'input_route') { - return `Waiting for input from ${wait.upstreamOperatorIds.join(', ')}`; - } - if (wait.kind === 'activation_missing') { - return `Waiting for ${wait.operatorId} activation`; - } - return `Waiting for ${wait.operatorId} to settle`; -} - -function waitReasonZh(operator: AgentGraphClientOperator): string | undefined { - const wait = firstWait(operator); - if (!wait) return undefined; - if (wait.kind === 'input_route') { - return `等待 ${wait.upstreamOperatorIds.join('、')} 的输入`; - } - if (wait.kind === 'activation_missing') { - return `等待 ${wait.operatorId} activation`; - } - return `等待 ${wait.operatorId} 结束`; -} - -function waitReasonZhTw(operator: AgentGraphClientOperator): string | undefined { - const wait = firstWait(operator); - if (!wait) return undefined; - if (wait.kind === 'input_route') { - return `等待 ${wait.upstreamOperatorIds.join('、')} 的輸入`; - } - if (wait.kind === 'activation_missing') { - return `等待 ${wait.operatorId} 啟用`; - } - return `等待 ${wait.operatorId} 結束`; -} 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 2eea3f9b7b..d219524aa2 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 @@ -214,7 +214,7 @@ const zhCopy = { cardAria: (name: string, description: string) => `添加模型供应商:${name},${description}`, }, add: { - invalidSlug: '连接标识格式不正确', duplicateSlug: '连接标识已存在', cloudflareAccount: '请填写 Cloudflare Account ID', endpointRequired: '这个供应商需要填写服务地址', + slugIssues: { required: '请填写连接标识', format: '连接标识只能包含小写字母、数字和连字符', too_long: '连接标识不能超过 64 个字符' }, duplicateSlug: '连接标识已存在', cloudflareAccount: '请填写 Cloudflare Account ID', endpointRequired: '这个供应商需要填写服务地址', accountLogin: '请到账号连接完成登录;登录成功后会自动创建模型连接。', apiKeyPlaceholder: '输入或粘贴 API Key', cancel: '取消', accountTitle: '使用账号连接登录', advancedRequest: '高级请求设置', expandAdvancedRequest: '展开高级请求设置', collapseAdvancedRequest: '收起高级请求设置', @@ -378,7 +378,7 @@ const zhTwCopy = { cardAria: (name: string, description: string) => `新增模型供應商:${name},${description}`, }, add: { - invalidSlug: '連線標識格式不正確', duplicateSlug: '連線標識已存在', cloudflareAccount: '請填寫 Cloudflare Account ID', endpointRequired: '這個供應商需要填寫服務地址', + slugIssues: { required: '請填寫連線標識', format: '連線標識只能包含小寫字母、數字和連字號', too_long: '連線標識不能超過 64 個字元' }, duplicateSlug: '連線標識已存在', cloudflareAccount: '請填寫 Cloudflare Account ID', endpointRequired: '這個供應商需要填寫服務地址', accountLogin: '請到帳號連線完成登入;登入成功後會自動建立模型連線。', apiKeyPlaceholder: '輸入或貼上 API Key', cancel: '取消', accountTitle: '使用帳號連線登入', advancedRequest: '高階請求設定', expandAdvancedRequest: '展開高階請求設定', collapseAdvancedRequest: '收起高階請求設定', @@ -543,7 +543,7 @@ const enCopy: ProviderSettingsCopy = { cardAria: (name: string, description: string) => `Add model provider: ${name}; ${description}`, }, add: { - invalidSlug: 'The connection identifier format is invalid', duplicateSlug: 'Connection identifier already exists', cloudflareAccount: 'Enter the Cloudflare Account ID', endpointRequired: 'This provider requires a service URL', + slugIssues: { required: 'Enter a connection identifier', format: 'Connection identifiers use lowercase letters, digits, and hyphens', too_long: 'Connection identifiers are at most 64 characters' }, duplicateSlug: 'Connection identifier already exists', cloudflareAccount: 'Enter the Cloudflare Account ID', endpointRequired: 'This provider requires a service URL', accountLogin: 'Complete sign-in under account connections. A model connection is created automatically afterward.', apiKeyPlaceholder: 'Enter or paste API key', cancel: 'Cancel', accountTitle: 'Sign in with an account connection', advancedRequest: 'Advanced request settings', expandAdvancedRequest: 'Show advanced request settings', collapseAdvancedRequest: 'Hide advanced request settings', diff --git a/apps/desktop/src/renderer/features/conversation/model/task-readiness-notice.ts b/apps/desktop/src/renderer/features/conversation/model/task-readiness-notice.ts index df90d2a57e..4a27c74ffe 100644 --- a/apps/desktop/src/renderer/features/conversation/model/task-readiness-notice.ts +++ b/apps/desktop/src/renderer/features/conversation/model/task-readiness-notice.ts @@ -22,6 +22,7 @@ import type { SessionSendProjection } from '@maka/core/session-send-projection'; import type { TaskSubmissionReadinessDimension, TaskSubmissionReadinessSnapshot } from '@maka/core/task-submission-readiness'; import type { UiLocale } from '@maka/core/ui-locale'; +import { getTaskReadinessCopy } from '../../../locales/task-readiness-copy.js'; /** Selects the stored model target for the renderer's readiness probe. */ export function resolveTaskReadinessModelTarget( @@ -85,57 +86,22 @@ function noticeForBlocker( blocker: TaskSubmissionReadinessDimension, locale: UiLocale, ): TaskReadinessNotice { + const copy = getTaskReadinessCopy(locale); if (blocker.id === 'runtime') { - if (locale === 'zh-CN') { - return { - tone: 'destructive', - title: 'Maka 运行服务暂时不可用。', - description: '任务尚未提交。重新检测运行服务后再试。', - actionLabel: '重新检测', - action: 'retry', - }; - } - if (locale === 'zh-TW') { - return { - tone: 'destructive', - title: 'Maka 執行服務暫時無法使用。', - description: '任務尚未送出。重新檢查執行服務後再試。', - actionLabel: '重新檢查', - action: 'retry', - }; - } return { tone: 'destructive', - title: 'The Maka runtime is unavailable.', - description: 'The task was not submitted. Check the runtime again before retrying.', - actionLabel: 'Check again', + title: copy.runtime.title, + description: copy.runtime.description, + actionLabel: copy.runtime.actionLabel, action: 'retry', }; } const action = blocker.repairTarget?.kind === 'workspace_picker' ? 'workspace_picker' : 'retry'; - if (locale === 'zh-CN') { - return { - tone: 'destructive', - title: '当前任务的工作区不可用。', - description: '原目录可能已移动、删除或无法访问。请选择可用工作区。', - actionLabel: action === 'workspace_picker' ? '选择工作区' : '重新检测', - action, - }; - } - if (locale === 'zh-TW') { - return { - tone: 'destructive', - title: '目前任務的工作區無法使用。', - description: '原始資料夾可能已移動、刪除或無法存取。請選擇可用的工作區。', - actionLabel: action === 'workspace_picker' ? '選擇工作區' : '重新檢查', - action, - }; - } return { tone: 'destructive', - title: 'This task workspace is unavailable.', - description: 'The folder may have moved, been deleted, or become inaccessible. Choose an available workspace.', - actionLabel: action === 'workspace_picker' ? 'Choose workspace' : 'Check again', + title: copy.workspace.title, + description: copy.workspace.description, + actionLabel: copy.workspace.actionLabel[action], action, }; } diff --git a/apps/desktop/src/renderer/features/runtime-host-management/index.ts b/apps/desktop/src/renderer/features/runtime-host-management/index.ts index 2e7cd2244e..a64ae63f53 100644 --- a/apps/desktop/src/renderer/features/runtime-host-management/index.ts +++ b/apps/desktop/src/renderer/features/runtime-host-management/index.ts @@ -18,7 +18,6 @@ */ export { - getRuntimeHostPeerMeshCopy, RuntimeHostPeerMeshDialog, } from './ui/runtime-host-peer-mesh-dialog.js'; export { PeerMeshPeerIdButton } from './ui/peer-mesh-peer-id-button.js'; diff --git a/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx b/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx index ecd956369b..30d49263d7 100644 --- a/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx +++ b/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx @@ -25,7 +25,6 @@ import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { HStack } from '@astryxdesign/core/Stack'; import { Tooltip } from '@astryxdesign/core/Tooltip'; import type { PeerMeshProjection, PeerMeshQueryResult } from '@maka/runtime-host/protocol'; -import type { UiLocale } from '@maka/core/ui-locale'; import { decodeRuntimeHostWebRtcStunPolicy, type RuntimeHostWebRtcStunPolicy, @@ -56,6 +55,7 @@ import { RefreshCcw, Workflow, } from '@maka/ui/icons'; +import { getPeerMeshCopy, type PeerMeshCopy } from '../../../locales/peer-mesh-copy.js'; import { useRuntimeHostManagementServices } from '../services-context.js'; import { PeerMeshOperationOutcomeUnknownError, @@ -127,7 +127,7 @@ export function RuntimeHostPeerMeshDialog(props: { readonly onClose: () => void; }) { const locale = useUiLocale(); - const copy = getRuntimeHostPeerMeshCopy(locale); + const copy = getPeerMeshCopy(locale); const toast = useToast(); const services = useRuntimeHostManagementServices().peerMesh; const [snapshot, setSnapshot] = useState(); @@ -856,7 +856,7 @@ export function RuntimeHostPeerMeshDialog(props: { function PeerMeshAdvancedSettings(props: { readonly localPeerId?: string; readonly connectivity?: DesktopConnectivityControls; - readonly copy: ReturnType; + readonly copy: PeerMeshCopy; readonly onCopyPeerId: (peerId: string) => void; }) { const { connectivity } = props; @@ -1008,7 +1008,7 @@ function PeerMeshAdvancedSettings(props: { function Overview(props: { readonly snapshot: PeerMeshQueryResult | undefined; - readonly copy: ReturnType; + readonly copy: PeerMeshCopy; readonly working: boolean; readonly localPeerLabel: string; readonly onInvite: (meshId: string) => void; @@ -1233,7 +1233,7 @@ function Overview(props: { function UnavailableEndpoint(props: { readonly setup: ManagedHostPeerSetup; readonly working: boolean; - readonly copy: ReturnType; + readonly copy: PeerMeshCopy; readonly onEnable: () => void; readonly onInspect?: () => void; readonly onRefresh: () => void; @@ -1316,7 +1316,7 @@ function UnavailableEndpoint(props: { function JoinView(props: { readonly value: string; readonly working: boolean; - readonly copy: ReturnType; + readonly copy: PeerMeshCopy; readonly onChange: (value: string) => void; }) { return ( @@ -1348,7 +1348,7 @@ function JoinView(props: { function InvitationView(props: { readonly invitation: Extract; - readonly copy: ReturnType; + readonly copy: PeerMeshCopy; }) { return (
@@ -1399,7 +1399,7 @@ function InvitationView(props: { function MeshCard(props: { readonly mesh: PeerMeshProjection; readonly transit: PeerMeshQueryResult['transit']; - readonly copy: ReturnType; + readonly copy: PeerMeshCopy; readonly working: boolean; readonly onInvite: () => void; readonly onRemove: (peerId: string) => void; @@ -1699,7 +1699,7 @@ function TransitMetric(props: { readonly label: string; readonly value: string } function PeerIdText(props: { readonly peerId: string; - readonly copy: ReturnType; + readonly copy: PeerMeshCopy; readonly onCopy: (peerId: string) => void; }) { return ( @@ -1718,7 +1718,7 @@ function PeerIdText(props: { function MeshIdText(props: { readonly meshId: string; - readonly copy: ReturnType; + readonly copy: PeerMeshCopy; readonly onCopy: (meshId: string) => void; }) { return ( @@ -1803,507 +1803,3 @@ function fingerprint(meshId: string): string { function abbreviate(peerId: string): string { return peerId.length <= 22 ? peerId : `${peerId.slice(0, 11)}…${peerId.slice(-7)}`; } - -export function getRuntimeHostPeerMeshCopy(locale: UiLocale) { - return locale === 'zh-CN' - ? { - title: 'Peer Mesh', - experimental: '实验性', - failed: 'Peer Mesh 操作失败', - invalidResult: 'Peer Mesh 返回了无效结果', - unknownError: 'Peer Mesh 操作失败', - outcomeUnknown: 'Host 可能已完成此操作。已刷新当前状态,请确认后再重试。', - invitationOutcomeUnknown: - 'Host 可能已创建邀请码,但代码未能返回且无法恢复。它会自动过期;创建新邀请前请先检查待使用邀请数量。', - outcomeUnknownRefreshFailed: - 'Host 可能已完成此操作,但当前状态也未能刷新。请恢复连接并刷新后再重试。', - unavailable: '当前 endpoint 不支持 Peer Mesh', - loading: '正在读取 Mesh 状态…', - checkingPeerConnection: '正在检查此 Runtime Host 的 Peer 连接…', - peerConnectionDisabled: '此 Runtime Host 尚未开启 Peer 连接', - peerConnectionDisabledHint: - '开启后,此 Host 才能创建或加入 Mesh;现有 SSH 连接会继续保留。', - peerConnectionDisableProfileFirst: - '此 Host 的 Direct peer 连接正在使用中,请先在 Host 列表中将它停用。', - enablePeerConnection: '开启 Peer 连接', - peerConnectionStarting: 'Peer 连接已开启,Mesh endpoint 正在就绪', - peerConnectionStartingHint: '通常只需几秒;也可以立即重新检查。', - peerConnectionUpgradeRequired: '此 Runtime Host 版本尚不支持 Peer Mesh 管理', - peerConnectionUpgradeRequiredHint: '请先更新此 Host,再回来开启 Peer 连接。', - working: { - refresh: '正在刷新 Peer Mesh…', - create: '正在创建 Mesh…', - join: '正在加入 Mesh…', - invite: '正在准备邀请…', - 'add-host': '正在将 Runtime Host 加入 Mesh…', - 'enable-peer': '正在为 Runtime Host 开启 Peer 连接…', - update: '正在更新 Mesh…', - rename: '正在保存名称…', - }, - settling: '正在确认最终状态…', - endpoint: '管理对象', - desktopEndpoint: 'Desktop Client', - hostEndpoint: '本机 Runtime Host', - desktopEndpointHelp: '此 Client 用于连接 Mesh 中的 Runtime Host。', - hostEndpointHelp: '此 Host 加入后,其他成员才能连接本机分享的任务。', - advancedSettings: '高级设置', - technicalDetails: '身份与连接配置', - connectivityAutomatic: '自动连接(推荐)', - connectivityKnownRoutesOnly: '仅使用已知路径', - connectivityCustom: '自定义地址发现', - restartRequired: '需要重启', - adaptiveConnectivity: '自适应连接', - adaptiveConnectivityHelp: - 'Maka 会自动竞速可用的直连方式,并在获准时使用成员转发;这里不需要选择具体协议。', - connectivityPolicyLoading: '正在读取连接策略…', - connectivityPolicyLoadFailed: '无法读取连接策略', - connectivityPolicySaveFailed: '无法保存连接策略', - restoreDefaultConnectivityPolicy: '恢复默认设置', - connectivityPolicyRestartRequired: - '重启 Maka 后,已保存的连接策略变更会应用到新连接。', - publicAddressDiscovery: '公网地址发现', - publicStunDefault: '公共 STUN(推荐)', - publicStunDisabled: '不使用公共 STUN', - publicStunCustom: '自定义 STUN', - customStunUrls: 'STUN 地址', - customStunUrlsInvalid: - '请输入以逗号分隔的 stun:主机[:端口] 地址,最多 8 个。', - publicStunDefaultHelp: - '使用 Cloudflare 公共 STUN 尽力发现公网映射。它不承载 Maka 流量,但提供方可观察源 IP 和请求时间;Maka 不保证其可用性。', - publicStunDisabledHelp: - '仅尝试本地地址和其他已知直连路径;跨 NAT 的直连成功率可能降低。', - publicStunCustomHelp: - '使用逗号分隔的 stun: 地址。STUN 只发现网络地址,不承载 Session 内容。', - saveConnectivityPolicy: '保存更改', - peerId: 'Peer ID', - peerIdHelp: '此 endpoint 在 Mesh 中的技术身份;点击 ID 可复制完整值。', - meshId: 'Mesh ID', - meshIdHelp: '用于诊断和识别此 Mesh;点击 ID 可复制完整值。', - thisRuntimeHost: '本机 Runtime Host', - thisDesktop: '本机 Desktop', - peerPathDirect: '直连', - peerPathTransit: '成员转发', - peerPathOther: '其他', - displayName: '在 Mesh 中显示的名称', - meshDisplayName: 'Mesh 名称', - unnamedMesh: '未命名 Mesh', - rename: '修改名称', - renameMesh: '修改 Mesh 名称', - save: '保存', - peerIdCopied: 'Peer ID 已复制', - peerIdCopyFailed: '无法复制 Peer ID', - meshIdCopied: 'Mesh ID 已复制', - copyPeerId: (value: string) => `复制完整 Peer ID:${value}`, - copyMeshId: (value: string) => `复制完整 Mesh ID:${value}`, - empty: '建立你的第一个 Mesh', - emptyHint: '创建新 Mesh,或通过一次性邀请码加入。', - meshes: 'Mesh', - mesh: 'Mesh', - members: '成员', - activeMeshCount: (value: number) => `${value} 个使用中`, - showClosedMeshes: (value: number) => `显示已关闭(${value})`, - noActiveMeshes: '没有使用中的 Mesh', - noActiveMeshesHint: '已关闭的 Mesh 默认隐藏;可通过上方筛选查看。', - authority: '管理者', - member: '成员', - closed: '已关闭', - memberCount: (value: number) => `${value} 个成员`, - pending: (value: number) => `${value} 个待使用邀请`, - transit: '成员转发', - transitHelp: '允许此 Mesh 的成员通过本机建立连接;会使用本机带宽。', - transitToggle: '为此 Mesh 提供转发', - transitStatus: '成员转发状态', - transitLimitsLabel: '成员转发限制', - transitLimits: (value: PeerMeshQueryResult['transit']) => - value - ? `固定上限:每个成员 ${value.maxCircuitsPerPeer} 条连接,每条最长 ${formatHours(value.maxCircuitDurationSeconds)},最多 ${formatMebibytes(value.maxCircuitBytes)}。一次只能为一个 Mesh 开启。` - : '成员转发使用固定资源上限,一次只能为一个 Mesh 开启。', - allowedMembers: '允许成员', - reservations: 'Reservation', - circuits: '连接', - routeState: { - local: '本机', - connecting: '正在连接', - reachable: '可达', - reconnecting: '正在恢复连接', - needs_repair: '需要新邀请码修复', - }, - endpointKind: { - client: 'Client', - host: 'Runtime Host', - unknown: '未标识 Peer', - }, - endpointKindHelp: { - client: 'Client 是操作界面:它连接 Host、浏览任务并发起操作,本身不持有任务。', - host: 'Runtime Host 持有任务和运行状态,并执行经过授权的工作。', - unknown: '此 Peer 尚未报告它是 Client 还是 Runtime Host,通常来自旧版本。', - }, - joinTitle: '加入 Mesh', - joinHint: '粘贴另一个 Peer 生成的一次性邀请码。', - joinCode: '邀请码', - join: '加入', - joinMesh: '加入 Mesh', - invite: '邀请成员', - invitationTitle: '邀请成员', - invitationFor: (value: string) => `Mesh ${value}`, - invitationWarning: '该代码只能使用一次;获得代码的人可以让一个 peer 加入此 Mesh。', - invitationDirectOnly: - '尚未连接到协调节点。此邀请码只包含直接地址,跨 NAT 时可能无法连接。', - invitationExpires: (value: string) => `有效期至 ${value}`, - invitationCopied: '邀请码已复制', - copyInvitation: '复制邀请码', - create: '创建 Mesh', - refresh: '刷新', - back: '返回', - leave: '退出 Mesh', - closeMesh: '关闭 Mesh', - remove: '移除成员', - cancel: '取消', - closeConfirm: '关闭这个 Mesh?', - leaveConfirm: '退出这个 Mesh?', - removeConfirm: '移除这个成员?', - meshActions: 'Mesh 操作', - memberActions: (peerId: string) => `${peerId} 的操作`, - addLocalHost: '添加本机 Runtime Host', - localRuntimeHost: 'Runtime Host', - localHostMissing: '本机 Runtime Host 尚未加入', - localHostMissingHint: '加入后,其他成员才能通过此 Mesh 连接本机分享的任务。', - } - : locale === 'zh-TW' - ? { - title: 'Peer Mesh', - experimental: '實驗性', - failed: 'Peer Mesh 操作失敗', - invalidResult: 'Peer Mesh 回傳了無效結果', - unknownError: 'Peer Mesh 操作失敗', - outcomeUnknown: 'Host 可能已完成此操作。已重新整理目前狀態,請確認後再重試。', - invitationOutcomeUnknown: - 'Host 可能已建立邀請碼,但代碼未能回傳且無法復原。它會自動過期;建立新邀請前請先檢查待使用邀請數量。', - outcomeUnknownRefreshFailed: - 'Host 可能已完成此操作,但目前狀態也無法重新整理。請恢復連線並重新整理後再重試。', - unavailable: '目前 endpoint 不支援 Peer Mesh', - loading: '正在讀取 Mesh 狀態…', - checkingPeerConnection: '正在檢查此 Runtime Host 的 Peer 連線…', - peerConnectionDisabled: '此 Runtime Host 尚未開啟 Peer 連線', - peerConnectionDisabledHint: - '開啟後,此 Host 才能建立或加入 Mesh;現有 SSH 連線會繼續保留。', - peerConnectionDisableProfileFirst: - '此 Host 的 Direct peer 連線正在使用中,請先在 Host 列表中將它停用。', - enablePeerConnection: '開啟 Peer 連線', - peerConnectionStarting: 'Peer 連線已開啟,Mesh endpoint 正在就緒', - peerConnectionStartingHint: '通常只需幾秒;也可以立即重新檢查。', - peerConnectionUpgradeRequired: '此 Runtime Host 版本尚不支援 Peer Mesh 管理', - peerConnectionUpgradeRequiredHint: '請先更新此 Host,再回來開啟 Peer 連線。', - working: { - refresh: '正在重新整理 Peer Mesh…', - create: '正在建立 Mesh…', - join: '正在加入 Mesh…', - invite: '正在準備邀請…', - 'add-host': '正在將 Runtime Host 加入 Mesh…', - 'enable-peer': '正在為 Runtime Host 開啟 Peer 連線…', - update: '正在更新 Mesh…', - rename: '正在儲存名稱…', - }, - settling: '正在確認最終狀態…', - endpoint: '管理對象', - desktopEndpoint: 'Desktop Client', - hostEndpoint: '本機 Runtime Host', - desktopEndpointHelp: '此 Client 用於連線至 Mesh 中的 Runtime Host。', - hostEndpointHelp: '此 Host 加入後,其他成員才能連線至本機分享的任務。', - advancedSettings: '進階設定', - technicalDetails: '身分與連線設定', - connectivityAutomatic: '自動連線(建議)', - connectivityKnownRoutesOnly: '僅使用已知路徑', - connectivityCustom: '自訂位址探索', - restartRequired: '需要重新啟動', - adaptiveConnectivity: '自適應連線', - adaptiveConnectivityHelp: - 'Maka 會自動競速可用的直接連線方式,並在獲准時使用成員轉送;此處不需要選擇特定通訊協定。', - connectivityPolicyLoading: '正在讀取連線策略…', - connectivityPolicyLoadFailed: '無法讀取連線策略', - connectivityPolicySaveFailed: '無法儲存連線策略', - restoreDefaultConnectivityPolicy: '恢復預設設定', - connectivityPolicyRestartRequired: - '重新啟動 Maka 後,已儲存的連線策略變更會套用至新連線。', - publicAddressDiscovery: '公網位址探索', - publicStunDefault: '公共 STUN(建議)', - publicStunDisabled: '不使用公共 STUN', - publicStunCustom: '自訂 STUN', - customStunUrls: 'STUN 位址', - customStunUrlsInvalid: '請輸入以逗號分隔的 stun:主機[:連接埠] 位址,最多 8 個。', - publicStunDefaultHelp: - '使用 Cloudflare 公共 STUN 盡力探索公網對映。它不承載 Maka 流量,但提供者可觀察來源 IP 和請求時間;Maka 不保證其可用性。', - publicStunDisabledHelp: - '僅嘗試本機位址和其他已知直接連線路徑;跨 NAT 的直接連線成功率可能降低。', - publicStunCustomHelp: - '使用逗號分隔的 stun: 位址。STUN 只探索網路位址,不承載 Session 內容。', - saveConnectivityPolicy: '儲存變更', - peerId: 'Peer ID', - peerIdHelp: '此 endpoint 在 Mesh 中的技術身分;點選 ID 可複製完整值。', - meshId: 'Mesh ID', - meshIdHelp: '用於診斷和識別此 Mesh;點選 ID 可複製完整值。', - thisRuntimeHost: '本機 Runtime Host', - thisDesktop: '本機 Desktop', - peerPathDirect: '直接連線', - peerPathTransit: '成員轉送', - peerPathOther: '其他', - displayName: '在 Mesh 中顯示的名稱', - meshDisplayName: 'Mesh 名稱', - unnamedMesh: '未命名 Mesh', - rename: '修改名稱', - renameMesh: '修改 Mesh 名稱', - save: '儲存', - peerIdCopied: 'Peer ID 已複製', - peerIdCopyFailed: '無法複製 Peer ID', - meshIdCopied: 'Mesh ID 已複製', - copyPeerId: (value: string) => `複製完整 Peer ID:${value}`, - copyMeshId: (value: string) => `複製完整 Mesh ID:${value}`, - empty: '建立你的第一個 Mesh', - emptyHint: '建立新的 Mesh,或透過一次性邀請碼加入。', - meshes: 'Mesh', - mesh: 'Mesh', - members: '成員', - activeMeshCount: (value: number) => `${value} 個使用中`, - showClosedMeshes: (value: number) => `顯示已關閉(${value})`, - noActiveMeshes: '沒有使用中的 Mesh', - noActiveMeshesHint: '已關閉的 Mesh 預設隱藏;可透過上方篩選查看。', - authority: '管理者', - member: '成員', - closed: '已關閉', - memberCount: (value: number) => `${value} 個成員`, - pending: (value: number) => `${value} 個待使用邀請`, - transit: '成員轉送', - transitHelp: '允許此 Mesh 的成員透過本機建立連線;會使用本機頻寬。', - transitToggle: '為此 Mesh 提供轉送', - transitStatus: '成員轉送狀態', - transitLimitsLabel: '成員轉送限制', - transitLimits: (value: PeerMeshQueryResult['transit']) => - value - ? `固定上限:每位成員 ${value.maxCircuitsPerPeer} 條連線,每條最長 ${formatHours(value.maxCircuitDurationSeconds)},最多 ${formatMebibytes(value.maxCircuitBytes)}。一次只能為一個 Mesh 開啟。` - : '成員轉送使用固定資源上限,一次只能為一個 Mesh 開啟。', - allowedMembers: '允許成員', - reservations: 'Reservation', - circuits: '連線', - routeState: { - local: '本機', - route_available: '路徑可用', - coordination_only: '僅協調路徑', - stale: '路徑已過期', - unknown: '路徑未知', - }, - endpointKind: { - client: 'Client', - host: 'Runtime Host', - unknown: '未識別 Peer', - }, - endpointKindHelp: { - client: 'Client 是操作介面:它連線至 Host、瀏覽任務並發起操作,本身不持有任務。', - host: 'Runtime Host 持有任務和執行狀態,並執行經過授權的工作。', - unknown: '此 Peer 尚未回報它是 Client 或 Runtime Host,通常來自舊版本。', - }, - joinTitle: '加入 Mesh', - joinHint: '貼上另一個 Peer 產生的一次性邀請碼。', - joinCode: '邀請碼', - join: '加入', - joinMesh: '加入 Mesh', - invite: '邀請成員', - invitationTitle: '邀請成員', - invitationFor: (value: string) => `Mesh ${value}`, - invitationWarning: '此代碼只能使用一次;取得代碼的人可以讓一個 peer 加入此 Mesh。', - invitationDirectOnly: - '尚未連線至協調節點。此邀請碼只包含直接位址,跨 NAT 時可能無法連線。', - invitationExpires: (value: string) => `有效期限至 ${value}`, - invitationCopied: '邀請碼已複製', - copyInvitation: '複製邀請碼', - create: '建立 Mesh', - refresh: '重新整理', - back: '返回', - leave: '退出 Mesh', - closeMesh: '關閉 Mesh', - remove: '移除成員', - cancel: '取消', - closeConfirm: '關閉這個 Mesh?', - leaveConfirm: '退出這個 Mesh?', - removeConfirm: '移除這個成員?', - meshActions: 'Mesh 操作', - memberActions: (peerId: string) => `${peerId} 的操作`, - addLocalHost: '新增本機 Runtime Host', - localRuntimeHost: 'Runtime Host', - localHostMissing: '本機 Runtime Host 尚未加入', - localHostMissingHint: '加入後,其他成員才能透過此 Mesh 連線至本機分享的任務。', - } - : { - title: 'Peer Mesh', - experimental: 'Experimental', - failed: 'Peer Mesh operation failed', - invalidResult: 'Peer Mesh returned an invalid result', - unknownError: 'Peer Mesh operation failed', - outcomeUnknown: - 'The Host may have completed this operation. Its current state was refreshed; review it before trying again.', - invitationOutcomeUnknown: - 'The Host may have created an invitation, but its one-time code was not returned and cannot be recovered. It will expire automatically; review the pending invitation count before creating another.', - outcomeUnknownRefreshFailed: - 'The Host may have completed this operation, but its current state could not be refreshed. Reconnect and refresh before trying again.', - unavailable: 'Peer Mesh is unavailable for this endpoint', - loading: 'Loading Mesh status…', - checkingPeerConnection: "Checking this Runtime Host's peer connection…", - peerConnectionDisabled: 'Peer connectivity is not enabled for this Runtime Host', - peerConnectionDisabledHint: - 'Enable it so this Host can create or join Meshes. The existing SSH connection remains available.', - peerConnectionDisableProfileFirst: - "This Host's Direct peer connection is in use. Disable it in the Host list before changing the listener.", - enablePeerConnection: 'Enable peer connectivity', - peerConnectionStarting: 'Peer connectivity is enabled; the Mesh endpoint is starting', - peerConnectionStartingHint: 'This normally takes a few seconds. You can also check again.', - peerConnectionUpgradeRequired: 'This Runtime Host version cannot manage Peer Mesh', - peerConnectionUpgradeRequiredHint: 'Update this Host before enabling peer connectivity.', - working: { - refresh: 'Refreshing Peer Mesh…', - create: 'Creating Mesh…', - join: 'Joining Mesh…', - invite: 'Preparing invitation…', - 'add-host': 'Adding the Runtime Host to the Mesh…', - 'enable-peer': 'Enabling peer connectivity for the Runtime Host…', - update: 'Updating Mesh…', - rename: 'Saving name…', - }, - settling: 'Confirming the final state…', - endpoint: 'Manage endpoint', - desktopEndpoint: 'Desktop Client', - hostEndpoint: 'Local Runtime Host', - desktopEndpointHelp: 'This Client connects to Runtime Hosts in the Mesh.', - hostEndpointHelp: 'Add this Host so other members can reach tasks shared from this device.', - advancedSettings: 'Advanced settings', - technicalDetails: 'Identity and connectivity details', - connectivityAutomatic: 'Automatic connectivity (recommended)', - connectivityKnownRoutesOnly: 'Known routes only', - connectivityCustom: 'Custom address discovery', - restartRequired: 'Restart required', - adaptiveConnectivity: 'Adaptive connectivity', - adaptiveConnectivityHelp: - 'Maka races available direct paths automatically and uses approved member transit when needed. You do not choose a transport protocol here.', - connectivityPolicyLoading: 'Loading connectivity policy…', - connectivityPolicyLoadFailed: 'Could not load connectivity policy', - connectivityPolicySaveFailed: 'Could not save connectivity policy', - restoreDefaultConnectivityPolicy: 'Restore defaults', - connectivityPolicyRestartRequired: - 'Restart Maka to apply saved connectivity-policy changes to new connections.', - publicAddressDiscovery: 'Public address discovery', - publicStunDefault: 'Public STUN (recommended)', - publicStunDisabled: 'No public STUN', - publicStunCustom: 'Custom STUN', - customStunUrls: 'STUN addresses', - customStunUrlsInvalid: - 'Enter up to 8 comma-separated stun:host[:port] addresses.', - publicStunDefaultHelp: - 'Uses Cloudflare public STUN on a best-effort basis to discover public mappings. It never carries Maka traffic, but the provider can observe source IPs and request timing; Maka provides no availability guarantee.', - publicStunDisabledHelp: - 'Only local addresses and other known direct paths are attempted; direct connectivity across NAT may be reduced.', - publicStunCustomHelp: - 'Enter comma-separated stun: addresses. STUN discovers network addresses and never carries Session content.', - saveConnectivityPolicy: 'Save changes', - peerId: 'Peer ID', - peerIdHelp: 'Technical identity for this endpoint. Select the ID to copy its full value.', - meshId: 'Mesh ID', - meshIdHelp: 'Used to identify and diagnose this Mesh. Select the ID to copy its full value.', - thisRuntimeHost: 'This Runtime Host', - thisDesktop: 'This Desktop', - peerPathDirect: 'Direct', - peerPathTransit: 'Member transit', - peerPathOther: 'Other', - displayName: 'Name shown in the Mesh', - meshDisplayName: 'Mesh name', - unnamedMesh: 'Unnamed Mesh', - rename: 'Rename', - renameMesh: 'Rename Mesh', - save: 'Save', - peerIdCopied: 'Peer ID copied', - peerIdCopyFailed: 'Could not copy Peer ID', - meshIdCopied: 'Mesh ID copied', - copyPeerId: (value: string) => `Copy full Peer ID: ${value}`, - copyMeshId: (value: string) => `Copy full Mesh ID: ${value}`, - empty: 'Build your first Mesh', - emptyHint: 'Create a new Mesh or join one with a one-time invitation.', - meshes: 'Meshes', - mesh: 'Mesh', - members: 'Members', - activeMeshCount: (value: number) => `${value} active`, - showClosedMeshes: (value: number) => `Show closed (${value})`, - noActiveMeshes: 'No active Meshes', - noActiveMeshesHint: 'Closed Meshes are hidden by default. Use the filter above to show them.', - authority: 'Owner', - member: 'Member', - closed: 'Closed', - memberCount: (value: number) => value === 1 ? '1 member' : `${value} members`, - pending: (value: number) => `${value} pending invites`, - transit: 'Member transit', - transitHelp: 'Let members of this Mesh connect through this device using its bandwidth.', - transitToggle: 'Provide transit for this Mesh', - transitStatus: 'Member transit status', - transitLimitsLabel: 'Member transit limits', - transitLimits: (value: PeerMeshQueryResult['transit']) => - value - ? `Fixed limits: ${value.maxCircuitsPerPeer} circuits per member, ${formatHours(value.maxCircuitDurationSeconds)} per circuit, and ${formatMebibytes(value.maxCircuitBytes)}. Only one Mesh can be served at a time.` - : 'Member transit uses fixed resource limits. Only one Mesh can be served at a time.', - allowedMembers: 'Allowed members', - reservations: 'Reservations', - circuits: 'Circuits', - routeState: { - local: 'Local', - connecting: 'Connecting', - reachable: 'Reachable', - reconnecting: 'Reconnecting', - needs_repair: 'Needs a new invitation', - }, - endpointKind: { - client: 'Client', - host: 'Runtime Host', - unknown: 'Unidentified peer', - }, - endpointKindHelp: { - client: 'A Client is the interface that connects to Hosts, browses tasks, and starts actions. It does not own tasks.', - host: 'A Runtime Host owns tasks and runtime state, and executes authorized work.', - unknown: 'This peer has not reported whether it is a Client or Runtime Host, usually because it uses an older build.', - }, - joinTitle: 'Join a Mesh', - joinHint: 'Paste a one-time invitation created by another peer.', - joinCode: 'Invitation', - join: 'Join', - joinMesh: 'Join Mesh', - invite: 'Invite member', - invitationTitle: 'Invite a member', - invitationFor: (value: string) => `Mesh ${value}`, - invitationWarning: - 'This code works once. Anyone holding it can admit one peer to this Mesh.', - invitationDirectOnly: - 'No coordination peer is available yet. This invitation contains direct routes only and may not work across NATs.', - invitationExpires: (value: string) => `Expires ${value}`, - invitationCopied: 'Invitation copied', - copyInvitation: 'Copy invitation', - create: 'Create Mesh', - refresh: 'Refresh', - back: 'Back', - leave: 'Leave Mesh', - closeMesh: 'Close Mesh', - remove: 'Remove member', - cancel: 'Cancel', - closeConfirm: 'Close this Mesh?', - leaveConfirm: 'Leave this Mesh?', - removeConfirm: 'Remove this member?', - meshActions: 'Mesh actions', - memberActions: (peerId: string) => `Actions for ${peerId}`, - addLocalHost: 'Add local Runtime Host', - localRuntimeHost: 'Runtime Host', - localHostMissing: 'Local Runtime Host has not joined', - localHostMissingHint: - 'Add it so other members can reach tasks shared from this device through the Mesh.', - }; -} - -function formatHours(seconds: number): string { - return `${seconds / 3_600}h`; -} - -function formatMebibytes(bytes: number): string { - return `${bytes / (1024 * 1024)} MiB`; -} diff --git a/apps/desktop/src/renderer/locales/agent-graph-copy.ts b/apps/desktop/src/renderer/locales/agent-graph-copy.ts new file mode 100644 index 0000000000..e87370a159 --- /dev/null +++ b/apps/desktop/src/renderer/locales/agent-graph-copy.ts @@ -0,0 +1,235 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { + AgentGraphClientOperator, + AgentGraphClientSnapshot, +} from '@maka/runtime/stream-graph-read-model'; +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; + +export interface AgentGraphPanelCopy { + title: string; + loading: string; + retry: string; + collapse: string; + expand: string; + dismiss: string; + stop: string; + stopping: string; + stopFailed: string; + loadFailed: string; + openSession: string; + operators: string; + selectedResults: string; + epoch: string; + currentEpoch: string; + historicalEpoch: string; + cappedEpochs(count: number): string; + noOperators: string; + hiddenOperators(count: number): string; + progress(settled: number, total: number, hasOmitted: boolean): string; + status(status: AgentGraphClientSnapshot['status']): string; + operatorStatus(status: AgentGraphClientOperator['status']): string; + wait(operator: AgentGraphClientOperator): string | undefined; +} + +const AGENT_GRAPH_PANEL_COPY = { + 'zh-CN': { + title: 'Agent Graph', + loading: '正在读取 Graph 状态…', + retry: '重试', + collapse: '收起 Agent Graph', + expand: '展开 Agent Graph', + dismiss: '关闭 Agent Graph', + stop: '停止 Graph', + stopping: '停止中…', + stopFailed: '停止 Graph 失败,请重试。', + loadFailed: 'Graph 状态刷新失败。', + openSession: '打开子任务', + operators: 'Operators', + selectedResults: '已选择结果', + epoch: 'Graph 运行轮次', + currentEpoch: '当前', + historicalEpoch: '历史记录(只读)', + cappedEpochs: (count) => `仅显示最近 ${count} 次运行`, + noOperators: '等待主 Agent 创建 operator…', + hiddenOperators: (count) => `另有 ${count} 个 operator`, + progress: (settled, total, hasOmitted) => + hasOmitted ? `可见 ${settled}/${total} 已结束` : `${settled}/${total} 已结束`, + status: (status) => + ({ + empty: '等待调度', + active: '运行中', + closing: '收尾中', + waiting: '等待中', + stopped: '已停止', + failed: '失败', + completed: '已完成', + })[status], + operatorStatus: (status) => + ({ + not_started: '未启动', + waiting: '等待', + runnable: '可运行', + running: '运行中', + blocked: '受阻', + completed: '完成', + failed: '失败', + aborted: '中止', + cancelled: '取消', + })[status], + wait: waitReasonZh, + }, + 'zh-TW': { + title: 'Agent Graph', + loading: '正在讀取 Graph 狀態…', + retry: '重試', + collapse: '收起 Agent Graph', + expand: '展開 Agent Graph', + dismiss: '關閉 Agent Graph', + stop: '停止 Graph', + stopping: '停止中…', + stopFailed: '停止 Graph 失敗,請重試。', + loadFailed: 'Graph 狀態重新整理失敗。', + openSession: '開啟子任務', + operators: 'Operators', + selectedResults: '已選取結果', + epoch: 'Graph 執行輪次', + currentEpoch: '目前', + historicalEpoch: '歷史記錄(唯讀)', + cappedEpochs: (count) => `僅顯示最近 ${count} 次執行`, + noOperators: '等待主 Agent 建立 operator…', + hiddenOperators: (count) => `另有 ${count} 個 operator`, + progress: (settled, total, hasOmitted) => + hasOmitted ? `可見項目中 ${settled}/${total} 已結束` : `${settled}/${total} 已結束`, + status: (status) => + ({ + empty: '等待排程', + active: '執行中', + closing: '收尾中', + waiting: '等待中', + stopped: '已停止', + failed: '失敗', + completed: '已完成', + })[status], + operatorStatus: (status) => + ({ + not_started: '尚未啟動', + waiting: '等待', + runnable: '可執行', + running: '執行中', + blocked: '受阻', + completed: '完成', + failed: '失敗', + aborted: '已中止', + cancelled: '已取消', + })[status], + wait: waitReasonZhTw, + }, + en: { + title: 'Agent Graph', + loading: 'Loading graph state…', + retry: 'Retry', + collapse: 'Collapse Agent Graph', + expand: 'Expand Agent Graph', + dismiss: 'Dismiss Agent Graph', + stop: 'Stop graph', + stopping: 'Stopping…', + stopFailed: 'Could not stop the graph. Try again.', + loadFailed: 'Could not refresh graph state.', + openSession: 'Open child task', + operators: 'Operators', + selectedResults: 'Selected results', + epoch: 'Graph run', + currentEpoch: 'Current', + historicalEpoch: 'History (read-only)', + cappedEpochs: (count) => `Showing the newest ${count} runs`, + noOperators: 'Waiting for the main agent to create an operator…', + hiddenOperators: (count) => `${count} more operator${count === 1 ? '' : 's'}`, + progress: (settled, total, hasOmitted) => + hasOmitted ? `${settled}/${total} visible settled` : `${settled}/${total} settled`, + status: (status) => + ({ + empty: 'Awaiting schedule', + active: 'Running', + closing: 'Finishing', + waiting: 'Waiting', + stopped: 'Stopped', + failed: 'Failed', + completed: 'Completed', + })[status], + operatorStatus: (status) => + ({ + not_started: 'Not started', + waiting: 'Waiting', + runnable: 'Runnable', + running: 'Running', + blocked: 'Blocked', + completed: 'Completed', + failed: 'Failed', + aborted: 'Aborted', + cancelled: 'Cancelled', + })[status], + wait: waitReasonEn, + }, +} satisfies UiCatalog; + +export function getAgentGraphPanelCopy(locale: UiLocale): AgentGraphPanelCopy { + return AGENT_GRAPH_PANEL_COPY[locale]; +} + +function firstWait(operator: AgentGraphClientOperator) { + return operator.readiness.find((readiness) => readiness.status === 'waiting')?.waitingFor[0]; +} + +function waitReasonEn(operator: AgentGraphClientOperator): string | undefined { + const wait = firstWait(operator); + if (!wait) return undefined; + if (wait.kind === 'input_route') { + return `Waiting for input from ${wait.upstreamOperatorIds.join(', ')}`; + } + if (wait.kind === 'activation_missing') { + return `Waiting for ${wait.operatorId} activation`; + } + return `Waiting for ${wait.operatorId} to settle`; +} + +function waitReasonZh(operator: AgentGraphClientOperator): string | undefined { + const wait = firstWait(operator); + if (!wait) return undefined; + if (wait.kind === 'input_route') { + return `等待 ${wait.upstreamOperatorIds.join('、')} 的输入`; + } + if (wait.kind === 'activation_missing') { + return `等待 ${wait.operatorId} activation`; + } + return `等待 ${wait.operatorId} 结束`; +} + +function waitReasonZhTw(operator: AgentGraphClientOperator): string | undefined { + const wait = firstWait(operator); + if (!wait) return undefined; + if (wait.kind === 'input_route') { + return `等待 ${wait.upstreamOperatorIds.join('、')} 的輸入`; + } + if (wait.kind === 'activation_missing') { + return `等待 ${wait.operatorId} 啟用`; + } + return `等待 ${wait.operatorId} 結束`; +} diff --git a/apps/desktop/src/renderer/locales/peer-mesh-copy.ts b/apps/desktop/src/renderer/locales/peer-mesh-copy.ts new file mode 100644 index 0000000000..c9fa53ee34 --- /dev/null +++ b/apps/desktop/src/renderer/locales/peer-mesh-copy.ts @@ -0,0 +1,641 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { PeerMeshMemberProjection, PeerMeshQueryResult } from '@maka/runtime-host/protocol'; +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; + +export interface PeerMeshCopy { + readonly title: string; + readonly experimental: string; + readonly failed: string; + readonly invalidResult: string; + readonly unknownError: string; + readonly outcomeUnknown: string; + readonly invitationOutcomeUnknown: string; + readonly outcomeUnknownRefreshFailed: string; + readonly unavailable: string; + readonly loading: string; + readonly checkingPeerConnection: string; + readonly peerConnectionDisabled: string; + readonly peerConnectionDisabledHint: string; + readonly peerConnectionDisableProfileFirst: string; + readonly enablePeerConnection: string; + readonly peerConnectionStarting: string; + readonly peerConnectionStartingHint: string; + readonly peerConnectionUpgradeRequired: string; + readonly peerConnectionUpgradeRequiredHint: string; + readonly working: Record< + 'refresh' | 'create' | 'join' | 'invite' | 'add-host' | 'enable-peer' | 'update' | 'rename', + string + >; + readonly settling: string; + readonly endpoint: string; + readonly desktopEndpoint: string; + readonly hostEndpoint: string; + readonly desktopEndpointHelp: string; + readonly hostEndpointHelp: string; + readonly advancedSettings: string; + readonly technicalDetails: string; + readonly connectivityAutomatic: string; + readonly connectivityKnownRoutesOnly: string; + readonly connectivityCustom: string; + readonly restartRequired: string; + readonly adaptiveConnectivity: string; + readonly adaptiveConnectivityHelp: string; + readonly connectivityPolicyLoading: string; + readonly connectivityPolicyLoadFailed: string; + readonly connectivityPolicySaveFailed: string; + readonly restoreDefaultConnectivityPolicy: string; + readonly connectivityPolicyRestartRequired: string; + readonly publicAddressDiscovery: string; + readonly publicStunDefault: string; + readonly publicStunDisabled: string; + readonly publicStunCustom: string; + readonly customStunUrls: string; + readonly customStunUrlsInvalid: string; + readonly publicStunDefaultHelp: string; + readonly publicStunDisabledHelp: string; + readonly publicStunCustomHelp: string; + readonly saveConnectivityPolicy: string; + readonly peerId: string; + readonly peerIdHelp: string; + readonly meshId: string; + readonly meshIdHelp: string; + readonly thisRuntimeHost: string; + readonly thisDesktop: string; + readonly displayName: string; + readonly meshDisplayName: string; + readonly unnamedMesh: string; + readonly rename: string; + readonly renameMesh: string; + readonly save: string; + readonly peerIdCopied: string; + readonly meshIdCopied: string; + copyPeerId(value: string): string; + copyMeshId(value: string): string; + readonly empty: string; + readonly emptyHint: string; + readonly meshes: string; + readonly mesh: string; + readonly members: string; + activeMeshCount(value: number): string; + showClosedMeshes(value: number): string; + readonly noActiveMeshes: string; + readonly noActiveMeshesHint: string; + readonly authority: string; + readonly member: string; + readonly closed: string; + memberCount(value: number): string; + pending(value: number): string; + readonly transit: string; + readonly transitHelp: string; + readonly transitToggle: string; + readonly transitStatus: string; + readonly transitLimitsLabel: string; + transitLimits(value: PeerMeshQueryResult['transit']): string; + readonly allowedMembers: string; + readonly reservations: string; + readonly circuits: string; + readonly routeState: Record; + readonly endpointKind: Record<'client' | 'host' | 'unknown', string>; + readonly endpointKindHelp: Record<'client' | 'host' | 'unknown', string>; + readonly joinTitle: string; + readonly joinHint: string; + readonly joinCode: string; + readonly join: string; + readonly joinMesh: string; + readonly invite: string; + readonly invitationTitle: string; + invitationFor(value: string): string; + readonly invitationWarning: string; + readonly invitationDirectOnly: string; + invitationExpires(value: string): string; + readonly invitationCopied: string; + readonly copyInvitation: string; + readonly create: string; + readonly refresh: string; + readonly back: string; + readonly leave: string; + readonly closeMesh: string; + readonly remove: string; + readonly cancel: string; + readonly closeConfirm: string; + readonly leaveConfirm: string; + readonly removeConfirm: string; + readonly meshActions: string; + memberActions(peerId: string): string; + readonly addLocalHost: string; + readonly localRuntimeHost: string; + readonly localHostMissing: string; + readonly localHostMissingHint: string; +} + +const PEER_MESH_COPY = { + 'zh-CN': { + title: 'Peer Mesh', + experimental: '实验性', + failed: 'Peer Mesh 操作失败', + invalidResult: 'Peer Mesh 返回了无效结果', + unknownError: 'Peer Mesh 操作失败', + outcomeUnknown: 'Host 可能已完成此操作。已刷新当前状态,请确认后再重试。', + invitationOutcomeUnknown: + 'Host 可能已创建邀请码,但代码未能返回且无法恢复。它会自动过期;创建新邀请前请先检查待使用邀请数量。', + outcomeUnknownRefreshFailed: + 'Host 可能已完成此操作,但当前状态也未能刷新。请恢复连接并刷新后再重试。', + unavailable: '当前 endpoint 不支持 Peer Mesh', + loading: '正在读取 Mesh 状态…', + checkingPeerConnection: '正在检查此 Runtime Host 的 Peer 连接…', + peerConnectionDisabled: '此 Runtime Host 尚未开启 Peer 连接', + peerConnectionDisabledHint: + '开启后,此 Host 才能创建或加入 Mesh;现有 SSH 连接会继续保留。', + peerConnectionDisableProfileFirst: + '此 Host 的 Direct peer 连接正在使用中,请先在 Host 列表中将它停用。', + enablePeerConnection: '开启 Peer 连接', + peerConnectionStarting: 'Peer 连接已开启,Mesh endpoint 正在就绪', + peerConnectionStartingHint: '通常只需几秒;也可以立即重新检查。', + peerConnectionUpgradeRequired: '此 Runtime Host 版本尚不支持 Peer Mesh 管理', + peerConnectionUpgradeRequiredHint: '请先更新此 Host,再回来开启 Peer 连接。', + working: { + refresh: '正在刷新 Peer Mesh…', + create: '正在创建 Mesh…', + join: '正在加入 Mesh…', + invite: '正在准备邀请…', + 'add-host': '正在将 Runtime Host 加入 Mesh…', + 'enable-peer': '正在为 Runtime Host 开启 Peer 连接…', + update: '正在更新 Mesh…', + rename: '正在保存名称…', + }, + settling: '正在确认最终状态…', + endpoint: '管理对象', + desktopEndpoint: 'Desktop Client', + hostEndpoint: '本机 Runtime Host', + desktopEndpointHelp: '此 Client 用于连接 Mesh 中的 Runtime Host。', + hostEndpointHelp: '此 Host 加入后,其他成员才能连接本机分享的任务。', + advancedSettings: '高级设置', + technicalDetails: '身份与连接配置', + connectivityAutomatic: '自动连接(推荐)', + connectivityKnownRoutesOnly: '仅使用已知路径', + connectivityCustom: '自定义地址发现', + restartRequired: '需要重启', + adaptiveConnectivity: '自适应连接', + adaptiveConnectivityHelp: + 'Maka 会自动竞速可用的直连方式,并在获准时使用成员转发;这里不需要选择具体协议。', + connectivityPolicyLoading: '正在读取连接策略…', + connectivityPolicyLoadFailed: '无法读取连接策略', + connectivityPolicySaveFailed: '无法保存连接策略', + restoreDefaultConnectivityPolicy: '恢复默认设置', + connectivityPolicyRestartRequired: + '重启 Maka 后,已保存的连接策略变更会应用到新连接。', + publicAddressDiscovery: '公网地址发现', + publicStunDefault: '公共 STUN(推荐)', + publicStunDisabled: '不使用公共 STUN', + publicStunCustom: '自定义 STUN', + customStunUrls: 'STUN 地址', + customStunUrlsInvalid: + '请输入以逗号分隔的 stun:主机[:端口] 地址,最多 8 个。', + publicStunDefaultHelp: + '使用 Cloudflare 公共 STUN 尽力发现公网映射。它不承载 Maka 流量,但提供方可观察源 IP 和请求时间;Maka 不保证其可用性。', + publicStunDisabledHelp: + '仅尝试本地地址和其他已知直连路径;跨 NAT 的直连成功率可能降低。', + publicStunCustomHelp: + '使用逗号分隔的 stun: 地址。STUN 只发现网络地址,不承载 Session 内容。', + saveConnectivityPolicy: '保存更改', + peerId: 'Peer ID', + peerIdHelp: '此 endpoint 在 Mesh 中的技术身份;点击 ID 可复制完整值。', + meshId: 'Mesh ID', + meshIdHelp: '用于诊断和识别此 Mesh;点击 ID 可复制完整值。', + thisRuntimeHost: '本机 Runtime Host', + thisDesktop: '本机 Desktop', + displayName: '在 Mesh 中显示的名称', + meshDisplayName: 'Mesh 名称', + unnamedMesh: '未命名 Mesh', + rename: '修改名称', + renameMesh: '修改 Mesh 名称', + save: '保存', + peerIdCopied: 'Peer ID 已复制', + meshIdCopied: 'Mesh ID 已复制', + copyPeerId: (value: string) => `复制完整 Peer ID:${value}`, + copyMeshId: (value: string) => `复制完整 Mesh ID:${value}`, + empty: '建立你的第一个 Mesh', + emptyHint: '创建新 Mesh,或通过一次性邀请码加入。', + meshes: 'Mesh', + mesh: 'Mesh', + members: '成员', + activeMeshCount: (value: number) => `${value} 个使用中`, + showClosedMeshes: (value: number) => `显示已关闭(${value})`, + noActiveMeshes: '没有使用中的 Mesh', + noActiveMeshesHint: '已关闭的 Mesh 默认隐藏;可通过上方筛选查看。', + authority: '管理者', + member: '成员', + closed: '已关闭', + memberCount: (value: number) => `${value} 个成员`, + pending: (value: number) => `${value} 个待使用邀请`, + transit: '成员转发', + transitHelp: '允许此 Mesh 的成员通过本机建立连接;会使用本机带宽。', + transitToggle: '为此 Mesh 提供转发', + transitStatus: '成员转发状态', + transitLimitsLabel: '成员转发限制', + transitLimits: (value: PeerMeshQueryResult['transit']) => + value + ? `固定上限:每个成员 ${value.maxCircuitsPerPeer} 条连接,每条最长 ${formatHours(value.maxCircuitDurationSeconds)},最多 ${formatMebibytes(value.maxCircuitBytes)}。一次只能为一个 Mesh 开启。` + : '成员转发使用固定资源上限,一次只能为一个 Mesh 开启。', + allowedMembers: '允许成员', + reservations: 'Reservation', + circuits: '连接', + routeState: { + local: '本机', + connecting: '正在连接', + reachable: '可达', + reconnecting: '正在恢复连接', + needs_repair: '需要新邀请码修复', + }, + endpointKind: { + client: 'Client', + host: 'Runtime Host', + unknown: '未标识 Peer', + }, + endpointKindHelp: { + client: 'Client 是操作界面:它连接 Host、浏览任务并发起操作,本身不持有任务。', + host: 'Runtime Host 持有任务和运行状态,并执行经过授权的工作。', + unknown: '此 Peer 尚未报告它是 Client 还是 Runtime Host,通常来自旧版本。', + }, + joinTitle: '加入 Mesh', + joinHint: '粘贴另一个 Peer 生成的一次性邀请码。', + joinCode: '邀请码', + join: '加入', + joinMesh: '加入 Mesh', + invite: '邀请成员', + invitationTitle: '邀请成员', + invitationFor: (value: string) => `Mesh ${value}`, + invitationWarning: '该代码只能使用一次;获得代码的人可以让一个 peer 加入此 Mesh。', + invitationDirectOnly: + '尚未连接到协调节点。此邀请码只包含直接地址,跨 NAT 时可能无法连接。', + invitationExpires: (value: string) => `有效期至 ${value}`, + invitationCopied: '邀请码已复制', + copyInvitation: '复制邀请码', + create: '创建 Mesh', + refresh: '刷新', + back: '返回', + leave: '退出 Mesh', + closeMesh: '关闭 Mesh', + remove: '移除成员', + cancel: '取消', + closeConfirm: '关闭这个 Mesh?', + leaveConfirm: '退出这个 Mesh?', + removeConfirm: '移除这个成员?', + meshActions: 'Mesh 操作', + memberActions: (peerId: string) => `${peerId} 的操作`, + addLocalHost: '添加本机 Runtime Host', + localRuntimeHost: 'Runtime Host', + localHostMissing: '本机 Runtime Host 尚未加入', + localHostMissingHint: '加入后,其他成员才能通过此 Mesh 连接本机分享的任务。', + }, + 'zh-TW': { + title: 'Peer Mesh', + experimental: '實驗性', + failed: 'Peer Mesh 操作失敗', + invalidResult: 'Peer Mesh 回傳了無效結果', + unknownError: 'Peer Mesh 操作失敗', + outcomeUnknown: 'Host 可能已完成此操作。已重新整理目前狀態,請確認後再重試。', + invitationOutcomeUnknown: + 'Host 可能已建立邀請碼,但代碼未能回傳且無法復原。它會自動過期;建立新邀請前請先檢查待使用邀請數量。', + outcomeUnknownRefreshFailed: + 'Host 可能已完成此操作,但目前狀態也無法重新整理。請恢復連線並重新整理後再重試。', + unavailable: '目前 endpoint 不支援 Peer Mesh', + loading: '正在讀取 Mesh 狀態…', + checkingPeerConnection: '正在檢查此 Runtime Host 的 Peer 連線…', + peerConnectionDisabled: '此 Runtime Host 尚未開啟 Peer 連線', + peerConnectionDisabledHint: + '開啟後,此 Host 才能建立或加入 Mesh;現有 SSH 連線會繼續保留。', + peerConnectionDisableProfileFirst: + '此 Host 的 Direct peer 連線正在使用中,請先在 Host 列表中將它停用。', + enablePeerConnection: '開啟 Peer 連線', + peerConnectionStarting: 'Peer 連線已開啟,Mesh endpoint 正在就緒', + peerConnectionStartingHint: '通常只需幾秒;也可以立即重新檢查。', + peerConnectionUpgradeRequired: '此 Runtime Host 版本尚不支援 Peer Mesh 管理', + peerConnectionUpgradeRequiredHint: '請先更新此 Host,再回來開啟 Peer 連線。', + working: { + refresh: '正在重新整理 Peer Mesh…', + create: '正在建立 Mesh…', + join: '正在加入 Mesh…', + invite: '正在準備邀請…', + 'add-host': '正在將 Runtime Host 加入 Mesh…', + 'enable-peer': '正在為 Runtime Host 開啟 Peer 連線…', + update: '正在更新 Mesh…', + rename: '正在儲存名稱…', + }, + settling: '正在確認最終狀態…', + endpoint: '管理對象', + desktopEndpoint: 'Desktop Client', + hostEndpoint: '本機 Runtime Host', + desktopEndpointHelp: '此 Client 用於連線至 Mesh 中的 Runtime Host。', + hostEndpointHelp: '此 Host 加入後,其他成員才能連線至本機分享的任務。', + advancedSettings: '進階設定', + technicalDetails: '身分與連線設定', + connectivityAutomatic: '自動連線(建議)', + connectivityKnownRoutesOnly: '僅使用已知路徑', + connectivityCustom: '自訂位址探索', + restartRequired: '需要重新啟動', + adaptiveConnectivity: '自適應連線', + adaptiveConnectivityHelp: + 'Maka 會自動競速可用的直接連線方式,並在獲准時使用成員轉送;此處不需要選擇特定通訊協定。', + connectivityPolicyLoading: '正在讀取連線策略…', + connectivityPolicyLoadFailed: '無法讀取連線策略', + connectivityPolicySaveFailed: '無法儲存連線策略', + restoreDefaultConnectivityPolicy: '恢復預設設定', + connectivityPolicyRestartRequired: + '重新啟動 Maka 後,已儲存的連線策略變更會套用至新連線。', + publicAddressDiscovery: '公網位址探索', + publicStunDefault: '公共 STUN(建議)', + publicStunDisabled: '不使用公共 STUN', + publicStunCustom: '自訂 STUN', + customStunUrls: 'STUN 位址', + customStunUrlsInvalid: '請輸入以逗號分隔的 stun:主機[:連接埠] 位址,最多 8 個。', + publicStunDefaultHelp: + '使用 Cloudflare 公共 STUN 盡力探索公網對映。它不承載 Maka 流量,但提供者可觀察來源 IP 和請求時間;Maka 不保證其可用性。', + publicStunDisabledHelp: + '僅嘗試本機位址和其他已知直接連線路徑;跨 NAT 的直接連線成功率可能降低。', + publicStunCustomHelp: + '使用逗號分隔的 stun: 位址。STUN 只探索網路位址,不承載 Session 內容。', + saveConnectivityPolicy: '儲存變更', + peerId: 'Peer ID', + peerIdHelp: '此 endpoint 在 Mesh 中的技術身分;點選 ID 可複製完整值。', + meshId: 'Mesh ID', + meshIdHelp: '用於診斷和識別此 Mesh;點選 ID 可複製完整值。', + thisRuntimeHost: '本機 Runtime Host', + thisDesktop: '本機 Desktop', + displayName: '在 Mesh 中顯示的名稱', + meshDisplayName: 'Mesh 名稱', + unnamedMesh: '未命名 Mesh', + rename: '修改名稱', + renameMesh: '修改 Mesh 名稱', + save: '儲存', + peerIdCopied: 'Peer ID 已複製', + meshIdCopied: 'Mesh ID 已複製', + copyPeerId: (value: string) => `複製完整 Peer ID:${value}`, + copyMeshId: (value: string) => `複製完整 Mesh ID:${value}`, + empty: '建立你的第一個 Mesh', + emptyHint: '建立新的 Mesh,或透過一次性邀請碼加入。', + meshes: 'Mesh', + mesh: 'Mesh', + members: '成員', + activeMeshCount: (value: number) => `${value} 個使用中`, + showClosedMeshes: (value: number) => `顯示已關閉(${value})`, + noActiveMeshes: '沒有使用中的 Mesh', + noActiveMeshesHint: '已關閉的 Mesh 預設隱藏;可透過上方篩選查看。', + authority: '管理者', + member: '成員', + closed: '已關閉', + memberCount: (value: number) => `${value} 個成員`, + pending: (value: number) => `${value} 個待使用邀請`, + transit: '成員轉送', + transitHelp: '允許此 Mesh 的成員透過本機建立連線;會使用本機頻寬。', + transitToggle: '為此 Mesh 提供轉送', + transitStatus: '成員轉送狀態', + transitLimitsLabel: '成員轉送限制', + transitLimits: (value: PeerMeshQueryResult['transit']) => + value + ? `固定上限:每位成員 ${value.maxCircuitsPerPeer} 條連線,每條最長 ${formatHours(value.maxCircuitDurationSeconds)},最多 ${formatMebibytes(value.maxCircuitBytes)}。一次只能為一個 Mesh 開啟。` + : '成員轉送使用固定資源上限,一次只能為一個 Mesh 開啟。', + allowedMembers: '允許成員', + reservations: 'Reservation', + circuits: '連線', + routeState: { + local: '本機', + connecting: '正在連線', + reachable: '可連線', + reconnecting: '正在恢復連線', + needs_repair: '需要新邀請碼修復', + }, + endpointKind: { + client: 'Client', + host: 'Runtime Host', + unknown: '未識別 Peer', + }, + endpointKindHelp: { + client: 'Client 是操作介面:它連線至 Host、瀏覽任務並發起操作,本身不持有任務。', + host: 'Runtime Host 持有任務和執行狀態,並執行經過授權的工作。', + unknown: '此 Peer 尚未回報它是 Client 或 Runtime Host,通常來自舊版本。', + }, + joinTitle: '加入 Mesh', + joinHint: '貼上另一個 Peer 產生的一次性邀請碼。', + joinCode: '邀請碼', + join: '加入', + joinMesh: '加入 Mesh', + invite: '邀請成員', + invitationTitle: '邀請成員', + invitationFor: (value: string) => `Mesh ${value}`, + invitationWarning: '此代碼只能使用一次;取得代碼的人可以讓一個 peer 加入此 Mesh。', + invitationDirectOnly: + '尚未連線至協調節點。此邀請碼只包含直接位址,跨 NAT 時可能無法連線。', + invitationExpires: (value: string) => `有效期限至 ${value}`, + invitationCopied: '邀請碼已複製', + copyInvitation: '複製邀請碼', + create: '建立 Mesh', + refresh: '重新整理', + back: '返回', + leave: '退出 Mesh', + closeMesh: '關閉 Mesh', + remove: '移除成員', + cancel: '取消', + closeConfirm: '關閉這個 Mesh?', + leaveConfirm: '退出這個 Mesh?', + removeConfirm: '移除這個成員?', + meshActions: 'Mesh 操作', + memberActions: (peerId: string) => `${peerId} 的操作`, + addLocalHost: '新增本機 Runtime Host', + localRuntimeHost: 'Runtime Host', + localHostMissing: '本機 Runtime Host 尚未加入', + localHostMissingHint: '加入後,其他成員才能透過此 Mesh 連線至本機分享的任務。', + }, + en: { + title: 'Peer Mesh', + experimental: 'Experimental', + failed: 'Peer Mesh operation failed', + invalidResult: 'Peer Mesh returned an invalid result', + unknownError: 'Peer Mesh operation failed', + outcomeUnknown: + 'The Host may have completed this operation. Its current state was refreshed; review it before trying again.', + invitationOutcomeUnknown: + 'The Host may have created an invitation, but its one-time code was not returned and cannot be recovered. It will expire automatically; review the pending invitation count before creating another.', + outcomeUnknownRefreshFailed: + 'The Host may have completed this operation, but its current state could not be refreshed. Reconnect and refresh before trying again.', + unavailable: 'Peer Mesh is unavailable for this endpoint', + loading: 'Loading Mesh status…', + checkingPeerConnection: "Checking this Runtime Host's peer connection…", + peerConnectionDisabled: 'Peer connectivity is not enabled for this Runtime Host', + peerConnectionDisabledHint: + 'Enable it so this Host can create or join Meshes. The existing SSH connection remains available.', + peerConnectionDisableProfileFirst: + "This Host's Direct peer connection is in use. Disable it in the Host list before changing the listener.", + enablePeerConnection: 'Enable peer connectivity', + peerConnectionStarting: 'Peer connectivity is enabled; the Mesh endpoint is starting', + peerConnectionStartingHint: 'This normally takes a few seconds. You can also check again.', + peerConnectionUpgradeRequired: 'This Runtime Host version cannot manage Peer Mesh', + peerConnectionUpgradeRequiredHint: 'Update this Host before enabling peer connectivity.', + working: { + refresh: 'Refreshing Peer Mesh…', + create: 'Creating Mesh…', + join: 'Joining Mesh…', + invite: 'Preparing invitation…', + 'add-host': 'Adding the Runtime Host to the Mesh…', + 'enable-peer': 'Enabling peer connectivity for the Runtime Host…', + update: 'Updating Mesh…', + rename: 'Saving name…', + }, + settling: 'Confirming the final state…', + endpoint: 'Manage endpoint', + desktopEndpoint: 'Desktop Client', + hostEndpoint: 'Local Runtime Host', + desktopEndpointHelp: 'This Client connects to Runtime Hosts in the Mesh.', + hostEndpointHelp: 'Add this Host so other members can reach tasks shared from this device.', + advancedSettings: 'Advanced settings', + technicalDetails: 'Identity and connectivity details', + connectivityAutomatic: 'Automatic connectivity (recommended)', + connectivityKnownRoutesOnly: 'Known routes only', + connectivityCustom: 'Custom address discovery', + restartRequired: 'Restart required', + adaptiveConnectivity: 'Adaptive connectivity', + adaptiveConnectivityHelp: + 'Maka races available direct paths automatically and uses approved member transit when needed. You do not choose a transport protocol here.', + connectivityPolicyLoading: 'Loading connectivity policy…', + connectivityPolicyLoadFailed: 'Could not load connectivity policy', + connectivityPolicySaveFailed: 'Could not save connectivity policy', + restoreDefaultConnectivityPolicy: 'Restore defaults', + connectivityPolicyRestartRequired: + 'Restart Maka to apply saved connectivity-policy changes to new connections.', + publicAddressDiscovery: 'Public address discovery', + publicStunDefault: 'Public STUN (recommended)', + publicStunDisabled: 'No public STUN', + publicStunCustom: 'Custom STUN', + customStunUrls: 'STUN addresses', + customStunUrlsInvalid: + 'Enter up to 8 comma-separated stun:host[:port] addresses.', + publicStunDefaultHelp: + 'Uses Cloudflare public STUN on a best-effort basis to discover public mappings. It never carries Maka traffic, but the provider can observe source IPs and request timing; Maka provides no availability guarantee.', + publicStunDisabledHelp: + 'Only local addresses and other known direct paths are attempted; direct connectivity across NAT may be reduced.', + publicStunCustomHelp: + 'Enter comma-separated stun: addresses. STUN discovers network addresses and never carries Session content.', + saveConnectivityPolicy: 'Save changes', + peerId: 'Peer ID', + peerIdHelp: 'Technical identity for this endpoint. Select the ID to copy its full value.', + meshId: 'Mesh ID', + meshIdHelp: 'Used to identify and diagnose this Mesh. Select the ID to copy its full value.', + thisRuntimeHost: 'This Runtime Host', + thisDesktop: 'This Desktop', + displayName: 'Name shown in the Mesh', + meshDisplayName: 'Mesh name', + unnamedMesh: 'Unnamed Mesh', + rename: 'Rename', + renameMesh: 'Rename Mesh', + save: 'Save', + peerIdCopied: 'Peer ID copied', + meshIdCopied: 'Mesh ID copied', + copyPeerId: (value: string) => `Copy full Peer ID: ${value}`, + copyMeshId: (value: string) => `Copy full Mesh ID: ${value}`, + empty: 'Build your first Mesh', + emptyHint: 'Create a new Mesh or join one with a one-time invitation.', + meshes: 'Meshes', + mesh: 'Mesh', + members: 'Members', + activeMeshCount: (value: number) => `${value} active`, + showClosedMeshes: (value: number) => `Show closed (${value})`, + noActiveMeshes: 'No active Meshes', + noActiveMeshesHint: 'Closed Meshes are hidden by default. Use the filter above to show them.', + authority: 'Owner', + member: 'Member', + closed: 'Closed', + memberCount: (value: number) => value === 1 ? '1 member' : `${value} members`, + pending: (value: number) => `${value} pending invites`, + transit: 'Member transit', + transitHelp: 'Let members of this Mesh connect through this device using its bandwidth.', + transitToggle: 'Provide transit for this Mesh', + transitStatus: 'Member transit status', + transitLimitsLabel: 'Member transit limits', + transitLimits: (value: PeerMeshQueryResult['transit']) => + value + ? `Fixed limits: ${value.maxCircuitsPerPeer} circuits per member, ${formatHours(value.maxCircuitDurationSeconds)} per circuit, and ${formatMebibytes(value.maxCircuitBytes)}. Only one Mesh can be served at a time.` + : 'Member transit uses fixed resource limits. Only one Mesh can be served at a time.', + allowedMembers: 'Allowed members', + reservations: 'Reservations', + circuits: 'Circuits', + routeState: { + local: 'Local', + connecting: 'Connecting', + reachable: 'Reachable', + reconnecting: 'Reconnecting', + needs_repair: 'Needs a new invitation', + }, + endpointKind: { + client: 'Client', + host: 'Runtime Host', + unknown: 'Unidentified peer', + }, + endpointKindHelp: { + client: 'A Client is the interface that connects to Hosts, browses tasks, and starts actions. It does not own tasks.', + host: 'A Runtime Host owns tasks and runtime state, and executes authorized work.', + unknown: 'This peer has not reported whether it is a Client or Runtime Host, usually because it uses an older build.', + }, + joinTitle: 'Join a Mesh', + joinHint: 'Paste a one-time invitation created by another peer.', + joinCode: 'Invitation', + join: 'Join', + joinMesh: 'Join Mesh', + invite: 'Invite member', + invitationTitle: 'Invite a member', + invitationFor: (value: string) => `Mesh ${value}`, + invitationWarning: + 'This code works once. Anyone holding it can admit one peer to this Mesh.', + invitationDirectOnly: + 'No coordination peer is available yet. This invitation contains direct routes only and may not work across NATs.', + invitationExpires: (value: string) => `Expires ${value}`, + invitationCopied: 'Invitation copied', + copyInvitation: 'Copy invitation', + create: 'Create Mesh', + refresh: 'Refresh', + back: 'Back', + leave: 'Leave Mesh', + closeMesh: 'Close Mesh', + remove: 'Remove member', + cancel: 'Cancel', + closeConfirm: 'Close this Mesh?', + leaveConfirm: 'Leave this Mesh?', + removeConfirm: 'Remove this member?', + meshActions: 'Mesh actions', + memberActions: (peerId: string) => `Actions for ${peerId}`, + addLocalHost: 'Add local Runtime Host', + localRuntimeHost: 'Runtime Host', + localHostMissing: 'Local Runtime Host has not joined', + localHostMissingHint: + 'Add it so other members can reach tasks shared from this device through the Mesh.', + }, +} satisfies UiCatalog; + +export function getPeerMeshCopy(locale: UiLocale): PeerMeshCopy { + return PEER_MESH_COPY[locale]; +} + +function formatHours(seconds: number): string { + return `${seconds / 3_600}h`; +} + +function formatMebibytes(bytes: number): string { + return `${bytes / (1024 * 1024)} MiB`; +} diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts index c393ee1f08..729f7bb33c 100644 --- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts @@ -121,6 +121,10 @@ export type SettingsProjectsCopy = { saveAndEnable: string; defaultBadge: string; experimentalBadge: string; + peerIdCopyFailed: string; + peerPathDirect: string; + peerPathTransit: string; + peerPathTransportOther: string; defaultDisableHelp: string; unavailable: string; manage: string; @@ -423,6 +427,10 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { saveAndEnable: '保存并启用', defaultBadge: '默认', experimentalBadge: '实验性', + peerIdCopyFailed: '无法复制 Peer ID', + peerPathDirect: '直连', + peerPathTransit: '成员转发', + peerPathTransportOther: '其他', defaultDisableHelp: '先选择另一个默认 Host,才能停用此 Host', unavailable: '无法连接', manage: '管理', @@ -732,6 +740,10 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { credentialHelp: '在遠端機器使用 desktop-client preset 簽發', saveAndEnable: '儲存並啟用', defaultBadge: '預設', + peerIdCopyFailed: '無法複製 Peer ID', + peerPathDirect: '直接連線', + peerPathTransit: '成員轉送', + peerPathTransportOther: '其他', defaultDisableHelp: '先選擇另一個預設 Host,才能停用此 Host', unavailable: '無法連線', manage: '管理', @@ -1043,6 +1055,10 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = { saveAndEnable: 'Save and enable', defaultBadge: 'Default', experimentalBadge: 'Experimental', + peerIdCopyFailed: 'Could not copy Peer ID', + peerPathDirect: 'Direct', + peerPathTransit: 'Member transit', + peerPathTransportOther: 'Other', defaultDisableHelp: 'Choose another default Host before disabling this Host', unavailable: 'Unavailable', manage: 'Manage', diff --git a/apps/desktop/src/renderer/locales/task-readiness-copy.ts b/apps/desktop/src/renderer/locales/task-readiness-copy.ts new file mode 100644 index 0000000000..0c66a86515 --- /dev/null +++ b/apps/desktop/src/renderer/locales/task-readiness-copy.ts @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; + +export interface TaskReadinessCopy { + readonly runtime: { + readonly title: string; + readonly description: string; + readonly actionLabel: string; + }; + readonly workspace: { + readonly title: string; + readonly description: string; + readonly actionLabel: Record<'workspace_picker' | 'retry', string>; + }; +} + +const TASK_READINESS_COPY = { + 'zh-CN': { + runtime: { + title: 'Maka 运行服务暂时不可用。', + description: '任务尚未提交。重新检测运行服务后再试。', + actionLabel: '重新检测', + }, + workspace: { + title: '当前任务的工作区不可用。', + description: '原目录可能已移动、删除或无法访问。请选择可用工作区。', + actionLabel: { workspace_picker: '选择工作区', retry: '重新检测' }, + }, + }, + 'zh-TW': { + runtime: { + title: 'Maka 執行服務暫時無法使用。', + description: '任務尚未送出。重新檢查執行服務後再試。', + actionLabel: '重新檢查', + }, + workspace: { + title: '目前任務的工作區無法使用。', + description: '原始資料夾可能已移動、刪除或無法存取。請選擇可用的工作區。', + actionLabel: { workspace_picker: '選擇工作區', retry: '重新檢查' }, + }, + }, + en: { + runtime: { + title: 'The Maka runtime is unavailable.', + description: 'The task was not submitted. Check the runtime again before retrying.', + actionLabel: 'Check again', + }, + workspace: { + title: 'This task workspace is unavailable.', + description: 'The folder may have moved, been deleted, or become inaccessible. Choose an available workspace.', + actionLabel: { workspace_picker: 'Choose workspace', retry: 'Check again' }, + }, + }, +} satisfies UiCatalog; + +export function getTaskReadinessCopy(locale: UiLocale): TaskReadinessCopy { + return TASK_READINESS_COPY[locale]; +} diff --git a/apps/desktop/src/renderer/mcp-catalog.ts b/apps/desktop/src/renderer/mcp-catalog.ts index 9d5b834ecf..09b15f47ef 100644 --- a/apps/desktop/src/renderer/mcp-catalog.ts +++ b/apps/desktop/src/renderer/mcp-catalog.ts @@ -17,18 +17,15 @@ * under the License. */ -import type { UiLocale } from '@maka/core/ui-locale'; +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; import type { McpServerConfig } from '@maka/core/mcp'; export type McpCatalogId = | 'dingtalk' | 'feishu' | 'slack' | 'line' | 'notion' | 'macos-apps' | 'google-calendar' | 'figma' | 'vercel' | 'supabase' | 'filesystem' | 'memory' | 'playwright' | 'sequential-thinking'; -export type McpCatalogEntry = { +type McpCatalogDefinition = { id: McpCatalogId; - name: string; - description: string; - category: string; /** Text fallback glyph for entries without a library brand mark (see mcp-brand-marks.tsx). Branded entries render their real mark instead, so they omit this. */ @@ -36,19 +33,14 @@ export type McpCatalogEntry = { aliases?: string[]; config: McpServerConfig; setupRequired?: boolean; - setupLabel?: string; platform?: 'darwin'; }; -export const MCP_CATALOG: McpCatalogEntry[] = [ +export const MCP_CATALOG: McpCatalogDefinition[] = [ { id: 'dingtalk', - name: '钉钉', - description: '管理联系人、日历、待办与协作信息。', - category: '沟通协作', aliases: ['DingTalk'], setupRequired: true, - setupLabel: '需要 Client ID 与 Client Secret', config: { enabled: false, command: 'npx', @@ -62,12 +54,8 @@ export const MCP_CATALOG: McpCatalogEntry[] = [ }, { id: 'feishu', - name: '飞书', - description: '访问飞书文档、日历、消息与 OpenAPI。', - category: '沟通协作', aliases: ['Feishu', 'Lark'], setupRequired: true, - setupLabel: '需要 App ID 与 App Secret', config: { enabled: false, command: 'npx', @@ -77,12 +65,8 @@ export const MCP_CATALOG: McpCatalogEntry[] = [ }, { id: 'slack', - name: 'Slack', - description: '发送消息、管理频道并与 Slack workspace 协作。', - category: '沟通协作', mark: 'S', setupRequired: true, - setupLabel: '需要 Bot Token 与 Team ID', config: { enabled: false, command: 'npx', @@ -92,12 +76,8 @@ export const MCP_CATALOG: McpCatalogEntry[] = [ }, { id: 'line', - name: 'LINE', - description: '通过 LINE Bot Messaging API 发送和管理消息。', - category: '沟通协作', mark: 'LINE', setupRequired: true, - setupLabel: '需要 Channel Access Token', config: { enabled: false, command: 'npx', @@ -107,32 +87,21 @@ export const MCP_CATALOG: McpCatalogEntry[] = [ }, { id: 'notion', - name: 'Notion', - description: '搜索、读取和更新 Notion workspace。', - category: '知识与文档', mark: 'N', setupRequired: true, - setupLabel: '需要登录授权', config: { enabled: false, url: 'https://mcp.notion.com/mcp', transport: 'streamable-http', protocol: 'auto' }, }, { id: 'macos-apps', - name: 'macOS 应用', - description: '连接系统日历与提醒事项,并使用原生权限模型。', - category: '系统与效率', aliases: ['Apple', 'Calendar', 'Reminders'], platform: 'darwin', config: { enabled: true, command: 'npx', args: ['-y', 'mcp-server-apple-events@1.4.0'] }, }, { id: 'google-calendar', - name: 'Google 日历', - description: '管理日程、创建会议并查询空闲时间。', - category: '系统与效率', mark: '31', aliases: ['Google Calendar'], setupRequired: true, - setupLabel: '需要 OAuth credentials 文件', config: { enabled: false, command: 'npx', @@ -142,12 +111,8 @@ export const MCP_CATALOG: McpCatalogEntry[] = [ }, { id: 'figma', - name: 'Figma', - description: '读取设计文件、组件与开发交付信息。', - category: '设计与开发', mark: 'F', setupRequired: true, - setupLabel: '需要 Personal Access Token', config: { enabled: false, command: 'npx', @@ -157,32 +122,20 @@ export const MCP_CATALOG: McpCatalogEntry[] = [ }, { id: 'vercel', - name: 'Vercel', - description: '检查项目、部署状态、日志与平台文档。', - category: '设计与开发', mark: '▲', setupRequired: true, - setupLabel: '需要登录授权', config: { enabled: false, url: 'https://mcp.vercel.com', transport: 'streamable-http', protocol: 'auto' }, }, { id: 'supabase', - name: 'Supabase', - description: '管理数据库、项目配置、迁移与 Edge Functions。', - category: '设计与开发', mark: 'S', setupRequired: true, - setupLabel: '需要登录授权', config: { enabled: false, url: 'https://mcp.supabase.com/mcp', transport: 'streamable-http', protocol: 'auto' }, }, { id: 'filesystem', - name: '本地文件', - description: '在指定目录中安全地读取、写入和管理文件。', - category: '文件与知识', mark: 'FS', setupRequired: true, - setupLabel: '需要选择允许访问的目录', config: { enabled: false, command: 'npx', @@ -191,25 +144,16 @@ export const MCP_CATALOG: McpCatalogEntry[] = [ }, { id: 'memory', - name: '持久记忆', - description: '用结构化知识图谱记住实体、关系和重要事实。', - category: '文件与知识', mark: 'M', config: { enabled: true, command: 'npx', args: ['-y', '@modelcontextprotocol/server-memory'] }, }, { id: 'playwright', - name: '浏览器自动化', - description: '让 Maka 通过 Playwright 读取和操作真实网页。', - category: '设计与开发', mark: 'PW', config: { enabled: true, command: 'npx', args: ['-y', '@playwright/mcp@latest'] }, }, { id: 'sequential-thinking', - name: '序列思考', - description: '为复杂问题提供可修正、可验证的结构化推理。', - category: '推理与规划', mark: 'ST', config: { enabled: true, @@ -219,47 +163,68 @@ export const MCP_CATALOG: McpCatalogEntry[] = [ }, ]; -type McpCatalogLocalizedCopy = Pick & { +type McpCatalogLocalizedCopy = { + name: string; + description: string; + category: string; setupLabel: string | undefined; }; -const MCP_CATALOG_ENGLISH_COPY: Record = { - dingtalk: { name: 'DingTalk', description: 'Manage contacts, calendars, tasks, and collaboration data.', category: 'Communication', setupLabel: 'Requires Client ID and Client Secret' }, - feishu: { name: 'Feishu', description: 'Access Feishu documents, calendars, messages, and OpenAPI.', category: 'Communication', setupLabel: 'Requires App ID and App Secret' }, - slack: { name: 'Slack', description: 'Send messages, manage channels, and collaborate in a Slack workspace.', category: 'Communication', setupLabel: 'Requires Bot Token and Team ID' }, - line: { name: 'LINE', description: 'Send and manage messages through the LINE Bot Messaging API.', category: 'Communication', setupLabel: 'Requires Channel Access Token' }, - notion: { name: 'Notion', description: 'Search, read, and update a Notion workspace.', category: 'Knowledge and documents', setupLabel: 'Requires sign-in authorization' }, - 'macos-apps': { name: 'macOS apps', description: 'Connect Calendar and Reminders through native system permissions.', category: 'System and productivity', setupLabel: undefined }, - 'google-calendar': { name: 'Google Calendar', description: 'Manage events, create meetings, and check availability.', category: 'System and productivity', setupLabel: 'Requires an OAuth credentials file' }, - figma: { name: 'Figma', description: 'Read design files, components, and developer handoff data.', category: 'Design and development', setupLabel: 'Requires a Personal Access Token' }, - vercel: { name: 'Vercel', description: 'Inspect projects, deployments, logs, and platform documentation.', category: 'Design and development', setupLabel: 'Requires sign-in authorization' }, - supabase: { name: 'Supabase', description: 'Manage databases, project configuration, migrations, and Edge Functions.', category: 'Design and development', setupLabel: 'Requires sign-in authorization' }, - filesystem: { name: 'Local files', description: 'Safely read, write, and manage files in selected directories.', category: 'Files and knowledge', setupLabel: 'Requires selecting allowed directories' }, - memory: { name: 'Persistent memory', description: 'Remember entities, relationships, and important facts in a structured knowledge graph.', category: 'Files and knowledge', setupLabel: undefined }, - playwright: { name: 'Browser automation', description: 'Let Maka read and operate real web pages through Playwright.', category: 'Design and development', setupLabel: undefined }, - 'sequential-thinking': { name: 'Sequential thinking', description: 'Provide revisable, verifiable structured reasoning for complex problems.', category: 'Reasoning and planning', setupLabel: undefined }, -}; +export type McpCatalogEntry = McpCatalogDefinition & McpCatalogLocalizedCopy; -const MCP_CATALOG_TRADITIONAL_COPY: Record = { - dingtalk: { name: 'DingTalk', description: '管理聯絡人、行事曆、任務和協作資料。', category: '通訊', setupLabel: '需要 Client ID 和 Client Secret' }, - feishu: { name: '飛書', description: '存取飛書文件、行事曆、訊息和 OpenAPI。', category: '通訊', setupLabel: '需要 App ID 和 App Secret' }, - slack: { name: 'Slack', description: '傳送訊息、管理頻道,並在 Slack 工作區協作。', category: '通訊', setupLabel: '需要 Bot Token 和 Team ID' }, - line: { name: 'LINE', description: '透過 LINE Bot Messaging API 傳送和管理訊息。', category: '通訊', setupLabel: '需要 Channel Access Token' }, - notion: { name: 'Notion', description: '搜尋、讀取和更新 Notion 工作區。', category: '知識與文件', setupLabel: '需要登入授權' }, - 'macos-apps': { name: 'macOS 應用程式', description: '透過原生系統權限連接「行事曆」與「提醒事項」。', category: '系統與生產力', setupLabel: undefined }, - 'google-calendar': { name: 'Google 日曆', description: '管理活動、建立會議並檢查可用時間。', category: '系統與生產力', setupLabel: '需要 OAuth 憑證檔案' }, - figma: { name: 'Figma', description: '讀取設計檔案、元件和開發交付資料。', category: '設計與開發', setupLabel: '需要 Personal Access Token' }, - vercel: { name: 'Vercel', description: '檢查專案、部署、記錄和平台文件。', category: '設計與開發', setupLabel: '需要登入授權' }, - supabase: { name: 'Supabase', description: '管理資料庫、專案設定、移轉和 Edge Functions。', category: '設計與開發', setupLabel: '需要登入授權' }, - filesystem: { name: '本機檔案', description: '在選取的資料夾中安全地讀取、寫入和管理檔案。', category: '檔案與知識', setupLabel: '需要選擇允許存取的資料夾' }, - memory: { name: '持久記憶', description: '使用結構化知識圖譜記住實體、關係和重要事實。', category: '檔案與知識', setupLabel: undefined }, - playwright: { name: '瀏覽器自動化', description: '讓 Maka 透過 Playwright 讀取和操作實際網頁。', category: '設計與開發', setupLabel: undefined }, - 'sequential-thinking': { name: '循序思考', description: '為複雜問題提供可修正、可驗證的結構化推理。', category: '推理與規劃', setupLabel: undefined }, -}; +const MCP_CATALOG_COPY = { + 'zh-CN': { + dingtalk: { name: '钉钉', description: '管理联系人、日历、待办与协作信息。', category: '沟通协作', setupLabel: '需要 Client ID 与 Client Secret' }, + feishu: { name: '飞书', description: '访问飞书文档、日历、消息与 OpenAPI。', category: '沟通协作', setupLabel: '需要 App ID 与 App Secret' }, + slack: { name: 'Slack', description: '发送消息、管理频道并与 Slack workspace 协作。', category: '沟通协作', setupLabel: '需要 Bot Token 与 Team ID' }, + line: { name: 'LINE', description: '通过 LINE Bot Messaging API 发送和管理消息。', category: '沟通协作', setupLabel: '需要 Channel Access Token' }, + notion: { name: 'Notion', description: '搜索、读取和更新 Notion workspace。', category: '知识与文档', setupLabel: '需要登录授权' }, + 'macos-apps': { name: 'macOS 应用', description: '连接系统日历与提醒事项,并使用原生权限模型。', category: '系统与效率', setupLabel: undefined }, + 'google-calendar': { name: 'Google 日历', description: '管理日程、创建会议并查询空闲时间。', category: '系统与效率', setupLabel: '需要 OAuth credentials 文件' }, + figma: { name: 'Figma', description: '读取设计文件、组件与开发交付信息。', category: '设计与开发', setupLabel: '需要 Personal Access Token' }, + vercel: { name: 'Vercel', description: '检查项目、部署状态、日志与平台文档。', category: '设计与开发', setupLabel: '需要登录授权' }, + supabase: { name: 'Supabase', description: '管理数据库、项目配置、迁移与 Edge Functions。', category: '设计与开发', setupLabel: '需要登录授权' }, + filesystem: { name: '本地文件', description: '在指定目录中安全地读取、写入和管理文件。', category: '文件与知识', setupLabel: '需要选择允许访问的目录' }, + memory: { name: '持久记忆', description: '用结构化知识图谱记住实体、关系和重要事实。', category: '文件与知识', setupLabel: undefined }, + playwright: { name: '浏览器自动化', description: '让 Maka 通过 Playwright 读取和操作真实网页。', category: '设计与开发', setupLabel: undefined }, + 'sequential-thinking': { name: '序列思考', description: '为复杂问题提供可修正、可验证的结构化推理。', category: '推理与规划', setupLabel: undefined }, + }, + 'zh-TW': { + dingtalk: { name: 'DingTalk', description: '管理聯絡人、行事曆、任務和協作資料。', category: '通訊', setupLabel: '需要 Client ID 和 Client Secret' }, + feishu: { name: '飛書', description: '存取飛書文件、行事曆、訊息和 OpenAPI。', category: '通訊', setupLabel: '需要 App ID 和 App Secret' }, + slack: { name: 'Slack', description: '傳送訊息、管理頻道,並在 Slack 工作區協作。', category: '通訊', setupLabel: '需要 Bot Token 和 Team ID' }, + line: { name: 'LINE', description: '透過 LINE Bot Messaging API 傳送和管理訊息。', category: '通訊', setupLabel: '需要 Channel Access Token' }, + notion: { name: 'Notion', description: '搜尋、讀取和更新 Notion 工作區。', category: '知識與文件', setupLabel: '需要登入授權' }, + 'macos-apps': { name: 'macOS 應用程式', description: '透過原生系統權限連接「行事曆」與「提醒事項」。', category: '系統與生產力', setupLabel: undefined }, + 'google-calendar': { name: 'Google 日曆', description: '管理活動、建立會議並檢查可用時間。', category: '系統與生產力', setupLabel: '需要 OAuth 憑證檔案' }, + figma: { name: 'Figma', description: '讀取設計檔案、元件和開發交付資料。', category: '設計與開發', setupLabel: '需要 Personal Access Token' }, + vercel: { name: 'Vercel', description: '檢查專案、部署、記錄和平台文件。', category: '設計與開發', setupLabel: '需要登入授權' }, + supabase: { name: 'Supabase', description: '管理資料庫、專案設定、移轉和 Edge Functions。', category: '設計與開發', setupLabel: '需要登入授權' }, + filesystem: { name: '本機檔案', description: '在選取的資料夾中安全地讀取、寫入和管理檔案。', category: '檔案與知識', setupLabel: '需要選擇允許存取的資料夾' }, + memory: { name: '持久記憶', description: '使用結構化知識圖譜記住實體、關係和重要事實。', category: '檔案與知識', setupLabel: undefined }, + playwright: { name: '瀏覽器自動化', description: '讓 Maka 透過 Playwright 讀取和操作實際網頁。', category: '設計與開發', setupLabel: undefined }, + 'sequential-thinking': { name: '循序思考', description: '為複雜問題提供可修正、可驗證的結構化推理。', category: '推理與規劃', setupLabel: undefined }, + }, + en: { + dingtalk: { name: 'DingTalk', description: 'Manage contacts, calendars, tasks, and collaboration data.', category: 'Communication', setupLabel: 'Requires Client ID and Client Secret' }, + feishu: { name: 'Feishu', description: 'Access Feishu documents, calendars, messages, and OpenAPI.', category: 'Communication', setupLabel: 'Requires App ID and App Secret' }, + slack: { name: 'Slack', description: 'Send messages, manage channels, and collaborate in a Slack workspace.', category: 'Communication', setupLabel: 'Requires Bot Token and Team ID' }, + line: { name: 'LINE', description: 'Send and manage messages through the LINE Bot Messaging API.', category: 'Communication', setupLabel: 'Requires Channel Access Token' }, + notion: { name: 'Notion', description: 'Search, read, and update a Notion workspace.', category: 'Knowledge and documents', setupLabel: 'Requires sign-in authorization' }, + 'macos-apps': { name: 'macOS apps', description: 'Connect Calendar and Reminders through native system permissions.', category: 'System and productivity', setupLabel: undefined }, + 'google-calendar': { name: 'Google Calendar', description: 'Manage events, create meetings, and check availability.', category: 'System and productivity', setupLabel: 'Requires an OAuth credentials file' }, + figma: { name: 'Figma', description: 'Read design files, components, and developer handoff data.', category: 'Design and development', setupLabel: 'Requires a Personal Access Token' }, + vercel: { name: 'Vercel', description: 'Inspect projects, deployments, logs, and platform documentation.', category: 'Design and development', setupLabel: 'Requires sign-in authorization' }, + supabase: { name: 'Supabase', description: 'Manage databases, project configuration, migrations, and Edge Functions.', category: 'Design and development', setupLabel: 'Requires sign-in authorization' }, + filesystem: { name: 'Local files', description: 'Safely read, write, and manage files in selected directories.', category: 'Files and knowledge', setupLabel: 'Requires selecting allowed directories' }, + memory: { name: 'Persistent memory', description: 'Remember entities, relationships, and important facts in a structured knowledge graph.', category: 'Files and knowledge', setupLabel: undefined }, + playwright: { name: 'Browser automation', description: 'Let Maka read and operate real web pages through Playwright.', category: 'Design and development', setupLabel: undefined }, + 'sequential-thinking': { name: 'Sequential thinking', description: 'Provide revisable, verifiable structured reasoning for complex problems.', category: 'Reasoning and planning', setupLabel: undefined }, + }, +} satisfies UiCatalog>; export function getMcpCatalog(locale: UiLocale): McpCatalogEntry[] { - if (locale === 'zh-CN') return MCP_CATALOG; - const copy = locale === 'zh-TW' ? MCP_CATALOG_TRADITIONAL_COPY : MCP_CATALOG_ENGLISH_COPY; + const copy = MCP_CATALOG_COPY[locale]; return MCP_CATALOG.map((entry) => ({ ...entry, ...copy[entry.id] })); } diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index 1588995847..339cebc7a6 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -179,11 +179,7 @@ export function AddProviderForm(props: { // a locale in the assertion. function issueMessage(issue: AddProviderIssue): string { if (issue.field === 'slug') { - return issue.reason === 'duplicate' - ? copy.duplicateSlug - : locale === 'zh-CN' - ? issue.detail - : copy.invalidSlug; + return issue.reason === 'duplicate' ? copy.duplicateSlug : copy.slugIssues[issue.detail]; } if (issue.field === 'apiKey') return copy.keyRequired(display.name); if (issue.field === 'accountId') return copy.cloudflareAccount; diff --git a/apps/desktop/src/renderer/settings/provider-add-submission.ts b/apps/desktop/src/renderer/settings/provider-add-submission.ts index 0006ff54fe..bea9ea9b75 100644 --- a/apps/desktop/src/renderer/settings/provider-add-submission.ts +++ b/apps/desktop/src/renderer/settings/provider-add-submission.ts @@ -26,7 +26,11 @@ import { type ModelInfo, type ProviderType, } from '@maka/core/llm-connections'; -import type { CreateConnectionInput, IdentifiedLlmConnection } from '@maka/core/llm-connections'; +import type { + CreateConnectionInput, + IdentifiedLlmConnection, + SlugValidationIssue, +} from '@maka/core/llm-connections'; export type ApiKeyOnboardingRoute = | { readonly kind: 'host' } | { @@ -97,7 +101,7 @@ export function initialOnboardingModelIds( export type AddProviderField = 'slug' | 'apiKey' | 'accountId' | 'baseUrl' | 'form'; export type AddProviderIssue = - | { readonly field: 'slug'; readonly reason: 'invalid'; readonly detail: string } + | { readonly field: 'slug'; readonly reason: 'invalid'; readonly detail: SlugValidationIssue } | { readonly field: 'slug'; readonly reason: 'duplicate' } | { readonly field: 'apiKey'; readonly reason: 'required' } | { readonly field: 'accountId'; readonly reason: 'required' } diff --git a/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx b/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx index 49fbb8ac3f..8f99f5ca46 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx +++ b/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx @@ -49,6 +49,9 @@ import { } from "@maka/ui"; import { Cpu, ICON_SIZE } from "@maka/ui/icons"; import { getSettingsProjectsCopy } from "../locales/settings-projects-copy.js"; +import { getPeerMeshCopy } from "../locales/peer-mesh-copy.js"; +import type { UiLocale } from "@maka/core/ui-locale"; + import { PasswordInput } from "./password-input.js"; import { settingsActionErrorMessage } from "./settings-error-copy.js"; import { SettingsField, SettingsRow, SettingsSection } from "./settings-section.js"; @@ -59,7 +62,6 @@ import { } from './runtime-host-management-dialog.js'; import { PeerMeshPeerIdButton, - getRuntimeHostPeerMeshCopy, RuntimeHostAddComputerMenu, RuntimeHostConnectionCodeDialog, RuntimeHostPairingRecoveryButton, @@ -93,7 +95,7 @@ export function RuntimeHostProfilesSection(props: { }) { const locale = useUiLocale(); const copy = getSettingsProjectsCopy(locale).runtimeHost; - const peerMeshCopy = getRuntimeHostPeerMeshCopy(locale); + const peerMeshCopy = getPeerMeshCopy(locale); const pairingActionCopy: RuntimeHostPairingActionCopy = { retry: copy.resolvePairingRecovery, retryFailed: copy.resolvePairingRecoveryFailed, @@ -409,10 +411,10 @@ export function RuntimeHostProfilesSection(props: { localAccessEnabling ? copy.remoteAccessEnabling : localAccess?.state === 'unsupported' - ? localAccess.message - : localAccess?.state === 'unavailable' ? localAccess.message - : copy.thisComputerRemoteAccessHelp + : localAccess?.state === 'unavailable' + ? localAccess.message + : copy.thisComputerRemoteAccessHelp } end={( @@ -672,7 +674,7 @@ export function RuntimeHostProfilesSection(props: { displayValue={abbreviatePeerId(profile.transport.reachability.lease.peerId)} copyLabel={peerMeshCopy.copyPeerId(profile.transport.reachability.lease.peerId)} copiedTitle={peerMeshCopy.peerIdCopied} - failedTitle={peerMeshCopy.peerIdCopyFailed} + failedTitle={copy.peerIdCopyFailed} errorMessage={(error) => settingsActionErrorMessage(error, locale)} className="settingsRuntimeHostPeerId" /> @@ -695,13 +697,13 @@ export function RuntimeHostProfilesSection(props: { ) : null} {entry.peerPath ? ( - + @@ -844,8 +846,9 @@ export function RuntimeHostProfilesSection(props: { function peerPathDetail( path: RuntimeHostPeerConnectionPath, - copy: ReturnType, + locale: UiLocale, ): string { + const copy = getSettingsProjectsCopy(locale).runtimeHost; if (path.kind === 'transit') { return `${copy.peerPathTransit} · ${abbreviatePeerId(path.relayPeerId)}`; } @@ -855,11 +858,10 @@ function peerPathDetail( ? 'QUIC' : path.transport === 'tcp' ? 'TCP' - : copy.peerPathOther; + : copy.peerPathTransportOther; return `${copy.peerPathDirect} · ${transport}`; } - function abbreviatePeerId(peerId: string): string { return peerId.length <= 20 ? peerId : `${peerId.slice(0, 10)}…${peerId.slice(-6)}`; } diff --git a/apps/desktop/stories/agent-graph-panel.stories.tsx b/apps/desktop/stories/agent-graph-panel.stories.tsx index cb60e7647c..de038fb7f9 100644 --- a/apps/desktop/stories/agent-graph-panel.stories.tsx +++ b/apps/desktop/stories/agent-graph-panel.stories.tsx @@ -23,7 +23,8 @@ import type { AgentGraphClientOperator, AgentGraphClientSnapshot, } from '@maka/runtime/stream-graph-read-model'; -import { AgentGraphPanel, getAgentGraphPanelCopy } from '../src/renderer/agent-graph-panel'; +import { AgentGraphPanel } from '../src/renderer/agent-graph-panel'; +import { getAgentGraphPanelCopy } from '../src/renderer/locales/agent-graph-copy'; import { withScopedMakaBridge } from './maka-bridge'; // Fidelity convention (#1433): every story names the real app path that diff --git a/packages/core/src/__tests__/llm-connections.test.ts b/packages/core/src/__tests__/llm-connections.test.ts index 97cce94cb9..5399065a50 100644 --- a/packages/core/src/__tests__/llm-connections.test.ts +++ b/packages/core/src/__tests__/llm-connections.test.ts @@ -34,6 +34,7 @@ import { providerAuthSupportsApiKey, reconcileConnectionAfterModelFetch, validateConnectionBaseUrl, + validateSlug, type IdentifiedLlmConnection, type ProviderType, } from '../llm-connections.js'; @@ -58,6 +59,17 @@ function chatModelChoicesFor( ); } +test('slug validation returns stable issues and preserves format and length boundaries', () => { + for (const slug of ['', ' ']) assert.equal(validateSlug(slug), 'required'); + for (const slug of ['A-slug', 'with space', '-slug', 'slug-', 'a']) { + assert.equal(validateSlug(slug), 'format', slug); + } + assert.equal(validateSlug('a'.repeat(65)), 'too_long'); + for (const slug of ['ab', 'valid-slug-1', 'a'.repeat(64)]) { + assert.equal(validateSlug(slug), null, slug); + } +}); + test('connection base URLs allow HTTP(S) and reject unsafe or malformed inputs', () => { assert.equal(validateConnectionBaseUrl(undefined), null); assert.equal(validateConnectionBaseUrl('https://api.example.com/v1'), null); diff --git a/packages/core/src/__tests__/runtime-policy-codec.test.ts b/packages/core/src/__tests__/runtime-policy-codec.test.ts index d6aba85a60..021f5d792a 100644 --- a/packages/core/src/__tests__/runtime-policy-codec.test.ts +++ b/packages/core/src/__tests__/runtime-policy-codec.test.ts @@ -218,6 +218,29 @@ test('normalizes catalog inputs while canonical entries reject noncanonical endp ); }); +for (const [slug, detail] of [ + ['', 'Slug is required'], + ['Not A Slug', 'Slug must be lowercase letters, digits, and hyphens'], + ['a'.repeat(65), 'Slug must be 64 characters or fewer'], +]) { + test(`catalog decoding preserves the diagnostic: ${detail}`, () => { + assert.throws( + () => + decodeCanonicalConnectionCatalogEntry({ + connectionId: '123e4567-e89b-42d3-a456-426614174000', + revision: 1, + slug, + name: 'OpenAI', + providerType: 'openai', + enabled: true, + enabledModelIds: [], + models: [], + }), + { name: 'RuntimePolicyDomainDecodeError', message: `connection slug: ${detail}` }, + ); + }); +} + test('rejects new connections for the retired Gemini CLI account provider', () => { assert.throws( () => diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index b17ffff8ed..3a15f5c2e5 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -567,12 +567,12 @@ export function effectiveBaseUrl(c: Pick 64) return 'Slug must be 64 characters or fewer'; +export type SlugValidationIssue = 'required' | 'format' | 'too_long'; + +export function validateSlug(slug: string): SlugValidationIssue | null { + if (!slug.trim()) return 'required'; + if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(slug)) return 'format'; + if (slug.length > 64) return 'too_long'; return null; } diff --git a/packages/core/src/runtime-policy/connection-catalog-codec.ts b/packages/core/src/runtime-policy/connection-catalog-codec.ts index 67c5796987..766ce2d3f4 100644 --- a/packages/core/src/runtime-policy/connection-catalog-codec.ts +++ b/packages/core/src/runtime-policy/connection-catalog-codec.ts @@ -26,6 +26,7 @@ import { validateSlug, type ModelModality, type ProviderType, + type SlugValidationIssue, } from '../llm-connections.js'; import { CONNECTION_MODEL_DESCRIPTION_MAX_LENGTH, @@ -756,7 +757,14 @@ export function normalizeConnectionModelDiscoveryResult( export function decodeConnectionSlug(value: unknown): string { if (typeof value !== 'string') throw domainError('connection slug must be a string'); const error = validateSlug(value); - if (error) throw domainError(`connection slug: ${error}`); + if (error) { + const messages = { + required: 'Slug is required', + format: 'Slug must be lowercase letters, digits, and hyphens', + too_long: 'Slug must be 64 characters or fewer', + } satisfies Record; + throw domainError(`connection slug: ${messages[error]}`); + } return value; }