@steerable/agent-shell-web 0.6.53 → 0.6.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/package.json +4 -4
  2. package/src/brand.ts +1 -1
  3. package/src/components/AgentSidebar.tsx +60 -52
  4. package/src/components/ChatHeader.tsx +41 -25
  5. package/src/components/ConfirmDialog.tsx +4 -3
  6. package/src/components/CreateProjectModal.tsx +29 -19
  7. package/src/components/LocalLlmSettingsModal.tsx +4 -3
  8. package/src/components/SidebarRelease.tsx +2 -1
  9. package/src/components/TerminalPanel.tsx +3 -2
  10. package/src/components/TerminalView.tsx +12 -7
  11. package/src/components/chat/ApprovalModal.tsx +39 -25
  12. package/src/components/chat/ApprovalPromptProvider.tsx +1 -1
  13. package/src/components/chat/AskUserPromptProvider.tsx +1 -1
  14. package/src/components/chat/AskUserQuestionMenu.tsx +19 -14
  15. package/src/components/chat/AssistantMessage.tsx +18 -13
  16. package/src/components/chat/ChatInput.tsx +56 -40
  17. package/src/components/chat/ChatProjectBadge.tsx +24 -19
  18. package/src/components/chat/EmptyChatHero.tsx +2 -1
  19. package/src/components/chat/ExecPolicyPicker.tsx +12 -11
  20. package/src/components/chat/ExecutedActionsCard.tsx +45 -33
  21. package/src/components/chat/FilePathCode.tsx +8 -3
  22. package/src/components/chat/InterruptedTurnCard.tsx +4 -3
  23. package/src/components/chat/LocalChatPanel.tsx +2 -1
  24. package/src/components/chat/Markdown.tsx +2 -1
  25. package/src/components/chat/MessageList.tsx +3 -2
  26. package/src/components/chat/ModelPicker.tsx +25 -16
  27. package/src/components/chat/OrchestrationChildrenCard.tsx +2 -1
  28. package/src/components/chat/OrchestrationFlowCard.tsx +23 -22
  29. package/src/components/chat/PlanTodoList.tsx +2 -1
  30. package/src/components/chat/ProjectTrustBanner.tsx +19 -5
  31. package/src/components/chat/SessionOrchestrationFlow.tsx +27 -26
  32. package/src/components/chat/SessionTodoList.tsx +8 -7
  33. package/src/components/chat/SessionTreeModal.tsx +10 -9
  34. package/src/components/chat/StreamingStatus.tsx +4 -3
  35. package/src/components/chat/SuggestedReplies.tsx +3 -2
  36. package/src/components/chat/TaskOutcomeCards.tsx +8 -5
  37. package/src/components/chat/TaskPanelModal.tsx +31 -21
  38. package/src/components/chat/TaskProcessPanel.tsx +14 -13
  39. package/src/components/chat/TurnFilesCard.tsx +14 -13
  40. package/src/components/chat/UserMessage.tsx +12 -11
  41. package/src/components/chat/executed-actions-model.ts +47 -35
  42. package/src/components/chat/orchestration-flow-model.ts +16 -5
  43. package/src/components/chat/process-status.ts +8 -7
  44. package/src/components/chat/todo-list-model.ts +5 -4
  45. package/src/components/chat/turn-files.ts +8 -8
  46. package/src/components/settings/AgentCapabilityFields.tsx +24 -21
  47. package/src/components/settings/AgentsSettingsPanel.tsx +30 -25
  48. package/src/components/settings/AppUpdateSettingsPanel.tsx +3 -2
  49. package/src/components/settings/AppearanceSettingsPanel.tsx +81 -37
  50. package/src/components/settings/DiagnoseSettingsPanel.tsx +11 -8
  51. package/src/components/settings/InsightsSettingsPanel.tsx +43 -28
  52. package/src/components/settings/LlmSettingsPanel.tsx +104 -83
  53. package/src/components/settings/McpSettingsPanel.tsx +60 -34
  54. package/src/components/settings/ModelIdCombobox.tsx +2 -1
  55. package/src/components/settings/OrchestrationSettingsPanel.tsx +8 -5
  56. package/src/components/settings/PortableSettingsPanel.tsx +83 -55
  57. package/src/components/settings/PythonRunnerSettingsPanel.tsx +22 -19
  58. package/src/components/settings/SecuritySettingsPanel.tsx +55 -31
  59. package/src/components/settings/SettingsNavMenu.tsx +3 -2
  60. package/src/components/settings/SettingsSaveButton.tsx +2 -1
  61. package/src/components/settings/SkillsSettingsPanel.tsx +36 -31
  62. package/src/components/settings/TelemetrySettingsPanel.tsx +15 -12
  63. package/src/components/settings/UsagePanel.tsx +19 -20
  64. package/src/components/settings/WebSearchSettingsPanel.tsx +23 -14
  65. package/src/components/settings/llm-vendors.ts +12 -7
  66. package/src/components/settings/model-capabilities.ts +16 -15
  67. package/src/components/settings/preset-choice-model.ts +6 -3
  68. package/src/fixtures/browser-dev-data.json +24 -24
  69. package/src/hooks/useProjects.ts +1 -1
  70. package/src/i18n/agent-label.ts +15 -0
  71. package/src/i18n/index.tsx +68 -0
  72. package/src/i18n/runtime.ts +117 -0
  73. package/src/i18n/translate.ts +65 -0
  74. package/src/layouts/AgentLayout.tsx +7 -6
  75. package/src/lib/app-release.ts +15 -10
  76. package/src/lib/attachments.ts +16 -7
  77. package/src/lib/browser-dev-electron-mock.ts +34 -34
  78. package/src/lib/chat-title.ts +3 -2
  79. package/src/lib/chat-transport.ts +6 -2
  80. package/src/lib/date-groups.ts +23 -15
  81. package/src/lib/electron-bridge.ts +4 -5
  82. package/src/lib/http-bridge.ts +4 -1
  83. package/src/lib/portable.ts +8 -7
  84. package/src/lib/show-thinking-content.ts +15 -3
  85. package/src/main.tsx +25 -2
  86. package/src/pages/AgentPage.tsx +17 -12
  87. package/src/pages/ChatPanelPreviewPage.tsx +46 -44
  88. package/src/pages/SettingsPage.tsx +48 -42
  89. package/src/vite-env.d.ts +3 -0
  90. package/vite.base.js +6 -2
