zen-gitsync 2.17.16 → 2.17.18
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.
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/paths.js +13 -0
- package/src/ui/public/assets/AgentView-AMFKSDeH.css +1 -0
- package/src/ui/public/assets/AgentView-DQAB0ypS.js +13 -0
- package/src/ui/public/assets/AppVersionBadge-B5VG1_Lu.js +2 -0
- package/src/ui/public/assets/AppVersionBadge-DDYQqoy7.css +1 -0
- package/src/ui/public/assets/BranchSelector-Cj6athRO.js +1 -0
- package/src/ui/public/assets/BranchSelector-F4ibldMN.css +1 -0
- package/src/ui/public/assets/CommitForm-4kI2csgs.css +1 -0
- package/src/ui/public/assets/CommitForm-a2tMsfF9.js +6 -0
- package/src/ui/public/assets/CommonDialog-I-GCqRMC.js +1 -0
- package/src/ui/public/assets/CommonDialog-w6EmBEXv.css +1 -0
- package/src/ui/public/assets/EditorView-DBqhjm84.css +1 -0
- package/src/ui/public/assets/EditorView-wTi53Nxc.js +16 -0
- package/src/ui/public/assets/FileDiffViewer-B7i4BmbH.js +15 -0
- package/src/ui/public/assets/FileDiffViewer-n5ea9qMe.css +1 -0
- package/src/ui/public/assets/{FlowExecutionViewer-Drw4hlJG.js → FlowExecutionViewer-xZjrf8e1.js} +1 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-DwuSSxJ1.css +1 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-R0QKmUpG.js +21 -0
- package/src/ui/public/assets/IconButton-B6eqvssJ.js +1 -0
- package/src/ui/public/assets/IconButton-RlXqKOeu.css +1 -0
- package/src/ui/public/assets/LogList-BPqurE9g.js +7 -0
- package/src/ui/public/assets/LogList-XspUhx-C.css +1 -0
- package/src/ui/public/assets/MindmapView-Diq2Cu4O.css +1 -0
- package/src/ui/public/assets/MindmapView-NdFNlkTF.js +2 -0
- package/src/ui/public/assets/MonitorView-DuSZa8a3.js +1 -0
- package/src/ui/public/assets/MonitorView-DwUwJXnY.css +1 -0
- package/src/ui/public/assets/ProjectStartupButton-BrfKVP-w.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-CB8IOg1E.css +1 -0
- package/src/ui/public/assets/RemoteManagerDialog-BI7fRsOQ.js +4 -0
- package/src/ui/public/assets/RemoteManagerDialog-D4WphMtO.css +1 -0
- package/src/ui/public/assets/RemoteRepoCard-B4E8Ud3F.js +7 -0
- package/src/ui/public/assets/RemoteRepoCard-o01IrbVz.css +1 -0
- package/src/ui/public/assets/SourceMapView-B0OzHjxP.js +3 -0
- package/src/ui/public/assets/SourceMapView-CfDydtsu.css +1 -0
- package/src/ui/public/assets/SvgIcon-B2h7B2yF.js +1 -0
- package/src/ui/public/assets/TemplateManager-DO7EPfj8.css +1 -0
- package/src/ui/public/assets/TemplateManager-mIWE3Qf8.js +1 -0
- package/src/ui/public/assets/UserInputNode-BafC-B2U.js +1 -0
- package/src/ui/public/assets/UserInputNode-DDNSarag.css +1 -0
- package/src/ui/public/assets/WorkbenchView-BU2kJCAA.css +1 -0
- package/src/ui/public/assets/WorkbenchView-BseGz8hJ.js +56 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-dg2VUnSQ.js → _plugin-vue_export-helper-Dvuzpv2Y.js} +7 -7
- package/src/ui/public/assets/agentConversations-BEKqkGcm.js +5 -0
- package/src/ui/public/assets/{configStore-DtjAc3w9.js → configStore-CX0FkQ94.js} +1 -1
- package/src/ui/public/assets/element-plus-C1xzgNSR.css +1 -0
- package/src/ui/public/assets/{element-plus-DxBPQwL7.js → element-plus-D7LDHcAn.js} +3 -3
- package/src/ui/public/assets/flow-mindmap-zXXF3qDh.js +139 -0
- package/src/ui/public/assets/gitStore-CMuWWr2d.js +6 -0
- package/src/ui/public/assets/index-CB2cIiHl.js +115 -0
- package/src/ui/public/assets/index-DIU5200G.css +1 -0
- package/src/ui/public/assets/{office-docx-CZXLkCf5.js → office-docx-C8sYW8O1.js} +5 -5
- package/src/ui/public/assets/{office-excel-CBKszjVA.js → office-excel-DpZn5My2.js} +1 -1
- package/src/ui/public/assets/{office-pptx-NeUSnN3k.js → office-pptx-BnYBTwT7.js} +1 -1
- package/src/ui/public/assets/previewSandbox-CxLaNg4X.css +1 -0
- package/src/ui/public/assets/previewSandbox-DxR4iN9P.js +0 -0
- package/src/ui/public/assets/toolsStore-DMCSd287.js +1 -0
- package/src/ui/public/assets/{useThemeObserver-E7Hk50l3.js → useThemeObserver-QpveGpnn.js} +1 -1
- package/src/ui/public/assets/{vendor-DnB9EJa3.css → vendor-C-GCxMMu.css} +1 -1
- package/src/ui/public/assets/{vendor-bdHhLiED.js → vendor-CLmPUj3K.js} +8 -8
- package/src/ui/public/assets/vue-flow-CxnoC7Uq.js +39 -0
- package/src/ui/public/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/src/ui/public/fonts/plus-jakarta-sans-latin-ext.woff2 +0 -0
- package/src/ui/public/fonts/plus-jakarta-sans-latin-italic.woff2 +0 -0
- package/src/ui/public/fonts/plus-jakarta-sans-latin.woff2 +0 -0
- package/src/ui/public/index.html +28 -27
- package/src/ui/server/index.js +28 -2
- package/src/ui/server/routes/aiContext/collectors.js +360 -0
- package/src/ui/server/routes/aiContext/collectors.test.js +297 -0
- package/src/ui/server/routes/aiContext/index.js +444 -0
- package/src/ui/server/routes/aiContext/index.test.js +433 -0
- package/src/ui/server/routes/aiContext/jobRefresh.js +110 -0
- package/src/ui/server/routes/aiContext/jobRefresh.test.js +191 -0
- package/src/ui/server/routes/aiContext/render.js +366 -0
- package/src/ui/server/routes/aiContext/render.test.js +255 -0
- package/src/ui/server/routes/aiContext/wiring.js +80 -0
- package/src/ui/server/routes/fs.js +43 -0
- package/src/ui/server/routes/mindmap.js +36 -24
- package/src/ui/server/routes/monitor.js +44 -35
- package/src/ui/server/routes/remoteRepos.js +57 -48
- package/src/ui/server/routes/workbench/agentChat.js +94 -10
- package/src/ui/server/routes/workbench/agentChatContext.test.js +171 -0
- package/src/ui/server/routes/workbench/agentChatShared.test.js +33 -3
- package/src/ui/server/routes/workbench/agentEngines.js +77 -0
- package/src/ui/server/routes/workbench/agentEngines.test.js +65 -0
- package/src/ui/server/routes/workbench/agentRoutes.js +208 -48
- package/src/ui/server/routes/workbench/agentRoutes.test.js +160 -4
- package/src/ui/server/routes/workbench/agentSessionStore.js +3 -0
- package/src/ui/server/routes/workbench/envContext.js +61 -64
- package/src/ui/server/routes/workbench/envContext.test.js +133 -63
- package/src/ui/server/routes/workbench/index.js +78 -32
- package/src/ui/server/routes/workbench/runExternalTurn.js +279 -0
- package/src/ui/server/routes/workbench/runExternalTurn.test.js +275 -0
- package/src/ui/server/routes/workbench/shared.js +17 -0
- package/src/ui/server/utils/agentAttachments.js +169 -0
- package/src/ui/server/utils/agentAttachments.test.js +140 -0
- package/src/ui/public/assets/AgentView-DOyN97Ve.js +0 -5
- package/src/ui/public/assets/AgentView-DkM2KXuc.css +0 -1
- package/src/ui/public/assets/AppVersionBadge-CJrDr7GV.css +0 -1
- package/src/ui/public/assets/AppVersionBadge-oON3sAj4.js +0 -2
- package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +0 -1
- package/src/ui/public/assets/BranchSelector-Cpj7eGqY.js +0 -1
- package/src/ui/public/assets/CommitForm-CQ0K8Yy2.css +0 -1
- package/src/ui/public/assets/CommitForm-D_ovYhVw.js +0 -6
- package/src/ui/public/assets/CommonDialog-ChZbqfRF.js +0 -1
- package/src/ui/public/assets/CommonDialog-jP0E9mco.css +0 -1
- package/src/ui/public/assets/EditorView-BPzpI0GV.js +0 -1
- package/src/ui/public/assets/EditorView-DZMLvQds.css +0 -1
- package/src/ui/public/assets/FileDiffViewer-B9EkuKdF.css +0 -1
- package/src/ui/public/assets/FileDiffViewer-B_qEKA9L.js +0 -15
- package/src/ui/public/assets/FlowOrchestrationWorkspace-C7yTD1gj.js +0 -21
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +0 -1
- package/src/ui/public/assets/IconButton-DEARmMkZ.js +0 -1
- package/src/ui/public/assets/IconButton-tjkOjzkC.css +0 -1
- package/src/ui/public/assets/LogList-BA_gowxE.js +0 -7
- package/src/ui/public/assets/LogList-BlJplIi1.css +0 -1
- package/src/ui/public/assets/MindmapView-8X6z245q.css +0 -1
- package/src/ui/public/assets/MindmapView-K-XcLLXr.js +0 -2
- package/src/ui/public/assets/MonitorView-C0RL5K9W.js +0 -1
- package/src/ui/public/assets/MonitorView-DetlFFXq.css +0 -1
- package/src/ui/public/assets/ProjectStartupButton-DC36_vvU.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +0 -1
- package/src/ui/public/assets/RemoteManagerDialog-BauAeH2w.css +0 -1
- package/src/ui/public/assets/RemoteManagerDialog-Bkqja660.js +0 -4
- package/src/ui/public/assets/RemoteRepoCard-Bsm7cj_H.js +0 -7
- package/src/ui/public/assets/RemoteRepoCard-CG-jARTe.css +0 -1
- package/src/ui/public/assets/SourceMapView-CN4Hkyp4.css +0 -1
- package/src/ui/public/assets/SourceMapView-Y5NKdIk6.js +0 -3
- package/src/ui/public/assets/SvgIcon-D_mFHXP4.js +0 -1
- package/src/ui/public/assets/TemplateManager-BzKVXZhy.js +0 -1
- package/src/ui/public/assets/TemplateManager-DMuG4i3G.css +0 -1
- package/src/ui/public/assets/UserInputNode-BPLK4aYx.js +0 -1
- package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +0 -1
- package/src/ui/public/assets/WorkbenchView-B1BJhWHi.css +0 -1
- package/src/ui/public/assets/WorkbenchView-xbGdiVMQ.js +0 -56
- package/src/ui/public/assets/element-plus-Cwwq-Gje.css +0 -1
- package/src/ui/public/assets/flow-mindmap-BqFb1WO5.js +0 -139
- package/src/ui/public/assets/gitStore-BZo9_zyy.js +0 -6
- package/src/ui/public/assets/index-CHuCYLWm.js +0 -111
- package/src/ui/public/assets/index-CmbatbSR.css +0 -1
- package/src/ui/public/assets/previewSandbox-BTTTXooN.js +0 -0
- package/src/ui/public/assets/previewSandbox-CgdwPcuW.css +0 -1
- package/src/ui/public/assets/vue-flow-fLUQ0_kE.js +0 -39
|
@@ -25,9 +25,13 @@ import path from 'node:path';
|
|
|
25
25
|
import { asyncRoute, HttpError } from '../../utils/asyncRoute.js';
|
|
26
26
|
import { agentSessionStore } from './agentSessionStore.js';
|
|
27
27
|
import { runAgentTurn } from './agentChat.js';
|
|
28
|
+
import { runExternalTurn } from './runExternalTurn.js';
|
|
29
|
+
import { normalizeAgentEngine, isExternalEngine, engineLabel } from './agentEngines.js';
|
|
30
|
+
import { saveAgentAttachments, MAX_ATTACHMENTS } from '../../utils/agentAttachments.js';
|
|
28
31
|
import { registerAgentMarketplaceRoutes } from './agentMarketplace.js';
|
|
29
32
|
import { createProjectListProvider } from './projectTool.js';
|
|
30
|
-
import {
|
|
33
|
+
import { createWorkspaceSnapshotter } from '../aiContext/wiring.js';
|
|
34
|
+
import { nowIso, logger } from './shared.js';
|
|
31
35
|
|
|
32
36
|
const { genSessionId, autoTitle, read: readSession, write: writeSession, delete: deleteSession, listMeta: listSessionsMeta, enforceRetention, rename: renameSession } = agentSessionStore;
|
|
33
37
|
|
|
@@ -51,6 +55,9 @@ function sessionBelongsTo(sessionCwd, projectCwd) {
|
|
|
51
55
|
// matching response. The entry is removed on every completion or disconnect.
|
|
52
56
|
const pendingAgentQuestions = new Map();
|
|
53
57
|
|
|
58
|
+
/** 正在进行中的会话 id 集合:同一会话同一时刻只允许一个生成中的轮次 */
|
|
59
|
+
const activeSessionTurns = new Set();
|
|
60
|
+
|
|
54
61
|
function interactionKey(sessionId, interactionId) {
|
|
55
62
|
return `${String(sessionId)}:${String(interactionId)}`;
|
|
56
63
|
}
|
|
@@ -110,7 +117,7 @@ export function submitAgentAnswer({ sessionId, interactionId, answer }) {
|
|
|
110
117
|
* @param {() => string} deps.getCurrentProjectPath
|
|
111
118
|
* @param {Object} deps.configManager
|
|
112
119
|
*/
|
|
113
|
-
export function registerAgentRoutes({ app, getCurrentProjectPath, configManager }) {
|
|
120
|
+
export function registerAgentRoutes({ app, getCurrentProjectPath, configManager, snapshotter }) {
|
|
114
121
|
|
|
115
122
|
registerAgentMarketplaceRoutes({ app, getCurrentProjectPath });
|
|
116
123
|
|
|
@@ -119,10 +126,61 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
|
|
|
119
126
|
// 不给它加缓存:agent 一轮里最多问一两次,而"项目状态"本来就要现读才准。
|
|
120
127
|
const listProjects = createProjectListProvider({ configManager, getCurrentProjectPath });
|
|
121
128
|
|
|
129
|
+
// 工作区状态快照(七个板块的摘要 + 落盘文件路径)。同样在这里建一次、复用 ——
|
|
130
|
+
// 它要读配置、tasks.json、执行记录、git 状态、监控与思维导图,这些依赖本层一并拿得最全。
|
|
131
|
+
//
|
|
132
|
+
// `snapshotter` 是个**只为测试留的注入口**(与 remoteRepos 的 *Impl 同一套做法):
|
|
133
|
+
// 生产不传,走真实实现;单测传一个空壳,免得跑一次对话就在测试机上 spawn `gh`
|
|
134
|
+
// 和 PowerShell、还去连一次网。默认值写成参数默认值,不传时才构造。
|
|
135
|
+
const workspaceSnapshotter = snapshotter || createWorkspaceSnapshotter({ configManager, getCurrentProjectPath });
|
|
136
|
+
const getContextBlock = ({ locale } = {}) => workspaceSnapshotter.getBlock({ locale });
|
|
137
|
+
|
|
122
138
|
// ════════════════════════════════════════════════════════════════════════
|
|
123
139
|
// §1. 会话列表
|
|
124
140
|
// ════════════════════════════════════════════════════════════════════════
|
|
141
|
+
/**
|
|
142
|
+
* 预热工作区状态快照 —— 这是"初始加载"该做的事,但**不是**把七个界面板块都挂起来。
|
|
143
|
+
*
|
|
144
|
+
* 用户打开智能体页时前端会拉这个会话列表,此刻就顺手在后台把快照生成好;
|
|
145
|
+
* 等他打完字发出去,快照早已就绪,那一轮就能带上完整上下文。
|
|
146
|
+
* 刻意不 await:它要跑 gh/gitee/git/PowerShell,等它会让会话列表白屏几秒。
|
|
147
|
+
* 也刻意不放在 registerAgentRoutes 里:启动时预热会让"打开服务"这条路径凭空多几次
|
|
148
|
+
* 子进程与网络调用,而用户可能压根不会打开智能体页。
|
|
149
|
+
*/
|
|
150
|
+
function warmSnapshotInBackground() {
|
|
151
|
+
try {
|
|
152
|
+
if (typeof workspaceSnapshotter?.warm === 'function') workspaceSnapshotter.warm().catch(() => {});
|
|
153
|
+
} catch (err) {
|
|
154
|
+
logger.warn(`[agentRoutes] 预热工作区快照失败: ${err?.message || err}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* POST /api/ai-context/refresh body: { sections?: string[], force?: boolean }
|
|
160
|
+
*
|
|
161
|
+
* 给前端"切到某个面板就更新对应板块"用的定向刷新口。**按板块**而不是全量:
|
|
162
|
+
* 七个板块的取数成本差两个数量级,切个 Git 面板没必要顺带联网拉一遍仓库列表。
|
|
163
|
+
*
|
|
164
|
+
* 前端一律 fire-and-forget 调它(不等响应) —— 一次全量要 6~7 秒,没有哪个 UI 该等它。
|
|
165
|
+
* 真正拖慢也不怕:force 仍受各板块自己的 forceTtlMs 地板限制,联网板块最多 60s 一次。
|
|
166
|
+
*
|
|
167
|
+
* 顺带把各板块状态回给调用方(采集时间/是否新鲜),排查"为什么模型看到的是旧的"时很好用。
|
|
168
|
+
*/
|
|
169
|
+
app.post('/api/ai-context/refresh', asyncRoute(async (req, res) => {
|
|
170
|
+
const body = req.body || {};
|
|
171
|
+
const ids = Array.isArray(body.sections) ? body.sections.map(String) : undefined;
|
|
172
|
+
const force = body.force === true;
|
|
173
|
+
const state = await workspaceSnapshotter.refreshSections(ids, { force });
|
|
174
|
+
res.json({ success: true, ...state });
|
|
175
|
+
}));
|
|
176
|
+
|
|
177
|
+
/** GET 版本:只读回各板块状态,不触发任何取数(给未来做面板用) */
|
|
178
|
+
app.get('/api/ai-context/state', asyncRoute(async (_req, res) => {
|
|
179
|
+
res.json({ success: true, ...workspaceSnapshotter.getState() });
|
|
180
|
+
}));
|
|
181
|
+
|
|
125
182
|
app.get('/api/agent/sessions', asyncRoute(async (req, res) => {
|
|
183
|
+
warmSnapshotInBackground();
|
|
126
184
|
let sessions = await listSessionsMeta();
|
|
127
185
|
// 按项目隔离:前端传当前项目路径时只返回该项目的会话
|
|
128
186
|
const cwdFilter = String(req.query?.cwd || '').trim();
|
|
@@ -187,10 +245,19 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
|
|
|
187
245
|
.filter(u => typeof u === 'string' && /^data:image\/[\w.+-]+;base64,/.test(u))
|
|
188
246
|
.slice(0, 10);
|
|
189
247
|
|
|
190
|
-
|
|
248
|
+
// 非图片附件:前端只传文件名 + 字节,服务端落盘到数据目录,只把**绝对路径**交给模型。
|
|
249
|
+
// 内容不进消息体(省 token,模型自己用 read/grep 按需取),校验见 utils/agentAttachments.js。
|
|
250
|
+
const rawAttachments = (Array.isArray(req.body?.attachments) ? req.body.attachments : [])
|
|
251
|
+
.slice(0, MAX_ATTACHMENTS)
|
|
252
|
+
.map(a => ({ name: a?.name, dataUrl: a?.dataUrl }));
|
|
253
|
+
|
|
254
|
+
if (!userMessage && images.length === 0 && rawAttachments.length === 0) {
|
|
191
255
|
return res.status(400).json({ success: false, error: '消息内容不能为空' });
|
|
192
256
|
}
|
|
193
257
|
|
|
258
|
+
// 文件空间对话:客户端把"当前打开的文档"带上来,服务端只在请求副本里注入上下文(不落库)
|
|
259
|
+
const openFilePath = String(req.body?.openFilePath || '').trim().slice(0, 512);
|
|
260
|
+
|
|
194
261
|
// SSE 头
|
|
195
262
|
res.set({
|
|
196
263
|
'Content-Type': 'text/event-stream',
|
|
@@ -207,6 +274,15 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
|
|
|
207
274
|
let finished = false;
|
|
208
275
|
let activeChild = null;
|
|
209
276
|
|
|
277
|
+
// 同一会话同一时刻只允许一个进行中的生成:跨视图(对话 Tab / 文件空间面板)
|
|
278
|
+
// 或前端连点都靠这道闸挡住,否则两个流各自的会话快照会互相覆盖。
|
|
279
|
+
if (sessionIdInput && activeSessionTurns.has(sessionIdInput)) {
|
|
280
|
+
send({ type: 'error', error: '该会话正在生成中,请等它结束后再发送' });
|
|
281
|
+
finished = true;
|
|
282
|
+
return res.end();
|
|
283
|
+
}
|
|
284
|
+
let activeSessionKey = null;
|
|
285
|
+
|
|
210
286
|
// 客户端断开
|
|
211
287
|
if (req.socket) {
|
|
212
288
|
req.socket.once('close', () => {
|
|
@@ -249,6 +325,25 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
|
|
|
249
325
|
finished = true;
|
|
250
326
|
return res.end();
|
|
251
327
|
}
|
|
328
|
+
// 引擎在会话**建立时就锁死**,中途不许换。
|
|
329
|
+
// 为什么:三家 CLI 的续聊标识互不通用(claude --resume / opencode --session /
|
|
330
|
+
// codex exec resume),而且历史消息格式不同(我们存的是 OpenAI chat completions
|
|
331
|
+
// 形状,喂给 claude 没有意义)。允许中途切换的表现是"看起来切了,上下文却串了"——
|
|
332
|
+
// 与工作台 taskExecutor.ts 注释里那条"续哪个执行器由上一轮 job.agent 决定"同源。
|
|
333
|
+
const requestedEngine = req.body?.engine === undefined
|
|
334
|
+
? null
|
|
335
|
+
: normalizeAgentEngine(req.body?.engine);
|
|
336
|
+
const sessionEngine = normalizeAgentEngine(session.engine);
|
|
337
|
+
if (requestedEngine && requestedEngine !== sessionEngine && Array.isArray(session.messages) && session.messages.length > 0) {
|
|
338
|
+
send({
|
|
339
|
+
type: 'error',
|
|
340
|
+
error: `该会话由 ${engineLabel(sessionEngine)} 运行,无法中途切换引擎。请新建会话再选 ${engineLabel(requestedEngine)}。`,
|
|
341
|
+
code: 'ENGINE_LOCKED'
|
|
342
|
+
});
|
|
343
|
+
finished = true;
|
|
344
|
+
return res.end();
|
|
345
|
+
}
|
|
346
|
+
session.engine = sessionEngine;
|
|
252
347
|
} else {
|
|
253
348
|
session = {
|
|
254
349
|
version: 1,
|
|
@@ -257,6 +352,11 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
|
|
|
257
352
|
source: 'web',
|
|
258
353
|
cwd: currentProjectCwd,
|
|
259
354
|
model: '',
|
|
355
|
+
// 引擎:gai(内置,默认)| claude | opencode | codex
|
|
356
|
+
engine: normalizeAgentEngine(req.body?.engine),
|
|
357
|
+
// 外部 CLI 自己的续聊标识(claude session_id / opencode sessionID /
|
|
358
|
+
// codex thread_id)。g ai 用不到,留着空串。
|
|
359
|
+
engineSessionId: '',
|
|
260
360
|
createdAt: nowIso(),
|
|
261
361
|
updatedAt: nowIso(),
|
|
262
362
|
messages: []
|
|
@@ -264,26 +364,47 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
|
|
|
264
364
|
isNew = true;
|
|
265
365
|
}
|
|
266
366
|
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
return
|
|
278
|
-
}
|
|
279
|
-
if (!model) {
|
|
280
|
-
send({ type: 'error', error: '未配置 AI 模型,请先在通用设置中添加模型' });
|
|
281
|
-
finished = true;
|
|
282
|
-
return res.end();
|
|
283
|
-
}
|
|
367
|
+
// 本轮占用该会话(finally 里释放)
|
|
368
|
+
activeSessionKey = session.sessionId;
|
|
369
|
+
activeSessionTurns.add(activeSessionKey);
|
|
370
|
+
|
|
371
|
+
// 附件落盘(拿到绝对路径后再把路径交给模型)。落盘失败不该拖垮整轮对话:
|
|
372
|
+
// 拿不到路径最多是模型看不到附件,用户仍能正常对话。
|
|
373
|
+
const attachments = await saveAgentAttachments(rawAttachments, {
|
|
374
|
+
onSkip: (info) => console.warn('[agent] 附件被忽略:', info.name, info.reason)
|
|
375
|
+
}).catch((err) => {
|
|
376
|
+
console.warn('[agent] 附件落盘失败:', err?.message || err);
|
|
377
|
+
return [];
|
|
378
|
+
});
|
|
284
379
|
|
|
285
|
-
//
|
|
286
|
-
|
|
380
|
+
// 获取模型配置。**只有内置 g ai 需要** —— 外部 CLI 用自己的模型与配置
|
|
381
|
+
// (claude/opencode/codex 各自的 CLI 配置;本机上 Codex 甚至是走本地代理跑
|
|
382
|
+
// deepseek-v4.1-flash)。为外部引擎要求"先在设置里配模型"是错的:
|
|
383
|
+
// 明明能跑,却先被一道无关的校验拦住。
|
|
384
|
+
const useExternal = isExternalEngine(session.engine);
|
|
385
|
+
let model = null;
|
|
386
|
+
if (!useExternal) {
|
|
387
|
+
try {
|
|
388
|
+
if (!configManager) throw new Error('configManager 不可用');
|
|
389
|
+
const rawConfig = await configManager.readRawConfigFile();
|
|
390
|
+
const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
|
|
391
|
+
model = models.find(m => m.isDefault) || models[0];
|
|
392
|
+
} catch (err) {
|
|
393
|
+
send({ type: 'error', error: '读取 AI 配置失败: ' + err.message });
|
|
394
|
+
finished = true;
|
|
395
|
+
return res.end();
|
|
396
|
+
}
|
|
397
|
+
if (!model) {
|
|
398
|
+
send({ type: 'error', error: '未配置 AI 模型,请先在通用设置中添加模型' });
|
|
399
|
+
finished = true;
|
|
400
|
+
return res.end();
|
|
401
|
+
}
|
|
402
|
+
// 更新 session 的 model 信息
|
|
403
|
+
session.model = `${model.model || ''} (${model.name || ''})`;
|
|
404
|
+
} else {
|
|
405
|
+
// 外部引擎:不落具体模型名(它随 CLI 自己的配置变),只记引擎,便于列表里辨认
|
|
406
|
+
session.model = '';
|
|
407
|
+
}
|
|
287
408
|
|
|
288
409
|
// 工作目录
|
|
289
410
|
const cwd = session.cwd || (typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : process.cwd());
|
|
@@ -296,33 +417,64 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
|
|
|
296
417
|
title: isNew ? autoTitle([{ role: 'user', content: userMessage }]) : session.title
|
|
297
418
|
});
|
|
298
419
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
420
|
+
if (useExternal) {
|
|
421
|
+
// 外部 CLI 引擎:CLI 自己跑循环,服务端只做 spawn + 事件翻译。
|
|
422
|
+
// 工作区状态块走 prompt 前缀(见 runExternalTurn 文件头:往项目里写
|
|
423
|
+
// AGENTS.md/CLAUDE.md 会弄脏用户工作区,且三家读取口径实测不一致)。
|
|
424
|
+
let promptPrefix = '';
|
|
425
|
+
if (typeof getContextBlock === 'function') {
|
|
426
|
+
try { promptPrefix = await getContextBlock({ locale }); } catch (err) { logger.warn(`[agent] 外部引擎状态块获取失败: ${err?.message || err}`); }
|
|
427
|
+
}
|
|
428
|
+
// 图片附件转成文件路径交给 CLI(它们收路径不收 base64)。走同一套落盘校验。
|
|
429
|
+
const imageAttachments = images.map((u, i) => ({ name: `pasted-${i + 1}.png`, dataUrl: u }));
|
|
430
|
+
const imageFiles = imageAttachments.length > 0
|
|
431
|
+
? await saveAgentAttachments(imageAttachments, {
|
|
432
|
+
onSkip: (info) => console.warn('[agent] 图片附件被忽略:', info.name, info.reason)
|
|
433
|
+
}).catch((err) => {
|
|
434
|
+
console.warn('[agent] 图片附件落盘失败:', err?.message || err);
|
|
435
|
+
return [];
|
|
436
|
+
})
|
|
437
|
+
: [];
|
|
438
|
+
const filePaths = [...attachments.map(a => a.path), ...imageFiles.map(f => f.path)];
|
|
439
|
+
|
|
440
|
+
await runExternalTurn({
|
|
441
|
+
session,
|
|
442
|
+
engine: session.engine,
|
|
443
|
+
userMessage,
|
|
444
|
+
cwd,
|
|
445
|
+
promptPrefix,
|
|
446
|
+
filePaths,
|
|
447
|
+
signal: abortController.signal,
|
|
317
448
|
send,
|
|
449
|
+
onChild: (child) => { activeChild = child; },
|
|
450
|
+
});
|
|
451
|
+
} else {
|
|
452
|
+
// 运行 agent 循环(内置 g ai:服务端跑 LLM + 工具)
|
|
453
|
+
await runAgentTurn({
|
|
454
|
+
session,
|
|
455
|
+
model,
|
|
456
|
+
userMessage,
|
|
457
|
+
images,
|
|
458
|
+
cwd,
|
|
459
|
+
locale,
|
|
460
|
+
openFilePath,
|
|
461
|
+
attachments,
|
|
318
462
|
signal: abortController.signal,
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
463
|
+
send,
|
|
464
|
+
onChild: (child) => { activeChild = child; },
|
|
465
|
+
askUser: (args, meta = {}) => waitForAgentAnswer({
|
|
466
|
+
sessionId: session.sessionId,
|
|
467
|
+
interactionId: meta.interactionId,
|
|
468
|
+
question: args.question,
|
|
469
|
+
options: Array.isArray(args.options) ? args.options : [],
|
|
470
|
+
allowFreeText: args.allowFreeText !== false,
|
|
471
|
+
multiple: args.multiple === true,
|
|
472
|
+
send,
|
|
473
|
+
signal: abortController.signal,
|
|
474
|
+
}),
|
|
475
|
+
listProjects,
|
|
476
|
+
getContextBlock
|
|
477
|
+
});
|
|
326
478
|
}
|
|
327
479
|
|
|
328
480
|
// 更新标题(新会话从第一条 user 消息自动生成)
|
|
@@ -330,7 +482,8 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
|
|
|
330
482
|
session.title = autoTitle(session.messages);
|
|
331
483
|
}
|
|
332
484
|
|
|
333
|
-
//
|
|
485
|
+
// 持久化。中止(用户点"停止")的轮次同样要落盘 —— 磁盘上没有这条会话时,
|
|
486
|
+
// 前端停止后立刻刷新列表会把左栏这一条整个吞掉(表现为"一停止任务就没了")。
|
|
334
487
|
session.updatedAt = nowIso();
|
|
335
488
|
await writeSession(session.sessionId, session);
|
|
336
489
|
enforceRetention().catch(() => {});
|
|
@@ -341,6 +494,13 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager
|
|
|
341
494
|
send({ type: 'error', error: '智能体对话失败: ' + (err?.message || String(err)) });
|
|
342
495
|
finished = true;
|
|
343
496
|
res.end();
|
|
497
|
+
} finally {
|
|
498
|
+
if (activeSessionKey) activeSessionTurns.delete(activeSessionKey);
|
|
344
499
|
}
|
|
345
500
|
});
|
|
501
|
+
|
|
502
|
+
// 把生成器交出去:调用方(server/index.js)要在服务启动后预热一次快照,
|
|
503
|
+
// 覆盖"g ui 刚启动"这个时机。预热调用刻意留在**生产入口**而不是这里 ——
|
|
504
|
+
// 单测会调 registerAgentRoutes 四次,预热写在这里等于测试机上 spawn 四轮 gh。
|
|
505
|
+
return { snapshotter: workspaceSnapshotter };
|
|
346
506
|
}
|
|
@@ -1,7 +1,87 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, readdirSync, readFileSync } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
3
5
|
import path from 'node:path';
|
|
4
|
-
|
|
6
|
+
|
|
7
|
+
// agentSessionStore 的落盘目录在模块加载时由 os.homedir() 求值,必须在 import 之前
|
|
8
|
+
// 把 HOME 指到沙箱,否则这个文件会往真实 ~/.zen-gitsync/agent-sessions 里写会话。
|
|
9
|
+
const SANDBOX = mkdtempSync(path.join(os.tmpdir(), 'zen-agent-routes-'));
|
|
10
|
+
process.env.USERPROFILE = SANDBOX;
|
|
11
|
+
process.env.HOME = SANDBOX;
|
|
12
|
+
delete process.env.HOMEDRIVE;
|
|
13
|
+
delete process.env.HOMEPATH;
|
|
14
|
+
|
|
15
|
+
const { registerAgentRoutes, waitForAgentAnswer } = await import('./agentRoutes.js');
|
|
16
|
+
|
|
17
|
+
// 工作区状态快照的替身。真实实现会 spawn `gh` / `gitee` / PowerShell 并联网,
|
|
18
|
+
// 单测里一律用空壳 —— 否则跑一次对话就在测试机上拉起一串子进程、还连一次网。
|
|
19
|
+
// getBlock 回空串 = "这一轮不注入快照",与冷启动时的真实行为一致(见 aiContext/index.js
|
|
20
|
+
// 的 getSnapshot:冷启动先回 null,后台再生成)。
|
|
21
|
+
const STUB_SNAPSHOTTER = {
|
|
22
|
+
getBlock: async () => '',
|
|
23
|
+
warm: async () => null,
|
|
24
|
+
refreshAll: async () => null,
|
|
25
|
+
refreshSections: async () => ({ dir: '', persistedAt: null, sections: [] }),
|
|
26
|
+
getState: () => ({ dir: '', persistedAt: null, sections: [] }),
|
|
27
|
+
invalidate() {}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const event = data => `data: ${JSON.stringify(data)}\n\n`;
|
|
31
|
+
|
|
32
|
+
// 轮询等待条件成立(SSE 是边跑边写出来的,没有可 await 的句柄)
|
|
33
|
+
async function waitUntil(check, timeoutMs = 2000) {
|
|
34
|
+
const start = Date.now();
|
|
35
|
+
while (!check()) {
|
|
36
|
+
if (Date.now() - start > timeoutMs) throw new Error('等待超时');
|
|
37
|
+
await new Promise(resolve => setTimeout(resolve, 5));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 面板切换 → 定向刷新快照的端点。守两件事:
|
|
42
|
+
// ① sections 原样透传给生成器(前端按"我在看哪一块"决定刷哪几块)
|
|
43
|
+
// ② 不带 body / 不带 sections 时等于"全部",不是"什么都不刷"
|
|
44
|
+
test('ai-context 刷新端点:按板块定向刷新,并把各板块状态回给前端', async () => {
|
|
45
|
+
const calls = [];
|
|
46
|
+
let refreshHandler;
|
|
47
|
+
let stateHandler;
|
|
48
|
+
const FAKE_STATE = { dir: 'D:/snap', persistedAt: '2026-09-28 10:00:00', sections: [{ id: 'git', fresh: true }] };
|
|
49
|
+
const app = {
|
|
50
|
+
get(route, handler) { if (route === '/api/ai-context/state') stateHandler = handler; },
|
|
51
|
+
delete() {},
|
|
52
|
+
put() {},
|
|
53
|
+
post(route, handler) { if (route === '/api/ai-context/refresh') refreshHandler = handler; }
|
|
54
|
+
};
|
|
55
|
+
registerAgentRoutes({
|
|
56
|
+
app,
|
|
57
|
+
getCurrentProjectPath: () => path.resolve('active-project'),
|
|
58
|
+
configManager: null,
|
|
59
|
+
snapshotter: {
|
|
60
|
+
...STUB_SNAPSHOTTER,
|
|
61
|
+
refreshSections: async (ids, opts) => { calls.push({ ids, opts }); return FAKE_STATE; },
|
|
62
|
+
getState: () => FAKE_STATE
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
let payload;
|
|
67
|
+
const res = { json(v) { payload = v; } };
|
|
68
|
+
|
|
69
|
+
await refreshHandler({ body: { sections: ['github'], force: true } }, res);
|
|
70
|
+
assert.deepEqual(calls[0].ids, ['github']);
|
|
71
|
+
assert.equal(calls[0].opts.force, true);
|
|
72
|
+
assert.equal(payload.success, true);
|
|
73
|
+
assert.equal(payload.dir, 'D:/snap');
|
|
74
|
+
|
|
75
|
+
// 不带 body:sections 为 undefined = 全部,force 默认 false(尊重各板块 TTL)
|
|
76
|
+
await refreshHandler({}, res);
|
|
77
|
+
assert.equal(calls[1].ids, undefined);
|
|
78
|
+
assert.equal(calls[1].opts.force, false);
|
|
79
|
+
|
|
80
|
+
// 只读状态的口:不触发任何取数
|
|
81
|
+
await stateHandler({}, res);
|
|
82
|
+
assert.equal(payload.dir, 'D:/snap');
|
|
83
|
+
assert.equal(calls.length, 2, 'GET /state 不该触发刷新');
|
|
84
|
+
});
|
|
5
85
|
|
|
6
86
|
test('agent respond resolves a pending ask_user question and validates answers', async () => {
|
|
7
87
|
let respondHandler;
|
|
@@ -16,7 +96,8 @@ test('agent respond resolves a pending ask_user question and validates answers',
|
|
|
16
96
|
registerAgentRoutes({
|
|
17
97
|
app,
|
|
18
98
|
getCurrentProjectPath: () => path.resolve('active-project'),
|
|
19
|
-
configManager: null
|
|
99
|
+
configManager: null,
|
|
100
|
+
snapshotter: STUB_SNAPSHOTTER
|
|
20
101
|
});
|
|
21
102
|
|
|
22
103
|
const sent = [];
|
|
@@ -65,7 +146,8 @@ test('agent respond: 多选提交逐项校验,回给模型的是 JSON 数组字
|
|
|
65
146
|
registerAgentRoutes({
|
|
66
147
|
app,
|
|
67
148
|
getCurrentProjectPath: () => path.resolve('active-project'),
|
|
68
|
-
configManager: null
|
|
149
|
+
configManager: null,
|
|
150
|
+
snapshotter: STUB_SNAPSHOTTER
|
|
69
151
|
});
|
|
70
152
|
|
|
71
153
|
const sent = [];
|
|
@@ -113,7 +195,8 @@ test('agent chat rejects a cwd that differs from the active server project', asy
|
|
|
113
195
|
registerAgentRoutes({
|
|
114
196
|
app,
|
|
115
197
|
getCurrentProjectPath: () => activeCwd,
|
|
116
|
-
configManager: null
|
|
198
|
+
configManager: null,
|
|
199
|
+
snapshotter: STUB_SNAPSHOTTER
|
|
117
200
|
});
|
|
118
201
|
|
|
119
202
|
let output = '';
|
|
@@ -133,3 +216,76 @@ test('agent chat rejects a cwd that differs from the active server project', asy
|
|
|
133
216
|
assert.match(output, /PROJECT_MISMATCH/);
|
|
134
217
|
assert.match(output, /项目不一致/);
|
|
135
218
|
});
|
|
219
|
+
|
|
220
|
+
// 回归:用户点"停止"(客户端断开连接 → 服务端 abort)后,这条会话仍要落盘。
|
|
221
|
+
// 之前中止分支直接 res.end(),磁盘上没有会话文件,前端停止后立刻刷新列表
|
|
222
|
+
// 就把左栏这一条整个吞掉了 —— 用户看到的是"一停止任务就没了"。
|
|
223
|
+
test('停止(客户端断开)后会话仍要落盘,并带上自动标题与已流出的正文', async () => {
|
|
224
|
+
const activeCwd = path.resolve('active-project');
|
|
225
|
+
let chatHandler;
|
|
226
|
+
const app = {
|
|
227
|
+
get() {},
|
|
228
|
+
delete() {},
|
|
229
|
+
put() {},
|
|
230
|
+
post(route, handler) {
|
|
231
|
+
if (route === '/api/agent/chat') chatHandler = handler;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
registerAgentRoutes({
|
|
235
|
+
app,
|
|
236
|
+
getCurrentProjectPath: () => activeCwd,
|
|
237
|
+
configManager: {
|
|
238
|
+
readRawConfigFile: async () => ({
|
|
239
|
+
models: [{ model: 'test', name: 'T', baseURL: 'https://example.invalid/v1', apiKey: 'k', isDefault: true }]
|
|
240
|
+
})
|
|
241
|
+
},
|
|
242
|
+
snapshotter: STUB_SNAPSHOTTER
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// 模型流:吐一段正文后挂住(模拟"停在生成中")。真 fetch 在 signal abort 时会打断
|
|
246
|
+
// body 读取,桩里照做才能真的走到中止分支
|
|
247
|
+
const hanging = init => new Response(new ReadableStream({
|
|
248
|
+
start(stream) {
|
|
249
|
+
const bytes = new TextEncoder();
|
|
250
|
+
stream.enqueue(bytes.encode(event({ choices: [{ delta: { content: '已经写了一半' } }] })));
|
|
251
|
+
init?.signal?.addEventListener('abort', () => stream.error(new DOMException('Aborted', 'AbortError')));
|
|
252
|
+
}
|
|
253
|
+
}), { status: 200, headers: { 'content-type': 'text/event-stream' } });
|
|
254
|
+
|
|
255
|
+
const originalFetch = globalThis.fetch;
|
|
256
|
+
globalThis.fetch = async (_url, init) => hanging(init);
|
|
257
|
+
|
|
258
|
+
let onSocketClose;
|
|
259
|
+
let output = '';
|
|
260
|
+
let ended = false;
|
|
261
|
+
const req = {
|
|
262
|
+
body: { userMessage: '请列出当前项目的目录结构', cwd: activeCwd },
|
|
263
|
+
headers: {},
|
|
264
|
+
socket: { once(_evt, cb) { onSocketClose = cb; } }
|
|
265
|
+
};
|
|
266
|
+
const res = {
|
|
267
|
+
set() {},
|
|
268
|
+
flushHeaders() {},
|
|
269
|
+
write(chunk) { output += chunk; },
|
|
270
|
+
end() { ended = true; }
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const route = chatHandler(req, res);
|
|
275
|
+
await waitUntil(() => output.includes('"type":"content"'));
|
|
276
|
+
onSocketClose();
|
|
277
|
+
await route;
|
|
278
|
+
} finally {
|
|
279
|
+
globalThis.fetch = originalFetch;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
assert.equal(ended, true, '中止后也要正常收尾');
|
|
283
|
+
const dir = path.join(SANDBOX, '.zen-gitsync', 'agent-sessions');
|
|
284
|
+
const files = readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
285
|
+
assert.equal(files.length, 1, '被停止的会话必须落盘,否则左栏这一条会消失');
|
|
286
|
+
const saved = JSON.parse(readFileSync(path.join(dir, files[0]), 'utf-8'));
|
|
287
|
+
assert.equal(saved.title, '请列出当前项目的目录结构');
|
|
288
|
+
const last = saved.messages[saved.messages.length - 1];
|
|
289
|
+
assert.equal(last.role, 'assistant');
|
|
290
|
+
assert.equal(last.content, '已经写了一半');
|
|
291
|
+
});
|
|
@@ -120,6 +120,9 @@ async function listAgentSessionsMeta() {
|
|
|
120
120
|
source: data.source || 'web', // 'web' | 'cli'
|
|
121
121
|
cwd: data.cwd || '',
|
|
122
122
|
model: data.model || '',
|
|
123
|
+
// 引擎:老会话没有这个字段,一律视为内置 g ai(历史行为),
|
|
124
|
+
// 与 agentEngines.normalizeAgentEngine 的回落口径一致。
|
|
125
|
+
engine: data.engine || 'gai',
|
|
123
126
|
createdAt: data.createdAt || '',
|
|
124
127
|
updatedAt: data.updatedAt || '',
|
|
125
128
|
messageCount: Array.isArray(data.messages) ? data.messages.length : 0,
|