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.
Files changed (144) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -1
  3. package/src/paths.js +13 -0
  4. package/src/ui/public/assets/AgentView-AMFKSDeH.css +1 -0
  5. package/src/ui/public/assets/AgentView-DQAB0ypS.js +13 -0
  6. package/src/ui/public/assets/AppVersionBadge-B5VG1_Lu.js +2 -0
  7. package/src/ui/public/assets/AppVersionBadge-DDYQqoy7.css +1 -0
  8. package/src/ui/public/assets/BranchSelector-Cj6athRO.js +1 -0
  9. package/src/ui/public/assets/BranchSelector-F4ibldMN.css +1 -0
  10. package/src/ui/public/assets/CommitForm-4kI2csgs.css +1 -0
  11. package/src/ui/public/assets/CommitForm-a2tMsfF9.js +6 -0
  12. package/src/ui/public/assets/CommonDialog-I-GCqRMC.js +1 -0
  13. package/src/ui/public/assets/CommonDialog-w6EmBEXv.css +1 -0
  14. package/src/ui/public/assets/EditorView-DBqhjm84.css +1 -0
  15. package/src/ui/public/assets/EditorView-wTi53Nxc.js +16 -0
  16. package/src/ui/public/assets/FileDiffViewer-B7i4BmbH.js +15 -0
  17. package/src/ui/public/assets/FileDiffViewer-n5ea9qMe.css +1 -0
  18. package/src/ui/public/assets/{FlowExecutionViewer-Drw4hlJG.js → FlowExecutionViewer-xZjrf8e1.js} +1 -1
  19. package/src/ui/public/assets/FlowOrchestrationWorkspace-DwuSSxJ1.css +1 -0
  20. package/src/ui/public/assets/FlowOrchestrationWorkspace-R0QKmUpG.js +21 -0
  21. package/src/ui/public/assets/IconButton-B6eqvssJ.js +1 -0
  22. package/src/ui/public/assets/IconButton-RlXqKOeu.css +1 -0
  23. package/src/ui/public/assets/LogList-BPqurE9g.js +7 -0
  24. package/src/ui/public/assets/LogList-XspUhx-C.css +1 -0
  25. package/src/ui/public/assets/MindmapView-Diq2Cu4O.css +1 -0
  26. package/src/ui/public/assets/MindmapView-NdFNlkTF.js +2 -0
  27. package/src/ui/public/assets/MonitorView-DuSZa8a3.js +1 -0
  28. package/src/ui/public/assets/MonitorView-DwUwJXnY.css +1 -0
  29. package/src/ui/public/assets/ProjectStartupButton-BrfKVP-w.js +1 -0
  30. package/src/ui/public/assets/ProjectStartupButton-CB8IOg1E.css +1 -0
  31. package/src/ui/public/assets/RemoteManagerDialog-BI7fRsOQ.js +4 -0
  32. package/src/ui/public/assets/RemoteManagerDialog-D4WphMtO.css +1 -0
  33. package/src/ui/public/assets/RemoteRepoCard-B4E8Ud3F.js +7 -0
  34. package/src/ui/public/assets/RemoteRepoCard-o01IrbVz.css +1 -0
  35. package/src/ui/public/assets/SourceMapView-B0OzHjxP.js +3 -0
  36. package/src/ui/public/assets/SourceMapView-CfDydtsu.css +1 -0
  37. package/src/ui/public/assets/SvgIcon-B2h7B2yF.js +1 -0
  38. package/src/ui/public/assets/TemplateManager-DO7EPfj8.css +1 -0
  39. package/src/ui/public/assets/TemplateManager-mIWE3Qf8.js +1 -0
  40. package/src/ui/public/assets/UserInputNode-BafC-B2U.js +1 -0
  41. package/src/ui/public/assets/UserInputNode-DDNSarag.css +1 -0
  42. package/src/ui/public/assets/WorkbenchView-BU2kJCAA.css +1 -0
  43. package/src/ui/public/assets/WorkbenchView-BseGz8hJ.js +56 -0
  44. package/src/ui/public/assets/{_plugin-vue_export-helper-dg2VUnSQ.js → _plugin-vue_export-helper-Dvuzpv2Y.js} +7 -7
  45. package/src/ui/public/assets/agentConversations-BEKqkGcm.js +5 -0
  46. package/src/ui/public/assets/{configStore-DtjAc3w9.js → configStore-CX0FkQ94.js} +1 -1
  47. package/src/ui/public/assets/element-plus-C1xzgNSR.css +1 -0
  48. package/src/ui/public/assets/{element-plus-DxBPQwL7.js → element-plus-D7LDHcAn.js} +3 -3
  49. package/src/ui/public/assets/flow-mindmap-zXXF3qDh.js +139 -0
  50. package/src/ui/public/assets/gitStore-CMuWWr2d.js +6 -0
  51. package/src/ui/public/assets/index-CB2cIiHl.js +115 -0
  52. package/src/ui/public/assets/index-DIU5200G.css +1 -0
  53. package/src/ui/public/assets/{office-docx-CZXLkCf5.js → office-docx-C8sYW8O1.js} +5 -5
  54. package/src/ui/public/assets/{office-excel-CBKszjVA.js → office-excel-DpZn5My2.js} +1 -1
  55. package/src/ui/public/assets/{office-pptx-NeUSnN3k.js → office-pptx-BnYBTwT7.js} +1 -1
  56. package/src/ui/public/assets/previewSandbox-CxLaNg4X.css +1 -0
  57. package/src/ui/public/assets/previewSandbox-DxR4iN9P.js +0 -0
  58. package/src/ui/public/assets/toolsStore-DMCSd287.js +1 -0
  59. package/src/ui/public/assets/{useThemeObserver-E7Hk50l3.js → useThemeObserver-QpveGpnn.js} +1 -1
  60. package/src/ui/public/assets/{vendor-DnB9EJa3.css → vendor-C-GCxMMu.css} +1 -1
  61. package/src/ui/public/assets/{vendor-bdHhLiED.js → vendor-CLmPUj3K.js} +8 -8
  62. package/src/ui/public/assets/vue-flow-CxnoC7Uq.js +39 -0
  63. package/src/ui/public/fonts/jetbrains-mono-latin.woff2 +0 -0
  64. package/src/ui/public/fonts/plus-jakarta-sans-latin-ext.woff2 +0 -0
  65. package/src/ui/public/fonts/plus-jakarta-sans-latin-italic.woff2 +0 -0
  66. package/src/ui/public/fonts/plus-jakarta-sans-latin.woff2 +0 -0
  67. package/src/ui/public/index.html +28 -27
  68. package/src/ui/server/index.js +28 -2
  69. package/src/ui/server/routes/aiContext/collectors.js +360 -0
  70. package/src/ui/server/routes/aiContext/collectors.test.js +297 -0
  71. package/src/ui/server/routes/aiContext/index.js +444 -0
  72. package/src/ui/server/routes/aiContext/index.test.js +433 -0
  73. package/src/ui/server/routes/aiContext/jobRefresh.js +110 -0
  74. package/src/ui/server/routes/aiContext/jobRefresh.test.js +191 -0
  75. package/src/ui/server/routes/aiContext/render.js +366 -0
  76. package/src/ui/server/routes/aiContext/render.test.js +255 -0
  77. package/src/ui/server/routes/aiContext/wiring.js +80 -0
  78. package/src/ui/server/routes/fs.js +43 -0
  79. package/src/ui/server/routes/mindmap.js +36 -24
  80. package/src/ui/server/routes/monitor.js +44 -35
  81. package/src/ui/server/routes/remoteRepos.js +57 -48
  82. package/src/ui/server/routes/workbench/agentChat.js +94 -10
  83. package/src/ui/server/routes/workbench/agentChatContext.test.js +171 -0
  84. package/src/ui/server/routes/workbench/agentChatShared.test.js +33 -3
  85. package/src/ui/server/routes/workbench/agentEngines.js +77 -0
  86. package/src/ui/server/routes/workbench/agentEngines.test.js +65 -0
  87. package/src/ui/server/routes/workbench/agentRoutes.js +208 -48
  88. package/src/ui/server/routes/workbench/agentRoutes.test.js +160 -4
  89. package/src/ui/server/routes/workbench/agentSessionStore.js +3 -0
  90. package/src/ui/server/routes/workbench/envContext.js +61 -64
  91. package/src/ui/server/routes/workbench/envContext.test.js +133 -63
  92. package/src/ui/server/routes/workbench/index.js +78 -32
  93. package/src/ui/server/routes/workbench/runExternalTurn.js +279 -0
  94. package/src/ui/server/routes/workbench/runExternalTurn.test.js +275 -0
  95. package/src/ui/server/routes/workbench/shared.js +17 -0
  96. package/src/ui/server/utils/agentAttachments.js +169 -0
  97. package/src/ui/server/utils/agentAttachments.test.js +140 -0
  98. package/src/ui/public/assets/AgentView-DOyN97Ve.js +0 -5
  99. package/src/ui/public/assets/AgentView-DkM2KXuc.css +0 -1
  100. package/src/ui/public/assets/AppVersionBadge-CJrDr7GV.css +0 -1
  101. package/src/ui/public/assets/AppVersionBadge-oON3sAj4.js +0 -2
  102. package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +0 -1
  103. package/src/ui/public/assets/BranchSelector-Cpj7eGqY.js +0 -1
  104. package/src/ui/public/assets/CommitForm-CQ0K8Yy2.css +0 -1
  105. package/src/ui/public/assets/CommitForm-D_ovYhVw.js +0 -6
  106. package/src/ui/public/assets/CommonDialog-ChZbqfRF.js +0 -1
  107. package/src/ui/public/assets/CommonDialog-jP0E9mco.css +0 -1
  108. package/src/ui/public/assets/EditorView-BPzpI0GV.js +0 -1
  109. package/src/ui/public/assets/EditorView-DZMLvQds.css +0 -1
  110. package/src/ui/public/assets/FileDiffViewer-B9EkuKdF.css +0 -1
  111. package/src/ui/public/assets/FileDiffViewer-B_qEKA9L.js +0 -15
  112. package/src/ui/public/assets/FlowOrchestrationWorkspace-C7yTD1gj.js +0 -21
  113. package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +0 -1
  114. package/src/ui/public/assets/IconButton-DEARmMkZ.js +0 -1
  115. package/src/ui/public/assets/IconButton-tjkOjzkC.css +0 -1
  116. package/src/ui/public/assets/LogList-BA_gowxE.js +0 -7
  117. package/src/ui/public/assets/LogList-BlJplIi1.css +0 -1
  118. package/src/ui/public/assets/MindmapView-8X6z245q.css +0 -1
  119. package/src/ui/public/assets/MindmapView-K-XcLLXr.js +0 -2
  120. package/src/ui/public/assets/MonitorView-C0RL5K9W.js +0 -1
  121. package/src/ui/public/assets/MonitorView-DetlFFXq.css +0 -1
  122. package/src/ui/public/assets/ProjectStartupButton-DC36_vvU.js +0 -1
  123. package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +0 -1
  124. package/src/ui/public/assets/RemoteManagerDialog-BauAeH2w.css +0 -1
  125. package/src/ui/public/assets/RemoteManagerDialog-Bkqja660.js +0 -4
  126. package/src/ui/public/assets/RemoteRepoCard-Bsm7cj_H.js +0 -7
  127. package/src/ui/public/assets/RemoteRepoCard-CG-jARTe.css +0 -1
  128. package/src/ui/public/assets/SourceMapView-CN4Hkyp4.css +0 -1
  129. package/src/ui/public/assets/SourceMapView-Y5NKdIk6.js +0 -3
  130. package/src/ui/public/assets/SvgIcon-D_mFHXP4.js +0 -1
  131. package/src/ui/public/assets/TemplateManager-BzKVXZhy.js +0 -1
  132. package/src/ui/public/assets/TemplateManager-DMuG4i3G.css +0 -1
  133. package/src/ui/public/assets/UserInputNode-BPLK4aYx.js +0 -1
  134. package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +0 -1
  135. package/src/ui/public/assets/WorkbenchView-B1BJhWHi.css +0 -1
  136. package/src/ui/public/assets/WorkbenchView-xbGdiVMQ.js +0 -56
  137. package/src/ui/public/assets/element-plus-Cwwq-Gje.css +0 -1
  138. package/src/ui/public/assets/flow-mindmap-BqFb1WO5.js +0 -139
  139. package/src/ui/public/assets/gitStore-BZo9_zyy.js +0 -6
  140. package/src/ui/public/assets/index-CHuCYLWm.js +0 -111
  141. package/src/ui/public/assets/index-CmbatbSR.css +0 -1
  142. package/src/ui/public/assets/previewSandbox-BTTTXooN.js +0 -0
  143. package/src/ui/public/assets/previewSandbox-CgdwPcuW.css +0 -1
  144. package/src/ui/public/assets/vue-flow-fLUQ0_kE.js +0 -39