@@ -1,3 +1,4 @@
1
+ import { t } from '@/i18n';
1
2
  import type { AppReleasePhase, AppReleaseSnapshot } from './electron-bridge';
2
3
 
3
4
  export interface AppReleaseView {
@@ -20,21 +21,25 @@ export function sidebarUpdateLabel(
20
21
  if (!snap.enabled || snap.phase === 'disabled') return null;
21
22
  switch (snap.phase) {
22
23
  case 'checking':
23
- return '检查中';
24
+ return t('Checking');
24
25
  case 'downloading':
25
- return typeof snap.percent === 'number' ? `下载 ${Math.round(snap.percent)}%` : '下载中';
26
+ return typeof snap.percent === 'number'
27
+ ? t('Downloading {percent}%', { percent: Math.round(snap.percent) })
28
+ : t('Downloading…');
26
29
  case 'ready':
27
- return snap.availableVersion ? `升级 ${snap.availableVersion}` : '升级';
30
+ return snap.availableVersion
31
+ ? t('Update to {version}', { version: snap.availableVersion })
32
+ : t('Update');
28
33
  case 'installing':
29
- return '正在安装';
34
+ return t('Installing');
30
35
  case 'error':
31
- return '重试';
36
+ return t('Retry');
32
37
  case 'idle':
33
- if (snap.availableVersion) return `发现 ${snap.availableVersion}`;
38
+ if (snap.availableVersion) return t('{version} available', { version: snap.availableVersion });
34
39
  if (snap.message) return snap.message;
35
- return confirmedCurrent ? '已是最新' : '检查更新';
40
+ return confirmedCurrent ? t('Up to date') : t('Check for updates');
36
41
  default:
37
- return '检查更新';
42
+ return t('Check for updates');
38
43
  }
39
44
  }
40
45
 
@@ -43,9 +48,9 @@ export function sidebarUpdateTitle(snap: AppReleaseSnapshot): string | undefined
43
48
  return snap.message;
44
49
  }
45
50
  if (snap.phase === 'ready' && snap.availableVersion) {
46
- return `重启并安装 ${snap.availableVersion}`;
51
+ return t('Restart and install {version}', { version: snap.availableVersion });
47
52
  }
48
- if (snap.phase === 'downloading') return '正在下载更新';
53
+ if (snap.phase === 'downloading') return t('Downloading update');
49
54
  return undefined;
50
55
  }
51
56
 
@@ -9,6 +9,7 @@
9
9
  * - BS/browser:File 没有路径,这里把字节读成 base64 走 HTTP 端点落盘。
10
10
  * 返回的落盘路径会重写消息正文里的文件引用与图像附件元数据。
11
11
  */
12
+ import { t } from '@/i18n';
12
13
  import { getElectronBridge } from './electron-bridge';
13
14
 
14
15
  export interface AttachmentFile {
@@ -109,7 +110,7 @@ export async function saveChatAttachments(
109
110
  kept.push(file);
110
111
  return;
111
112
  }
112
- failures.push({ name: file.name, error: entry?.error || '上传失败', file });
113
+ failures.push({ name: file.name, error: entry?.error || t('Upload failed'), file });
113
114
  });
114
115
  return { files: kept, failures };
