zen-gitsync 2.16.14 → 2.16.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 (60) hide show
  1. package/package.json +1 -1
  2. package/src/cli/ai/sessionStore.js +97 -97
  3. package/src/ui/public/assets/AgentView-seyN__fu.js +4 -0
  4. package/src/ui/public/assets/AppVersionBadge-CnWL0hFX.js +2 -0
  5. package/src/ui/public/assets/BranchSelector-7IbUHOcF.js +1 -0
  6. package/src/ui/public/assets/CommitForm-DZa67HGc.js +6 -0
  7. package/src/ui/public/assets/EditorView-JJsAc5Px.js +0 -0
  8. package/src/ui/public/assets/FlowExecutionViewer-BbgVCXwD.js +1 -0
  9. package/src/ui/public/assets/FlowOrchestrationWorkspace-VRKXC4BW.js +21 -0
  10. package/src/ui/public/assets/LogList-BNAx53Pd.js +15 -0
  11. package/src/ui/public/assets/MindmapView-BaOPrXCt.js +2 -0
  12. package/src/ui/public/assets/MonitorView-BY-vMlOx.js +1 -0
  13. package/src/ui/public/assets/ProjectStartupButton-DE584iOv.js +1 -0
  14. package/src/ui/public/assets/RemoteRepoCard-DbNKpdZg.js +5 -0
  15. package/src/ui/public/assets/SourceMapView-DJEerBG6.js +3 -0
  16. package/src/ui/public/assets/UserInputNode-BhRkpHhB.js +1 -0
  17. package/src/ui/public/assets/WorkbenchView-B0v0Hzvq.js +28 -0
  18. package/src/ui/public/assets/element-plus-CXfbWXaJ.css +1 -0
  19. package/src/ui/public/assets/element-plus-tlkbQ0I3.js +32 -0
  20. package/src/ui/public/assets/{flow-mindmap-DnyDhwJv.js → flow-mindmap-Cm36aorg.js} +12 -12
  21. package/src/ui/public/assets/index-B8NvO42m.js +82 -0
  22. package/src/ui/public/assets/index-BVnb4Q7g.css +1 -0
  23. package/src/ui/public/assets/{monaco-Dje8Rg13.js → monaco-DPQ3OaiL.js} +2 -2
  24. package/src/ui/public/assets/socket-io-B9UuzwTN.js +1 -0
  25. package/src/ui/public/assets/{ts.worker-Dth06zuC.js → ts.worker-METxwbDZ.js} +1 -16
  26. package/src/ui/public/assets/{vendor-DVvq7qtS.css → vendor-C2ZpZPB4.css} +1 -1
  27. package/src/ui/public/assets/{vendor-exjCs1jx.js → vendor-DjLepuxR.js} +25 -66
  28. package/src/ui/public/assets/vue-flow-CZY2uUK0.js +39 -0
  29. package/src/ui/public/index.html +9 -15
  30. package/src/ui/server/routes/fileOpen.js +142 -2
  31. package/src/ui/server/routes/workbench/agentRoutes.js +218 -218
  32. package/src/ui/server/routes/workbench/agentSessionStore.js +173 -173
  33. package/src/ui/public/assets/AgentView-tIASiLaj.js +0 -4
  34. package/src/ui/public/assets/AppVersionBadge-BaS_yj3n.js +0 -2
  35. package/src/ui/public/assets/BranchSelector-B7RamkH9.js +0 -1
  36. package/src/ui/public/assets/CommitForm-DUjKTZ_Z.js +0 -6
  37. package/src/ui/public/assets/CommonDialog-ChSKsEz2.css +0 -1
  38. package/src/ui/public/assets/CommonDialog-FNHz2ADP.js +0 -1
  39. package/src/ui/public/assets/EditorView-BsLgJ2gr.js +0 -0
  40. package/src/ui/public/assets/FlowExecutionViewer-B6YdupsX.js +0 -1
  41. package/src/ui/public/assets/FlowOrchestrationWorkspace-DBeNhoNV.js +0 -21
  42. package/src/ui/public/assets/LogList-UvFfOD6q.js +0 -15
  43. package/src/ui/public/assets/MindmapView-DaUBWuao.js +0 -2
  44. package/src/ui/public/assets/MonitorView-DCYcyM3i.js +0 -1
  45. package/src/ui/public/assets/ProjectStartupButton-BLXDeEfO.js +0 -1
  46. package/src/ui/public/assets/RemoteRepoCard-RHaqsUVJ.js +0 -5
  47. package/src/ui/public/assets/SourceMapView-DRrnpkUE.js +0 -3
  48. package/src/ui/public/assets/SvgIcon-BDMUZ0Pv.js +0 -1
  49. package/src/ui/public/assets/SvgIcon-CDNsk4-Q.css +0 -1
  50. package/src/ui/public/assets/UserInputNode-BsMEfkJ_.js +0 -1
  51. package/src/ui/public/assets/WorkbenchView-BXBpVF4k.js +0 -28
  52. package/src/ui/public/assets/_plugin-vue_export-helper-DvYp0OXm.js +0 -15
  53. package/src/ui/public/assets/configStore-BoBfkqsG.js +0 -1
  54. package/src/ui/public/assets/element-plus-BId3Dnij.js +0 -25
  55. package/src/ui/public/assets/element-plus-D3Dn7nX0.css +0 -1
  56. package/src/ui/public/assets/index-CiQmfjtm.css +0 -1
  57. package/src/ui/public/assets/index-DHzXn8Zw.js +0 -68
  58. package/src/ui/public/assets/socket-io-CGezBd--.js +0 -1
  59. package/src/ui/public/assets/vue-flow-Dj_X_Vr1.js +0 -39
  60. /package/src/ui/public/assets/{dagre-BCXBo6I9.js → dagre-CePNlbK4.js} +0 -0