@@ -0,0 +1,191 @@
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
+ // 1. 只有**终态**才触发(running/pending 期间疯狂刷快照会把机器拖垮);
19
+ // 2. 同一次结束只刷一次(取消一个任务会先后收到两条 cancelled —— cancel 路由的
20
+ // 立即反馈 + 进程退出后 taskRunner 的 finally);
21
+ // 3. 刷新是 **fire-and-forget**:事件处理函数不 await,任务收尾逻辑绝不被拖住;
22
+ // 4. 取不到生成器 / 刷新抛错都**不抛给调用方**(任务已经结束了,刷不动快照
23
+ // 不该反过来影响任何东西)。
24
+
25
+ import { test } from 'node:test';
26
+ import assert from 'node:assert/strict';
27
+
28
+ import {
29
+ createJobSettledRefresher,
30
+ JOB_SETTLED_SECTIONS,
31
+ TERMINAL_JOB_STATUSES,
32
+ SEEN_TERMINAL_LIMIT,
33
+ } from './jobRefresh.js';
34
+
35
+ /** 假快照生成器:记下每次刷新调用,便于断言 */
36
+ function fakeSnapshotter({ fail = false } = {}) {
37
+ const calls = [];
38
+ return {
39
+ calls,
40
+ refreshSections(sections, opts) {
41
+ calls.push({ sections, opts });
42
+ if (fail) return Promise.reject(new Error('boom'));
43
+ return Promise.resolve({ ok: true });
44
+ },
45
+ };
46
+ }
47
+
48
+ const evt = (status, id = 'j1') => ({ event: 'job:update', payload: { id, status }, ts: 'x' });
49
+
50
+ /** 等一轮微任务 + 一轮宏任务,让 fire-and-forget 的那条链跑完 */
51
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
52
+
53
+ test('终态集合:只有 done / cancelled / error 算"执行结束"', () => {
54
+ assert.deepEqual([...TERMINAL_JOB_STATUSES].sort(), ['cancelled', 'done', 'error']);
55
+ assert.equal(TERMINAL_JOB_STATUSES.has('running'), false);
56
+ assert.equal(TERMINAL_JOB_STATUSES.has('pending'), false);
57
+ });
58
+
59
+ test('非终态与无关事件都不触发刷新', () => {
60
+ const snap = fakeSnapshotter();
61
+ const handle = createJobSettledRefresher({ getSnapshotter: () => snap });
62
+
63
+ assert.equal(handle(evt('running')), false);
64
+ assert.equal(handle(evt('pending')), false);
65
+ // 别的事件类型(同一总线还跑着 job:output-delta / job:toolcalls / hello …)
66
+ assert.equal(handle({ event: 'job:output-delta', payload: { id: 'j1' } }), false);
67
+ assert.equal(handle({ event: 'hello', payload: {} }), false);
68
+ // 脏数据不该把监听器打挂
69
+ assert.equal(handle(null), false);
70
+ assert.equal(handle({}), false);
71
+ assert.equal(handle({ event: 'job:update' }), false);
72
+ assert.equal(handle({ event: 'job:update', payload: {} }), false);
73
+
74
+ assert.equal(snap.calls.length, 0);
75
+ });
76
+
77
+ test('终态触发一次定向 force 刷新,板块清单就是 JOB_SETTLED_SECTIONS', () => {
78
+ const snap = fakeSnapshotter();
79
+ const handle = createJobSettledRefresher({ getSnapshotter: () => snap });
80
+
81
+ assert.equal(handle(evt('done')), true);
82
+ assert.equal(snap.calls.length, 1);
83
+ assert.deepEqual(snap.calls[0].sections, JOB_SETTLED_SECTIONS);
84
+ // 必须 force:{ force: true }。不 force 的话 tasks 的 30s TTL 会把它挡回去,
85
+ // 表现就是"任务刚跑完,g ai 还说它在进行中"。
86
+ assert.deepEqual(snap.calls[0].opts, { force: true });
87
+ // 联网那两块必须在清单里,否则任务顺手推上去的仓库不会进快照
88
+ assert.ok(JOB_SETTLED_SECTIONS.includes('tasks'));
89
+ assert.ok(JOB_SETTLED_SECTIONS.includes('git'));
90
+ assert.ok(JOB_SETTLED_SECTIONS.includes('github'));
91
+ assert.ok(JOB_SETTLED_SECTIONS.includes('gitee'));
92
+ });
93
+
94
+ test('同一次结束只刷一次(取消场景的两条 cancelled 只算一条)', () => {
95
+ const snap = fakeSnapshotter();
96
+ const handle = createJobSettledRefresher({ getSnapshotter: () => snap });
97
+
98
+ // cancel 路由先推一条立即反馈,进程退出后 taskRunner 的 finally 再推一条
99
+ assert.equal(handle(evt('cancelled')), true);
100
+ assert.equal(handle(evt('cancelled')), false);
101
+ assert.equal(snap.calls.length, 1);
102
+
103
+ // 换一个 job 照常触发
104
+ assert.equal(handle(evt('done', 'j2')), true);
105
+ assert.equal(snap.calls.length, 2);
106
+
107
+ // 去重键是 `${id}:${status}`:同一 job 的**另一种**终态算新的一次结束。
108
+ // 现实里能凑出的组合只有 cancelled(cancel 路由的立即反馈)→ cancelled(finally),
109
+ // 已经被上面去重掉了;这里只是把键的语义钉住 —— 别哪天有人改成"按 id 去重"
110
+ // 顺手把"同一 job 先后两个终态"的那次刷新吃掉。
111
+ assert.equal(handle(evt('error', 'j1')), true);
112
+ assert.equal(handle(evt('error', 'j1')), false, '同 id 同 status 再来一次不刷');
113
+ assert.equal(snap.calls.length, 3);
114
+ });
115
+
116
+ test('fire-and-forget:事件处理函数不等刷新完成,也不返回 Promise', () => {
117
+ let resolved = false;
118
+ const snap = {
119
+ refreshSections() {
120
+ return new Promise((resolve) => { setTimeout(() => { resolved = true; resolve(); }, 5); });
121
+ },
122
+ };
123
+ const handle = createJobSettledRefresher({ getSnapshotter: () => snap });
124
+
125
+ const ret = handle(evt('done'));
126
+ assert.equal(ret, true, '同步返回 true,不是 Promise');
127
+ assert.equal(resolved, false, '此刻刷新还没完成 —— 说明监听器没有 await 它');
128
+ });
129
+
130
+ test('生成器还没建好 / 不可用时不抛,只当没触发', () => {
131
+ for (const getSnapshotter of [
132
+ undefined,
133
+ () => null,
134
+ () => ({}), // 有对象但没有 refreshSections
135
+ () => ({ refreshSections: 'not a function' }),
136
+ ]) {
137
+ const handle = createJobSettledRefresher({ getSnapshotter });
138
+ assert.equal(handle(evt('done')), false);
139
+ }
140
+ // 不传 getSnapshotter 也不该炸
141
+ assert.equal(createJobSettledRefresher({})(evt('done')), false);
142
+ });
143
+
144
+ test('刷新失败走 onError,不产生未处理的 rejection', async () => {
145
+ const seen = [];
146
+ const handle = createJobSettledRefresher({
147
+ getSnapshotter: () => fakeSnapshotter({ fail: true }),
148
+ onError: (err) => seen.push(err.message),
149
+ });
150
+
151
+ assert.equal(handle(evt('done')), true);
152
+ await settle();
153
+ assert.deepEqual(seen, ['boom']);
154
+
155
+ // onError 自己也抛的话,同样必须被吞掉(日志失败不能反过来搞挂主链路)
156
+ const handle2 = createJobSettledRefresher({
157
+ getSnapshotter: () => fakeSnapshotter({ fail: true }),
158
+ onError: () => { throw new Error('日志也挂了'); },
159
+ });
160
+ assert.equal(handle2(evt('done')), true);
161
+ await settle();
162
+
163
+ // 不传 onError 时静默 —— 但也不能崩
164
+ const handle3 = createJobSettledRefresher({ getSnapshotter: () => fakeSnapshotter({ fail: true }) });
165
+ assert.equal(handle3(evt('done')), true);
166
+ await settle();
167
+ });
168
+
169
+ test('去重集合有上限:长跑服务里不会无限膨胀', () => {
170
+ const snap = fakeSnapshotter();
171
+ const handle = createJobSettledRefresher({ getSnapshotter: () => snap });
172
+
173
+ for (let i = 0; i < SEEN_TERMINAL_LIMIT + 50; i += 1) {
174
+ handle({ event: 'job:update', payload: { id: `j${i}`, status: 'done' } });
175
+ }
176
+ assert.equal(snap.calls.length, SEEN_TERMINAL_LIMIT + 50, '每一次都该刷(只是去重表有上限)');
177
+
178
+ // 最旧的那条已经被淘汰 → 再来一次会被当成新的。
179
+ // 这是**有意的取舍**:去重表不该为了"永远记得"而无限长,
180
+ // 而重放一条几百次之前结束的 job 事件在现实中不会发生。
181
+ assert.equal(handle({ event: 'job:update', payload: { id: 'j0', status: 'done' } }), true);
182
+ // 但最近的仍然记得
183
+ assert.equal(handle({ event: 'job:update', payload: { id: `j${SEEN_TERMINAL_LIMIT + 49}`, status: 'done' } }), false);
184
+ });
185
+
186
+ test('sections 可覆盖(调用方想刷别的板块时)', () => {
187
+ const snap = fakeSnapshotter();
188
+ const handle = createJobSettledRefresher({ getSnapshotter: () => snap, sections: ['tasks'] });
189
+ handle(evt('done'));
190
+ assert.deepEqual(snap.calls[0].sections, ['tasks']);
191
+ });
@@ -0,0 +1,366 @@
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
+ // 工作区状态快照的**拼装层**(纯函数,无 IO、无副作用,单测直接覆盖)。
16
+ //
17
+ // 为什么分成「一个板块一个文件 + 一份 INDEX」而不是一个大 JSON:
18
+ // 模型看的是"哪一块现在是什么状态"。塞成一个大文件,它每次都得整份读进上下文;
19
+ // 拆开后 INDEX 负责"一眼看全",具体板块才按需读 —— 这才是省 token 的形状。
20
+ //
21
+ // 为什么注入给模型的**只有摘要 + 路径**,不内联正文:
22
+ // 与 envContext.js 同一条理由(见该文件头第 28-29 行):几十条仓库/任务的全文既烧 token,
23
+ // 又会把用户真正要办的那句话淹掉。这里把它固化成契约 —— buildContextBlock 只吃
24
+ // `summary` 字段,body 一行都不进(有单测钉住这条)。
25
+ //
26
+ // ⚠️ 快照最大的风险不是"没有数据",而是**过期数据被当成现状**。
27
+ // 所以每个文件头都写生成时间,注入块的收尾句也点名"这是快照"。少这一句,
28
+ // 模型会拿十分钟前的 git 状态理直气壮地回答"你现在没有未提交改动"。
29
+ //
30
+ // 文案为什么可以是中文硬编码:注入块走双语(用户 locale 可能是 en),而**文件正文**
31
+ // 一律中文 —— 与 envContext.js 第 31-34 行同一条判断:喂给模型的内容不经过前端 $t(),
32
+ // 也不显示在界面上,不该跟着界面语言变。
33
+
34
+ // 快照覆盖的板块。顺序即 INDEX 与注入块里的展示顺序(id 是取数据用的键,不要拿下标当键)。
35
+ //
36
+ // 每个板块两档时间参数 + 一句"实时怎么查":
37
+ //
38
+ // · `ttlMs` —— 软过期。后台自动刷新(面板切换、对话开始)时的判据:没到点就不重跑。
39
+ // · `forceTtlMs` —— 强制刷新时也要遵守的地板。前端"切到某面板就刷一次"会传 force,
40
+ // 但 github/gitee 这种要联网、单条命令 25s 超时的板块不能跟着被刷爆,所以给个地板;
41
+ // 本地板块(读文件、探 git、扫目录)一律 0 —— 它们刷一次是毫秒级,没必要留地板。
42
+ // · `queryHint` —— **要"现在"的值该跑什么命令**。快照天生会旧,所以真正扛实时性的
43
+ // 是这句话而不是快照本身:它被无条件注入(哪怕快照还没生成),模型照着跑就是真值。
44
+ export const AI_CONTEXT_SECTIONS = [
45
+ {
46
+ id: 'git',
47
+ file: 'git-current.md',
48
+ title: '当前项目 Git 状态',
49
+ ttlMs: 15_000,
50
+ forceTtlMs: 0,
51
+ queryHint: 'run_command 跑 `git status --porcelain`(文件级改动清单)、`git status -sb`(分支与领先/落后)、`git remote -v`(远端)。都是本地信息,不联网、秒回',
52
+ },
53
+ {
54
+ id: 'github',
55
+ file: 'remote-github.md',
56
+ title: 'GitHub 仓库',
57
+ ttlMs: 300_000,
58
+ forceTtlMs: 60_000,
59
+ queryHint: 'run_command 跑 `gh repo list --limit 50 --json name,visibility,updatedAt,primaryLanguage`;登录态 `gh auth status`;某仓库详情 `gh repo view <owner/repo>`;PR `gh pr list`。要联网,timeout_seconds 给 30',
60
+ },
61
+ {
62
+ id: 'gitee',
63
+ file: 'remote-gitee.md',
64
+ title: 'Gitee 仓库',
65
+ ttlMs: 300_000,
66
+ forceTtlMs: 60_000,
67
+ queryHint: 'run_command 跑 `gitee repo list --json`(继续用 `--page` 翻页);登录态看 `gitee auth status --json` 的 status 字段 —— **未登录时退出码也是 0**,只看退出码会误判成已登录。要联网,timeout_seconds 给 30',
68
+ },
69
+ {
70
+ id: 'commands',
71
+ file: 'custom-commands.md',
72
+ title: '自定义命令',
73
+ ttlMs: 60_000,
74
+ forceTtlMs: 0,
75
+ queryHint: 'read_file 读下面的 config.json,看 `customCommands` 数组(每条有 name / command / description)',
76
+ },
77
+ {
78
+ id: 'tasks',
79
+ file: 'workbench-tasks.md',
80
+ title: '工作台任务',
81
+ ttlMs: 30_000,
82
+ forceTtlMs: 0,
83
+ queryHint: 'read_file 读下面的 tasks.json 拿全部任务全文;执行记录在 jobs.json;也可以调 list_projects 工具(口径与看板界面完全一致)',
84
+ },
85
+ {
86
+ id: 'system',
87
+ file: 'system.md',
88
+ title: '系统状态',
89
+ ttlMs: 60_000,
90
+ forceTtlMs: 0,
91
+ queryHint: 'run_command 跑 `netstat -ano | findstr LISTENING`(Windows 端口占用)或 `tasklist`;CPU/内存用 `powershell -NoProfile "Get-CimInstance Win32_OperatingSystem | Select-Object FreePhysicalMemory,TotalVisibleMemorySize"`。**别用 Unix 的 top/free/ss**',
92
+ },
93
+ {
94
+ id: 'mindmap',
95
+ file: 'mindmap.md',
96
+ title: '思维导图',
97
+ ttlMs: 60_000,
98
+ forceTtlMs: 0,
99
+ queryHint: 'list_files 扫下面列出的思维导图目录,找 `*.mindmap.json`(这是本项目约定的扩展名)',
100
+ },
101
+ ];
102
+
103
+ export const INDEX_FILE = 'INDEX.md';
104
+
105
+ /** 单条摘要的字符上限。超了当场截断 —— 摘要失控就等于把正文搬进 prompt。 */
106
+ export const MAX_SUMMARY_CHARS = 200;
107
+
108
+ /** 未知板块的默认软过期时间 */
109
+ export const DEFAULT_SECTION_TTL_MS = 60 * 1000;
110
+
111
+ /**
112
+ * 整个注入块的字符上限。
113
+ * 非"硬切"用:超了先压缩摘要,再不行才丢摘要行 —— **实时查询方法一行都不许丢**,
114
+ * 它是唯一能在快照过期时救场的东西(见下方 buildContextBlock)。
115
+ */
116
+ export const MAX_CONTEXT_BLOCK_CHARS = 5000;
117
+
118
+ /** 超限时摘要的压缩档位 */
119
+ const SUMMARY_COMPACT_CHARS = 60;
120
+
121
+ /** 板块 id → 文件名 / 标题 */
122
+ export function sectionMeta(id) {
123
+ return AI_CONTEXT_SECTIONS.find(s => s.id === id) || null;
124
+ }
125
+
126
+ /** 压成单行(换行/多空格归一并去首尾),摘要里不能出现换行 —— 会破坏一行一条的排版 */
127
+ export function oneLine(text) {
128
+ return String(text ?? '').replace(/\s+/g, ' ').trim();
129
+ }
130
+
131
+ /** 单行截断,超长补省略号 */
132
+ export function truncate(text, max = MAX_SUMMARY_CHARS) {
133
+ const s = oneLine(text);
134
+ if (s.length <= max) return s;
135
+ return `${s.slice(0, Math.max(0, max - 1))}…`;
136
+ }
137
+
138
+ /** 生成时间统一本地化展示 —— 模型据此判断新旧 */
139
+ export function formatStamp(date = new Date()) {
140
+ const d = date instanceof Date ? date : new Date(date);
141
+ const pad = n => String(n).padStart(2, '0');
142
+ return (
143
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
144
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
145
+ );
146
+ }
147
+
148
+ /**
149
+ * 把快照目录与板块文件名拼成绝对路径。
150
+ *
151
+ * 为什么需要它:注入块里每行摘要只写了裸文件名(`remote-gitee.md`),目录单独在顶部给一次。
152
+ * 模型要自己把两者拼起来 —— 但**这一步它经常拼错**(拿 cwd 当基准拼成
153
+ * `<项目目录>/remote-gitee.md`,读不到,然后回一句"我看不到"),而这恰恰是整个块
154
+ * 反复叮嘱不要出现的结果。给定分隔符风格由 dir 自身决定(Windows 用 `\`、POSIX 用 `/`),
155
+ * 这样单测跨平台断言也是稳定的。
156
+ */
157
+ export function joinSnapshotPath(dir, file) {
158
+ if (!dir) return file || '';
159
+ const sep = dir.includes('\\') && !dir.includes('/') ? '\\' : '/';
160
+ return `${String(dir).replace(/[\\/]+$/, '')}${sep}${file || ''}`;
161
+ }
162
+
163
+ /** 板块的软过期时间;未知 id 回落到全局默认 */
164
+ export function sectionTtl(id, fallback = DEFAULT_SECTION_TTL_MS) {
165
+ const n = sectionMeta(id)?.ttlMs;
166
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
167
+ }
168
+
169
+ /** 板块强制刷新时的地板间隔;未知 id 回落 0(不让它挡住刷新) */
170
+ export function sectionForceTtl(id) {
171
+ const n = sectionMeta(id)?.forceTtlMs;
172
+ return Number.isFinite(n) && n > 0 ? n : 0;
173
+ }
174
+
175
+ /** 板块的"实时怎么查"提示;未知 id 回空串 */
176
+ export function sectionQueryHint(id) {
177
+ return sectionMeta(id)?.queryHint || '';
178
+ }
179
+
180
+ /**
181
+ * 渲染单个板块文件。
182
+ *
183
+ * @param {object} input
184
+ * @param {string} input.id 板块 id(用来取该板块的实时查询方法)
185
+ * @param {string} input.title
186
+ * @param {string} input.generatedAt 该板块**自己**的采集时间(每个板块独立刷新,不共用)
187
+ * @param {string} [input.scope] 这份快照覆盖什么(如"当前项目 D:\ws\x")
188
+ * @param {string[]} [input.lines] 正文行
189
+ * @param {string} [input.error] 取数失败时的原因
190
+ */
191
+ export function renderSectionFile({ id = '', title, generatedAt, scope = '', lines = [], error = '' } = {}) {
192
+ const head = [`# ${title}`, '', `- 采集时间: ${generatedAt}`];
193
+ if (scope) head.push(`- 覆盖范围: ${scope}`);
194
+ head.push('- ⚠️ 这是**快照**,不是实时值。');
195
+ // 每个文件里都带上"这一块怎么查实时值" —— 模型可能只读这一个文件就去回答
196
+ const hint = sectionQueryHint(id);
197
+ if (hint) head.push(`- 要实时值: ${hint}`);
198
+ head.push('');
199
+
200
+ if (error) {
201
+ // 失败同样落盘。只写"空"会让模型把"取不到"读成"没有"——
202
+ // 这正是 agentChat 系统提示里为 gh/gitee 反复叮嘱的那个坑。
203
+ return [...head, `取数失败: ${oneLine(error)}`, '', '(不要据此推断"没有内容",请如实告诉用户这一块暂时取不到。)', ''].join('\n');
204
+ }
205
+
206
+ const body = (Array.isArray(lines) ? lines : []).map(l => String(l ?? ''));
207
+ return [...head, ...body, ''].join('\n');
208
+ }
209
+
210
+ /**
211
+ * 渲染 INDEX.md = 各板块的摘要总览。
212
+ * 每个板块标各自的采集时间 —— 各板块是独立刷新的,共用一个时间会撒谎。
213
+ *
214
+ * @param {object} input
215
+ * @param {string} [input.generatedAt] 本次汇总时间
216
+ * @param {object[]} input.results [{ id, title, file, summary, ok, error, collectedAt }]
217
+ */
218
+ export function renderIndexFile({ generatedAt = '', results = [] } = {}) {
219
+ const lines = ['# 工作区状态快照 · INDEX', ''];
220
+ if (generatedAt) lines.push(`- 本次汇总: ${generatedAt}`);
221
+ lines.push('- ⚠️ 以下均为**快照**,各板块采集时间不同,注意看各自的时间;需要实时值请照每块给的方法自己查。', '');
222
+ lines.push('## 各板块摘要', '');
223
+ for (const r of results) {
224
+ const name = r.title || r.id;
225
+ const at = r.collectedAt ? `[${r.collectedAt}] ` : '';
226
+ const text = r.ok ? truncate(r.summary || '(无内容)') : `取数失败: ${truncate(r.error || '未知原因', 120)}`;
227
+ lines.push(`- ${at}${name}(${r.file}): ${text}`);
228
+ }
229
+ lines.push('');
230
+ lines.push('## 实时查询方法', '');
231
+ for (const r of results) {
232
+ const hint = sectionQueryHint(r.id);
233
+ if (hint) lines.push(`- ${r.title || r.id}: ${hint}`);
234
+ }
235
+ lines.push('');
236
+ return lines.join('\n');
237
+ }
238
+
239
+ /**
240
+ * 拼注入给模型的上下文块。
241
+ *
242
+ * 这个块的结构按**重要性**排,不是按阅读顺序:
243
+ * ① 实时查询方法 —— **无条件注入、绝不截断**。快照天生会旧,这一节才是"实时"的保证:
244
+ * 它告诉模型每一块该跑什么命令,照着跑拿到的就是真值。冷启动(快照还没生成)时,
245
+ * 整个块就只剩这一节 + 目录路径,依然有用。
246
+ * ② 各板块摘要 + **各自**的采集时间 —— 有就带上,让模型知道哪块新鲜哪块旧。
247
+ * 各板块独立刷新,所以时间必须逐个标;共用一个时间就是在撒谎。
248
+ * ③ 真相源文件路径 —— 要细节自己去读。
249
+ *
250
+ * **只输出摘要与路径**,板块正文一律不进 —— 有单测钉住(见 render.test.js)。
251
+ *
252
+ * @param {object} input
253
+ * @param {string} [input.generatedAt] 本次汇总时间
254
+ * @param {object[]} [input.results] [{ id, title, file, summary, ok, error, collectedAt }]
255
+ * @param {string} [input.dirPath] 快照目录绝对路径
256
+ * @param {object} [input.truthFiles] { tasksFile, jobsFile, orchestratorFile, configFile }
257
+ * 真相源路径(清单见 workbench/shared.js 的 TRUTH_FILES)
258
+ * @param {string} [input.locale] 'zh' | 'en'
259
+ * @returns {string} 永远不会是空串 —— 至少要给出实时查询方法
260
+ */
261
+ export function buildContextBlock({
262
+ generatedAt = '',
263
+ results = [],
264
+ dirPath = '',
265
+ truthFiles = {},
266
+ locale = 'zh',
267
+ } = {}) {
268
+ const en = String(locale || '').startsWith('en');
269
+ const list = Array.isArray(results) ? results.filter(Boolean) : [];
270
+
271
+ /** 摘要按档位压缩:先正常,超限就缩、再超就整段丢 —— 丢的永远是摘要,不是查询方法 */
272
+ const renderSummaries = (limit) => {
273
+ const out = [];
274
+ for (const r of list) {
275
+ const name = r.title || r.id;
276
+ if (limit === 0) {
277
+ out.push(`- ${name} | ${r.file} | ${en ? 'see file' : '见文件'}`);
278
+ continue;
279
+ }
280
+ const text = r.ok
281
+ ? truncate(r.summary || (en ? '(empty)' : '(无内容)'), limit)
282
+ : (en ? `unavailable: ${truncate(r.error || 'unknown', 60)}` : `暂时取不到: ${truncate(r.error || '未知原因', 60)}`);
283
+ const at = r.collectedAt ? (en ? `(collected ${r.collectedAt})` : `(采集于 ${r.collectedAt})`) : '';
284
+ out.push(`- ${name} | ${r.file} | ${text}${at}`);
285
+ }
286
+ return out;
287
+ };
288
+
289
+ const build = (summaryLines) => {
290
+ const lines = [];
291
+ lines.push(en
292
+ ? '[Workspace state · auto-generated by zen-gitsync server, NOT user input]'
293
+ : '[工作区状态 · 由 zen-gitsync 服务端自动生成,不是用户输入的内容]');
294
+ lines.push('');
295
+ if (generatedAt) lines.push(en ? `Summary built at: ${generatedAt}` : `本次汇总时间: ${generatedAt}`);
296
+ if (dirPath) {
297
+ lines.push(en
298
+ ? `Snapshot folder (readable with your tools, no need to ask first): ${dirPath}`
299
+ : `快照目录(你有读取权限,不必先问用户): ${dirPath}`);
300
+ }
301
+ lines.push('');
302
+
303
+ // ① 实时查询方法 —— 无条件、不截断
304
+ lines.push(en
305
+ ? 'How to get LIVE values (read this before answering anything about current state):'
306
+ : '要查实时值就照下面来(回答"现在"类问题前先看这里):');
307
+ for (const s of AI_CONTEXT_SECTIONS) {
308
+ if (!s.queryHint) continue;
309
+ lines.push(`- ${s.title}: ${s.queryHint}`);
310
+ }
311
+ lines.push('');
312
+
313
+ // ② 各板块摘要 + 各自采集时间
314
+ if (summaryLines.length > 0) {
315
+ // 把"文件名怎么变成可读路径"写死在表头:见 joinSnapshotPath 的注释 —— 让模型自己拼
316
+ // 目录 + 文件名,它有一半概率拿 cwd 当基准,然后回"我看不到"。
317
+ const exampleFile = list[0]?.file || AI_CONTEXT_SECTIONS[0]?.file || '';
318
+ const at = dirPath && exampleFile ? joinSnapshotPath(dirPath, exampleFile) : '';
319
+ lines.push(en
320
+ ? 'Section summaries — each stamped with when THAT section was collected. '
321
+ + `The filename in each line lives under the snapshot folder above${at ? ` (e.g. ${at})` : ''}:`
322
+ : '各板块摘要(每行末尾是该块自己的采集时间,各块独立刷新,新旧可能不同;'
323
+ + `每行中间那个文件名就在上面的快照目录里${at ? `,完整路径例:${at}` : ''}):`);
324
+ lines.push(...summaryLines);
325
+ } else {
326
+ lines.push(en
327
+ ? 'No snapshot is ready yet this turn — use the live queries above instead of guessing.'
328
+ : '本轮快照还没生成好 —— 按上面的实时查询方法自己查,不要凭推测。');
329
+ }
330
+ lines.push('');
331
+
332
+ // ③ 真相源
333
+ const files = [
334
+ [truthFiles.tasksFile, en ? 'all workbench tasks (full text)' : '全部工作台任务全文'],
335
+ [truthFiles.jobsFile, en ? 'execution records' : '历次执行记录'],
336
+ // 编排台指令流水:与 envContext(派发任务时的运行环境块)报的是同一份清单 ——
337
+ // 两个页面各自承认哪些文件存在,不该有差别,否则同一句提问会得到不同答案。
338
+ [truthFiles.orchestratorFile, en ? 'instructions you sent from the dispatch console' : '用户在调度台发过的指令流水'],
339
+ [truthFiles.configFile, en ? 'app config (customCommands / recentDirectories)' : '应用配置(customCommands / recentDirectories)'],
340
+ ].filter(([p]) => p);
341
+ if (files.length > 0) {
342
+ lines.push(en ? 'Truth-source files (you have read access):' : '真相源文件(你读得到,不必先问):');
343
+ for (const [p, desc] of files) lines.push(`- ${p} —— ${desc}`);
344
+ lines.push('');
345
+ }
346
+
347
+ lines.push(en
348
+ ? 'Anything in the summaries is a SNAPSHOT, not live. For anything that must be current right now '
349
+ + '(can I pull? any uncommitted changes?), run the command yourself before answering.'
350
+ : '摘要里的东西全是**快照**,不是实时值。凡是要"此刻"才作数的(能不能 pull、有没有未提交改动),'
351
+ + '先自己跑命令核实再回答。');
352
+ lines.push('');
353
+ lines.push(en
354
+ ? 'When asked about any section: read the matching file (or run the live query) first, then answer. '
355
+ + 'Do not guess, and do not answer "I cannot see it / cannot access it".'
356
+ : '问到上面任何一块时,先读对应文件(或直接照实时查询方法跑)再回答;'
357
+ + '不要凭推测,也不要回答"我看不到 / 无法访问"。');
358
+ return lines.join('\n');
359
+ };
360
+
361
+ // 先按正常摘要长度拼;超限就逐档压缩,保证查询方法那一段永远在
362
+ let block = build(renderSummaries(MAX_SUMMARY_CHARS));
363
+ if (block.length > MAX_CONTEXT_BLOCK_CHARS) block = build(renderSummaries(SUMMARY_COMPACT_CHARS));
364
+ if (block.length > MAX_CONTEXT_BLOCK_CHARS) block = build(renderSummaries(0));
365
+ return block;
366
+ }