115
116
  } catch (err) {
@@ -123,13 +124,21 @@ export async function saveChatAttachments(
123
124
  }
124
125
  }
125
126
 
126
- /** 把失败项拼成一条给用户看的中文提示(供各提交入口复用)。 */
127
+ /** 把失败项拼成一条给用户看的提示(供各提交入口复用)。 */
127
128
  export function formatAttachmentFailures(failures: AttachmentSaveFailure[]): string {
128
129
  if (failures.length === 0) return '';
129
- const detail = failures.map((f) => `${f.name}(${f.error})`).join(';');
130
+ const detail = failures
131
+ .map((f) => t('{name} ({error})', { name: f.name, error: f.error }))
132
+ .join(t('; '));
130
133
  return failures.length === 1
131
- ? `文件「${failures[0].name}」未能上传,本条消息未包含它:${failures[0].error}`
132
- : `${failures.length} 个文件未能上传,本条消息未包含它们:${detail}`;
134
+ ? t('File "{name}" failed to upload and is not included in this message: {error}', {
135
+ name: failures[0].name,
136
+ error: failures[0].error,
137
+ })
138
+ : t('{count} files failed to upload and are not included in this message: {detail}', {
139
+ count: failures.length,
140
+ detail,
141
+ });
133
142
  }
134
143
 