@@ -18,6 +18,105 @@ import path from 'path';
18
18
  import open from 'open';
19
19
  import { spawn, spawnSync } from 'child_process';
20
20
 
21
+ const TOOL_INSTALL_PACKAGES = Object.freeze({
22
+ claude: '@anthropic-ai/claude-code',
23
+ codex: '@openai/codex',
24
+ opencode: 'opencode-ai',
25
+ });
26
+
27
+ const TOOL_DOCS_URLS = Object.freeze({
28
+ vscode: 'https://code.visualstudio.com/download',
29
+ claude: 'https://docs.anthropic.com/en/docs/claude-code/getting-started',
30
+ codex: 'https://help.openai.com/en/articles/11096431-openai-codex-cli-getting-started',
31
+ opencode: 'https://opencode.ai/docs',
32
+ });
33
+
34
+ function commandExists(command, platform = process.platform) {
35
+ const checker = platform === 'win32' ? 'where.exe' : 'which';
36
+ const result = spawnSync(checker, [command], {
37
+ stdio: 'ignore',
38
+ windowsHide: true,
39
+ });
40
+ return result.status === 0;
41
+ }
42
+
43
+ /**
44
+ * 返回当前平台可展示、可执行的安装方案。
45
+ * executable/args 只在服务端使用;前端只能提交固定 tool id,不能提交命令。
46
+ */
47
+ export function getToolInstallers(platform = process.platform, hasCommand = commandExists) {
48
+ const npmExecutable = platform === 'win32' ? 'npm.cmd' : 'npm';
49
+ const npmAvailable = hasCommand('npm', platform);
50
+ const installers = {};
51
+
52
+ for (const [tool, packageName] of Object.entries(TOOL_INSTALL_PACKAGES)) {
53
+ installers[tool] = {
54
+ supported: npmAvailable,
55
+ command: `npm install -g ${packageName}`,
56
+ packageManager: 'npm',
57
+ docsUrl: TOOL_DOCS_URLS[tool],
58
+ note: npmAvailable
59
+ ? '将在新终端中执行全局安装。安装过程中可能需要登录或确认权限。'
60
+ : '未检测到 npm,请先安装 Node.js/npm,或按照官方文档手动安装。',
61
+ executable: npmExecutable,
62
+ args: ['install', '-g', packageName],
63
+ };
64
+ }
65
+
66
+ if (platform === 'win32') {
67
+ const wingetAvailable = hasCommand('winget', platform);
68
+ installers.vscode = {
69
+ supported: wingetAvailable,
70
+ command: 'winget install --id Microsoft.VisualStudioCode -e --accept-package-agreements --accept-source-agreements',
71
+ packageManager: 'winget',
72
+ docsUrl: TOOL_DOCS_URLS.vscode,
73
+ note: wingetAvailable
74
+ ? '将在新终端中通过 winget 安装 Visual Studio Code。'
75
+ : '未检测到 winget,请使用 VS Code 官方安装程序。',
76
+ executable: 'winget',
77
+ args: ['install', '--id', 'Microsoft.VisualStudioCode', '-e', '--accept-package-agreements', '--accept-source-agreements'],
78
+ };
79
+ } else if (platform === 'darwin') {
80
+ const brewAvailable = hasCommand('brew', platform);
81
+ installers.vscode = {
82
+ supported: brewAvailable,
83
+ command: 'brew install --cask visual-studio-code',
84
+ packageManager: 'Homebrew',
85
+ docsUrl: TOOL_DOCS_URLS.vscode,
86
+ note: brewAvailable
87
+ ? '将在新终端中通过 Homebrew 安装 Visual Studio Code。'
88
+ : '未检测到 Homebrew,请使用 VS Code 官方安装程序。',
89
+ executable: 'brew',
90
+ args: ['install', '--cask', 'visual-studio-code'],
91
+ };
92
+ } else {
93
+ const snapAvailable = hasCommand('snap', platform);
94
+ installers.vscode = {
95
+ supported: snapAvailable,
96
+ command: 'sudo snap install code --classic',
97
+ packageManager: 'snap',
98
+ docsUrl: TOOL_DOCS_URLS.vscode,
99
+ note: snapAvailable
100
+ ? '将在新终端中通过 snap 安装 Visual Studio Code,可能需要输入系统密码。'
101
+ : '当前 Linux 环境未检测到 snap,请按照发行版对应的官方说明安装。',
102
+ executable: 'sudo',
103
+ args: ['snap', 'install', 'code', '--classic'],
104
+ };
105
+ }
106
+
107
+ return installers;
108
+ }
109
+
110
+ function publicInstallerInfo(installers) {
111
+ return Object.fromEntries(Object.entries(installers).map(([tool, installer]) => [tool, {
112
+ supported: installer.supported,
113
+ command: installer.command,
114
+ packageManager: installer.packageManager,
115
+ docsUrl: installer.docsUrl,
116
+ note: installer.note,
117
+ }]));
118
+ }
119
+
21
120
  function spawnDetached(command, args, options = {}) {
22
121
  return new Promise((resolve, reject) => {
23
122
  const child = spawn(command, args, {
@@ -125,6 +224,18 @@ async function launchOpenCode(dirPath) {
125
224
  return launchInTerminal(dirPath, 'opencode');
126
225
  }
127
226
 
227
+ async function launchToolInstaller(installer, dirPath = process.cwd()) {
228
+ if (process.platform === 'win32') {
229
+ // 安装命令完全来自服务端白名单。使用可见 cmd 窗口,让用户看到进度和错误。
230
+ const commandLine = [installer.executable, ...installer.args].join(' ');
231
+ return spawnDetached('cmd.exe', ['/c', 'start', '', 'cmd.exe', '/k', commandLine], {
232
+ cwd: dirPath,
233
+ });
234
+ }
235
+
236
+ return launchInTerminal(dirPath, installer.executable, installer.args);
237
+ }
238
+
128
239
  export function registerFileOpenRoutes({
129
240
  app
130
241
  }) {
@@ -405,6 +516,26 @@ export function registerFileOpenRoutes({
405
516
  }
406
517
  }));
407
518
 
519
+ // 安装本地工具:前端只能传固定 tool id,实际命令由服务端按平台从白名单选择。
520
+ app.post('/api/install-tool', asyncRoute(async (req, res) => {
521
+ const { tool } = req.body || {};
522
+ const installers = getToolInstallers();
523
+ const installer = installers[tool];
524
+
525
+ if (!installer) {
526
+ throw new HttpError(400, '不支持的工具');
527
+ }
528
+ if (!installer.supported) {
529
+ throw new HttpError(400, installer.note || '当前系统不支持一键安装,请按照官方文档手动安装');
530
+ }
531
+
532
+ await launchToolInstaller(installer);
533
+ res.json({
534
+ success: true,
535
+ message: '安装命令已在新终端中启动',
536
+ });
537
+ }));
538
+
408
539
  // 检测本地工具是否已安装(供前端根据结果决定是否显示对应按钮)
409
540
  // 检测方式: spawn 'tool --version',exit 0 即视为已安装
410
541
  // 超时 15s:Claude CLI / opencode 等 Node 包装的工具首次冷启动
@@ -435,6 +566,15 @@ export function registerFileOpenRoutes({
435
566
  checkCmd('opencode'),
436
567
  ]);
437
568
 
438
- res.json({ success: true, vscode, claude, codex, opencode });
569
+ const installers = getToolInstallers();
570
+ res.json({
571
+ success: true,
572
+ platform: process.platform,
573
+ installers: publicInstallerInfo(installers),
574
+ vscode,
575
+ claude,
576
+ codex,
577
+ opencode,
578
+ });
439
579
  }));
440
- }
580
+ }
@@ -1,218 +1,218 @@
1
- // Copyright 2026 xz333221
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
- //
15
- // 智能体路由入口。
16
- //
17
- // 路由清单:
18
- // GET /api/agent/sessions — 列出所有会话(仅 metadata)
19
- // GET /api/agent/sessions/:id — 获取会话详情(含完整消息)
20
- // DELETE /api/agent/sessions/:id — 删除会话
21
- // PUT /api/agent/sessions/:id — 重命名会话
22
- // POST /api/agent/chat — SSE 流式聊天(含工具调用)
23
-
24
- import { asyncRoute, HttpError } from '../../utils/asyncRoute.js';
25
- import { agentSessionStore } from './agentSessionStore.js';
26
- import { runAgentTurn } from './agentChat.js';
27
- import { nowIso } from './shared.js';
28
-
29
- const { genSessionId, autoTitle, read: readSession, write: writeSession, delete: deleteSession, listMeta: listSessionsMeta, enforceRetention, rename: renameSession } = agentSessionStore;
30
-
31
- /**
32
- * 注册智能体路由。
33
- * @param {Object} deps
34
- * @param {import('express').Express} deps.app
35
- * @param {() => string} deps.getCurrentProjectPath
36
- * @param {Object} deps.configManager
37
- */
38
- export function registerAgentRoutes({ app, getCurrentProjectPath, configManager }) {
39
-
40
- // ════════════════════════════════════════════════════════════════════════
41
- // §1. 会话列表
42
- // ════════════════════════════════════════════════════════════════════════
43
- app.get('/api/agent/sessions', asyncRoute(async (_req, res) => {
44
- const sessions = await listSessionsMeta();
45
- res.json({ success: true, sessions });
46
- }));
47
-
48
- // ════════════════════════════════════════════════════════════════════════
49
- // §2. 会话详情
50
- // ════════════════════════════════════════════════════════════════════════
51
- app.get('/api/agent/sessions/:sessionId', asyncRoute(async (req, res) => {
52
- const session = await readSession(req.params.sessionId);
53
- res.json({ success: true, session });
54
- }));
55
-
56
- // ════════════════════════════════════════════════════════════════════════
57
- // §3. 删除会话
58
- // ════════════════════════════════════════════════════════════════════════
59
- app.delete('/api/agent/sessions/:sessionId', asyncRoute(async (req, res) => {
60
- await deleteSession(req.params.sessionId);
61
- res.json({ success: true });
62
- }));
63
-
64
- // ════════════════════════════════════════════════════════════════════════
65
- // §4. 重命名会话
66
- // ════════════════════════════════════════════════════════════════════════
67
- app.put('/api/agent/sessions/:sessionId', asyncRoute(async (req, res) => {
68
- const title = String(req.body?.title || '').trim();
69
- if (!title) throw new HttpError(400, '标题不能为空');
70
- const session = await renameSession(req.params.sessionId, title);
71
- res.json({ success: true, session });
72
- }));
73
-
74
- // ════════════════════════════════════════════════════════════════════════
75
- // §5. SSE 流式聊天(含工具调用循环)
76
- // ════════════════════════════════════════════════════════════════════════
77
- app.post('/api/agent/chat', async (req, res) => {
78
- const userMessage = String(req.body?.userMessage || '').trim();
79
- const sessionIdInput = String(req.body?.sessionId || '').trim();
80
- const locale = String(req.body?.locale || req.headers['accept-language'] || 'zh').startsWith('en') ? 'en' : 'zh';
81
-
82
- // 图片附件(base64 dataURL 数组):前端已限制只能选图片,这里再做一层白名单校验
83
- const images = (Array.isArray(req.body?.images) ? req.body.images : [])
84
- .filter(u => typeof u === 'string' && /^data:image\/[\w.+-]+;base64,/.test(u))
85
- .slice(0, 10);
86
-
87
- if (!userMessage && images.length === 0) {
88
- return res.status(400).json({ success: false, error: '消息内容不能为空' });
89
- }
90
-
91
- // SSE 头
92
- res.set({
93
- 'Content-Type': 'text/event-stream',
94
- 'Cache-Control': 'no-cache, no-transform',
95
- 'Connection': 'keep-alive',
96
- 'X-Accel-Buffering': 'no'
97
- });
98
- res.flushHeaders?.();
99
- const send = (obj) => {
100
- try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch {}
101
- };
102
-
103
- const abortController = new AbortController();
104
- let finished = false;
105
- let activeChild = null;
106
-
107
- // 客户端断开
108
- if (req.socket) {
109
- req.socket.once('close', () => {
110
- if (!finished) {
111
- abortController.abort();
112
- if (activeChild) {
113
- try { activeChild.kill('SIGTERM'); } catch {}
114
- }
115
- }
116
- });
117
- }
118
-
119
- try {
120
- // 加载或新建 session
121
- let session;
122
- let isNew = false;
123
-
124
- if (sessionIdInput) {
125
- try {
126
- session = await readSession(sessionIdInput);
127
- } catch (err) {
128
- if (err.statusCode === 404) {
129
- send({ type: 'error', error: '会话不存在' });
130
- finished = true;
131
- return res.end();
132
- }
133
- throw err;
134
- }
135
- } else {
136
- session = {
137
- version: 1,
138
- sessionId: genSessionId(),
139
- title: '(新会话)',
140
- source: 'web',
141
- cwd: typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : process.cwd(),
142
- model: '',
143
- createdAt: nowIso(),
144
- updatedAt: nowIso(),
145
- messages: []
146
- };
147
- isNew = true;
148
- }
149
-
150
- // 获取模型配置
151
- let model;
152
- try {
153
- if (!configManager) throw new Error('configManager 不可用');
154
- const rawConfig = await configManager.readRawConfigFile();
155
- const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
156
- model = models.find(m => m.isDefault) || models[0];
157
- } catch (err) {
158
- send({ type: 'error', error: '读取 AI 配置失败: ' + err.message });
159
- finished = true;
160
- return res.end();
161
- }
162
- if (!model) {
163
- send({ type: 'error', error: '未配置 AI 模型,请先在通用设置中添加模型' });
164
- finished = true;
165
- return res.end();
166
- }
167
-
168
- // 更新 session 的 model 信息
169
- session.model = `${model.model || ''} (${model.name || ''})`;
170
-
171
- // 工作目录
172
- const cwd = session.cwd || (typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : process.cwd());
173
-
174
- // 推 meta
175
- send({
176
- type: 'meta',
177
- sessionId: session.sessionId,
178
- isNew,
179
- title: isNew ? autoTitle([{ role: 'user', content: userMessage }]) : session.title
180
- });
181
-
182
- // 运行 agent 循环
183
- const { aborted } = await runAgentTurn({
184
- session,
185
- model,
186
- userMessage,
187
- images,
188
- cwd,
189
- locale,
190
- signal: abortController.signal,
191
- send,
192
- onChild: (child) => { activeChild = child; }
193
- });
194
-
195
- if (aborted) {
196
- finished = true;
197
- return res.end();
198
- }
199
-
200
- // 更新标题(新会话从第一条 user 消息自动生成)
201
- if (isNew) {
202
- session.title = autoTitle(session.messages);
203
- }
204
-
205
- // 持久化
206
- session.updatedAt = nowIso();
207
- await writeSession(session.sessionId, session);
208
- enforceRetention().catch(() => {});
209
-
210
- finished = true;
211
- res.end();
212
- } catch (err) {
213
- send({ type: 'error', error: '智能体对话失败: ' + (err?.message || String(err)) });
214
- finished = true;
215
- res.end();
216
- }
217
- });
218
- }
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // 智能体路由入口。
16
+ //
17
+ // 路由清单:
18
+ // GET /api/agent/sessions — 列出所有会话(仅 metadata)
19
+ // GET /api/agent/sessions/:id — 获取会话详情(含完整消息)
20
+ // DELETE /api/agent/sessions/:id — 删除会话
21
+ // PUT /api/agent/sessions/:id — 重命名会话
22
+ // POST /api/agent/chat — SSE 流式聊天(含工具调用)
23
+
24
+ import { asyncRoute, HttpError } from '../../utils/asyncRoute.js';
25
+ import { agentSessionStore } from './agentSessionStore.js';
26
+ import { runAgentTurn } from './agentChat.js';
27
+ import { nowIso } from './shared.js';
28
+
29
+ const { genSessionId, autoTitle, read: readSession, write: writeSession, delete: deleteSession, listMeta: listSessionsMeta, enforceRetention, rename: renameSession } = agentSessionStore;
30
+
31
+ /**
32
+ * 注册智能体路由。
33
+ * @param {Object} deps
34
+ * @param {import('express').Express} deps.app
35
+ * @param {() => string} deps.getCurrentProjectPath
36
+ * @param {Object} deps.configManager
37
+ */
38
+ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager }) {
39
+
40
+ // ════════════════════════════════════════════════════════════════════════
41
+ // §1. 会话列表
42
+ // ════════════════════════════════════════════════════════════════════════
43
+ app.get('/api/agent/sessions', asyncRoute(async (_req, res) => {
44
+ const sessions = await listSessionsMeta();
45
+ res.json({ success: true, sessions });
46
+ }));
47
+
48
+ // ════════════════════════════════════════════════════════════════════════
49
+ // §2. 会话详情
50
+ // ════════════════════════════════════════════════════════════════════════
51
+ app.get('/api/agent/sessions/:sessionId', asyncRoute(async (req, res) => {
52
+ const session = await readSession(req.params.sessionId);
53
+ res.json({ success: true, session });
54
+ }));
55
+
56
+ // ════════════════════════════════════════════════════════════════════════
57
+ // §3. 删除会话
58
+ // ════════════════════════════════════════════════════════════════════════
59
+ app.delete('/api/agent/sessions/:sessionId', asyncRoute(async (req, res) => {
60
+ await deleteSession(req.params.sessionId);
61
+ res.json({ success: true });
62
+ }));
63
+
64
+ // ════════════════════════════════════════════════════════════════════════
65
+ // §4. 重命名会话
66
+ // ════════════════════════════════════════════════════════════════════════
67
+ app.put('/api/agent/sessions/:sessionId', asyncRoute(async (req, res) => {
68
+ const title = String(req.body?.title || '').trim();
69
+ if (!title) throw new HttpError(400, '标题不能为空');
70
+ const session = await renameSession(req.params.sessionId, title);
71
+ res.json({ success: true, session });
72
+ }));
73
+
74
+ // ════════════════════════════════════════════════════════════════════════
75
+ // §5. SSE 流式聊天(含工具调用循环)
76
+ // ════════════════════════════════════════════════════════════════════════
77
+ app.post('/api/agent/chat', async (req, res) => {
78
+ const userMessage = String(req.body?.userMessage || '').trim();
79
+ const sessionIdInput = String(req.body?.sessionId || '').trim();
80
+ const locale = String(req.body?.locale || req.headers['accept-language'] || 'zh').startsWith('en') ? 'en' : 'zh';
81
+
82
+ // 图片附件(base64 dataURL 数组):前端已限制只能选图片,这里再做一层白名单校验
83
+ const images = (Array.isArray(req.body?.images) ? req.body.images : [])
84
+ .filter(u => typeof u === 'string' && /^data:image\/[\w.+-]+;base64,/.test(u))
85
+ .slice(0, 10);
86
+
87
+ if (!userMessage && images.length === 0) {
88
+ return res.status(400).json({ success: false, error: '消息内容不能为空' });
89
+ }
90
+
91
+ // SSE 头
92
+ res.set({
93
+ 'Content-Type': 'text/event-stream',
94
+ 'Cache-Control': 'no-cache, no-transform',
95
+ 'Connection': 'keep-alive',
96
+ 'X-Accel-Buffering': 'no'
97
+ });
98
+ res.flushHeaders?.();
99
+ const send = (obj) => {
100
+ try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch {}
101
+ };
102
+
103
+ const abortController = new AbortController();
104
+ let finished = false;
105
+ let activeChild = null;
106
+
107
+ // 客户端断开
108
+ if (req.socket) {
109
+ req.socket.once('close', () => {
110
+ if (!finished) {
111
+ abortController.abort();
112
+ if (activeChild) {
113
+ try { activeChild.kill('SIGTERM'); } catch {}
114
+ }
115
+ }
116
+ });
117
+ }
118
+
119
+ try {
120
+ // 加载或新建 session
121
+ let session;
122
+ let isNew = false;
123
+
124
+ if (sessionIdInput) {
125
+ try {
126
+ session = await readSession(sessionIdInput);
127
+ } catch (err) {
128
+ if (err.statusCode === 404) {
129
+ send({ type: 'error', error: '会话不存在' });
130
+ finished = true;
131
+ return res.end();
132
+ }
133
+ throw err;
134
+ }
135
+ } else {
136
+ session = {
137
+ version: 1,
138
+ sessionId: genSessionId(),
139
+ title: '(新会话)',
140
+ source: 'web',
141
+ cwd: typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : process.cwd(),
142
+ model: '',
143
+ createdAt: nowIso(),
144
+ updatedAt: nowIso(),
145
+ messages: []
146
+ };
147
+ isNew = true;
148
+ }
149
+
150
+ // 获取模型配置
151
+ let model;
152
+ try {
153
+ if (!configManager) throw new Error('configManager 不可用');
154
+ const rawConfig = await configManager.readRawConfigFile();
155
+ const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
156
+ model = models.find(m => m.isDefault) || models[0];
157
+ } catch (err) {
158
+ send({ type: 'error', error: '读取 AI 配置失败: ' + err.message });
159
+ finished = true;
160
+ return res.end();
161
+ }
162
+ if (!model) {
163
+ send({ type: 'error', error: '未配置 AI 模型,请先在通用设置中添加模型' });
164
+ finished = true;
165
+ return res.end();
166
+ }
167
+
168
+ // 更新 session 的 model 信息
169
+ session.model = `${model.model || ''} (${model.name || ''})`;
170
+
171
+ // 工作目录
172
+ const cwd = session.cwd || (typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : process.cwd());
173
+
174
+ // 推 meta
175
+ send({
176
+ type: 'meta',
177
+ sessionId: session.sessionId,
178
+ isNew,
179
+ title: isNew ? autoTitle([{ role: 'user', content: userMessage }]) : session.title
180
+ });
181
+
182
+ // 运行 agent 循环
183
+ const { aborted } = await runAgentTurn({
184
+ session,
185
+ model,
186
+ userMessage,
187
+ images,
188
+ cwd,
189
+ locale,
190
+ signal: abortController.signal,
191
+ send,
192
+ onChild: (child) => { activeChild = child; }
193
+ });
194
+
195
+ if (aborted) {
196
+ finished = true;
197
+ return res.end();
198
+ }
199
+
200
+ // 更新标题(新会话从第一条 user 消息自动生成)
201
+ if (isNew) {
202
+ session.title = autoTitle(session.messages);
203
+ }
204
+
205
+ // 持久化
206
+ session.updatedAt = nowIso();
207
+ await writeSession(session.sessionId, session);
208
+ enforceRetention().catch(() => {});
209
+
210
+ finished = true;
211
+ res.end();
212
+ } catch (err) {
213
+ send({ type: 'error', error: '智能体对话失败: ' + (err?.message || String(err)) });
214
+ finished = true;
215
+ res.end();
216
+ }
217
+ });
218
+ }