@steerable/agent-shell-web 0.6.15

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 (121) hide show
  1. package/LICENSE +91 -0
  2. package/README.md +20 -0
  3. package/package.json +69 -0
  4. package/public/favicon.ico +0 -0
  5. package/public/favicon.png +0 -0
  6. package/src/AppShell.tsx +24 -0
  7. package/src/assets/logo-generic.png +0 -0
  8. package/src/brand.test.ts +22 -0
  9. package/src/brand.ts +48 -0
  10. package/src/components/AgentSidebar.test.tsx +127 -0
  11. package/src/components/AgentSidebar.tsx +1010 -0
  12. package/src/components/ChatHeader.test.tsx +230 -0
  13. package/src/components/ChatHeader.tsx +436 -0
  14. package/src/components/DockPanelHeader.tsx +69 -0
  15. package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
  16. package/src/components/LocalLlmSettingsModal.tsx +72 -0
  17. package/src/components/TerminalPanel.tsx +68 -0
  18. package/src/components/TerminalView.tsx +228 -0
  19. package/src/components/chat/ApprovalModal.test.tsx +83 -0
  20. package/src/components/chat/ApprovalModal.tsx +229 -0
  21. package/src/components/chat/AskUserModalHost.test.tsx +176 -0
  22. package/src/components/chat/AskUserModalHost.tsx +61 -0
  23. package/src/components/chat/AskUserQuestionMenu.tsx +621 -0
  24. package/src/components/chat/AssistantMessage.test.tsx +68 -0
  25. package/src/components/chat/AssistantMessage.tsx +469 -0
  26. package/src/components/chat/ChatInput.test.tsx +609 -0
  27. package/src/components/chat/ChatInput.tsx +2068 -0
  28. package/src/components/chat/ChatProjectBadge.tsx +362 -0
  29. package/src/components/chat/EmptyChat.tsx +18 -0
  30. package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
  31. package/src/components/chat/ExecPolicyPicker.tsx +120 -0
  32. package/src/components/chat/ExecutedActionsCard.tsx +216 -0
  33. package/src/components/chat/InterruptedTurnCard.tsx +53 -0
  34. package/src/components/chat/LocalChatPanel.tsx +408 -0
  35. package/src/components/chat/Markdown.test.tsx +170 -0
  36. package/src/components/chat/Markdown.tsx +432 -0
  37. package/src/components/chat/MessageList.tsx +332 -0
  38. package/src/components/chat/ModelPicker.test.tsx +149 -0
  39. package/src/components/chat/ModelPicker.tsx +289 -0
  40. package/src/components/chat/OrchestrationChildrenCard.tsx +40 -0
  41. package/src/components/chat/PlanTodoList.tsx +121 -0
  42. package/src/components/chat/ProjectTrustBanner.tsx +91 -0
  43. package/src/components/chat/SessionTreeModal.test.tsx +161 -0
  44. package/src/components/chat/SessionTreeModal.tsx +221 -0
  45. package/src/components/chat/StreamingStatus.tsx +95 -0
  46. package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
  47. package/src/components/chat/TaskOutcomeCards.tsx +128 -0
  48. package/src/components/chat/TaskPanelModal.test.tsx +198 -0
  49. package/src/components/chat/TaskPanelModal.tsx +310 -0
  50. package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
  51. package/src/components/chat/TaskProcessPanel.tsx +208 -0
  52. package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
  53. package/src/components/chat/TurnProcessGroup.tsx +193 -0
  54. package/src/components/chat/UnifiedDiff.tsx +31 -0
  55. package/src/components/chat/UserMessage.tsx +155 -0
  56. package/src/components/chat/elapsed.test.ts +42 -0
  57. package/src/components/chat/elapsed.ts +45 -0
  58. package/src/components/chat/executed-actions-model.test.ts +134 -0
  59. package/src/components/chat/executed-actions-model.ts +143 -0
  60. package/src/components/chat/orchestration-children-model.test.ts +115 -0
  61. package/src/components/chat/orchestration-children-model.ts +104 -0
  62. package/src/components/chat/timestamp.ts +24 -0
  63. package/src/components/chat/turn-timeline.test.ts +97 -0
  64. package/src/components/chat/turn-timeline.ts +132 -0
  65. package/src/components/chat/useChatTasks.test.ts +136 -0
  66. package/src/components/chat/useChatTasks.ts +134 -0
  67. package/src/components/chat/useCopy.ts +62 -0
  68. package/src/components/settings/AgentCapabilityFields.tsx +343 -0
  69. package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
  70. package/src/components/settings/AgentsSettingsPanel.tsx +412 -0
  71. package/src/components/settings/DiagnoseSettingsPanel.tsx +119 -0
  72. package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
  73. package/src/components/settings/InsightsSettingsPanel.tsx +309 -0
  74. package/src/components/settings/LlmSettingsPanel.tsx +875 -0
  75. package/src/components/settings/McpSettingsPanel.tsx +466 -0
  76. package/src/components/settings/ModelCapabilityChips.tsx +46 -0
  77. package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
  78. package/src/components/settings/ModelIdCombobox.tsx +185 -0
  79. package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
  80. package/src/components/settings/SecuritySettingsPanel.tsx +169 -0
  81. package/src/components/settings/SettingsSaveButton.tsx +35 -0
  82. package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
  83. package/src/components/settings/SkillsSettingsPanel.tsx +334 -0
  84. package/src/components/settings/TelemetrySettingsPanel.tsx +173 -0
  85. package/src/components/settings/UsagePanel.tsx +196 -0
  86. package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
  87. package/src/components/settings/WebSearchSettingsPanel.tsx +183 -0
  88. package/src/components/settings/compat-flags-model.test.ts +71 -0
  89. package/src/components/settings/compat-flags-model.ts +57 -0
  90. package/src/components/settings/llm-vendors.test.ts +171 -0
  91. package/src/components/settings/llm-vendors.ts +275 -0
  92. package/src/components/settings/model-capabilities.test.ts +88 -0
  93. package/src/components/settings/model-capabilities.ts +123 -0
  94. package/src/components/settings/preset-choice-model.test.ts +96 -0
  95. package/src/components/settings/preset-choice-model.ts +75 -0
  96. package/src/fixtures/browser-dev-data.json +315 -0
  97. package/src/hooks/useChatsAndAgents.ts +182 -0
  98. package/src/layouts/AgentLayout.tsx +574 -0
  99. package/src/lib/attachments.ts +78 -0
  100. package/src/lib/browser-dev-electron-mock.ts +640 -0
  101. package/src/lib/chat-title.ts +33 -0
  102. package/src/lib/chat-transport.ts +346 -0
  103. package/src/lib/date-groups.ts +34 -0
  104. package/src/lib/demo-flag.ts +15 -0
  105. package/src/lib/electron-bridge.ts +270 -0
  106. package/src/lib/exec-policy.ts +25 -0
  107. package/src/lib/http-bridge.ts +293 -0
  108. package/src/lib/insights.ts +17 -0
  109. package/src/lib/local-api.ts +714 -0
  110. package/src/lib/pending-first-message.test.ts +17 -0
  111. package/src/lib/pending-first-message.ts +36 -0
  112. package/src/lib/slash-sources.ts +191 -0
  113. package/src/main.tsx +103 -0
  114. package/src/packs/registry.ts +64 -0
  115. package/src/pages/AgentPage.tsx +1226 -0
  116. package/src/pages/ChatPanelPreviewPage.tsx +264 -0
  117. package/src/pages/SettingsPage.test.tsx +102 -0
  118. package/src/pages/SettingsPage.tsx +197 -0
  119. package/src/styles/index.css +156 -0
  120. package/src/vite-env.d.ts +1 -0
  121. package/vite.base.ts +94 -0