135
144
  /**
@@ -142,8 +151,8 @@ export function appendAttachmentRefs(rawText: string, files: AttachmentFile[]):
142
151
  if (files.length === 0) return rawText;
143
152
  const refs = files.map((f) => `- \`${f.path}\``).join('\n');
144
153
  return rawText
145
- ? `${rawText}\n\n---\n关联文件:\n${refs}`
146
- : `关联文件:\n${refs}`;
154
+ ? `${rawText}\n\n---\n${t('Related files:')}\n${refs}`
155
+ : `${t('Related files:')}\n${refs}`;
147
156
  }
148
157
 
149
158
  /**
@@ -97,33 +97,33 @@ function createInitialState(): BrowserMockState {
97
97
  {
98
98
  id: 'coach',
99
99
  slug: 'coach',
100
- name: '行动教练',
100
+ name: 'Action coach',
101
101
  icon: null,
102
102
  color: '#2563eb',
103
- description: '把目标拆成可执行计划,适合调试标准聊天路径。',
104
- rolePrompt: '你是一个行动教练。',
103
+ description: 'Breaks a goal into an executable plan. Useful for debugging the standard chat path.',
104
+ rolePrompt: 'You are an action coach.',
105
105
  isBuiltin: true,
106
106
  sortOrder: 1,
107
107
  },
108
108
  {
109
109
  id: 'tool-demo',
110
110
  slug: 'tool-demo',
111
- name: '工具演示专家',
111
+ name: 'Tool demo',
112
112
  icon: null,
113
113
  color: '#7c3aed',
114
- description: '模拟本地工具调用和执行摘要,适合调试工具卡片。',
115
- rolePrompt: '你是工具演示智能体。',
114
+ description: 'Simulates local tool calls and execution summaries. Useful for debugging tool cards.',
115
+ rolePrompt: 'You are the tool-demo agent.',
116
116
  isBuiltin: true,
117
117
  sortOrder: 2,
118
118
  },
119
119
  {
120
120
  id: 'writer',
121
121
  slug: 'writer',
122
- name: '文案顾问',
122
+ name: 'Writing advisor',
123
123
  icon: null,
124
124
  color: '#16a34a',
125
- description: '偏长文本回复样例,适合调试 markdown 与滚动。',
126
- rolePrompt: '你是一个文案顾问。',
125
+ description: 'Longer text replies, for debugging markdown and scrolling.',
126
+ rolePrompt: 'You are a writing advisor.',
127
127
  isBuiltin: true,
128
128
  sortOrder: 3,
129
129
  },
@@ -132,14 +132,14 @@ function createInitialState(): BrowserMockState {
132
132
  const chats = [
133
133
  createChatRecord({
134
134
  id: 'chat-browser-demo',
135
- title: '浏览器 UI 调试样例',
135
+ title: 'Browser UI debug sample',
136
136
  agentId: 'coach',
137
137
  minutesAgo: 8,
138
138
  isPinned: true,
139
139
  }),
140
140
  createChatRecord({
141
141
  id: 'chat-tool-demo',
142
- title: '工具调用卡片预览',
142
+ title: 'Tool-call card preview',
143
143
  agentId: 'tool-demo',
144
144
  minutesAgo: 64,
145
145
  }),
@@ -155,14 +155,14 @@ function createInitialState(): BrowserMockState {
155
155
  chatId: 'chat-browser-demo',
156
156
  role: 'assistant',
157
157
  content:
158
- '可以在这里调试普通浏览器里的聊天 UI。这个回复来自 dev mock bridge,不依赖 Electron IPC。',
158
+ 'You can debug the chat UI in a normal browser here. This reply comes from the dev mock bridge and does not use Electron IPC.',
159
159
  minutesAgo: 7,
160
160
  }),
161
161
  createMessage({
162
162
  id: 'msg-demo-user-1',
163
163
  chatId: 'chat-browser-demo',
164
164
  role: 'user',
165
- content: '我想在浏览器里调试聊天界面。',
165
+ content: 'I want to debug the chat UI in the browser.',
166
166
  minutesAgo: 8,
167
167
  }),
168
168
  ],
@@ -171,7 +171,7 @@ function createInitialState(): BrowserMockState {
171
171
  id: 'msg-tool-assistant-1',
172
172
  chatId: 'chat-tool-demo',
173
173
  role: 'assistant',
174
- content: '我已经模拟执行了一次本地命令,并把结果展示在工具卡片里。',
174
+ content: 'I simulated a local command and put the result in a tool card.',
175
175
  minutesAgo: 63,
176
176
  messageMetadata: JSON.stringify({
177
177
  executedActions: [
@@ -187,7 +187,7 @@ function createInitialState(): BrowserMockState {
187
187
  id: 'msg-tool-user-1',
188
188
  chatId: 'chat-tool-demo',
189
189
  role: 'user',
190
- content: '帮我看一下最近的工具调用记录。',
190
+ content: 'Show me the recent tool-call records.',
191
191
  minutesAgo: 64,
192
192
  }),
193
193
  ],
@@ -201,9 +201,9 @@ function createInitialState(): BrowserMockState {
201
201
  maxTotalTokens: 60_000,
202
202
  },
203
203
  skills: [
204
- { name: 'tool-demo', description: '模拟查询与回放工具。', builtin: true },
205
- { name: 'local-exec', description: '模拟本地命令执行。', builtin: true },
206
- { name: 'anti-deferred', description: '模拟防拖延执行策略。', builtin: true },
204
+ { name: 'tool-demo', description: 'Simulates query and replay tools.', builtin: true },
205
+ { name: 'local-exec', description: 'Simulates local command execution.', builtin: true },
206
+ { name: 'anti-deferred', description: 'Simulates the anti-deferral execution policy.', builtin: true },
207
207
  ],
208
208
  activeStreams: new Map(),
209
209
  source: 'fallback',
@@ -235,8 +235,8 @@ function createStateFromFixture(fixture: BrowserDevFixture): BrowserMockState |
235
235
  Array.isArray(fixture.skills) && fixture.skills.length > 0
236
236
  ? fixture.skills
237
237
  : [
238
- { name: 'tool-demo', description: '查询与回放工具。', builtin: true },
239
- { name: 'local-exec', description: '本地命令执行。', builtin: true },
238
+ { name: 'tool-demo', description: 'Query and replay tools.', builtin: true },
239
+ { name: 'local-exec', description: 'Local command execution.', builtin: true },
240
240
  ],
241
241
  activeStreams: new Map(),
242
242
  source: 'fixture',
@@ -318,7 +318,7 @@ async function request<T>(input: LocalBackendRequestInput): Promise<T> {
318
318
  const agent: LocalChatAgent = {
319
319
  id,
320
320
  slug: null,
321
- name: (body.name || '新助手').trim() || '新助手',
321
+ name: (body.name || 'New assistant').trim() || 'New assistant',
322
322
  icon: null,
323
323
  color: body.color ?? '#4f46e5',
324
324
  description: body.description ?? null,
@@ -392,10 +392,10 @@ async function request<T>(input: LocalBackendRequestInput): Promise<T> {
392
392
  if (method === 'GET' && cleanPath === '/api/v2/chat-agents/tools') {
393
393
  return {
394
394
  tools: [
395
- { name: 'local_exec_shell', description: '执行本地 shell 命令。', category: 'local' },
396
- { name: 'local_read_file', description: '读取本地文本文件。', category: 'local' },
397
- { name: 'local_write_file', description: '写入本地文件。', category: 'local' },
398
- { name: 'web_search', description: '联网搜索。', category: 'local' },
395
+ { name: 'local_exec_shell', description: 'Run a local shell command.', category: 'local' },
396
+ { name: 'local_read_file', description: 'Read a local text file.', category: 'local' },
397
+ { name: 'local_write_file', description: 'Write a local file.', category: 'local' },
398
+ { name: 'web_search', description: 'Search the web.', category: 'local' },
399
399
  ],
400
400
  } as T;
401
401
  }
@@ -404,7 +404,7 @@ async function request<T>(input: LocalBackendRequestInput): Promise<T> {
404
404
  const body = input.body as { path?: string } | undefined;
405
405
  const name = body?.path?.split(/[\\/]/).filter(Boolean).pop() || 'browser-skill';
406
406
  if (!state.skills.some((skill) => skill.name === name)) {
407
- state.skills.push({ name, description: '浏览器预览中导入的 mock skill。' });
407
+ state.skills.push({ name, description: 'Mock skill imported in the browser preview.' });
408
408
  }
409
409
  return { success: true, name } as T;
410
410
  }
@@ -444,7 +444,7 @@ async function request<T>(input: LocalBackendRequestInput): Promise<T> {
444
444
  const chatId = `chat-browser-${Date.now()}`;
445
445
  const chat = createChatRecord({
446
446
  id: chatId,
447
- title: '新会话',
447
+ title: 'New chat',
448
448
  agentId,
449
449
  minutesAgo: 0,
450
450
  });
@@ -515,7 +515,7 @@ async function startStream(
515
515
  }
516
516
 
517
517
  const body = input.body as { message?: string } | undefined;
518
- const message = body?.message?.trim() || '请演示一下浏览器 mock 回复。';
518
+ const message = body?.message?.trim() || 'Please demo a browser mock reply.';
519
519
  const streamId = `browser-stream-${Date.now()}`;
520
520
  state.activeStreams.set(streamId, []);
521
521
 
@@ -530,10 +530,10 @@ async function startStream(
530
530
  state.messagesByChatId[chatId] = [...(state.messagesByChatId[chatId] ?? []), userMessage];
531
531
 
532
532
  const reply =
533
- `这是浏览器 dev mock 的流式回复。你刚才输入的是:「${message}」。` +
534
- ' 这里可以调试消息气泡、滚动、停止按钮、工具执行卡片和后续状态。';
533
+ `This is a streaming reply from the browser dev mock. You wrote: "${message}".` +
534
+ ' Use it to debug message bubbles, scrolling, the stop button, tool cards, and later status.';
535
535
  const chunks = reply.match(/.{1,8}/g) ?? [reply];
536
- const reasoning = '先调用一次 mock 工具,再把回复按调用流程交错显示。';
536
+ const reasoning = 'Call one mock tool, then interleave the reply with that call.';
537
537
  const mockAction = {
538
538
  id: 'browser-mock-1',
539
539
  tool: 'browser_mock',
@@ -580,7 +580,7 @@ async function startStream(
580
580
  { type: 'text', content: reply },
581
581
  ],
582
582
  durationMs: 1_200,
583
- suggestedReplies: ['继续完善这份结果', '换一种呈现方式', '告诉我下一步怎么做'],
583
+ suggestedReplies: ['Keep improving this result', 'Try another presentation', 'Tell me the next step'],
584
584
  }),
585
585
  });
586
586
  assistantMessage.createdAt = new Date().toISOString();
@@ -593,7 +593,7 @@ async function startStream(
593
593
  pushSse(onEvent, '[DONE]');
594
594
  onEvent({ type: 'end', status: 200 });
595
595
  state.activeStreams.delete(streamId);
596
- const suggestions = ['继续完善这份结果', '换一种呈现方式', '告诉我下一步怎么做'];
596
+ const suggestions = ['Keep improving this result', 'Try another presentation', 'Tell me the next step'];
597
597
  for (const listener of suggestedReplyListeners) {
598
598
  listener({ chatId, messageId: assistantMessage.id, suggestions });
599
599
  }
@@ -625,7 +625,7 @@ export function installBrowserDevElectronMock() {
625
625
  // 浏览器 dev 模式没有 Electron capturePage/clipboard,如实返回失败。
626
626
  captureScreenshot: async () => ({
627
627
  success: false as const,
628
- error: '浏览器开发模式不支持窗口截图(需要 Electron 运行时)',
628
+ error: 'Browser dev mode cannot capture a window screenshot (it needs the Electron runtime).',
629
629
  }),
630
630
  },
631
631
  localBackend: {
@@ -5,8 +5,9 @@
5
5
  * automation.py:_AUTOMATION_TITLE_PREFIX), so the same parser works for both.
6
6
  */
