zen-gitsync 2.14.2 → 2.14.3
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 +1 -1
- package/src/ui/public/assets/{AppVersionBadge-M9ydXpd9.js → AppVersionBadge-z2kqhmtD.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-scIk6OeM.js → BranchSelector-CSjsCeeJ.js} +1 -1
- package/src/ui/public/assets/{CommandConsole-BikQuarw.js → CommandConsole-96MUoG1F.js} +2 -2
- package/src/ui/public/assets/{CommitForm-DuvNxUdP.js → CommitForm-BBKQ0GqO.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-G17HYMln.js → CommonDialog-CgQovpCJ.js} +1 -1
- package/src/ui/public/assets/{EditorView-rGLnccH3.js → EditorView-aoA30emq.js} +0 -0
- package/src/ui/public/assets/{FlowExecutionViewer-B4BADP8e.js → FlowExecutionViewer-BvXlaQai.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-C3l3Jco7.js → FlowOrchestrationWorkspace-CgWUF1Jc.js} +1 -1
- package/src/ui/public/assets/{LogList-7agk5Obq.js → LogList-uF4tir23.js} +1 -1
- package/src/ui/public/assets/{MindmapView-mOFG9TmR.js → MindmapView-B8wr0EFL.js} +1 -1
- package/src/ui/public/assets/{MonitorView-Dlpgy8Bb.js → MonitorView-DaJ0h8Im.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-CgS14qyg.js → ProjectStartupButton-CcH1NL2W.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-CFKl6CB9.js → RemoteRepoCard-BDbJ76uf.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-Dsc_a3t7.js → SourceMapView-2HjAlHnp.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-Cw2mL_zx.js → SvgIcon-CK6VGHb5.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-yBT2005h.js → UserInputNode-Cx8r5geA.js} +1 -1
- package/src/ui/public/assets/{WorkbenchView-CiG-qMl7.js → WorkbenchView-CG54iND-.js} +4 -4
- package/src/ui/public/assets/WorkbenchView-CvHPPs8Q.css +1 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-BMXWW0fG.js → _plugin-vue_export-helper-CJMndG_W.js} +2 -2
- package/src/ui/public/assets/{configStore-Cq6Qwn35.js → configStore-DDnE5eie.js} +1 -1
- package/src/ui/public/assets/index-B7FGNx_l.js +23 -0
- package/src/ui/public/assets/{index-gbRjS0sL.css → index-f6UhuE5j.css} +1 -1
- package/src/ui/public/index.html +6 -6
- package/src/ui/server/index.js +632 -625
- package/src/ui/server/middleware/errorHandler.js +84 -0
- package/src/ui/server/middleware/errorHandler.test.js +114 -0
- package/src/ui/server/routes/exec.js +0 -21
- package/src/ui/server/routes/gitOps.js +62 -0
- package/src/ui/server/routes/monitor.js +42 -26
- package/src/ui/server/routes/terminal.js +318 -320
- package/src/ui/server/routes/workbench/attachmentUtils.js +76 -0
- package/src/ui/server/routes/workbench/index.js +1680 -0
- package/src/ui/server/routes/workbench/instructionStore.js +175 -0
- package/src/ui/server/routes/workbench/jobStore.js +225 -0
- package/src/ui/server/routes/workbench/jsonParse.js +219 -0
- package/src/ui/server/routes/workbench/llmClient.js +214 -0
- package/src/ui/server/routes/workbench/projectScan.js +191 -0
- package/src/ui/server/routes/workbench/sessionStore.js +126 -0
- package/src/ui/server/routes/workbench/shared.js +115 -0
- package/src/ui/server/routes/workbench/taskRunner.js +493 -0
- package/src/ui/server/routes/workbench.js +16 -3523
- package/src/ui/public/assets/WorkbenchView-OmJ1eM5g.css +0 -1
- package/src/ui/public/assets/index-CPqKvZBK.js +0 -23
|
@@ -0,0 +1,214 @@
|
|
|
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
|
+
// OpenAI 兼容 LLM 客户端封装。
|
|
16
|
+
// 拆分自原 routes/workbench.js 454-635 行。
|
|
17
|
+
//
|
|
18
|
+
// 两个核心 API:
|
|
19
|
+
// - callLlmJson(model, prompt, opts) 非流式,强制 json_object 返回
|
|
20
|
+
// - callLlmStream(model, input, onDelta, opts) SSE 流式,回调推送 thinking/content delta
|
|
21
|
+
// 配套:
|
|
22
|
+
// - callLlmJsonWithRetry 自动重试一次
|
|
23
|
+
// - cloneMsgContent 深拷贝消息 content(避免外部 mutation 污染请求)
|
|
24
|
+
|
|
25
|
+
import { stripThinkingBlocks, extractFirstJsonObject } from './jsonParse.js';
|
|
26
|
+
|
|
27
|
+
// 非流式调用 LLM,要求返回 JSON 对象。
|
|
28
|
+
// 失败时抛错;成功时返回已 parse 的对象。
|
|
29
|
+
//
|
|
30
|
+
// 图片支持:opts.images 是 data URL 数组(如 'data:image/png;base64,...')。
|
|
31
|
+
// 非多模态模型遇到 image_url 会忽略图片块,相当于退化成纯文本,不会报错。
|
|
32
|
+
export async function callLlmJson(model, prompt, opts = {}) {
|
|
33
|
+
const { timeoutMs = 60000, images = [] } = opts;
|
|
34
|
+
const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }));
|
|
35
|
+
const url = `${String(model.baseURL || '').replace(/\/$/, '')}/chat/completions`;
|
|
36
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
37
|
+
if (model.apiKey) headers['Authorization'] = `Bearer ${model.apiKey}`;
|
|
38
|
+
|
|
39
|
+
let userContent;
|
|
40
|
+
if (Array.isArray(images) && images.length > 0) {
|
|
41
|
+
userContent = [
|
|
42
|
+
{ type: 'text', text: prompt },
|
|
43
|
+
...images.map(img => ({ type: 'image_url', image_url: { url: img } }))
|
|
44
|
+
];
|
|
45
|
+
} else {
|
|
46
|
+
userContent = prompt;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const body = JSON.stringify({
|
|
50
|
+
model: model.model,
|
|
51
|
+
messages: [{ role: 'user', content: userContent }],
|
|
52
|
+
temperature: 0.4,
|
|
53
|
+
response_format: { type: 'json_object' },
|
|
54
|
+
stream: false,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const controller = new AbortController();
|
|
58
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
59
|
+
try {
|
|
60
|
+
const resp = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
|
|
61
|
+
const data = await resp.json().catch(() => ({}));
|
|
62
|
+
if (!resp.ok) throw new Error(data?.error?.message || `HTTP ${resp.status}`);
|
|
63
|
+
const content = data?.choices?.[0]?.message?.content || '';
|
|
64
|
+
if (!content.trim()) {
|
|
65
|
+
throw new Error('LLM 返回内容为空');
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const cleaned = stripThinkingBlocks(content);
|
|
69
|
+
return JSON.parse(extractFirstJsonObject(cleaned));
|
|
70
|
+
} catch (err) {
|
|
71
|
+
const snippet = content.length > 500 ? content.slice(0, 500) + '…' : content;
|
|
72
|
+
throw new Error(`LLM 返回非 JSON 或被截断:${err.message};原始内容片段:${snippet}`);
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 自动重试一次的包装。重试间隔 800ms * (i+1)。
|
|
80
|
+
export async function callLlmJsonWithRetry(model, prompt, opts = {}, retries = 1) {
|
|
81
|
+
let lastErr;
|
|
82
|
+
for (let i = 0; i <= retries; i++) {
|
|
83
|
+
try {
|
|
84
|
+
return await callLlmJson(model, prompt, opts);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
lastErr = err;
|
|
87
|
+
if (i < retries) {
|
|
88
|
+
await new Promise(r => setTimeout(r, 800 * (i + 1)));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
throw lastErr;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 深拷贝 message content(字符串直接返回,数组浅拷贝每个 part)
|
|
96
|
+
export function cloneMsgContent(c) {
|
|
97
|
+
if (typeof c === 'string') return c;
|
|
98
|
+
if (Array.isArray(c)) return c.map(p => ({ ...p }));
|
|
99
|
+
return c;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 流式调用 OpenAI 兼容 LLM。每收到一个 chunk 调 onDelta 回调。
|
|
104
|
+
* onDelta 接收 { thinking?: string, content?: string },二选一。
|
|
105
|
+
* - reasoning_content / reasoning:部分模型(如 deepseek)放在 delta.reasoning_content
|
|
106
|
+
* - reasoning / reasoning_text:openai o1 风格
|
|
107
|
+
* - content:普通输出
|
|
108
|
+
* 返回 { content: string, aborted: boolean }。
|
|
109
|
+
*
|
|
110
|
+
* input 支持两种形态(判别 union):
|
|
111
|
+
* - string: 旧模式,拼成单条 user message,支持 opts.images 多模态
|
|
112
|
+
* - ChatMsg[]: 新模式(对话拆分),直接作为 messages 发送;opts.images/systemPrompt 忽略
|
|
113
|
+
*
|
|
114
|
+
* 取消支持:opts.signal 是外部 AbortController.signal,触发后 controller.abort(),
|
|
115
|
+
* 函数返回 { aborted: true },不抛错——上层决定怎么处理。
|
|
116
|
+
*/
|
|
117
|
+
export async function callLlmStream(model, input, onDelta, opts = {}) {
|
|
118
|
+
const { maxTokens = 2000, timeoutMs = 600000, signal, images = [], systemPrompt } = opts;
|
|
119
|
+
const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }));
|
|
120
|
+
const url = `${String(model.baseURL || '').replace(/\/$/, '')}/chat/completions`;
|
|
121
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
122
|
+
if (model.apiKey) headers['Authorization'] = `Bearer ${model.apiKey}`;
|
|
123
|
+
|
|
124
|
+
// 判别 input: string(旧,直接拆分)或 messages 数组(新,对话拆分)
|
|
125
|
+
let messages;
|
|
126
|
+
if (Array.isArray(input)) {
|
|
127
|
+
// messages 模式: 深拷贝避免外部 mutation 污染 LLM 请求
|
|
128
|
+
messages = input.map(m => ({ role: m.role, content: cloneMsgContent(m.content) }));
|
|
129
|
+
} else {
|
|
130
|
+
// 旧模式: string prompt 拼成单条 user message,支持多模态
|
|
131
|
+
const text = String(input || '');
|
|
132
|
+
let userContent;
|
|
133
|
+
if (Array.isArray(images) && images.length > 0) {
|
|
134
|
+
userContent = [
|
|
135
|
+
{ type: 'text', text },
|
|
136
|
+
...images.map(img => ({ type: 'image_url', image_url: { url: img } }))
|
|
137
|
+
];
|
|
138
|
+
} else {
|
|
139
|
+
userContent = text;
|
|
140
|
+
}
|
|
141
|
+
messages = [];
|
|
142
|
+
if (systemPrompt) messages.push({ role: 'system', content: String(systemPrompt) });
|
|
143
|
+
messages.push({ role: 'user', content: userContent });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const body = JSON.stringify({
|
|
147
|
+
model: model.model,
|
|
148
|
+
messages,
|
|
149
|
+
max_tokens: maxTokens,
|
|
150
|
+
temperature: 0.4,
|
|
151
|
+
// 注意:stream: true 模式下不能同时使用 response_format:{type:'json_object'},
|
|
152
|
+
// 部分 provider 会在收到两个一起时报错/静默卡住。改在 prompt 里约束 JSON 输出即可。
|
|
153
|
+
stream: true,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const controller = new AbortController();
|
|
157
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
158
|
+
const onAbort = () => controller.abort();
|
|
159
|
+
if (signal) {
|
|
160
|
+
if (signal.aborted) controller.abort();
|
|
161
|
+
else signal.addEventListener('abort', onAbort);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let fullContent = '';
|
|
165
|
+
let aborted = false;
|
|
166
|
+
try {
|
|
167
|
+
const resp = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
|
|
168
|
+
if (!resp.ok || !resp.body) {
|
|
169
|
+
const errText = await resp.text().catch(() => '');
|
|
170
|
+
throw new Error(errText || `HTTP ${resp.status}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// SSE 格式:每行 "data: {...}",最后 "data: [DONE]"
|
|
174
|
+
const decoder = new TextDecoder('utf-8');
|
|
175
|
+
let buf = '';
|
|
176
|
+
for await (const chunk of resp.body) {
|
|
177
|
+
buf += decoder.decode(chunk, { stream: true });
|
|
178
|
+
const lines = buf.split('\n');
|
|
179
|
+
buf = lines.pop() ?? '';
|
|
180
|
+
for (const line of lines) {
|
|
181
|
+
const trimmed = line.trim();
|
|
182
|
+
if (!trimmed || !trimmed.startsWith('data:')) continue;
|
|
183
|
+
const payload = trimmed.slice(5).trim();
|
|
184
|
+
if (payload === '[DONE]') continue;
|
|
185
|
+
try {
|
|
186
|
+
const evt = JSON.parse(payload);
|
|
187
|
+
const delta = evt.choices?.[0]?.delta || {};
|
|
188
|
+
// thinking 在不同 provider 字段名不同,全部尝试
|
|
189
|
+
const thinkingChunk = delta.reasoning_content
|
|
190
|
+
|| delta.reasoning
|
|
191
|
+
|| delta.reasoning_text
|
|
192
|
+
|| '';
|
|
193
|
+
const contentChunk = delta.content || '';
|
|
194
|
+
if (thinkingChunk) onDelta({ thinking: thinkingChunk });
|
|
195
|
+
if (contentChunk) {
|
|
196
|
+
fullContent += contentChunk;
|
|
197
|
+
onDelta({ content: contentChunk });
|
|
198
|
+
}
|
|
199
|
+
} catch { /* 跳过无法解析的行 */ }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (err?.name === 'AbortError' || controller.signal.aborted) {
|
|
204
|
+
aborted = true;
|
|
205
|
+
// 中断不算错误——上层会决定怎么处理
|
|
206
|
+
} else {
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
} finally {
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
212
|
+
}
|
|
213
|
+
return { content: fullContent, aborted };
|
|
214
|
+
}
|
|
@@ -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
|
+
// 子项目识别 / manifest 读取 / 目录树扫描。
|
|
16
|
+
// 拆分自原 routes/workbench.js 313-390 行 + 1000-1079 行。
|
|
17
|
+
//
|
|
18
|
+
// 用途:AI 生成架构说明时把项目结构喂给 LLM。
|
|
19
|
+
// 设计要点:
|
|
20
|
+
// - 只读 manifest 与 README,不读源码(避免上下文爆炸)
|
|
21
|
+
// - findSubProjects 递归找 .git / manifest;A 是 B 的祖先时只保留 B
|
|
22
|
+
// - 大文件用 safeReadFile 截断到 maxBytes
|
|
23
|
+
|
|
24
|
+
import fsp from 'fs/promises';
|
|
25
|
+
import path from 'path';
|
|
26
|
+
import { SKIP_DIRS, MANIFEST_FILES } from './shared.js';
|
|
27
|
+
|
|
28
|
+
// 轻量级:仅告诉 LLM 项目的主 manifest 是什么(用于 AI 拆分时让 LLM 知道项目类型)。
|
|
29
|
+
// 不读内容,只 stat 存在性——拆分子任务不需要细节。
|
|
30
|
+
export async function detectProjectManifest(projectPath) {
|
|
31
|
+
if (!projectPath) return '';
|
|
32
|
+
for (const f of MANIFEST_FILES) {
|
|
33
|
+
try {
|
|
34
|
+
const stat = await fsp.stat(path.join(projectPath, f));
|
|
35
|
+
if (stat.isFile()) return f;
|
|
36
|
+
} catch { /* 不存在,继续 */ }
|
|
37
|
+
}
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 读取项目所有 manifest 内容(限 20KB / 文件),返回 { [filename]: content }
|
|
42
|
+
export async function readProjectManifest(projectPath) {
|
|
43
|
+
const out = {};
|
|
44
|
+
for (const f of MANIFEST_FILES) {
|
|
45
|
+
const p = path.join(projectPath, f);
|
|
46
|
+
try {
|
|
47
|
+
const stat = await fsp.stat(p);
|
|
48
|
+
if (!stat.isFile()) continue;
|
|
49
|
+
const content = stat.size > 20000
|
|
50
|
+
? (await safeReadFile(p, 20000))
|
|
51
|
+
: (await fsp.readFile(p, 'utf8'));
|
|
52
|
+
out[f] = content;
|
|
53
|
+
} catch { /* 不存在就跳过 */ }
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 安全读文件:超 maxBytes 时只读前 maxBytes 字节,避免巨型文件把上下文打爆
|
|
59
|
+
export async function safeReadFile(filePath, maxBytes = 200000) {
|
|
60
|
+
try {
|
|
61
|
+
const stat = await fsp.stat(filePath);
|
|
62
|
+
if (stat.size > maxBytes) {
|
|
63
|
+
const buf = Buffer.alloc(maxBytes);
|
|
64
|
+
const fd = await fsp.open(filePath, 'r');
|
|
65
|
+
await fd.read(buf, 0, maxBytes, 0);
|
|
66
|
+
await fd.close();
|
|
67
|
+
return buf.toString('utf8').slice(0, maxBytes);
|
|
68
|
+
}
|
|
69
|
+
return await fsp.readFile(filePath, 'utf8');
|
|
70
|
+
} catch {
|
|
71
|
+
return '';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 列出项目目录树(默认 2 层),过滤掉 node_modules / dist / .git 等
|
|
76
|
+
export async function listDirTree(projectPath, maxDepth = 2, maxEntries = 400) {
|
|
77
|
+
const lines = [];
|
|
78
|
+
async function walk(dir, depth) {
|
|
79
|
+
if (depth > maxDepth || lines.length >= maxEntries) return;
|
|
80
|
+
let entries;
|
|
81
|
+
try { entries = await fsp.readdir(dir, { withFileTypes: true }); }
|
|
82
|
+
catch { return; }
|
|
83
|
+
const filtered = entries.filter(e => {
|
|
84
|
+
if (e.name.startsWith('.')) return false;
|
|
85
|
+
if (['node_modules', 'dist', 'build', '.next', '.nuxt', '__pycache__', 'target', 'out', 'coverage', 'vendor'].includes(e.name)) return false;
|
|
86
|
+
return true;
|
|
87
|
+
});
|
|
88
|
+
const indent = ' '.repeat(depth);
|
|
89
|
+
for (const e of filtered) {
|
|
90
|
+
if (lines.length >= maxEntries) return;
|
|
91
|
+
if (e.isDirectory()) {
|
|
92
|
+
lines.push(`${indent}${e.name}/`);
|
|
93
|
+
await walk(path.join(dir, e.name), depth + 1);
|
|
94
|
+
} else if (e.isFile()) {
|
|
95
|
+
lines.push(`${indent}${e.name}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
await walk(projectPath, 0);
|
|
100
|
+
return lines.join('\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 递归识别子项目:找含 .git 目录或 manifest 文件的目录。
|
|
105
|
+
* A 是 B 的祖先时只保留更深的 B(避免父项目重复包含子项目)。
|
|
106
|
+
*
|
|
107
|
+
* @param {string} projectPath 项目根路径
|
|
108
|
+
* @param {object} [opts]
|
|
109
|
+
* @param {number} [opts.maxDepth=4] 最大递归深度
|
|
110
|
+
* @returns {Promise<Array<{root, name, manifests, readme, dirTree}>>}
|
|
111
|
+
*/
|
|
112
|
+
export async function findSubProjects(projectPath, opts = {}) {
|
|
113
|
+
const { maxDepth = 4 } = opts;
|
|
114
|
+
const candidates = [];
|
|
115
|
+
|
|
116
|
+
async function walk(dir, depth) {
|
|
117
|
+
if (depth > maxDepth) return;
|
|
118
|
+
let entries;
|
|
119
|
+
try { entries = await fsp.readdir(dir, { withFileTypes: true }); }
|
|
120
|
+
catch { return; }
|
|
121
|
+
|
|
122
|
+
let hasManifest = false;
|
|
123
|
+
let hasGit = false;
|
|
124
|
+
const subDirs = [];
|
|
125
|
+
for (const e of entries) {
|
|
126
|
+
if (!e.isDirectory() && !e.isFile()) continue;
|
|
127
|
+
if (e.name.startsWith('.')) {
|
|
128
|
+
if (e.name === '.git' && e.isDirectory()) hasGit = true;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (e.isDirectory()) {
|
|
132
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
133
|
+
subDirs.push(path.join(dir, e.name));
|
|
134
|
+
} else if (e.isFile() && MANIFEST_FILES.includes(e.name)) {
|
|
135
|
+
hasManifest = true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (hasManifest || hasGit) {
|
|
139
|
+
candidates.push(dir);
|
|
140
|
+
return; // 子目录里若还有 manifest,会被自己发现;这里不再下钻避免冗余
|
|
141
|
+
}
|
|
142
|
+
if (depth >= maxDepth) return;
|
|
143
|
+
for (const sub of subDirs) {
|
|
144
|
+
await walk(sub, depth + 1);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
await walk(projectPath, 0);
|
|
149
|
+
|
|
150
|
+
// 去重:若 candidates 里 A 是 B 的祖先,只保留更深一级的 B
|
|
151
|
+
candidates.sort((a, b) => a.length - b.length);
|
|
152
|
+
const kept = [];
|
|
153
|
+
for (const c of candidates) {
|
|
154
|
+
let dominated = false;
|
|
155
|
+
for (const k of kept) {
|
|
156
|
+
if (c === k || c.startsWith(k + path.sep)) { dominated = true; break; }
|
|
157
|
+
}
|
|
158
|
+
if (!dominated) kept.push(c);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 收集每个子项目的关键文件
|
|
162
|
+
const result = [];
|
|
163
|
+
for (const root of kept) {
|
|
164
|
+
const manifests = {};
|
|
165
|
+
for (const m of MANIFEST_FILES) {
|
|
166
|
+
const p = path.join(root, m);
|
|
167
|
+
try {
|
|
168
|
+
const stat = await fsp.stat(p);
|
|
169
|
+
if (stat.isFile()) {
|
|
170
|
+
manifests[m] = stat.size > 20000
|
|
171
|
+
? await safeReadFile(p, 20000)
|
|
172
|
+
: await fsp.readFile(p, 'utf8');
|
|
173
|
+
}
|
|
174
|
+
} catch { /* 不存在就跳过 */ }
|
|
175
|
+
}
|
|
176
|
+
let readme = '';
|
|
177
|
+
try {
|
|
178
|
+
const stat = await fsp.stat(path.join(root, 'README.md'));
|
|
179
|
+
if (stat.isFile()) readme = await safeReadFile(path.join(root, 'README.md'), 8000);
|
|
180
|
+
} catch { /* 不存在就跳过 */ }
|
|
181
|
+
const dirTree = await listDirTree(root, 2, 200);
|
|
182
|
+
result.push({
|
|
183
|
+
root,
|
|
184
|
+
name: path.basename(root) || path.basename(projectPath),
|
|
185
|
+
manifests,
|
|
186
|
+
readme,
|
|
187
|
+
dirTree
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
// AI 对话拆分: session 持久化层。
|
|
16
|
+
// 拆分自原 routes/workbench.js 166-260 行。
|
|
17
|
+
//
|
|
18
|
+
// 文件命名: {sessionId}.json
|
|
19
|
+
// 内容: { version, sessionId, title, desc, taskId, promptId, createdAt, updatedAt,
|
|
20
|
+
// messages: [{role, content}], latestSubtasks, latestRaw, latestParseStage }
|
|
21
|
+
// 写入用临时文件 + rename 原子替换(与 workbench 现有 writeJson 模式一致),
|
|
22
|
+
// 避免半写状态被读到。并发安全: 单进程内同一 session 串行调用,
|
|
23
|
+
// 跨端点并发写同 session 概率极低,丢失只会是最后一次 user/assistant pair,可重发。
|
|
24
|
+
|
|
25
|
+
import fsp from 'fs/promises';
|
|
26
|
+
import path from 'path';
|
|
27
|
+
import { logger } from './shared.js';
|
|
28
|
+
import { SESSIONS_DIR, MAX_SESSIONS, SESSIONS_KEEP } from './shared.js';
|
|
29
|
+
|
|
30
|
+
// 16 字符: 时间戳后 8 位 + 随机 8 位 base36,既可读也基本不冲突
|
|
31
|
+
export function genSessionId() {
|
|
32
|
+
return `${Date.now().toString(36).slice(-8)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function readSessionFile(sessionId) {
|
|
36
|
+
if (!/^[a-z0-9-]{4,32}$/i.test(sessionId)) {
|
|
37
|
+
const err = new Error('非法 sessionId'); err.statusCode = 400; throw err;
|
|
38
|
+
}
|
|
39
|
+
const file = path.join(SESSIONS_DIR, `${sessionId}.json`);
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(await fsp.readFile(file, 'utf-8'));
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err.code === 'ENOENT') {
|
|
44
|
+
const e = new Error('会话不存在'); e.statusCode = 404; throw e;
|
|
45
|
+
}
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function writeSessionFile(sessionId, data) {
|
|
51
|
+
await fsp.mkdir(SESSIONS_DIR, { recursive: true });
|
|
52
|
+
const file = path.join(SESSIONS_DIR, `${sessionId}.json`);
|
|
53
|
+
const tmp = `${file}.tmp.${process.pid}.${Date.now()}`;
|
|
54
|
+
await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8');
|
|
55
|
+
await fsp.rename(tmp, file);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function deleteSessionFile(sessionId) {
|
|
59
|
+
if (!/^[a-z0-9-]{4,32}$/i.test(sessionId)) {
|
|
60
|
+
const err = new Error('非法 sessionId'); err.statusCode = 400; throw err;
|
|
61
|
+
}
|
|
62
|
+
const file = path.join(SESSIONS_DIR, `${sessionId}.json`);
|
|
63
|
+
try {
|
|
64
|
+
await fsp.unlink(file);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err.code === 'ENOENT') {
|
|
67
|
+
const e = new Error('会话不存在'); e.statusCode = 404; throw e;
|
|
68
|
+
}
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 列表只读 metadata 不读 messages 内容,启动无需预加载
|
|
74
|
+
async function listSessionsMeta() {
|
|
75
|
+
await fsp.mkdir(SESSIONS_DIR, { recursive: true });
|
|
76
|
+
const files = await fsp.readdir(SESSIONS_DIR);
|
|
77
|
+
const list = [];
|
|
78
|
+
for (const f of files) {
|
|
79
|
+
if (!f.endsWith('.json')) continue;
|
|
80
|
+
const sid = f.slice(0, -5);
|
|
81
|
+
try {
|
|
82
|
+
const file = path.join(SESSIONS_DIR, f);
|
|
83
|
+
const stat = await fsp.stat(file);
|
|
84
|
+
const data = JSON.parse(await fsp.readFile(file, 'utf-8'));
|
|
85
|
+
list.push({
|
|
86
|
+
sessionId: data.sessionId || sid,
|
|
87
|
+
title: data.title || '(无标题)',
|
|
88
|
+
taskId: data.taskId || '',
|
|
89
|
+
createdAt: data.createdAt || '',
|
|
90
|
+
updatedAt: data.updatedAt || '',
|
|
91
|
+
messageCount: Array.isArray(data.messages) ? data.messages.length : 0,
|
|
92
|
+
latestSubtaskCount: Array.isArray(data.latestSubtasks) ? data.latestSubtasks.length : 0,
|
|
93
|
+
size: stat.size
|
|
94
|
+
});
|
|
95
|
+
} catch { /* 跳过坏文件 */ }
|
|
96
|
+
}
|
|
97
|
+
list.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
|
|
98
|
+
return list;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 容量控制: 超过 MAX_SESSIONS 时按 mtime 删旧的,保留最新 SESSIONS_KEEP 个
|
|
102
|
+
async function enforceSessionsRetention() {
|
|
103
|
+
const files = await fsp.readdir(SESSIONS_DIR).catch(() => []);
|
|
104
|
+
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
|
105
|
+
if (jsonFiles.length < MAX_SESSIONS) return;
|
|
106
|
+
const stats = await Promise.all(jsonFiles.map(async f => {
|
|
107
|
+
const full = path.join(SESSIONS_DIR, f);
|
|
108
|
+
const s = await fsp.stat(full);
|
|
109
|
+
return { file: full, mtime: s.mtimeMs };
|
|
110
|
+
}));
|
|
111
|
+
stats.sort((a, b) => b.mtime - a.mtime);
|
|
112
|
+
const toDelete = stats.slice(SESSIONS_KEEP);
|
|
113
|
+
await Promise.all(toDelete.map(s => fsp.unlink(s.file).catch(() => {})));
|
|
114
|
+
if (toDelete.length > 0) {
|
|
115
|
+
logger.info(`[workbench] ai-split sessions: cleaned up ${toDelete.length} old files`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const sessionStore = {
|
|
120
|
+
genSessionId,
|
|
121
|
+
read: readSessionFile,
|
|
122
|
+
write: writeSessionFile,
|
|
123
|
+
delete: deleteSessionFile,
|
|
124
|
+
listMeta: listSessionsMeta,
|
|
125
|
+
enforceRetention: enforceSessionsRetention,
|
|
126
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
// workbench 子模块共享:常量 + 通用工具函数。
|
|
16
|
+
// 拆分自原 routes/workbench.js (3539 行) 顶层 1-46 行 + 778-805 行 + 932-944 行。
|
|
17
|
+
//
|
|
18
|
+
// 设计要点:
|
|
19
|
+
// - 所有 workbench/* 子模块都从这里 import 常量路径与通用工具
|
|
20
|
+
// - 不依赖任何其他 workbench 子模块(避免循环)
|
|
21
|
+
// - 持久化函数(readJson / writeJson)保持原子写(tmp + rename)
|
|
22
|
+
|
|
23
|
+
import fs from 'fs';
|
|
24
|
+
import fsp from 'fs/promises';
|
|
25
|
+
import path from 'path';
|
|
26
|
+
import os from 'os';
|
|
27
|
+
import logger from '../../utils/logger.js';
|
|
28
|
+
|
|
29
|
+
// ── 数据目录与持久化文件路径 ─────────────────────────────────
|
|
30
|
+
// 全部存在用户主目录 ~/.zen-gitsync/ 下,跨项目共享
|
|
31
|
+
export const DATA_DIR = path.join(os.homedir(), '.zen-gitsync');
|
|
32
|
+
export const PROMPTS_FILE = path.join(DATA_DIR, 'prompts.json');
|
|
33
|
+
export const TASKS_FILE = path.join(DATA_DIR, 'tasks.json');
|
|
34
|
+
export const IMAGES_DIR = path.join(DATA_DIR, 'workbench-images');
|
|
35
|
+
export const INSTRUCTION_FILE = path.join(DATA_DIR, 'ai-instruction.json');
|
|
36
|
+
export const SUBTASK_INSTRUCTION_FILE = path.join(DATA_DIR, 'ai-subtask-instruction.json');
|
|
37
|
+
// AI 对话拆分会话存档:每个 sessionId 一个文件,重启可恢复多轮上下文
|
|
38
|
+
export const SESSIONS_DIR = path.join(DATA_DIR, 'ai-split-sessions');
|
|
39
|
+
export const MAX_SESSIONS = 100; // 软上限,触发清理
|
|
40
|
+
export const SESSIONS_KEEP = 50; // 超过上限时保留最新 N 个
|
|
41
|
+
// 执行日志持久化:jobs.json 是历史档案,jobs-config.json 是保留策略
|
|
42
|
+
export const JOBS_FILE = path.join(DATA_DIR, 'jobs.json');
|
|
43
|
+
export const JOBS_CONFIG_FILE = path.join(DATA_DIR, 'jobs-config.json');
|
|
44
|
+
export const JOBS_SAVE_DEBOUNCE_MS = 1500;
|
|
45
|
+
export const DEFAULT_JOBS_CONFIG = { maxCount: 500, maxSizeMB: 256 };
|
|
46
|
+
|
|
47
|
+
// 子项目识别 / 文件扫描时需要跳过的目录
|
|
48
|
+
export const SKIP_DIRS = new Set([
|
|
49
|
+
'node_modules', 'dist', 'build', '.next', '.nuxt', '__pycache__',
|
|
50
|
+
'target', 'out', 'coverage', 'vendor', '.git', '.svn', '.hg',
|
|
51
|
+
'.idea', '.vscode', '.gradle', '.terraform', '.cache', '.parcel-cache',
|
|
52
|
+
'.turbo', '.svelte-kit', 'storybook-static'
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
// 解析 manifest 文件名(按优先级)
|
|
56
|
+
export const MANIFEST_FILES = [
|
|
57
|
+
'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml',
|
|
58
|
+
'pom.xml', 'build.gradle', 'build.gradle.kts', 'composer.json',
|
|
59
|
+
'Gemfile', 'pubspec.yaml'
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
// 附件大小 / 数量限制
|
|
63
|
+
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 单个附件最大 5MB
|
|
64
|
+
export const MAX_ATTACHMENTS_PER_SUBTASK = 9; // 一个子任务最多挂 9 个附件
|
|
65
|
+
|
|
66
|
+
// ── 时间 / ID 工具 ─────────────────────────────────────────
|
|
67
|
+
export function nowIso() {
|
|
68
|
+
return new Date().toISOString();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function genId() {
|
|
72
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── 文件持久化工具 ─────────────────────────────────────────
|
|
76
|
+
// 所有 workbench 数据都用这两个函数读写,确保原子写与 ENOENT 容错
|
|
77
|
+
export async function ensureDataDir() {
|
|
78
|
+
await fsp.mkdir(DATA_DIR, { recursive: true });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function readJson(file, fallback) {
|
|
82
|
+
try {
|
|
83
|
+
const buf = await fsp.readFile(file, 'utf-8');
|
|
84
|
+
return JSON.parse(buf);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (err && err.code === 'ENOENT') return fallback;
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 原子写:tmp + rename。避免半写状态被读到(与 sessionStore 一致)
|
|
92
|
+
export async function writeJson(file, data) {
|
|
93
|
+
await ensureDataDir();
|
|
94
|
+
const tmp = `${file}.tmp`;
|
|
95
|
+
await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8');
|
|
96
|
+
await fsp.rename(tmp, file);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 简单 Mustache 风格变量插值:{{task.title}} / {{task.desc}} / {{repo.path}} / {{branch}}
|
|
100
|
+
// 用于把用户可编辑的 prompt 模板里的占位符替换成实际上下文
|
|
101
|
+
export function interpolate(template, ctx) {
|
|
102
|
+
if (typeof template !== 'string') return template;
|
|
103
|
+
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, key) => {
|
|
104
|
+
const parts = key.split('.');
|
|
105
|
+
let cur = ctx;
|
|
106
|
+
for (const p of parts) {
|
|
107
|
+
if (cur == null) return '';
|
|
108
|
+
cur = cur[p];
|
|
109
|
+
}
|
|
110
|
+
return cur == null ? '' : String(cur);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 让子模块共享 logger,避免到处 import
|
|
115
|
+
export { logger, fs, fsp, path, os };
|