zen-gitsync 2.16.51 → 2.17.1
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/package.json +12 -2
- package/src/cli/ai/agent.js +2 -2
- package/src/cli/ai/images.js +5 -3
- package/src/cli/ai/modelSetup.js +1 -1
- package/src/cli/ai/sessionStore.js +4 -6
- package/src/config.js +178 -39
- package/src/gitCommit.js +8 -0
- package/src/ui/public/assets/AgentView-BsN9y4Kg.css +1 -0
- package/src/ui/public/assets/AgentView-YDATCFcA.js +4 -0
- package/src/ui/public/assets/AppVersionBadge-Bw4-tHkL.js +2 -0
- package/src/ui/public/assets/{AppVersionBadge-CJrDr7GV.css → AppVersionBadge-D5tJzJfa.css} +1 -1
- package/src/ui/public/assets/BranchSelector-BZTHIIaF.js +1 -0
- package/src/ui/public/assets/BranchSelector-T_rJm-JY.css +1 -0
- package/src/ui/public/assets/CommitForm-DKedPnu-.js +6 -0
- package/src/ui/public/assets/CommitForm-Oij8R6P-.css +1 -0
- package/src/ui/public/assets/CommonDialog-C_RjF9Mp.css +1 -0
- package/src/ui/public/assets/CommonDialog-OCekbVQM.js +1 -0
- package/src/ui/public/assets/EditorView-BgA-_8WV.css +1 -0
- package/src/ui/public/assets/EditorView-C5O3ihKq.js +1 -0
- package/src/ui/public/assets/FlowExecutionViewer-B5kh7VxS.css +1 -0
- package/src/ui/public/assets/FlowExecutionViewer-vIvDP419.js +1 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-BAQDU9Jd.css +1 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-SlVXZt60.js +21 -0
- package/src/ui/public/assets/LogList-DlPJsPy_.js +7 -0
- package/src/ui/public/assets/LogList-DlWwSZet.css +1 -0
- package/src/ui/public/assets/MindmapView-B1Go82pJ.js +1 -0
- package/src/ui/public/assets/MindmapView-CS2t30Ko.css +1 -0
- package/src/ui/public/assets/MonitorView-CphDOlQu.css +1 -0
- package/src/ui/public/assets/MonitorView-GQ34uXPs.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-0UNF9CeP.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-B7zvqRj_.css +1 -0
- package/src/ui/public/assets/RemoteManagerDialog-D51q3x7T.js +1 -0
- package/src/ui/public/assets/RemoteManagerDialog-bpwaVAvX.css +1 -0
- package/src/ui/public/assets/RemoteRepoCard-Bin9d3GZ.js +1 -0
- package/src/ui/public/assets/RemoteRepoCard-CDykPlPj.css +1 -0
- package/src/ui/public/assets/SourceMapView-DG_a_076.css +1 -0
- package/src/ui/public/assets/SourceMapView-OxlKvnEc.js +3 -0
- package/src/ui/public/assets/{SvgIcon-CDNsk4-Q.css → SvgIcon-BQ6bR7l0.css} +1 -1
- package/src/ui/public/assets/SvgIcon-BpKq_phu.js +1 -0
- package/src/ui/public/assets/UserInputNode-D2EDkZ_H.js +1 -0
- package/src/ui/public/assets/UserInputNode-DDtvv9Iq.css +1 -0
- package/src/ui/public/assets/WorkbenchView-DWMdGe8g.css +1 -0
- package/src/ui/public/assets/WorkbenchView-DyhIOjGD.js +13 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-N7dqbp6x.js → _plugin-vue_export-helper-BaSe6lmR.js} +3 -3
- package/src/ui/public/assets/configStore-CyAQ0a-U.js +1 -0
- package/src/ui/public/assets/element-plus-H7kW-grr.js +19 -0
- package/src/ui/public/assets/{flow-mindmap-wqtwJcDW.js → flow-mindmap-DEwSVurC.js} +12 -12
- package/src/ui/public/assets/index-BXdqTR9A.css +1 -0
- package/src/ui/public/assets/index-SjRTI8Of.js +46 -0
- package/src/ui/public/assets/office-docx-C67al1tG.js +31 -0
- package/src/ui/public/assets/{office-excel-5lGzd5Yy.js → office-excel-ffLec5k6.js} +1 -1
- package/src/ui/public/assets/office-pptx-vRGbnHIO.js +60 -0
- package/src/ui/public/assets/toolsStore-BcJP9HmK.js +1 -0
- package/src/ui/public/assets/{vendor-C6ntdXkR.js → vendor-Dwln8rZH.js} +39 -46
- package/src/ui/public/assets/vue-flow-BfBE1dKN.js +39 -0
- package/src/ui/public/index.html +13 -13
- package/src/ui/server/index.js +9 -0
- package/src/ui/server/routes/config.js +9 -6
- package/src/ui/server/routes/git/aiDiffSummary.js +2 -2
- package/src/ui/server/routes/gitOps.js +63 -7
- package/src/ui/server/routes/workbench/agentSessionStore.js +6 -9
- package/src/ui/server/routes/workbench/attachmentUtils.js +93 -0
- package/src/ui/server/routes/workbench/attachmentUtils.test.js +100 -0
- package/src/ui/server/routes/workbench/envContext.js +137 -0
- package/src/ui/server/routes/workbench/envContext.test.js +178 -0
- package/src/ui/server/routes/workbench/index.js +210 -8
- package/src/ui/server/routes/workbench/orchestratorStore.js +6 -0
- package/src/ui/server/routes/workbench/pathParity.test.js +73 -0
- package/src/ui/server/routes/workbench/projectRegistry.js +37 -16
- package/src/ui/server/routes/workbench/projectRegistry.test.js +62 -17
- package/src/ui/server/routes/workbench/shared.js +7 -2
- package/src/ui/server/routes/workbench/targetResolver.js +260 -0
- package/src/ui/server/routes/workbench/targetResolver.test.js +224 -0
- package/src/ui/server/routes/workbench/taskRunner.js +42 -0
- package/src/ui/server/utils/gitExitCode.js +116 -0
- package/src/ui/server/utils/gitExitCode.test.js +108 -0
- package/src/ui/server/utils/instanceRegistry.js +17 -6
- package/src/ui/public/assets/AgentView-8-xsOmE6.css +0 -1
- package/src/ui/public/assets/AgentView-DcTGw8-j.js +0 -4
- package/src/ui/public/assets/AppVersionBadge-CA5-POGA.js +0 -2
- package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +0 -1
- package/src/ui/public/assets/BranchSelector-qn-iGHOI.js +0 -1
- package/src/ui/public/assets/CommitForm-CQ0K8Yy2.css +0 -1
- package/src/ui/public/assets/CommitForm-DHfyJ331.js +0 -6
- package/src/ui/public/assets/CommonDialog-DJBixJcI.js +0 -1
- package/src/ui/public/assets/CommonDialog-jP0E9mco.css +0 -1
- package/src/ui/public/assets/EditorView-BRWoa7--.js +0 -1
- package/src/ui/public/assets/EditorView-DZMLvQds.css +0 -1
- package/src/ui/public/assets/FlowExecutionViewer-YoPgld-R.js +0 -1
- package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-DyEjlCBW.js +0 -21
- package/src/ui/public/assets/LogList-BlJplIi1.css +0 -1
- package/src/ui/public/assets/LogList-_z4dIgvS.js +0 -7
- package/src/ui/public/assets/MindmapView-BQTqx842.css +0 -1
- package/src/ui/public/assets/MindmapView-BrkcAcDh.js +0 -2
- package/src/ui/public/assets/MonitorView-8TG_K4m3.js +0 -1
- package/src/ui/public/assets/MonitorView-DetlFFXq.css +0 -1
- package/src/ui/public/assets/ProjectStartupButton-BqLyrik7.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-LOVV6KrB.js +0 -4
- package/src/ui/public/assets/RemoteRepoCard-CG-jARTe.css +0 -1
- package/src/ui/public/assets/RemoteRepoCard-SQyKcXDG.js +0 -7
- package/src/ui/public/assets/SourceMapView-BNRjvgzH.css +0 -1
- package/src/ui/public/assets/SourceMapView-DckLynaL.js +0 -3
- package/src/ui/public/assets/SvgIcon-CvIX1KL7.js +0 -1
- package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +0 -1
- package/src/ui/public/assets/UserInputNode-Bc124-BK.js +0 -1
- package/src/ui/public/assets/WorkbenchView-BtQkd9Oy.js +0 -37
- package/src/ui/public/assets/WorkbenchView-FdhmMI-b.css +0 -1
- package/src/ui/public/assets/configStore-NaVpBxPo.js +0 -1
- package/src/ui/public/assets/element-plus-Csu3CJKj.js +0 -39
- package/src/ui/public/assets/index-Ci3lbjk2.css +0 -1
- package/src/ui/public/assets/index-D9u0mCSi.js +0 -98
- package/src/ui/public/assets/office-docx-BKrAsewH.js +0 -32
- package/src/ui/public/assets/office-pptx-DKtkU_7r.js +0 -74
- package/src/ui/public/assets/toolsStore-CNDLLgUG.js +0 -1
- package/src/ui/public/assets/vue-flow-D7IxiTyP.js +0 -39
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
//
|
|
28
28
|
// 设计要点:
|
|
29
29
|
// - 路径归一与前端 utils/path.ts 的 canonicalProjectPath 必须**逐字一致**
|
|
30
|
-
//
|
|
30
|
+
// (Windows 形式转小写 + 斜杠归一);两边口径一旦分叉,
|
|
31
31
|
// 同一个目录会在两侧落到不同 key 上,表现为项目凭空分裂成两个。
|
|
32
32
|
// - 任务落在哪一列由 deriveTaskColumn 统一推导,是纯函数、单测覆盖;
|
|
33
33
|
// 前端不重复实现这套规则,避免两边口径漂移。
|
|
@@ -36,27 +36,41 @@
|
|
|
36
36
|
import { probeDirectoryGitStates } from '../../utils/directoryGitState.js';
|
|
37
37
|
|
|
38
38
|
/** 看板列,数组顺序即列顺序 */
|
|
39
|
-
export const TASK_COLUMNS = ['todo', 'doing', '
|
|
39
|
+
export const TASK_COLUMNS = ['todo', 'doing', 'done'];
|
|
40
40
|
|
|
41
41
|
/** 没有关联任何项目的任务归到这个 key(与前端 useWorkbenchProjectGroups 一致) */
|
|
42
42
|
export const NO_PROJECT_KEY = '__no_project__';
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* 项目路径归一。与前端 src/ui/client/src/utils/path.ts 的 canonicalProjectPath 同口径:
|
|
46
|
-
* Windows 形式(带盘符)→
|
|
46
|
+
* Windows 形式(带盘符)→ 转小写 + 反斜杠归一;其余(POSIX)原样返回。
|
|
47
47
|
*
|
|
48
48
|
* 为什么必须归斜杠:常用目录允许用户手输,同一个目录会同时存在 `D:/ws/proj`
|
|
49
49
|
* 与 `D:\ws\proj` 两种写法。文件系统不区分,字符串比较却会 ——
|
|
50
50
|
* 于是项目列表里出现两个同名项目(实测 article-generator 就是如此)。
|
|
51
51
|
* 归一是修一个看得见的重复项,不是顺手清理。
|
|
52
52
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
53
|
+
* 为什么必须归**大小写**(2026-09-18 补,同一类问题的第二张脸):
|
|
54
|
+
* 只归斜杠治不了目录段的大小写。`recentDirectories` 存的是用户输入时的**原始写法**
|
|
55
|
+
* (`saveRecentDirectory` 只用 normalizeProjectPath 做去重比较,落盘仍写原串),
|
|
56
|
+
* 而任务的 `projectPath` 来自别处 —— 于是同一个目录会以 `c:\users\xuze3`
|
|
57
|
+
* (常用目录里手输的小写盘符)和 `C:\Users\xuze3`(任务带出来的)两种形态并存,
|
|
58
|
+
* 拿到两个 key、渲染成两条项目行。
|
|
59
|
+
*
|
|
60
|
+
* 关键在于**小写化必须做进 key 本身**,而不是在每处比较之前各自归一:key 会被拿去
|
|
61
|
+
* `stats.get(key)` 精确查表、比 `← 当前` 标记、比派发目标、比前端选中的项目 ——
|
|
62
|
+
* 只要有一处忘了先归一,同一目录就又会裂成两条。统一收在这里,调用方拿到的 key
|
|
63
|
+
* 天然可跨写法相等比较。(Windows 文件系统不区分大小写,小写化不会把两个真目录并成一个;
|
|
64
|
+
* 与之对应的 config.js 项目键 `normalizeProjectPath` 也是全小写的,方向一致。)
|
|
65
|
+
*
|
|
66
|
+
* 刻意**不**做去尾斜杠 / 展开 `..` / 解析符号链接:那会把比较规则变得依赖文件系统语义。
|
|
67
|
+
* 小写化是这条底线上唯一的例外,但它是**纯字符串**操作(只看 `X:` 这个形状,不看
|
|
68
|
+
* process.platform),没有引入任何 fs 依赖 —— 底线还在。
|
|
55
69
|
*/
|
|
56
70
|
export function canonicalProjectPath(p) {
|
|
57
71
|
const s = String(p || '').trim();
|
|
58
72
|
if (!/^[a-zA-Z]:/.test(s)) return s;
|
|
59
|
-
return s.replace(
|
|
73
|
+
return s.replace(/\//g, '\\').toLowerCase();
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
/** 目录名(项目显示名),路径为空时返回空串而不是兜底文案(文案归前端 i18n 管) */
|
|
@@ -116,14 +130,14 @@ export function latestJob(jobsForTask) {
|
|
|
116
130
|
* 1. 有 job 处于 running/pending,或子任务状态是 running → `doing`
|
|
117
131
|
* 2. 有子任务且全部 done → `done`
|
|
118
132
|
* 3. 有子任务但没全 done:
|
|
119
|
-
* - 动过(有 done 的子任务 / 执行过 / 有 error 的子任务)→ `
|
|
133
|
+
* - 动过(有 done 的子任务 / 执行过 / 有 error 的子任务)→ `todo`
|
|
120
134
|
* - 完全没动过 → `todo`
|
|
121
135
|
* 4. 没子任务(简单任务,或还没拆分的复杂任务):
|
|
122
136
|
* - 从没执行过 → `todo`
|
|
123
|
-
* - 最近一条 job 是 done → `done`;其余终态(error/cancelled)→ `
|
|
137
|
+
* - 最近一条 job 是 done → `done`;其余终态(error/cancelled)→ `todo`
|
|
124
138
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
139
|
+
* error/cancelled 不再单列:列表示任务是否仍待处理,错误是这一轮执行的结果,
|
|
140
|
+
* 具体错误继续通过卡片标记和任务详情展示。
|
|
127
141
|
*/
|
|
128
142
|
export function deriveTaskColumn(task, jobsForTask = []) {
|
|
129
143
|
const subs = Array.isArray(task && task.subtasks) ? task.subtasks : [];
|
|
@@ -135,19 +149,18 @@ export function deriveTaskColumn(task, jobsForTask = []) {
|
|
|
135
149
|
if (subs.length > 0) {
|
|
136
150
|
const doneCount = subs.filter(s => s && s.status === 'done').length;
|
|
137
151
|
if (doneCount === subs.length) return 'done';
|
|
138
|
-
|
|
139
|
-
return touched ? 'review' : 'todo';
|
|
152
|
+
return 'todo';
|
|
140
153
|
}
|
|
141
154
|
|
|
142
155
|
if (jobs.length === 0) return 'todo';
|
|
143
156
|
const last = latestJob(jobs);
|
|
144
|
-
return last && last.status === 'done' ? 'done' : '
|
|
157
|
+
return last && last.status === 'done' ? 'done' : 'todo';
|
|
145
158
|
}
|
|
146
159
|
|
|
147
160
|
/** 空统计,避免各处手写零值 */
|
|
148
161
|
function emptyStats() {
|
|
149
162
|
return {
|
|
150
|
-
total: 0, todo: 0, doing: 0,
|
|
163
|
+
total: 0, todo: 0, doing: 0, done: 0,
|
|
151
164
|
progress: 0, runningJobs: 0, errorSubtasks: 0, lastActiveAt: null,
|
|
152
165
|
};
|
|
153
166
|
}
|
|
@@ -203,7 +216,13 @@ export function summarizeProjectTasks(tasks, jobs) {
|
|
|
203
216
|
|
|
204
217
|
/**
|
|
205
218
|
* 合并最近目录与任务里的 projectPath,得到去重后的项目条目。
|
|
206
|
-
* path 取"首次出现的写法"
|
|
219
|
+
* path 取"首次出现的写法"(先最近目录内部按原顺序、再任务),前端可以直接拿它跟其它列表对齐。
|
|
220
|
+
*
|
|
221
|
+
* ⚠️ "首次出现"要显式实现,不能靠 `map.set` 覆盖:常用目录里同一个目录
|
|
222
|
+
* 出现两种写法是常态(`c:\users\xuze3` 与 `C:\Users\xuze3`),
|
|
223
|
+
* 无脑 set 会让**最后一条**写法覆盖前面那条 —— 既违背上面这句约定
|
|
224
|
+
* (界面上的路径会随常用目录顺序来回跳),也会把先命中过的 fromTasks 抹掉,
|
|
225
|
+
* 让 `source` 从 'both' 退化成 'recent'。
|
|
207
226
|
*
|
|
208
227
|
* @param {{ recentDirs?: string[], tasks?: object[] }} input
|
|
209
228
|
* @returns {Array<{ path: string, key: string, name: string, source: 'recent'|'task'|'both' }>}
|
|
@@ -213,7 +232,9 @@ export function buildProjectEntries({ recentDirs = [], tasks = [] } = {}) {
|
|
|
213
232
|
for (const dir of recentDirs) {
|
|
214
233
|
const key = canonicalProjectPath(dir);
|
|
215
234
|
if (!key) continue;
|
|
216
|
-
|
|
235
|
+
const hit = map.get(key);
|
|
236
|
+
if (hit) hit.inRecent = true; // 同一目录的第二种写法:只并入来源,不覆盖 path
|
|
237
|
+
else map.set(key, { path: String(dir).trim(), key, inRecent: true, fromTasks: false });
|
|
217
238
|
}
|
|
218
239
|
for (const t of tasks) {
|
|
219
240
|
const key = canonicalProjectPath(t && t.projectPath);
|
|
@@ -40,15 +40,27 @@ const job = (id, taskId, status, startedAt, endedAt = null) => ({
|
|
|
40
40
|
|
|
41
41
|
// ── 路径归一 ────────────────────────────────────────────────────────
|
|
42
42
|
|
|
43
|
-
test('canonicalProjectPath:
|
|
44
|
-
assert.equal(canonicalProjectPath('e:\\workspace\\x'), '
|
|
45
|
-
assert.equal(canonicalProjectPath('
|
|
43
|
+
test('canonicalProjectPath: Windows 形式转小写 + 斜杠归一(与前端同口径)', () => {
|
|
44
|
+
assert.equal(canonicalProjectPath('e:\\workspace\\x'), 'e:\\workspace\\x');
|
|
45
|
+
assert.equal(canonicalProjectPath('E:\\Workspace\\X'), 'e:\\workspace\\x');
|
|
46
|
+
assert.equal(canonicalProjectPath(' D:/ws/y '), 'd:\\ws\\y');
|
|
46
47
|
// 同一目录的两种斜杠写法必须落到同一个 key,否则项目列表里出现重复项
|
|
47
48
|
// (实测 article-generator 就同时存在于 D:/... 与 D:\... 两种常用目录里)
|
|
48
49
|
assert.equal(canonicalProjectPath('D:/ws/y'), canonicalProjectPath('D:\\ws\\y'));
|
|
49
|
-
//
|
|
50
|
+
// 目录段大小写也必须归一:常用目录里手输的 c:\users\xuze3 与任务带出的
|
|
51
|
+
// C:\Users\xuze3 是同一个目录,旧口径只转盘符 → 左栏两条同名项目。
|
|
52
|
+
assert.equal(
|
|
53
|
+
canonicalProjectPath('C:\\Users\\xuze3'),
|
|
54
|
+
canonicalProjectPath('c:\\users\\xuze3'),
|
|
55
|
+
);
|
|
56
|
+
// 小写化是**纯字符串**规则,只看 `X:` 这个形状,不看 process.platform ——
|
|
57
|
+
// 所以 Windows 形态的断言在任何平台上都成立(POSIX 上跑测试也守得住)。
|
|
58
|
+
assert.equal(canonicalProjectPath('C:\\Users\\xuze3'), 'c:\\users\\xuze3');
|
|
59
|
+
// POSIX 路径原样返回,**不能**被小写化,也不能被改写成反斜杠:
|
|
60
|
+
// /home/Me/A 与 /home/me/a 是两个真目录,归一就是数据错误
|
|
50
61
|
assert.equal(canonicalProjectPath('/home/me/proj'), '/home/me/proj');
|
|
51
62
|
assert.equal(canonicalProjectPath('/home/me/proj/nested'), '/home/me/proj/nested');
|
|
63
|
+
assert.equal(canonicalProjectPath('/home/Me/A'), '/home/Me/A');
|
|
52
64
|
assert.equal(canonicalProjectPath(''), '');
|
|
53
65
|
assert.equal(canonicalProjectPath(null), '');
|
|
54
66
|
});
|
|
@@ -84,16 +96,16 @@ test('deriveTaskColumn: 有 job 在跑或子任务 running 就是进行中', ()
|
|
|
84
96
|
|
|
85
97
|
test('deriveTaskColumn: 子任务全部完成才算已完成', () => {
|
|
86
98
|
assert.equal(deriveTaskColumn({ subtasks: [sub('s1', 'done'), sub('s2', 'done')] }, []), 'done');
|
|
87
|
-
// 一半完成 →
|
|
88
|
-
assert.equal(deriveTaskColumn({ subtasks: [sub('s1', 'done'), sub('s2', 'todo')] }, []), '
|
|
99
|
+
// 一半完成 → 仍待处理,错误与进度由卡片标记展示
|
|
100
|
+
assert.equal(deriveTaskColumn({ subtasks: [sub('s1', 'done'), sub('s2', 'todo')] }, []), 'todo');
|
|
89
101
|
// 有子任务报错也算"需要人看一眼"
|
|
90
|
-
assert.equal(deriveTaskColumn({ subtasks: [sub('s1', 'error'), sub('s2', 'todo')] }, []), '
|
|
102
|
+
assert.equal(deriveTaskColumn({ subtasks: [sub('s1', 'error'), sub('s2', 'todo')] }, []), 'todo');
|
|
91
103
|
});
|
|
92
104
|
|
|
93
105
|
test('deriveTaskColumn: 无子任务的任务只能看执行记录', () => {
|
|
94
106
|
assert.equal(deriveTaskColumn({ subtasks: [], type: 'simple' }, [job('j1', 't1', 'done', '2026-01-01T00:00:00Z')]), 'done');
|
|
95
|
-
assert.equal(deriveTaskColumn({ subtasks: [], type: 'simple' }, [job('j1', 't1', 'cancelled', '2026-01-01T00:00:00Z')]), '
|
|
96
|
-
assert.equal(deriveTaskColumn({ subtasks: [], type: 'simple' }, [job('j1', 't1', 'error', '2026-01-01T00:00:00Z')]), '
|
|
107
|
+
assert.equal(deriveTaskColumn({ subtasks: [], type: 'simple' }, [job('j1', 't1', 'cancelled', '2026-01-01T00:00:00Z')]), 'todo');
|
|
108
|
+
assert.equal(deriveTaskColumn({ subtasks: [], type: 'simple' }, [job('j1', 't1', 'error', '2026-01-01T00:00:00Z')]), 'todo');
|
|
97
109
|
});
|
|
98
110
|
|
|
99
111
|
test('latestJob 按 startedAt 取最新的一条', () => {
|
|
@@ -107,7 +119,7 @@ test('latestJob 按 startedAt 取最新的一条', () => {
|
|
|
107
119
|
|
|
108
120
|
// ── 项目合并去重 ────────────────────────────────────────────────────
|
|
109
121
|
|
|
110
|
-
test('buildProjectEntries:
|
|
122
|
+
test('buildProjectEntries: 最近目录与任务路径取并集去重,大小写与斜杠都归一', () => {
|
|
111
123
|
const entries = buildProjectEntries({
|
|
112
124
|
recentDirs: ['e:\\ws\\proj-a', 'D:\\ws\\proj-b'],
|
|
113
125
|
tasks: [
|
|
@@ -118,10 +130,43 @@ test('buildProjectEntries: 最近目录与任务路径取并集去重,盘符
|
|
|
118
130
|
});
|
|
119
131
|
const byKey = new Map(entries.map(e => [e.key, e]));
|
|
120
132
|
assert.equal(entries.length, 3);
|
|
121
|
-
assert.equal(byKey.get('
|
|
122
|
-
assert.equal(byKey.get('
|
|
123
|
-
assert.equal(byKey.get('
|
|
124
|
-
assert.equal(byKey.get('
|
|
133
|
+
assert.equal(byKey.get('e:\\ws\\proj-a').source, 'both');
|
|
134
|
+
assert.equal(byKey.get('d:\\ws\\proj-b').source, 'recent');
|
|
135
|
+
assert.equal(byKey.get('d:\\ws\\proj-c').source, 'task');
|
|
136
|
+
assert.equal(byKey.get('d:\\ws\\proj-c').name, 'proj-c');
|
|
137
|
+
// path 保留首次出现的原始写法(前端要拿它跟其它列表对齐、也要显示出来),
|
|
138
|
+
// 只有 key 被归一 —— 别顺手把 path 也小写了
|
|
139
|
+
assert.equal(byKey.get('d:\\ws\\proj-b').path, 'D:\\ws\\proj-b');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('buildProjectEntries: 目录段大小写不同也是同一个项目(旧口径会裂成两条)', () => {
|
|
143
|
+
// 复现现场:常用目录里手输的小写盘符(saveRecentDirectory 落盘原始串)
|
|
144
|
+
// + 任务带出的 C:\Users\xuze3 —— 旧口径只转盘符,这里会得到两条 "xuze3"。
|
|
145
|
+
const entries = buildProjectEntries({
|
|
146
|
+
recentDirs: ['c:\\users\\xuze3'],
|
|
147
|
+
tasks: [{ id: 't1', projectPath: 'C:\\Users\\xuze3' }],
|
|
148
|
+
});
|
|
149
|
+
assert.equal(entries.length, 1, '同一目录的不同大小写写法不应分裂成两个项目');
|
|
150
|
+
assert.equal(entries[0].key, 'c:\\users\\xuze3');
|
|
151
|
+
assert.equal(entries[0].source, 'both', '应当被认成"既在常用目录、又有任务",而不是两个项目');
|
|
152
|
+
assert.equal(entries[0].name, 'xuze3');
|
|
153
|
+
// 项目行只认这个 key 的任务,key 分叉会连带把任务计数拆到两行上去
|
|
154
|
+
const stats = summarizeProjectTasks(
|
|
155
|
+
[{ id: 't1', projectPath: 'C:\\Users\\xuze3', subtasks: [] }],
|
|
156
|
+
[],
|
|
157
|
+
);
|
|
158
|
+
assert.equal(stats.get('c:\\users\\xuze3').total, 1);
|
|
159
|
+
assert.equal(stats.size, 1);
|
|
160
|
+
|
|
161
|
+
// 常用目录里同一个目录也可能历史累积出两种写法。此时只留**首次**那条:
|
|
162
|
+
// 否则界面上的路径会随常用目录的顺序跳变,`source` 也会从 both 退化掉。
|
|
163
|
+
const dup = buildProjectEntries({
|
|
164
|
+
recentDirs: ['C:\\Users\\xuze3', 'c:/users/xuze3'],
|
|
165
|
+
tasks: [{ id: 't1', projectPath: 'c:\\users\\xuze3' }],
|
|
166
|
+
});
|
|
167
|
+
assert.equal(dup.length, 1);
|
|
168
|
+
assert.equal(dup[0].path, 'C:\\Users\\xuze3', 'path 应取首次出现的写法');
|
|
169
|
+
assert.equal(dup[0].source, 'both', '重复的第二种写法不该抹掉已命中的来源');
|
|
125
170
|
});
|
|
126
171
|
|
|
127
172
|
test('summarizeProjectTasks: 按项目汇总列数量、进度、活跃执行与最后活跃时间', () => {
|
|
@@ -139,7 +184,7 @@ test('summarizeProjectTasks: 按项目汇总列数量、进度、活跃执行与
|
|
|
139
184
|
];
|
|
140
185
|
const stats = summarizeProjectTasks(tasks, jobs);
|
|
141
186
|
|
|
142
|
-
const a = stats.get('
|
|
187
|
+
const a = stats.get('d:\\a');
|
|
143
188
|
assert.equal(a.total, 2);
|
|
144
189
|
assert.equal(a.done, 1);
|
|
145
190
|
assert.equal(a.doing, 1);
|
|
@@ -148,7 +193,7 @@ test('summarizeProjectTasks: 按项目汇总列数量、进度、活跃执行与
|
|
|
148
193
|
// 最后活跃时间要把 job 的起止也算进来(任务体本身可能很久没改)
|
|
149
194
|
assert.equal(a.lastActiveAt, '2026-01-04T00:00:00Z');
|
|
150
195
|
|
|
151
|
-
const b = stats.get('
|
|
196
|
+
const b = stats.get('d:\\b');
|
|
152
197
|
assert.equal(b.total, 1);
|
|
153
198
|
assert.equal(b.todo, 1);
|
|
154
199
|
assert.equal(b.progress, 0);
|
|
@@ -163,7 +208,7 @@ test('decorateTaskForBoard: 只回卡片需要的字段', () => {
|
|
|
163
208
|
attachments: [{ id: 'at1' }],
|
|
164
209
|
createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-02T00:00:00Z',
|
|
165
210
|
}, []);
|
|
166
|
-
assert.equal(card.column, '
|
|
211
|
+
assert.equal(card.column, 'todo');
|
|
167
212
|
assert.equal(card.type, 'complex');
|
|
168
213
|
assert.equal(card.subtaskCount, 2);
|
|
169
214
|
assert.equal(card.subtaskDoneCount, 1);
|
|
@@ -25,12 +25,17 @@ import fsp from 'fs/promises';
|
|
|
25
25
|
import path from 'path';
|
|
26
26
|
import os from 'os';
|
|
27
27
|
import logger from '../../utils/logger.js';
|
|
28
|
+
import { DATA_DIR } from '../../../../paths.js';
|
|
28
29
|
|
|
29
30
|
// ── 数据目录与持久化文件路径 ─────────────────────────────────
|
|
30
|
-
// 全部存在用户主目录 ~/.zen-gitsync/
|
|
31
|
-
|
|
31
|
+
// 全部存在用户主目录 ~/.zen-gitsync/ 下,跨项目共享。
|
|
32
|
+
// 目录本身定义在 src/paths.js(全部数据路径的唯一真相源),这里只在其下拼文件名。
|
|
33
|
+
export { DATA_DIR };
|
|
32
34
|
export const PROMPTS_FILE = path.join(DATA_DIR, 'prompts.json');
|
|
33
35
|
export const TASKS_FILE = path.join(DATA_DIR, 'tasks.json');
|
|
36
|
+
// 应用主配置(projects / recentDirectories / models …)。这里只用来把**路径**告诉 Agent,
|
|
37
|
+
// 让它需要时自己读 —— 服务端不在这个流程里解析配置内容。
|
|
38
|
+
export const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
|
34
39
|
export const IMAGES_DIR = path.join(DATA_DIR, 'workbench-images');
|
|
35
40
|
export const INSTRUCTION_FILE = path.join(DATA_DIR, 'ai-instruction.json');
|
|
36
41
|
export const SUBTASK_INSTRUCTION_FILE = path.join(DATA_DIR, 'ai-subtask-instruction.json');
|
|
@@ -0,0 +1,260 @@
|
|
|
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
|
+
// 从「最确定的意图」往下退:
|
|
19
|
+
//
|
|
20
|
+
// 1. explicit —— 调用方明确指定(用户在左栏选中了具体项目)。
|
|
21
|
+
// 显式意图永远最优先,不做任何"更聪明"的覆盖,否则用户会发现
|
|
22
|
+
// 「我明明选了它,却派到别处去了」。
|
|
23
|
+
// 2. mention —— 指令原文里点名的项目(名字或完整路径)。纯字符串匹配,
|
|
24
|
+
// 零延迟零成本,覆盖「给 article-generator 加个功能」这类绝大多数写法。
|
|
25
|
+
// 3. agent —— 交给模型判断。只有前两级都没命中才走这里(要等一次网络往返);
|
|
26
|
+
// 失败、超时、编了个不存在的路径 —— 一律静默降级,绝不阻塞派发。
|
|
27
|
+
// 4. default —— 都没命中,用调用方给的默认项目(应用当前项目)。
|
|
28
|
+
//
|
|
29
|
+
// 返回值里的 source 是给 UI 用的:让界面能如实说明「这个落点是怎么来的」。
|
|
30
|
+
// 判错了的时候,用户至少知道该怪谁 —— 是模型猜的,还是压根没识别出来。
|
|
31
|
+
//
|
|
32
|
+
// 纯逻辑 + 单次可注入的模型调用,单测不碰网络(见 targetResolver.test.js)。
|
|
33
|
+
|
|
34
|
+
import { canonicalProjectPath, projectName } from './projectRegistry.js';
|
|
35
|
+
import { callLlmJson } from './llmClient.js';
|
|
36
|
+
|
|
37
|
+
/** 落点来源。UI 按它选文案,服务端不写死任何面向界面的中文 */
|
|
38
|
+
export const TARGET_SOURCE = {
|
|
39
|
+
EXPLICIT: 'explicit',
|
|
40
|
+
MENTION: 'mention',
|
|
41
|
+
AGENT: 'agent',
|
|
42
|
+
DEFAULT: 'default',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const TARGET_SOURCES = Object.values(TARGET_SOURCE);
|
|
46
|
+
|
|
47
|
+
/** 算作「词内字符」的集合:出现在 token 两侧就说明它只是某个长词的一部分 */
|
|
48
|
+
const WORD_CHAR = /[a-z0-9_-]/i;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 在 text 里找 token 的「成词」出现位置,找不到返回 -1。
|
|
52
|
+
*
|
|
53
|
+
* 为什么非要卡边界:项目名经常互为前缀 —— 清单里同时有 `claw-management`
|
|
54
|
+
* 与 `claw-management-api`、`claw-sdd` 与 `claw-sdd-project`。
|
|
55
|
+
* 光用 includes/indexOf,指令里写 `claw-management-api` 会先命中 `claw-management`,
|
|
56
|
+
* 改动就落到隔壁项目去了。
|
|
57
|
+
*/
|
|
58
|
+
export function indexOfToken(text, token) {
|
|
59
|
+
const needle = String(token || '');
|
|
60
|
+
if (!needle) return -1;
|
|
61
|
+
const hay = String(text || '').toLowerCase();
|
|
62
|
+
const low = needle.toLowerCase();
|
|
63
|
+
let from = 0;
|
|
64
|
+
for (;;) {
|
|
65
|
+
const i = hay.indexOf(low, from);
|
|
66
|
+
if (i < 0) return -1;
|
|
67
|
+
const before = i > 0 ? hay[i - 1] : '';
|
|
68
|
+
const after = i + low.length < hay.length ? hay[i + low.length] : '';
|
|
69
|
+
if (!WORD_CHAR.test(before) && !WORD_CHAR.test(after)) return i;
|
|
70
|
+
from = i + 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 规则匹配:指令原文里点名的项目。
|
|
76
|
+
*
|
|
77
|
+
* 优先级:完整路径(rank 3)> 项目名成词出现(rank 2)。
|
|
78
|
+
* 同级里取「匹配到的 token 更长」的那个 —— 更长即更具体,
|
|
79
|
+
* 用来压住 `claw-management` / `claw-management-api` 这类同族名。
|
|
80
|
+
*
|
|
81
|
+
* @param {string} text 指令原文
|
|
82
|
+
* @param {object[]} projects buildProjectEntries 的输出({name, path, key})
|
|
83
|
+
* @returns {{ project: object, matched: string } | null}
|
|
84
|
+
*/
|
|
85
|
+
export function matchProjectMention(text, projects) {
|
|
86
|
+
const src = String(text || '');
|
|
87
|
+
if (!src) return null;
|
|
88
|
+
const list = (Array.isArray(projects) ? projects : []).filter(p => p && p.key && p.path);
|
|
89
|
+
if (!list.length) return null;
|
|
90
|
+
|
|
91
|
+
const lower = src.toLowerCase();
|
|
92
|
+
let best = null;
|
|
93
|
+
|
|
94
|
+
for (const p of list) {
|
|
95
|
+
const path = String(p.path || '').trim();
|
|
96
|
+
const name = String(p.name || projectName(path)).trim();
|
|
97
|
+
|
|
98
|
+
let matched = '';
|
|
99
|
+
let rank = 0;
|
|
100
|
+
if (path && lower.includes(path.toLowerCase())) {
|
|
101
|
+
// 完整路径是最强信号:肯把路径敲全的人不会是在打比方
|
|
102
|
+
matched = path;
|
|
103
|
+
rank = 3;
|
|
104
|
+
} else if (name && indexOfToken(lower, name) >= 0) {
|
|
105
|
+
matched = name;
|
|
106
|
+
rank = 2;
|
|
107
|
+
}
|
|
108
|
+
if (!matched) continue;
|
|
109
|
+
|
|
110
|
+
if (!best || rank > best.rank || (rank === best.rank && matched.length > best.matched.length)) {
|
|
111
|
+
best = { project: p, matched, rank };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return best ? { project: best.project, matched: best.matched } : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 让模型判断落点(第 3 级)。
|
|
120
|
+
*
|
|
121
|
+
* 只在规则匹配不到时调用。返回的路径**必须是清单里真实存在的那一条**:
|
|
122
|
+
* 模型编出来的路径一律当作"没判断出来"。宁可退到默认项目,
|
|
123
|
+
* 也不能凭空造一个不存在的工作目录 —— 那会让派发在下一步直接报「项目目录不存在」,
|
|
124
|
+
* 用户看到的是"派发失败",比"派到默认项目"难懂得多。
|
|
125
|
+
*
|
|
126
|
+
* @returns {{ project: object, reason: string } | null}
|
|
127
|
+
*/
|
|
128
|
+
export async function pickProjectByAgent({
|
|
129
|
+
text,
|
|
130
|
+
projects,
|
|
131
|
+
model,
|
|
132
|
+
timeoutMs = 15000,
|
|
133
|
+
// 注入点:单测靠它替换掉真实网络调用(默认就是 callLlmJson)
|
|
134
|
+
callJson = callLlmJson,
|
|
135
|
+
} = {}) {
|
|
136
|
+
const list = (Array.isArray(projects) ? projects : []).filter(p => p && p.key && p.path);
|
|
137
|
+
if (!model || !list.length) return null;
|
|
138
|
+
|
|
139
|
+
const catalogue = list
|
|
140
|
+
.map((p, i) => `${i + 1}. ${p.name || projectName(p.path)} | ${p.path}`)
|
|
141
|
+
.join('\n');
|
|
142
|
+
|
|
143
|
+
const prompt = `你在「多项目编排台」里做一次路由判断:用户刚发了一条指令,请判断它应该在下面哪个项目里执行。
|
|
144
|
+
|
|
145
|
+
可选项目(序号. 名称 | 绝对路径):
|
|
146
|
+
${catalogue}
|
|
147
|
+
|
|
148
|
+
指令原文:
|
|
149
|
+
"""
|
|
150
|
+
${String(text || '').slice(0, 2000)}
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
判断规则:
|
|
154
|
+
1. 指令直接提到某个项目名或路径 —— 选它。
|
|
155
|
+
2. 指令描述的内容明显属于某个项目(例如"修一下文章生成器的导出"对应 article-generator,如果它在上面)—— 选它。
|
|
156
|
+
3. 只要不能确定,就返回空字符串。**不要猜**:猜错的代价是把改动做到错误的仓库里,比退到默认项目严重得多。
|
|
157
|
+
|
|
158
|
+
只返回 JSON:
|
|
159
|
+
{
|
|
160
|
+
"projectPath": "选中的项目绝对路径,必须是上面列出的路径原样复制;不确定就填空字符串",
|
|
161
|
+
"reason": "一句话说明依据"
|
|
162
|
+
}`;
|
|
163
|
+
|
|
164
|
+
const out = await callJson(model, prompt, { timeoutMs });
|
|
165
|
+
const picked = canonicalProjectPath(out && out.projectPath ? out.projectPath : '');
|
|
166
|
+
if (!picked) return null;
|
|
167
|
+
const hit = list.find(p => p.key === picked);
|
|
168
|
+
if (!hit) return null;
|
|
169
|
+
return { project: hit, reason: String((out && out.reason) || '').slice(0, 200) };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 解析指令的落点项目。
|
|
174
|
+
*
|
|
175
|
+
* @param {object} input
|
|
176
|
+
* @param {string} input.text 指令原文
|
|
177
|
+
* @param {object[]} input.projects 候选项目({name, path, key})
|
|
178
|
+
* @param {string} input.explicitPath 调用方显式指定的路径(空 = 没指定,走判断)
|
|
179
|
+
* @param {string} input.defaultPath 兜底路径(一般是应用当前项目)
|
|
180
|
+
* @param {function} [input.getModel] 懒取模型配置。**只有真的要走第 3 级时才会被调用** ——
|
|
181
|
+
* 常见情况(指令里点名了项目)不该为读一次 config 付成本
|
|
182
|
+
* @param {number} [input.agentTimeoutMs]
|
|
183
|
+
* @param {function} [input.onAgentError] 模型判断失败时的回调(调用方记日志用)。
|
|
184
|
+
* 不抛错:判断失败不该让整条指令派不出去
|
|
185
|
+
* @param {function} [input.pickByAgent] 注入点:单测替换第 3 级,避免真发网络请求
|
|
186
|
+
* @returns {Promise<{ path: string, name: string, source: string, matched: string, reason: string }>}
|
|
187
|
+
*/
|
|
188
|
+
export async function resolveInstructionTarget({
|
|
189
|
+
text = '',
|
|
190
|
+
projects = [],
|
|
191
|
+
explicitPath = '',
|
|
192
|
+
defaultPath = '',
|
|
193
|
+
getModel = null,
|
|
194
|
+
agentTimeoutMs = 15000,
|
|
195
|
+
onAgentError = null,
|
|
196
|
+
pickByAgent = pickProjectByAgent,
|
|
197
|
+
} = {}) {
|
|
198
|
+
const list = (Array.isArray(projects) ? projects : [])
|
|
199
|
+
.filter(p => p && p.key && p.path)
|
|
200
|
+
.map(p => ({ ...p, name: p.name || projectName(p.path) }));
|
|
201
|
+
const byKey = new Map(list.map(p => [p.key, p]));
|
|
202
|
+
|
|
203
|
+
// ── 1. 显式指定 ──────────────────────────────────────────────────
|
|
204
|
+
const explicit = String(explicitPath || '').trim();
|
|
205
|
+
if (explicit) {
|
|
206
|
+
const hit = byKey.get(canonicalProjectPath(explicit));
|
|
207
|
+
return {
|
|
208
|
+
path: hit ? hit.path : explicit,
|
|
209
|
+
name: hit ? hit.name : projectName(explicit),
|
|
210
|
+
source: TARGET_SOURCE.EXPLICIT,
|
|
211
|
+
matched: '',
|
|
212
|
+
reason: '',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── 2. 指令原文里点名 ────────────────────────────────────────────
|
|
217
|
+
const mention = matchProjectMention(text, list);
|
|
218
|
+
if (mention) {
|
|
219
|
+
return {
|
|
220
|
+
path: mention.project.path,
|
|
221
|
+
name: mention.project.name,
|
|
222
|
+
source: TARGET_SOURCE.MENTION,
|
|
223
|
+
matched: mention.matched,
|
|
224
|
+
reason: '',
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── 3. 模型判断 ──────────────────────────────────────────────────
|
|
229
|
+
if (typeof getModel === 'function') {
|
|
230
|
+
try {
|
|
231
|
+
const model = await getModel();
|
|
232
|
+
if (model) {
|
|
233
|
+
const picked = await pickByAgent({ text, projects: list, model, timeoutMs: agentTimeoutMs });
|
|
234
|
+
if (picked) {
|
|
235
|
+
return {
|
|
236
|
+
path: picked.project.path,
|
|
237
|
+
name: picked.project.name,
|
|
238
|
+
source: TARGET_SOURCE.AGENT,
|
|
239
|
+
matched: '',
|
|
240
|
+
reason: picked.reason,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} catch (err) {
|
|
245
|
+
if (typeof onAgentError === 'function') onAgentError(err);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── 4. 默认项目 ──────────────────────────────────────────────────
|
|
250
|
+
const fallback = String(defaultPath || '').trim();
|
|
251
|
+
const def = byKey.get(canonicalProjectPath(fallback));
|
|
252
|
+
const defPath = def ? def.path : fallback;
|
|
253
|
+
return {
|
|
254
|
+
path: defPath,
|
|
255
|
+
name: def ? def.name : projectName(defPath),
|
|
256
|
+
source: TARGET_SOURCE.DEFAULT,
|
|
257
|
+
matched: '',
|
|
258
|
+
reason: '',
|
|
259
|
+
};
|
|
260
|
+
}
|