7
7
 
8
- export const AUTOMATION_TITLE_PREFIX = '[自动化] ';
9
- const BARE_BRACKET_PREFIX = '[自动化]';
8
+ // 与后端 automation.py 的标题前缀是同一条持久化约定,不能按界面语言改写。
9
+ export const AUTOMATION_TITLE_PREFIX = '[自动化] '; // i18n:allow
10
+ const BARE_BRACKET_PREFIX = '[自动化]'; // i18n:allow
10
11
 
11
12
  export interface ParsedChatTitle {
12
13
  displayTitle: string;
@@ -53,6 +53,7 @@ import {
53
53
  } from '@steerable/agent-ui/state';
54
54
  import type { SSEEvent } from '@steerable/agent-protocol';
55
55
  import { getElectronBridge } from './electron-bridge';
56
+ import { t } from '@/i18n';
56
57
  import {
57
58
  appendDelta,
58
59
  freezeReasoningDurations,
@@ -372,7 +373,7 @@ export async function regenerateChatMessage(
372
373
  if (frame.event !== 'error' || !frame.data) return;
373
374
  const parsed = parseSSEData(frame.data);
374
375
  const message = (parsed as { message?: unknown } | null)?.message;
375
- refusal = typeof message === 'string' && message ? message : '重新生成失败';
376
+ refusal = typeof message === 'string' && message ? message : t('Regeneration failed');
376
377
  },
377
378
  });