@@ -0,0 +1,640 @@
1
+ import type {
2
+ ElectronBridge,
3
+ LocalBackendRequestInput,
4
+ LocalBackendStreamEvent,
5
+ } from './electron-bridge';
6
+ import type {
7
+ ChatAgentWriteInput,
8
+ LocalChat,
9
+ LocalChatAgent,
10
+ LocalChatMessage,
11
+ LlmSettings,
12
+ } from './local-api';
13
+ import { pickDefaultAgentId } from '@/brand';
14
+ import { isDemoMode } from './demo-flag';
15
+ import browserDevData from '../fixtures/browser-dev-data.json';
16
+
17
+ type MutableChat = LocalChat;
18
+ type MutableMessage = LocalChatMessage;
19
+
20
+ interface BrowserDevFixture {
21
+ version?: number;
22
+ exportedAt?: string | null;
23
+ source?: string | null;
24
+ agents?: LocalChatAgent[];
25
+ chats?: MutableChat[];
26
+ messagesByChatId?: Record<string, MutableMessage[]>;
27
+ llmSettings?: LlmSettings | null;
28
+ skills?: Array<{ name: string; description: string; builtin?: boolean }>;
29
+ }
30
+
31
+ interface BrowserMockState {
32
+ agents: LocalChatAgent[];
33
+ chats: MutableChat[];
34
+ messagesByChatId: Record<string, MutableMessage[]>;
35
+ llmSettings: LlmSettings;
36
+ skills: Array<{ name: string; description: string; builtin?: boolean }>;
37
+ activeStreams: Map<string, number[]>;
38
+ source: 'fixture' | 'fallback';
39
+ fixtureMeta: Pick<BrowserDevFixture, 'exportedAt' | 'source'> | null;
40
+ }
41
+
42
+ const USER_ID = 'browser-preview-user';
43
+ const now = new Date();
44
+
45
+ function iso(minutesAgo: number): string {
46
+ return new Date(now.getTime() - minutesAgo * 60_000).toISOString();
47
+ }
48
+
49
+ function createChatRecord(input: {
50
+ id: string;
51
+ title: string;
52
+ agentId: string | null;
53
+ minutesAgo: number;
54
+ isPinned?: boolean;
55
+ }): MutableChat {
56
+ return {
57
+ id: input.id,
58
+ projectId: null,
59
+ userId: USER_ID,
60
+ title: input.title,
61
+ agentId: input.agentId,
62
+ createdAt: iso(input.minutesAgo + 10),
63
+ updatedAt: iso(input.minutesAgo),
64
+ isPinned: Boolean(input.isPinned),
65
+ systemPrompt: null,
66
+ pinnedRefs: null,
67
+ };
68
+ }
69
+
70
+ function createMessage(input: {
71
+ id: string;
72
+ chatId: string;
73
+ role: MutableMessage['role'];
74
+ content: string;
75
+ minutesAgo: number;
76
+ messageMetadata?: string | null;
77
+ }): MutableMessage {
78
+ return {
79
+ id: input.id,
80
+ chatId: input.chatId,
81
+ role: input.role,
82
+ content: input.content,
83
+ createdAt: iso(input.minutesAgo),
84
+ messageMetadata: input.messageMetadata ?? null,
85
+ };
86
+ }
87
+
88
+ function createInitialState(): BrowserMockState {
89
+ const fixtureState = createStateFromFixture(browserDevData as BrowserDevFixture);
90
+ if (fixtureState) return fixtureState;
91
+
92
+ const agents: LocalChatAgent[] = [
93
+ {
94
+ id: 'coach',
95
+ slug: 'coach',
96
+ name: '行动教练',
97
+ icon: null,
98
+ color: '#2563eb',
99
+ description: '把目标拆成可执行计划,适合调试标准聊天路径。',
100
+ rolePrompt: '你是一个行动教练。',
101
+ isBuiltin: true,
102
+ sortOrder: 1,
103
+ },
104
+ {
105
+ id: 'tool-demo',
106
+ slug: 'tool-demo',
107
+ name: '工具演示专家',
108
+ icon: null,
109
+ color: '#7c3aed',
110
+ description: '模拟本地工具调用和执行摘要,适合调试工具卡片。',
111
+ rolePrompt: '你是工具演示智能体。',
112
+ isBuiltin: true,
113
+ sortOrder: 2,
114
+ },
115
+ {
116
+ id: 'writer',
117
+ slug: 'writer',
118
+ name: '文案顾问',
119
+ icon: null,
120
+ color: '#16a34a',
121
+ description: '偏长文本回复样例,适合调试 markdown 与滚动。',
122
+ rolePrompt: '你是一个文案顾问。',
123
+ isBuiltin: true,
124
+ sortOrder: 3,
125
+ },
126
+ ];
127
+
128
+ const chats = [
129
+ createChatRecord({
130
+ id: 'chat-browser-demo',
131
+ title: '浏览器 UI 调试样例',
132
+ agentId: 'coach',
133
+ minutesAgo: 8,
134
+ isPinned: true,
135
+ }),
136
+ createChatRecord({
137
+ id: 'chat-tool-demo',
138
+ title: '工具调用卡片预览',
139
+ agentId: 'tool-demo',
140
+ minutesAgo: 64,
141
+ }),
142
+ ];
143
+
144
+ return {
145
+ agents,
146
+ chats,
147
+ messagesByChatId: {
148
+ 'chat-browser-demo': [
149
+ createMessage({
150
+ id: 'msg-demo-assistant-1',
151
+ chatId: 'chat-browser-demo',
152
+ role: 'assistant',
153
+ content:
154
+ '可以在这里调试普通浏览器里的聊天 UI。这个回复来自 dev mock bridge,不依赖 Electron IPC。',
155
+ minutesAgo: 7,
156
+ }),
157
+ createMessage({
158
+ id: 'msg-demo-user-1',
159
+ chatId: 'chat-browser-demo',
160
+ role: 'user',
161
+ content: '我想在浏览器里调试聊天界面。',
162
+ minutesAgo: 8,
163
+ }),
164
+ ],
165
+ 'chat-tool-demo': [
166
+ createMessage({
167
+ id: 'msg-tool-assistant-1',
168
+ chatId: 'chat-tool-demo',
169
+ role: 'assistant',
170
+ content: '我已经模拟执行了一次本地命令,并把结果展示在工具卡片里。',
171
+ minutesAgo: 63,
172
+ messageMetadata: JSON.stringify({
173
+ executedActions: [
174
+ {
175
+ tool: 'local_exec',
176
+ arguments: { command: 'tool_demo list --limit 5' },
177
+ result: { success: true, output: 'Found 5 mock records.' },
178
+ },
179
+ ],
180
+ }),
181
+ }),
182
+ createMessage({
183
+ id: 'msg-tool-user-1',
184
+ chatId: 'chat-tool-demo',
185
+ role: 'user',
186
+ content: '帮我看一下最近的工具调用记录。',
187
+ minutesAgo: 64,
188
+ }),
189
+ ],
190
+ },
191
+ llmSettings: {
192
+ provider: 'ollama',
193
+ model: 'browser-preview-model',
194
+ baseUrl: 'http://127.0.0.1:11434',
195
+ apiKey: '',
196
+ temperature: 0.3,
197
+ maxTotalTokens: 60_000,
198
+ },
199
+ skills: [
200
+ { name: 'tool-demo', description: '模拟查询与回放工具。', builtin: true },
201
+ { name: 'local-exec', description: '模拟本地命令执行。', builtin: true },
202
+ { name: 'anti-deferred', description: '模拟防拖延执行策略。', builtin: true },
203
+ ],
204
+ activeStreams: new Map(),
205
+ source: 'fallback',
206
+ fixtureMeta: null,
207
+ };
208
+ }
209
+
210
+ const state = createInitialState();
211
+
212
+ function createStateFromFixture(fixture: BrowserDevFixture): BrowserMockState | null {
213
+ const agents = Array.isArray(fixture.agents) ? fixture.agents : [];
214
+ const chats = Array.isArray(fixture.chats) ? fixture.chats : [];
215
+ if (agents.length === 0 || chats.length === 0) return null;
216
+
217
+ const messagesByChatId = fixture.messagesByChatId ?? {};
218
+ return {
219
+ agents,
220
+ chats,
221
+ messagesByChatId,
222
+ llmSettings: fixture.llmSettings ?? {
223
+ provider: 'ollama',
224
+ model: 'browser-preview-model',
225
+ baseUrl: 'http://127.0.0.1:11434',
226
+ apiKey: '',
227
+ temperature: 0.3,
228
+ maxTotalTokens: 60_000,
229
+ },
230
+ skills:
231
+ Array.isArray(fixture.skills) && fixture.skills.length > 0
232
+ ? fixture.skills
233
+ : [
234
+ { name: 'tool-demo', description: '查询与回放工具。', builtin: true },
235
+ { name: 'local-exec', description: '本地命令执行。', builtin: true },
236
+ ],
237
+ activeStreams: new Map(),
238
+ source: 'fixture',
239
+ fixtureMeta: {
240
+ exportedAt: fixture.exportedAt ?? null,
241
+ source: fixture.source ?? null,
242
+ },
243
+ };
244
+ }
245
+
246
+ function paginateChats(page: number, limit: number) {
247
+ const sorted = [...state.chats].sort((a, b) => {
248
+ if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
249
+ return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
250
+ });
251
+ const start = (page - 1) * limit;
252
+ const chats = sorted.slice(start, start + limit);
253
+ return {
254
+ chats,
255
+ pagination: {
256
+ page,
257
+ limit,
258
+ total: sorted.length,
259
+ totalPages: Math.max(1, Math.ceil(sorted.length / limit)),
260
+ hasMore: start + chats.length < sorted.length,
261
+ },
262
+ };
263
+ }
264
+
265
+ function parseQuery(path: string): URLSearchParams {
266
+ const queryStart = path.indexOf('?');
267
+ return new URLSearchParams(queryStart >= 0 ? path.slice(queryStart + 1) : '');
268
+ }
269
+
270
+ function stripQuery(path: string): string {
271
+ const queryStart = path.indexOf('?');
272
+ return queryStart >= 0 ? path.slice(0, queryStart) : path;
273
+ }
274
+
275
+ function getChatIdFromPath(path: string, suffix = ''): string | null {
276
+ const cleanPath = stripQuery(path);
277
+ const match = cleanPath.match(/^\/api\/v2\/chats\/([^/]+)(?:\/.*)?$/);
278
+ if (!match) return null;
279
+ if (suffix && !cleanPath.endsWith(suffix)) return null;
280
+ return decodeURIComponent(match[1]);
281
+ }
282
+
283
+ function updateChatTimestamp(chatId: string) {
284
+ const chat = state.chats.find((item) => item.id === chatId);
285
+ if (chat) chat.updatedAt = new Date().toISOString();
286
+ }
287
+
288
+ async function request<T>(input: LocalBackendRequestInput): Promise<T> {
289
+ const method = input.method.toUpperCase();
290
+ const path = input.path;
291
+ const cleanPath = stripQuery(path);
292
+
293
+ if (method === 'GET' && cleanPath === '/api/v2/chats') {
294
+ const query = parseQuery(path);
295
+ const page = Number(query.get('page') ?? '1');
296
+ const limit = Number(query.get('limit') ?? '50');
297
+ return paginateChats(page, limit) as T;
298
+ }
299
+
300
+ if (method === 'GET' && cleanPath === '/api/v2/chat-agents') {
301
+ const includeArchived = parseQuery(path).get('include_archived') === 'true';
302
+ const agents = includeArchived
303
+ ? state.agents
304
+ : state.agents.filter((agent) => !agent.isArchived);
305
+ return { agents, total: agents.length } as T;
306
+ }
307
+
308
+ if (method === 'POST' && cleanPath === '/api/v2/chat-agents') {
309
+ const body = (input.body ?? {}) as Partial<ChatAgentWriteInput>;
310
+ const id =
311
+ typeof crypto !== 'undefined' && 'randomUUID' in crypto
312
+ ? crypto.randomUUID()
313
+ : `agent-browser-${Date.now()}`;
314
+ const agent: LocalChatAgent = {
315
+ id,
316
+ slug: null,
317
+ name: (body.name || '新助手').trim() || '新助手',
318
+ icon: null,
319
+ color: body.color ?? '#4f46e5',
320
+ description: body.description ?? null,
321
+ rolePrompt: body.rolePrompt ?? null,
322
+ skillIds: body.skillIds ?? [],
323
+ toolPolicy: body.toolPolicy ?? { mode: 'all', tools: [] },
324
+ allowExternalSkills: body.allowExternalSkills ?? true,
325
+ loadAllSkills: body.loadAllSkills ?? false,
326
+ isBuiltin: false,
327
+ isArchived: false,
328
+ sortOrder: body.sortOrder ?? state.agents.length,
329
+ };
330
+ state.agents.push(agent);
331
+ return { agent } as T;
332
+ }
333
+
334
+ const chatAgentMatch = cleanPath.match(/^\/api\/v2\/chat-agents\/([^/]+)$/);
335
+ if (chatAgentMatch) {
336
+ const agentId = decodeURIComponent(chatAgentMatch[1]);
337
+ const reserved = new Set(['skills', 'mcp-tools', 'templates', 'tools', 'generate']);
338
+ if (!reserved.has(agentId)) {
339
+ const index = state.agents.findIndex((agent) => agent.id === agentId);
340
+ if (index < 0) {
341
+ throw new Error(`Agent not found: ${agentId}`);
342
+ }
343
+ if (method === 'PATCH') {
344
+ const body = (input.body ?? {}) as Partial<LocalChatAgent>;
345
+ const current = state.agents[index];
346
+ const updated: LocalChatAgent = {
347
+ ...current,
348
+ name: typeof body.name === 'string' ? body.name : current.name,
349
+ color: body.color === undefined ? current.color : body.color,
350
+ description:
351
+ body.description === undefined ? current.description : body.description,
352
+ rolePrompt:
353
+ body.rolePrompt === undefined ? current.rolePrompt : body.rolePrompt,
354
+ skillIds: body.skillIds === undefined ? current.skillIds : body.skillIds,
355
+ toolPolicy:
356
+ body.toolPolicy === undefined ? current.toolPolicy : body.toolPolicy,
357
+ allowExternalSkills:
358
+ body.allowExternalSkills === undefined
359
+ ? current.allowExternalSkills
360
+ : body.allowExternalSkills,
361
+ loadAllSkills:
362
+ body.loadAllSkills === undefined ? current.loadAllSkills : body.loadAllSkills,
363
+ };
364
+ state.agents[index] = updated;
365
+ return { agent: updated } as T;
366
+ }
367
+ if (method === 'DELETE') {
368
+ state.agents[index] = { ...state.agents[index], isArchived: true };
369
+ return { id: agentId, status: 'archived' } as T;
370
+ }
371
+ }
372
+ }
373
+
374
+ if (method === 'GET' && cleanPath === '/api/v2/chat-agents/skills') {
375
+ // fixture 只存 name/description;智能体的技能勾选还要 id 与 layer,
376
+ // 这里按真实接口的字段补齐(内置技能视作常驻层)。
377
+ return {
378
+ skills: state.skills.map((skill) => ({
379
+ ...skill,
380
+ id: skill.name,
381
+ displayName: '',
382
+ layer: skill.builtin ? 'eager' : 'catalog',
383
+ origin: skill.builtin ? 'builtin' : 'user',
384
+ })),
385
+ } as T;
386
+ }
387
+
388
+ if (method === 'GET' && cleanPath === '/api/v2/chat-agents/tools') {
389
+ return {
390
+ tools: [
391
+ { name: 'local_exec_shell', description: '执行本地 shell 命令。', category: 'local' },
392
+ { name: 'local_read_file', description: '读取本地文本文件。', category: 'local' },
393
+ { name: 'local_write_file', description: '写入本地文件。', category: 'local' },
394
+ { name: 'web_search', description: '联网搜索。', category: 'local' },
395
+ ],
396
+ } as T;
397
+ }
398
+
399
+ if (method === 'POST' && cleanPath === '/api/v2/chat-agents/skills/import') {
400
+ const body = input.body as { path?: string } | undefined;
401
+ const name = body?.path?.split(/[\\/]/).filter(Boolean).pop() || 'browser-skill';
402
+ if (!state.skills.some((skill) => skill.name === name)) {
403
+ state.skills.push({ name, description: '浏览器预览中导入的 mock skill。' });
404
+ }
405
+ return { success: true, name } as T;
406
+ }
407
+
408
+ if (method === 'DELETE' && cleanPath.startsWith('/api/v2/chat-agents/skills/delete/')) {
409
+ const name = decodeURIComponent(cleanPath.split('/').pop() || '');
410
+ state.skills = state.skills.filter((skill) => skill.name !== name || skill.builtin);
411
+ return { success: true } as T;
412
+ }
413
+
414
+ if (method === 'GET' && cleanPath === '/api/v2/local-settings/llm') {
415
+ return state.llmSettings as T;
416
+ }
417
+
418
+ if (method === 'POST' && cleanPath === '/api/v2/local-settings/llm') {
419
+ state.llmSettings = { ...state.llmSettings, ...(input.body as Partial<LlmSettings>) };
420
+ return state.llmSettings as T;
421
+ }
422
+
423
+ if (method === 'POST' && cleanPath === '/api/v2/chats/prune-empty') {
424
+ const body = input.body as { exceptChatId?: string | null } | undefined;
425
+ const except = body?.exceptChatId ?? null;
426
+ const deletedChatIds: string[] = [];
427
+ for (const chat of [...state.chats]) {
428
+ if (except && chat.id === except) continue;
429
+ const messages = state.messagesByChatId[chat.id] ?? [];
430
+ if (messages.length === 0) deletedChatIds.push(chat.id);
431
+ }
432
+ state.chats = state.chats.filter((chat) => !deletedChatIds.includes(chat.id));
433
+ for (const id of deletedChatIds) delete state.messagesByChatId[id];
434
+ return { deletedChatIds } as T;
435
+ }
436
+
437
+ if (method === 'POST' && cleanPath === '/api/v2/chats/new') {
438
+ const body = input.body as { agentId?: string } | undefined;
439
+ const agentId = body?.agentId || pickDefaultAgentId(state.agents) || null;
440
+ const chatId = `chat-browser-${Date.now()}`;
441
+ const chat = createChatRecord({
442
+ id: chatId,
443
+ title: '新会话',
444
+ agentId,
445
+ minutesAgo: 0,
446
+ });
447
+ chat.createdAt = new Date().toISOString();
448
+ chat.updatedAt = chat.createdAt;
449
+ state.chats.unshift(chat);
450
+ state.messagesByChatId[chatId] = [];
451
+ return {
452
+ success: true,
453
+ chatId,
454
+ projectId: null,
455
+ isTemporary: false,
456
+ } as T;
457
+ }
458
+
459
+ const messagesChatId = getChatIdFromPath(path, '/messages');
460
+ if (method === 'GET' && messagesChatId) {
461
+ const messages = state.messagesByChatId[messagesChatId] ?? [];
462
+ return { messages: [...messages].sort((a, b) => b.createdAt.localeCompare(a.createdAt)) } as T;
463
+ }
464
+
465
+ const deleteChatId = getChatIdFromPath(path);
466
+ if (method === 'DELETE' && deleteChatId) {
467
+ const onlyIfEmpty = parseQuery(path).get('onlyIfEmpty') === '1';
468
+ if (onlyIfEmpty) {
469
+ const messages = state.messagesByChatId[deleteChatId] ?? [];
470
+ const exists = state.chats.some((chat) => chat.id === deleteChatId);
471
+ if (!exists || messages.length > 0) {
472
+ return { success: true, deleted: false, chatId: deleteChatId } as T;
473
+ }
474
+ state.chats = state.chats.filter((chat) => chat.id !== deleteChatId);
475
+ delete state.messagesByChatId[deleteChatId];
476
+ return { success: true, deleted: true, chatId: deleteChatId } as T;
477
+ }
478
+ state.chats = state.chats.filter((chat) => chat.id !== deleteChatId);
479
+ delete state.messagesByChatId[deleteChatId];
480
+ return { success: true, message: 'deleted in browser mock', chatId: deleteChatId } as T;
481
+ }
482
+
483
+ throw new Error(`Browser dev mock does not implement ${method} ${path}`);
484
+ }
485
+
486
+ function pushSse(
487
+ onEvent: (payload: LocalBackendStreamEvent) => void,
488
+ data: unknown,
489
+ event?: string,
490
+ ) {
491
+ const prefix = event ? `event: ${event}\n` : '';
492
+ const payload = typeof data === 'string' ? data : JSON.stringify(data);
493
+ onEvent({ type: 'data', chunk: `${prefix}data: ${payload}\n\n` });
494
+ }
495
+
496
+ function scheduleStreamTimer(streamId: string, callback: () => void, delay: number) {
497
+ const timer = window.setTimeout(callback, delay);
498
+ const timers = state.activeStreams.get(streamId) ?? [];
499
+ timers.push(timer);
500
+ state.activeStreams.set(streamId, timers);
501
+ }
502
+
503
+ async function startStream(
504
+ input: LocalBackendRequestInput,
505
+ onEvent: (payload: LocalBackendStreamEvent) => void,
506
+ ): Promise<string | null> {
507
+ const chatId = getChatIdFromPath(input.path, '/run');
508
+ if (!chatId) {
509
+ onEvent({ type: 'error', error: `Invalid mock stream path: ${input.path}` });
510
+ return null;
511
+ }
512
+
513
+ const body = input.body as { message?: string } | undefined;
514
+ const message = body?.message?.trim() || '请演示一下浏览器 mock 回复。';
515
+ const streamId = `browser-stream-${Date.now()}`;
516
+ state.activeStreams.set(streamId, []);
517
+
518
+ const userMessage = createMessage({
519
+ id: `msg-user-${Date.now()}`,
520
+ chatId,
521
+ role: 'user',
522
+ content: message,
523
+ minutesAgo: 0,
524
+ });
525
+ userMessage.createdAt = new Date().toISOString();
526
+ state.messagesByChatId[chatId] = [...(state.messagesByChatId[chatId] ?? []), userMessage];
527
+
528
+ const reply =
529
+ `这是浏览器 dev mock 的流式回复。你刚才输入的是:「${message}」。` +
530
+ ' 这里可以调试消息气泡、滚动、停止按钮、工具执行卡片和后续状态。';
531
+ const chunks = reply.match(/.{1,8}/g) ?? [reply];
532
+ const reasoning = '先调用一次 mock 工具,再把回复按调用流程交错显示。';
533
+ const mockAction = {
534
+ id: 'browser-mock-1',
535
+ tool: 'browser_mock',
536
+ arguments: { path: input.path },
537
+ result: { success: true, output: 'Generated mock stream in browser preview.' },
538
+ };
539
+
540
+ scheduleStreamTimer(streamId, () => {
541
+ pushSse(onEvent, { type: 'reasoning', content: reasoning });
542
+ }, 80);
543
+
544
+ scheduleStreamTimer(streamId, () => {
545
+ pushSse(onEvent, {
546
+ type: 'executed_actions',
547
+ actions: [{ ...mockAction, result: undefined }],
548
+ });
549
+ }, 160);
550
+
551
+ scheduleStreamTimer(streamId, () => {
552
+ pushSse(onEvent, {
553
+ type: 'executed_actions',
554
+ actions: [mockAction],
555
+ });
556
+ }, 280);
557
+
558
+ chunks.forEach((chunk, index) => {
559
+ scheduleStreamTimer(streamId, () => {
560
+ pushSse(onEvent, { content: chunk });
561
+ }, 360 + index * 80);
562
+ });
563
+
564
+ scheduleStreamTimer(streamId, () => {
565
+ const assistantMessage = createMessage({
566
+ id: `msg-assistant-${Date.now()}`,
567
+ chatId,
568
+ role: 'assistant',
569
+ content: reply,
570
+ minutesAgo: 0,
571
+ messageMetadata: JSON.stringify({
572
+ executedActions: [mockAction],
573
+ timeline: [
574
+ { type: 'reasoning', content: reasoning },
575
+ { type: 'tools', actions: [mockAction] },
576
+ { type: 'text', content: reply },
577
+ ],
578
+ durationMs: 1_200,
579
+ }),
580
+ });
581
+ assistantMessage.createdAt = new Date().toISOString();
582
+ state.messagesByChatId[chatId] = [
583
+ ...(state.messagesByChatId[chatId] ?? []),
584
+ assistantMessage,
585
+ ];
586
+ updateChatTimestamp(chatId);
587
+ pushSse(onEvent, { type: 'message_id', messageId: assistantMessage.id });
588
+ pushSse(onEvent, '[DONE]');
589
+ onEvent({ type: 'end', status: 200 });
590
+ state.activeStreams.delete(streamId);
591
+ }, 440 + chunks.length * 80);
592
+
593
+ return streamId;
594
+ }
595
+
596
+ function cancelStream(streamId: string) {
597
+ const timers = state.activeStreams.get(streamId) ?? [];
598
+ timers.forEach((timer) => window.clearTimeout(timer));
599
+ state.activeStreams.delete(streamId);
600
+ }
601
+
602
+ export function installBrowserDevElectronMock() {
603
+ if (typeof window === 'undefined' || window.electron) return;
604
+ // DEV 浏览器预览自动安装;官网静态 demo 构建(app-demo 入口)经 demo flag
605
+ // 显式安装。普通 prod 构建两条路都不通,本模块根本不会进产物。
606
+ if (!import.meta.env.DEV && !isDemoMode()) return;
607
+
608
+ window.electron = {
609
+ runtime: 'local',
610
+ platform: 'win32',
611
+ local: {
612
+ selectDirectory: async () => ({
613
+ canceled: false,
614
+ filePaths: ['C:\\browser-preview\\mock-skill'],
615
+ }),
616
+ // 浏览器 dev 模式没有 Electron capturePage/clipboard,如实返回失败。
617
+ captureScreenshot: async () => ({
618
+ success: false as const,
619
+ error: '浏览器开发模式不支持窗口截图(需要 Electron 运行时)',
620
+ }),
621
+ },
622
+ localBackend: {
623
+ request,
624
+ startStream,
625
+ cancelStream,
626
+ },
627
+ onMenuNewChat: () => undefined,
628
+ offMenuNewChat: () => undefined,
629
+ onMenuOpenTerminal: () => undefined,
630
+ offMenuOpenTerminal: () => undefined,
631
+ onChatTitleUpdated: () => () => undefined,
632
+ } satisfies ElectronBridge;
633
+
634
+ console.info(
635
+ '[browser-dev-electron-mock] Installed mock window.electron bridge.',
636
+ state.source === 'fixture'
637
+ ? { source: state.fixtureMeta?.source, exportedAt: state.fixtureMeta?.exportedAt }
638
+ : { source: 'built-in fallback' },
639
+ );
640
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Chat title parser — mirrors `deeppath/apps/web/src/types/chat.ts`'s
3
+ * `parseChatTitle`. The local-backend re-uses the same `[自动化] ` prefix
4
+ * convention as the cloud backend (deeppath-api/app/services/cron/executors/
5
+ * automation.py:_AUTOMATION_TITLE_PREFIX), so the same parser works for both.
6
+ */
7
+
8
+ export const AUTOMATION_TITLE_PREFIX = '[自动化] ';
9
+ const BARE_BRACKET_PREFIX = '[自动化]';
10
+
11
+ export interface ParsedChatTitle {
12
+ displayTitle: string;
13
+ isAutomation: boolean;
14
+ }
15
+
16
+ export function parseChatTitle(
17
+ rawTitle: string | null | undefined,
18
+ ): ParsedChatTitle {
19
+ const title = rawTitle ?? '';
20
+ if (title.startsWith(AUTOMATION_TITLE_PREFIX)) {
21
+ return {
22
+ displayTitle: title.slice(AUTOMATION_TITLE_PREFIX.length),
23
+ isAutomation: true,
24
+ };
25
+ }
26
+ if (title.startsWith(BARE_BRACKET_PREFIX)) {
27
+ return {
28
+ displayTitle: title.slice(BARE_BRACKET_PREFIX.length).trimStart(),
29
+ isAutomation: true,
30
+ };
31
+ }
32
+ return { displayTitle: title, isAutomation: false };
33
+ }