378
379
  bridge.localBackend
@@ -388,7 +389,10 @@ export async function regenerateChatMessage(
388
389
  parser.end();
389
390
  settle(
390
391
  payload.status >= 400
391
- ? new Error(refusal ?? `重新生成失败(HTTP ${payload.status})`)
392
+ ? new Error(
393
+ refusal ??
394
+ t('Regeneration failed (HTTP {status})', { status: payload.status }),
395
+ )
392
396
  : undefined,
393
397
  );
394
398
  } else if (payload.type === 'error') settle(new Error(payload.error));
@@ -1,34 +1,42 @@
1
1
  /**
2
2
  * Date-group helpers used by AgentSidebar to label chat groups
3
- * ("今天" / "昨天" / "N天前" / "M月D日" / "YYYY年M月D日"). Lifted verbatim
4
- * from `deeppath/apps/web/src/app/agent/AgentSidebar.tsx` so the visual
3
+ * ("Today" / "Yesterday" / "N days ago" / "M/D" / "M/D/YYYY"). Lifted from
4
+ * `deeppath/apps/web/src/app/agent/AgentSidebar.tsx` so the visual
5
5
  * grouping stays in lockstep.
6
6
  *
7
7
  * priority is used for ordering groups: smaller = newer = listed first.
8
+ * 标签走 t(),排序只看日期,不解析标签文本。
8
9
  */
10
+ import { t } from '@/i18n';
9
11
 
10
- export function getDateGroupLabel(date: Date): string {
12
+ function dayBuckets(date: Date): { diffDays: number; target: Date; today: Date } {
11
13
  const today = new Date();
12
14
  today.setHours(0, 0, 0, 0);
13
15
  const target = new Date(date);
14
16
  target.setHours(0, 0, 0, 0);
15
17
  const diffDays =
16
18
  (today.getTime() - target.getTime()) / (1000 * 60 * 60 * 24);
17
- if (diffDays === 0) return '今天';
18
- if (diffDays === 1) return '昨天';
19
- if (diffDays < 4) return `${Math.floor(diffDays)}天前`;
20
- const m = target.getMonth() + 1;
21
- const d = target.getDate();
19
+ return { diffDays, target, today };
20
+ }
21
+
22
+ export function getDateGroupLabel(date: Date): string {
23
+ const { diffDays, target, today } = dayBuckets(date);
24
+ if (diffDays === 0) return t('Today');
25
+ if (diffDays === 1) return t('Yesterday');
26
+ if (diffDays < 4) return t('{count} days ago', { count: Math.floor(diffDays) });
27
+ const month = target.getMonth() + 1;
28
+ const day = target.getDate();
22
29
  if (target.getFullYear() !== today.getFullYear()) {
23
- return `${target.getFullYear()}年${m}月${d}日`;
30
+ return t('{month}/{day}/{year}', { year: target.getFullYear(), month, day });
24
31
  }
25
- return `${m}月${d}日`;
32
+ return t('{month}/{day}', { month, day });
26
33
  }
27
34
 
28
- export function getDateGroupPriority(label: string): number {
29
- if (label === '今天') return 1;
30
- if (label === '昨天') return 2;
31
- if (label.endsWith('天前')) return 2 + parseInt(label, 10);
32
- if (label.includes('年')) return 1000;
35
+ export function getDateGroupPriority(date: Date): number {
36
+ const { diffDays, target, today } = dayBuckets(date);
37
+ if (diffDays === 0) return 1;
38
+ if (diffDays === 1) return 2;
39
+ if (diffDays < 4) return 2 + Math.floor(diffDays);
40
+ if (target.getFullYear() !== today.getFullYear()) return 1000;
33
41
  return 100;
34
42
  }
@@ -1,11 +1,8 @@
1
1
  /**
2
2
  * Host-neutral bridge used by the renderer in desktop and browser modes.
3
3
  *
4
- * This file MUST stay a subset / aligned shape with `ElectronAPI` in
5
- * `../../../src/preload.ts`. We intentionally don't TypeScript-reference that
6
- * file across workspaces — it would drag the whole main-process source tree
7
- * into the renderer's compile graph. Treat this as a contract surface and
8
- * extend it lazily as apps/web starts using new bridge methods.
4
+ * Implementations are `tauri-bridge.ts` (desktop) and `http-bridge.ts`
5
+ * (browser server); both must satisfy {@link HostBridge}.
9
6
  *
10
7
  * `window.electron` and `getElectronBridge()` remain compatibility aliases for
11
8
  * existing products. New integrations should use `window.steerableHost` and
@@ -184,6 +181,8 @@ export interface HostBridge {
184
181
  * to clear the listener (otherwise multiple AgentSidebar mounts
185
182
  * during HMR would queue up duplicate callbacks).
186
183
  */
184
+ /** 把当前界面语言告诉主进程,以便重建应用菜单。浏览器预览里没有。 */
185
+ setLocale?: (locale: string) => void;
187
186
  onMenuNewChat?: (callback: () => void) => void;
188
187
  offMenuNewChat?: () => void;
189
188
  /**
@@ -23,6 +23,7 @@ import type {
23
23
  TerminalSession,
24
24
  TerminalSpawnOptions,
25
25
  } from './electron-bridge';
26
+ import { t } from '@/i18n';
26
27
 
27
28
  interface BsBootstrap {
28
29
  platform: NodeJS.Platform;
@@ -225,7 +226,9 @@ export function createHttpBridge(): ElectronBridge {
225
226
  },
226
227
  captureScreenshot: async () => ({
227
228
  success: false as const,
228
- error: '浏览器模式不支持窗口截图,请用系统截图工具。',
229
+ error: t(
230
+ 'Browser mode cannot capture window screenshots. Use your system screenshot tool.',
231
+ ),
229
232
  }),
230
233
  },
231
234
 
@@ -3,6 +3,7 @@
3
3
  * 界面档位和命令权限存在 localStorage,由这里并进配置包;其余段走本地后端。
4
4
  */
5
5
 
6
+ import { t } from '@/i18n';
6
7
  import { getElectronBridge } from '@/lib/electron-bridge';
7
8
  import { listChats } from '@/lib/local-api';
8
9
  import { persistExecPolicy, readStoredExecPolicy, type ExecPolicy } from '@/lib/exec-policy';
@@ -69,9 +70,9 @@ export interface PortableChatImportResult {
69
70
  const CLIENT_SECTION_IDS = ['appearance', 'execPolicy'] as const;
70
71
 
71
72
  export function portableErrorMessage(err: unknown): string {
72
- if (!(err instanceof Error)) return '操作失败';
73
+ if (!(err instanceof Error)) return t('Operation failed');
73
74
  const message = err.message.trim();
74
- if (!message) return '操作失败';
75
+ if (!message) return t('Operation failed');
75
76
  try {
76
77
  const parsed = JSON.parse(message) as { detail?: unknown; error?: unknown };
77
78
  if (typeof parsed.detail === 'string' && parsed.detail.trim()) return parsed.detail;
@@ -86,13 +87,13 @@ export function parsePortableText(text: string): { ok: true; value: unknown } |
86
87
  try {
87
88
  return { ok: true, value: JSON.parse(text) as unknown };
88
89
  } catch {
89
- return { ok: false, error: '文件不是 JSON' };
90
+ return { ok: false, error: t('The file is not JSON') };
90
91
  }
91
92
  }
92
93
 
93
94
  export function safeDownloadName(stem: string, suffix: string): string {
94
95
  const cleaned = stem.replace(/[\\/:*?"<>|]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 40);
95
- return `${cleaned || '导出'}-${suffix}.json`;
96
+ return `${cleaned || t('Export')}-${suffix}.json`;
96
97
  }
97
98
 
98
99
  export function readLocalSections(): { appearance: { thinkingDisplay: ThinkingDisplayMode }; execPolicy: { policy: ExecPolicy } } {
@@ -139,7 +140,7 @@ export function isClientSection(id: string): boolean {
139
140
 
140
141
  async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
141
142
  const bridge = getElectronBridge();
142
- if (!bridge) throw new Error('当前环境没有本地后端');
143
+ if (!bridge) throw new Error(t('No local backend in this environment'));
143
144
  return bridge.localBackend.request<T>(body === undefined ? { method, path } : { method, path, body });
144
145
  }
145
146
 
@@ -159,7 +160,7 @@ export async function listPortableChats(): Promise<Array<{ id: string; title: st
159
160
  for (;;) {
160
161
  const res = await listChats({ page, limit: 100 });
161
162
  for (const chat of res.chats) {
162
- rows.push({ id: chat.id, title: chat.title.trim() || '未命名对话' });
163
+ rows.push({ id: chat.id, title: chat.title.trim() || t('Untitled chat') });
163
164
  }
164
165
  if (!res.pagination.hasMore || page >= 50) break;
165
166
  page += 1;
@@ -190,7 +191,7 @@ export async function saveJsonFile(filename: string, data: unknown): Promise<str
190
191
  const content = `${JSON.stringify(data, null, 2)}\n`;
191
192
  const save = getElectronBridge()?.local?.saveTextFile;
192
193
  if (save) {
193
- const saved = await save({ title: '导出', defaultPath: filename, content });
194
+ const saved = await save({ title: t('Export'), defaultPath: filename, content });
194
195
  if (saved.canceled || !saved.filePath) return null;
195
196
  return saved.filePath;
196
197
  }
@@ -19,9 +19,21 @@ export const THINKING_DISPLAY_OPTIONS: ReadonlyArray<{
19
19
  label: string;
20
20
  hint: string;
21
21
  }> = [
22
- { mode: 'hidden', label: '隐藏', hint: '不展示思考正文,只保留工作状态行。' },
23
- { mode: 'peek', label: '显示5行', hint: '当前轮思考最多露出 5 行,不足不撑开;该轮结束后自动折叠。完整正文请用「完整显示」。' },
24
- { mode: 'full', label: '完整显示', hint: '当前轮思考完整展开,该轮结束后自动折叠;工作行结束后仍自动折叠。' },
22
+ {
23
+ mode: 'hidden',
24
+ label: 'Hide',
25
+ hint: 'Hide the thinking text and keep only the work status line.',
26
+ },
27
+ {
28
+ mode: 'peek',
29
+ label: 'Show 5 lines',
30
+ hint: 'Show at most 5 lines of the current turn\'s thinking. Do not stretch the row when there is less, and collapse it when the turn ends. Use "Show all" for the full text.',
31
+ },
32
+ {
33
+ mode: 'full',
34
+ label: 'Show all',
35
+ hint: 'Expand the current turn\'s thinking, then collapse it when the turn ends. The work row still collapses when it finishes.',
36
+ },
25
37
  ];
26
38
 
27
39
  export function isThinkingDisplayMode(value: unknown): value is ThinkingDisplayMode {
package/src/main.tsx CHANGED
@@ -2,6 +2,7 @@ import { StrictMode, lazy, Suspense, type ReactNode } from 'react';
2
2
  import { createRoot, type Root } from 'react-dom/client';
3
3
  import { createHashRouter, RouterProvider } from 'react-router-dom';
4
4
  import { AppShell } from './AppShell';
5
+ import { I18nProvider, configureI18n, getLocale } from './i18n';
5
6
  import { AgentLayout } from './layouts/AgentLayout';
6
7
  import { AgentPage } from './pages/AgentPage';
7
8
  import { SettingsPage } from './pages/SettingsPage';
@@ -82,7 +83,9 @@ function renderApplication(root: Root): void {
82
83
 
83
84
  root.render(
84
85
  <StrictMode>
85
- <RouterProvider router={router} />
86
+ <I18nProvider>
87
+ <RouterProvider router={router} />
88
+ </I18nProvider>
86
89
  </StrictMode>,
87
90
  );
88
91
  }
@@ -97,6 +100,12 @@ export async function bootstrap(): Promise<void> {
97
100
 
98
101
  // 标题与 favicon 在 bootstrap 时设置——此刻产品入口已完成包注册
99
102
  // (品牌 logo 由包经 setBrandLogoUrl 注入,见 brand.ts)。
103
+ configureI18n({
104
+ locales: parseLocales(import.meta.env.VITE_LOCALES),
105
+ defaultLocale: import.meta.env.VITE_DEFAULT_LOCALE || 'en',
106
+ });
107
+ document.documentElement.lang = getLocale() === 'zh' ? 'zh-CN' : getLocale();
108
+ window.electron?.setLocale?.(getLocale());
100
109
  document.title = BRAND_NAME;
101
110
  document.querySelector<HTMLLinkElement>('link[rel="icon"]')?.setAttribute('href', getBrandLogoUrl());
102
111
  installHostPaste();
@@ -117,7 +126,9 @@ export async function bootstrap(): Promise<void> {
117
126
  const Gate = gate.Component;
118
127
  root.render(
119
128
  <StrictMode>
120
- <Gate onAuthenticated={onAuthenticated} />
129
+ <I18nProvider>
130
+ <Gate onAuthenticated={onAuthenticated} />
131
+ </I18nProvider>
121
132
  </StrictMode>,
122
133
  );
123
134
  return;
@@ -125,3 +136,15 @@ export async function bootstrap(): Promise<void> {
125
136
 
126
137
  renderApplication(root);
127
138
  }
139
+
140
+ function parseLocales(raw: string | undefined): string[] {
141
+ if (!raw) return ['en'];
142
+ try {
143
+ const parsed = JSON.parse(raw) as unknown;
144
+ if (!Array.isArray(parsed)) return ['en'];
145
+ const locales = parsed.filter((item): item is string => typeof item === 'string' && item.length > 0);
146
+ return locales.length > 0 ? locales : ['en'];
147
+ } catch {
148
+ return ['en'];
149
+ }
150
+ }