zen-gitsync 2.12.7 → 2.12.8

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.
@@ -0,0 +1,873 @@
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
+ // 并以 bypassPermissions 模式依次 spawn claude CLI 执行子任务(每次新窗口)。
17
+ // 数据存到用户主目录 ~/.zen-gitsync/,跨项目共享。
18
+
19
+ import fs from 'fs';
20
+ import fsp from 'fs/promises';
21
+ import path from 'path';
22
+ import os from 'os';
23
+ import { spawn, execFileSync } from 'child_process';
24
+ import { EventEmitter } from 'events';
25
+ import express from 'express';
26
+
27
+ const DATA_DIR = path.join(os.homedir(), '.zen-gitsync');
28
+ const PROMPTS_FILE = path.join(DATA_DIR, 'prompts.json');
29
+ const TASKS_FILE = path.join(DATA_DIR, 'tasks.json');
30
+ const IMAGES_DIR = path.join(DATA_DIR, 'workbench-images');
31
+
32
+ // 单个附件最大 5MB;与 Anthropic Messages API 文档约束一致
33
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
34
+ // 一个子任务最多挂 9 个附件
35
+ const MAX_ATTACHMENTS_PER_SUBTASK = 9;
36
+ // 白名单后缀:图片 + 常见文档(PDF / 纯文本 / Markdown)
37
+ const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
38
+ const DOC_EXTS = new Set(['pdf', 'txt', 'md', 'markdown', 'csv', 'json', 'log']);
39
+ const ALLOWED_EXTS = new Set([...IMAGE_EXTS, ...DOC_EXTS]);
40
+
41
+ // mime → 文件后缀;与前端 el-upload accept 对齐
42
+ const MIME_TO_EXT = {
43
+ 'image/png': 'png',
44
+ 'image/jpeg': 'jpg',
45
+ 'image/jpg': 'jpg',
46
+ 'image/gif': 'gif',
47
+ 'image/webp': 'webp',
48
+ 'image/bmp': 'bmp',
49
+ 'image/svg+xml': 'svg',
50
+ 'image/x-icon': 'ico',
51
+ 'image/vnd.microsoft.icon': 'ico',
52
+ 'application/pdf': 'pdf',
53
+ 'text/plain': 'txt',
54
+ 'text/markdown': 'md',
55
+ 'text/x-markdown': 'md',
56
+ 'text/csv': 'csv',
57
+ 'application/json': 'json',
58
+ 'text/json': 'json',
59
+ 'text/x-log': 'log',
60
+ };
61
+
62
+ function sanitizeExt(name, fallback = 'bin') {
63
+ if (typeof name !== 'string') return fallback;
64
+ const m = name.toLowerCase().match(/\.([a-z0-9]+)$/);
65
+ if (!m) return fallback;
66
+ return ALLOWED_EXTS.has(m[1]) ? m[1] : fallback;
67
+ }
68
+
69
+ function isImageExt(ext) {
70
+ return IMAGE_EXTS.has(String(ext || '').toLowerCase());
71
+ }
72
+
73
+ async function ensureImagesDir() {
74
+ await fsp.mkdir(IMAGES_DIR, { recursive: true });
75
+ }
76
+
77
+ // 把 mime 或文件名规范成统一后缀;遇到不在白名单的情况返回 null
78
+ function resolveExt({ originalName, mime }) {
79
+ if (mime && MIME_TO_EXT[mime.toLowerCase()]) {
80
+ return MIME_TO_EXT[mime.toLowerCase()];
81
+ }
82
+ const fromName = sanitizeExt(originalName, '');
83
+ if (fromName) return fromName;
84
+ return null;
85
+ }
86
+
87
+ // 解析 manifest 文件名(按优先级)
88
+ const MANIFEST_FILES = [
89
+ 'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml',
90
+ 'pom.xml', 'build.gradle', 'build.gradle.kts', 'composer.json',
91
+ 'Gemfile', 'pubspec.yaml'
92
+ ];
93
+
94
+ async function readProjectManifest(projectPath) {
95
+ const out = {};
96
+ for (const f of MANIFEST_FILES) {
97
+ const p = path.join(projectPath, f);
98
+ try {
99
+ const stat = await fsp.stat(p);
100
+ if (!stat.isFile()) continue;
101
+ // 限制大小,避免巨型 pom.xml 把上下文打爆
102
+ const content = stat.size > 20000
103
+ ? (await safeReadFile(p, 20000))
104
+ : (await fsp.readFile(p, 'utf8'));
105
+ out[f] = content;
106
+ } catch { /* 不存在就跳过 */ }
107
+ }
108
+ return out;
109
+ }
110
+
111
+ async function safeReadFile(filePath, maxBytes = 200000) {
112
+ try {
113
+ const stat = await fsp.stat(filePath);
114
+ if (stat.size > maxBytes) {
115
+ const buf = Buffer.alloc(maxBytes);
116
+ const fd = await fsp.open(filePath, 'r');
117
+ await fd.read(buf, 0, maxBytes, 0);
118
+ await fd.close();
119
+ return buf.toString('utf8').slice(0, maxBytes);
120
+ }
121
+ return await fsp.readFile(filePath, 'utf8');
122
+ } catch {
123
+ return '';
124
+ }
125
+ }
126
+
127
+ async function listDirTree(projectPath, maxDepth = 2, maxEntries = 400) {
128
+ const lines = [];
129
+ async function walk(dir, depth) {
130
+ if (depth > maxDepth || lines.length >= maxEntries) return;
131
+ let entries;
132
+ try { entries = await fsp.readdir(dir, { withFileTypes: true }); }
133
+ catch { return; }
134
+ const filtered = entries.filter(e => {
135
+ if (e.name.startsWith('.')) return false;
136
+ if (['node_modules', 'dist', 'build', '.next', '.nuxt', '__pycache__', 'target', 'out', 'coverage', 'vendor'].includes(e.name)) return false;
137
+ return true;
138
+ });
139
+ const indent = ' '.repeat(depth);
140
+ for (const e of filtered) {
141
+ if (lines.length >= maxEntries) return;
142
+ if (e.isDirectory()) {
143
+ lines.push(`${indent}${e.name}/`);
144
+ await walk(path.join(dir, e.name), depth + 1);
145
+ } else if (e.isFile()) {
146
+ lines.push(`${indent}${e.name}`);
147
+ }
148
+ }
149
+ }
150
+ await walk(projectPath, 0);
151
+ return lines.join('\n');
152
+ }
153
+
154
+ async function callLlmJson(model, prompt) {
155
+ const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }));
156
+ const url = `${String(model.baseURL || '').replace(/\/$/, '')}/chat/completions`;
157
+ const headers = { 'Content-Type': 'application/json' };
158
+ if (model.apiKey) headers['Authorization'] = `Bearer ${model.apiKey}`;
159
+
160
+ const body = JSON.stringify({
161
+ model: model.model,
162
+ messages: [{ role: 'user', content: prompt }],
163
+ max_tokens: 1500,
164
+ temperature: 0.4,
165
+ response_format: { type: 'json_object' },
166
+ stream: false,
167
+ });
168
+
169
+ const controller = new AbortController();
170
+ const timer = setTimeout(() => controller.abort(), 60000);
171
+ try {
172
+ const resp = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
173
+ const data = await resp.json().catch(() => ({}));
174
+ if (!resp.ok) throw new Error(data?.error?.message || `HTTP ${resp.status}`);
175
+ const content = data?.choices?.[0]?.message?.content || '{}';
176
+ try {
177
+ const m = content.match(/```json\s*([\s\S]*?)```/) || content.match(/({[\s\S]*})/);
178
+ return JSON.parse(m ? m[1] : content);
179
+ } catch {
180
+ return {};
181
+ }
182
+ } finally {
183
+ clearTimeout(timer);
184
+ }
185
+ }
186
+
187
+ function nowIso() {
188
+ return new Date().toISOString();
189
+ }
190
+
191
+ function genId() {
192
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
193
+ }
194
+
195
+ async function ensureDataDir() {
196
+ await fsp.mkdir(DATA_DIR, { recursive: true });
197
+ }
198
+
199
+ async function readJson(file, fallback) {
200
+ try {
201
+ const buf = await fsp.readFile(file, 'utf-8');
202
+ return JSON.parse(buf);
203
+ } catch (err) {
204
+ if (err && err.code === 'ENOENT') return fallback;
205
+ throw err;
206
+ }
207
+ }
208
+
209
+ async function writeJson(file, data) {
210
+ await ensureDataDir();
211
+ const tmp = `${file}.tmp`;
212
+ await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8');
213
+ await fsp.rename(tmp, file);
214
+ }
215
+
216
+ // 简单的 Mustache 风格变量插值:{{task.title}} / {{task.desc}} / {{repo.path}} / {{branch}}
217
+ function interpolate(template, ctx) {
218
+ if (typeof template !== 'string') return template;
219
+ return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, key) => {
220
+ const parts = key.split('.');
221
+ let cur = ctx;
222
+ for (const p of parts) {
223
+ if (cur == null) return '';
224
+ cur = cur[p];
225
+ }
226
+ return cur == null ? '' : String(cur);
227
+ });
228
+ }
229
+
230
+ // ── 进程表:记录每个子任务的运行状态 ──────────────────────────────────────
231
+ const bus = new EventEmitter();
232
+ const jobs = new Map(); // jobId -> { id, taskId, subId, status, pid, startedAt, endedAt, exitCode, error, prompt }
233
+
234
+ function publish(event, payload) {
235
+ bus.emit('event', { event, payload, ts: nowIso() });
236
+ }
237
+
238
+ function snapshotJobs() {
239
+ return Array.from(jobs.values()).map(j => ({
240
+ id: j.id,
241
+ taskId: j.taskId,
242
+ subId: j.subId,
243
+ title: j.title,
244
+ status: j.status,
245
+ prompt: j.prompt || '',
246
+ output: j.output || '',
247
+ pid: j.pid || null,
248
+ startedAt: j.startedAt || null,
249
+ endedAt: j.endedAt || null,
250
+ exitCode: typeof j.exitCode === 'number' ? j.exitCode : null,
251
+ error: j.error || null
252
+ }));
253
+ }
254
+
255
+ // 用 detached 进程跑 claude;进程退出时回填状态。
256
+ // 返回 { pid, child }:调用方可以监听 child.stdout/stderr 实时收集输出。
257
+ // 不再走 cmd /k 弹窗——claude -p 是非交互模式,输出通过 stdout pipe 实时回传
258
+ // 到前端面板展示。
259
+ function launchClaudeInNewWindow(cwd, promptText) {
260
+ return new Promise((resolve, reject) => {
261
+ const args = [
262
+ '-p', promptText,
263
+ '--output-format', 'text',
264
+ '--permission-mode', 'bypassPermissions',
265
+ '--dangerously-skip-permissions'
266
+ ];
267
+ let child;
268
+ let spawnedExe = 'claude';
269
+ if (process.platform === 'win32') {
270
+ // 直接 spawn claude.exe(npm 全局 @anthropic-ai/claude-code 里的真实二进制),
271
+ // 避开两件事:
272
+ // 1. Node 23 在 Windows 上拒绝 spawn .cmd/.bat(EINVAL)
273
+ // 2. shell:true 会把 argv 拼成命令行交给 cmd 解释,prompt 里的 \n 被切成多段
274
+ // 用 `where claude` 找到 claude.cmd,再从 cmd 内容推断对应 .exe 路径。
275
+ let claudeExe = 'claude.exe';
276
+ try {
277
+ const cmdShim = execFileSync('where', ['claude'], { encoding: 'utf8' })
278
+ .split(/\r?\n/).map(s => s.trim()).find(s => /\.cmd$/i.test(s));
279
+ if (cmdShim) {
280
+ const txt = fs.readFileSync(cmdShim, 'utf8');
281
+ if (/%dp0%\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude\.exe/i.test(txt)) {
282
+ claudeExe = path.join(path.dirname(cmdShim), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
283
+ }
284
+ }
285
+ } catch { /* fallback */ }
286
+ spawnedExe = claudeExe;
287
+ child = spawn(claudeExe, args, {
288
+ cwd,
289
+ stdio: ['ignore', 'pipe', 'pipe'],
290
+ windowsHide: false,
291
+ env: { ...process.env, LANG: 'zh_CN.UTF-8' }
292
+ });
293
+ } else {
294
+ // macOS / Linux:直接 spawn claude(Node spawn 不走 shell,
295
+ // prompt 中的引号 / 反斜杠无需手动 escape)
296
+ child = spawn('claude', args, {
297
+ cwd,
298
+ detached: true,
299
+ stdio: ['ignore', 'pipe', 'pipe'],
300
+ env: { ...process.env, LANG: 'zh_CN.UTF-8' }
301
+ });
302
+ }
303
+ child.on('error', reject);
304
+ child.on('spawn', () => {
305
+ // unref 让 claude 独立于父进程事件循环;返回 child 引用让调用方继续读 stdout。
306
+ child.unref();
307
+ resolve({ pid: child.pid, child });
308
+ });
309
+ });
310
+ }
311
+
312
+ // 顺序执行一个任务下所有子任务;上一个结束再启动下一个
313
+ async function runTaskQueue(task, repoPath, branch) {
314
+ for (const sub of task.subtasks) {
315
+ if (sub.status === 'done') continue;
316
+ const promptTemplate = sub.promptOverride || (task.promptId
317
+ ? (await readJson(PROMPTS_FILE, { prompts: [] })).prompts.find(p => p.id === task.promptId)?.content
318
+ : null) || '';
319
+ const ctx = {
320
+ task: { title: task.title, desc: task.desc || '' },
321
+ sub: { title: sub.title, desc: sub.desc || '' },
322
+ repo: { path: repoPath || '' },
323
+ branch: branch || ''
324
+ };
325
+ const interpolated = interpolate(promptTemplate, ctx);
326
+ const parts = [interpolated, sub.title, sub.desc].filter(s => s && s.trim());
327
+ let prompt = parts.join('\n\n');
328
+
329
+ // ── 附件:把 sub.attachments 列表里的本地绝对路径拼到 prompt 末尾 ──
330
+ // claude -p 字符串模式会扫描 prompt 中出现的本地文件路径并自动
331
+ // 识别为附件(图片 / PDF / 文本均可)。
332
+ const attachments = Array.isArray(sub.attachments) ? sub.attachments : [];
333
+ if (attachments.length > 0) {
334
+ const lines = attachments
335
+ .filter(a => a && a.absolutePath)
336
+ .map((a, i) => ` ${i + 1}. [${a.mimeType || 'application/octet-stream'}] ${a.absolutePath}`);
337
+ if (lines.length > 0) {
338
+ prompt += `\n\n---\n本子任务包含 ${lines.length} 个附件(请按文件路径读取,不要让用户重新提供):\n${lines.join('\n')}\n---`;
339
+ }
340
+ }
341
+
342
+ const jobId = genId();
343
+ const job = {
344
+ id: jobId,
345
+ taskId: task.id,
346
+ subId: sub.id,
347
+ title: `${task.title} / ${sub.title}`,
348
+ status: 'pending',
349
+ prompt
350
+ };
351
+ jobs.set(jobId, job);
352
+ sub.status = 'running';
353
+ publish('sub:update', { taskId: task.id, sub });
354
+ publish('job:update', job);
355
+
356
+ try {
357
+ const { pid, child } = await launchClaudeInNewWindow(repoPath || process.cwd(), prompt);
358
+ job.pid = pid;
359
+ job.startedAt = nowIso();
360
+ job.status = 'running';
361
+ publish('job:update', job);
362
+
363
+ // 累积子进程输出到 job.output,定期推送给前端;过长时截断尾部避免内存膨胀。
364
+ const MAX_OUTPUT = 256 * 1024;
365
+ const onChunk = (buf) => {
366
+ const text = buf.toString('utf8');
367
+ job.output = (job.output + text).slice(-MAX_OUTPUT);
368
+ publish('job:update', job);
369
+ };
370
+ if (child.stdout) child.stdout.on('data', onChunk);
371
+ if (child.stderr) child.stderr.on('data', onChunk);
372
+
373
+ // 等待进程退出(detached 不阻塞主进程,用 polling /proc 兜底)
374
+ await waitProcessExit(pid);
375
+ job.endedAt = nowIso();
376
+ job.exitCode = 0;
377
+ job.status = 'done';
378
+ sub.status = 'done';
379
+ } catch (err) {
380
+ job.error = err && err.message ? err.message : String(err);
381
+ job.status = 'error';
382
+ sub.status = 'error';
383
+ } finally {
384
+ publish('job:update', job);
385
+ publish('sub:update', { taskId: task.id, sub });
386
+ }
387
+ }
388
+ // 写回 tasks.json
389
+ const data = await readJson(TASKS_FILE, { tasks: [] });
390
+ const t = data.tasks.find(x => x.id === task.id);
391
+ if (t) {
392
+ t.subtasks = task.subtasks;
393
+ t.updatedAt = nowIso();
394
+ await writeJson(TASKS_FILE, data);
395
+ publish('task:update', t);
396
+ }
397
+ }
398
+
399
+ function waitProcessExit(pid) {
400
+ return new Promise(resolve => {
401
+ let exited = false;
402
+ const tryCheck = () => {
403
+ if (exited) return;
404
+ try {
405
+ process.kill(pid, 0); // 信号 0 = 探测存活
406
+ } catch (err) {
407
+ // 只在进程真的消失(ESRCH / EPERM)时才 resolve;
408
+ // 其他错误(比如参数类型)保留 polling 状态,由超时兜底。
409
+ if (err && (err.code === 'ESRCH' || err.code === 'EPERM')) {
410
+ exited = true;
411
+ resolve();
412
+ return;
413
+ }
414
+ }
415
+ setTimeout(tryCheck, 1500);
416
+ };
417
+ tryCheck();
418
+ // 兜底:30 分钟超时自动结束
419
+ setTimeout(() => { if (!exited) { exited = true; resolve(); } }, 30 * 60 * 1000);
420
+ });
421
+ }
422
+
423
+ export function registerWorkbenchRoutes({ app, getCurrentProjectPath, getProjectRoomId, io, configManager }) {
424
+ // ── AI 生成提示词(基于当前项目) ─────────────────────────────────────
425
+ app.post('/api/workbench/prompts/ai-generate', async (req, res) => {
426
+ try {
427
+ const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
428
+ if (!projectPath) {
429
+ return res.status(400).json({ success: false, error: '未选中项目' });
430
+ }
431
+ let stat;
432
+ try { stat = await fsp.stat(projectPath); }
433
+ catch { return res.status(400).json({ success: false, error: '项目路径不存在' }); }
434
+ if (!stat.isDirectory()) {
435
+ return res.status(400).json({ success: false, error: '项目路径不是目录' });
436
+ }
437
+
438
+ // 取模型
439
+ let model;
440
+ try {
441
+ if (!configManager) throw new Error('configManager 不可用');
442
+ const rawConfig = await configManager.readRawConfigFile();
443
+ const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
444
+ model = models.find(m => m.isDefault) || models[0];
445
+ } catch (err) {
446
+ return res.status(500).json({ success: false, error: '读取 AI 配置失败: ' + err.message });
447
+ }
448
+ if (!model) {
449
+ return res.status(400).json({ success: false, error: '未配置 AI 模型,请先在通用设置中添加模型' });
450
+ }
451
+
452
+ // 收集项目上下文
453
+ const dirTree = await listDirTree(projectPath, 2, 400);
454
+ const manifest = await readProjectManifest(projectPath);
455
+ const readme = await safeReadFile(path.join(projectPath, 'README.md'), 8000);
456
+
457
+ const manifestBlock = Object.entries(manifest)
458
+ .map(([name, content]) => `\n--- ${name} ---\n${content}`)
459
+ .join('\n');
460
+
461
+ const projectName = path.basename(projectPath);
462
+
463
+ const userPayload = `项目根目录:${projectPath}
464
+ 项目名称:${projectName}
465
+
466
+ ## 目录结构(前 2 层,截断)
467
+ ${dirTree || '(无)'}
468
+
469
+ ## README.md
470
+ ${readme || '(无)'}
471
+
472
+ ## 关键 manifest
473
+ ${manifestBlock || '(无)'}`;
474
+
475
+ // 第一阶段:让 LLM 写一段「可复用的提示词模板」
476
+ const templateSystemPrompt = `你是一名资深软件架构师。任务:根据用户提供的项目目录结构、README、manifest 文件,输出一段**可复用的提示词模板**。
477
+ 这段模板将作为指令注入到大模型的 system prompt 中,用来指导大模型对**当前项目**做「项目架构总结」。
478
+
479
+ 要求:
480
+ 1. 模板主体使用中文,语气专业、具体
481
+ 2. 模板中必须明确使用 4 个变量占位符:
482
+ - {{task.title}} - 任务标题
483
+ - {{task.desc}} - 任务详细描述
484
+ - {{sub.title}} - 子任务标题
485
+ - {{sub.desc}} - 子任务详细描述
486
+ 并向大模型说明:项目根目录是 {{repo.path}},当前 git 分支是 {{branch}}
487
+ 3. 模板应指导大模型:阅读项目目录、识别语言与框架、找出入口文件、画出主要模块依赖关系、输出 200-400 字的中文总结
488
+ 4. 模板长度控制在 300-600 字之间
489
+ 5. 只返回 JSON,不要任何额外解释
490
+
491
+ 返回 JSON:
492
+ {
493
+ "name": "模板名称(10-20字)",
494
+ "template": "模板正文"
495
+ }`;
496
+
497
+ const first = await callLlmJson(model, `${templateSystemPrompt}\n\n${userPayload}`);
498
+ const templateName = String(first.name || '').trim() || '项目架构总结';
499
+ const template = String(first.template || '').trim();
500
+ if (!template) {
501
+ return res.status(500).json({ success: false, error: 'AI 未返回有效模板' });
502
+ }
503
+
504
+ // 第二阶段:以模板为指令,喂入项目上下文,跑一次实际生成
505
+ const execPrompt = `${template}
506
+
507
+ ---
508
+
509
+ 以下是你需要分析的项目实际数据(请直接基于这些数据输出最终结果):
510
+
511
+ 项目根目录:${projectPath}
512
+ 项目名称:${projectName}
513
+
514
+ ## 目录结构(前 2 层)
515
+ ${dirTree || '(无)'}
516
+
517
+ ## README.md
518
+ ${readme || '(无)'}
519
+
520
+ ## 关键 manifest
521
+ ${manifestBlock || '(无)'}
522
+
523
+ 请输出一份 200-400 字的中文架构总结,包含:项目整体定位、技术栈、模块划分、核心流程、关键设计决策。只返回 JSON:
524
+
525
+ {
526
+ "summary": "架构总结正文"
527
+ }`;
528
+
529
+ const second = await callLlmJson(model, execPrompt);
530
+ const summary = String(second.summary || '').trim();
531
+
532
+ if (!summary) {
533
+ // 兜底:仅返回模板,结果留空
534
+ return res.json({
535
+ success: true,
536
+ name: templateName,
537
+ template,
538
+ result: '',
539
+ content: template
540
+ });
541
+ }
542
+
543
+ // 把模板和实际结果拼到一起,作为预置提示词存进 prompts.json
544
+ const content = `${template}\n\n## 当前项目架构总结(已生成于 ${nowIso()})\n\n${summary}`;
545
+
546
+ res.json({
547
+ success: true,
548
+ name: templateName,
549
+ template,
550
+ result: summary,
551
+ content
552
+ });
553
+ } catch (err) {
554
+ res.status(500).json({ success: false, error: err.message });
555
+ }
556
+ });
557
+
558
+ // SSE 事件流
559
+ app.get('/api/workbench/events', (req, res) => {
560
+ res.set({
561
+ 'Content-Type': 'text/event-stream',
562
+ 'Cache-Control': 'no-cache, no-transform',
563
+ 'Connection': 'keep-alive',
564
+ 'X-Accel-Buffering': 'no'
565
+ });
566
+ res.flushHeaders?.();
567
+ const send = (data) => {
568
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
569
+ };
570
+ // 初始快照
571
+ send({ event: 'hello', payload: { jobs: snapshotJobs() }, ts: nowIso() });
572
+ const handler = (evt) => send(evt);
573
+ bus.on('event', handler);
574
+ const ka = setInterval(() => res.write(`: keep-alive\n\n`), 15000);
575
+ req.on('close', () => {
576
+ clearInterval(ka);
577
+ bus.off('event', handler);
578
+ });
579
+ });
580
+
581
+ // ── 提示词 CRUD ─────────────────────────────────────────────────────
582
+ app.get('/api/workbench/prompts', async (_req, res) => {
583
+ try {
584
+ const data = await readJson(PROMPTS_FILE, { prompts: [] });
585
+ res.json({ success: true, prompts: data.prompts || [] });
586
+ } catch (err) {
587
+ res.status(500).json({ success: false, error: err.message });
588
+ }
589
+ });
590
+
591
+ app.post('/api/workbench/prompts', async (req, res) => {
592
+ try {
593
+ const { id, name, content } = req.body || {};
594
+ if (!name || typeof content !== 'string') {
595
+ return res.status(400).json({ success: false, error: 'name 和 content 必填' });
596
+ }
597
+ const data = await readJson(PROMPTS_FILE, { prompts: [] });
598
+ const prompts = data.prompts || [];
599
+ const now = nowIso();
600
+ if (id) {
601
+ const i = prompts.findIndex(p => p.id === id);
602
+ if (i < 0) return res.status(404).json({ success: false, error: '提示词不存在' });
603
+ prompts[i] = { ...prompts[i], name, content, updatedAt: now };
604
+ await writeJson(PROMPTS_FILE, { prompts });
605
+ return res.json({ success: true, prompt: prompts[i] });
606
+ }
607
+ const prompt = { id: genId(), name, content, createdAt: now, updatedAt: now };
608
+ prompts.push(prompt);
609
+ await writeJson(PROMPTS_FILE, { prompts });
610
+ res.json({ success: true, prompt });
611
+ } catch (err) {
612
+ res.status(500).json({ success: false, error: err.message });
613
+ }
614
+ });
615
+
616
+ app.delete('/api/workbench/prompts/:id', async (req, res) => {
617
+ try {
618
+ const data = await readJson(PROMPTS_FILE, { prompts: [] });
619
+ const prompts = (data.prompts || []).filter(p => p.id !== req.params.id);
620
+ await writeJson(PROMPTS_FILE, { prompts });
621
+ res.json({ success: true });
622
+ } catch (err) {
623
+ res.status(500).json({ success: false, error: err.message });
624
+ }
625
+ });
626
+
627
+ // ── 任务 CRUD ───────────────────────────────────────────────────────
628
+ app.get('/api/workbench/tasks', async (_req, res) => {
629
+ try {
630
+ const data = await readJson(TASKS_FILE, { tasks: [] });
631
+ res.json({ success: true, tasks: data.tasks || [] });
632
+ } catch (err) {
633
+ res.status(500).json({ success: false, error: err.message });
634
+ }
635
+ });
636
+
637
+ app.post('/api/workbench/tasks', async (req, res) => {
638
+ try {
639
+ const { id, title, desc, promptId, subtasks } = req.body || {};
640
+ if (!title) return res.status(400).json({ success: false, error: 'title 必填' });
641
+ const data = await readJson(TASKS_FILE, { tasks: [] });
642
+ const tasks = data.tasks || [];
643
+ const now = nowIso();
644
+ if (id) {
645
+ const i = tasks.findIndex(t => t.id === id);
646
+ if (i < 0) return res.status(404).json({ success: false, error: '任务不存在' });
647
+ tasks[i] = {
648
+ ...tasks[i],
649
+ title,
650
+ desc: desc || '',
651
+ promptId: promptId || null,
652
+ subtasks: Array.isArray(subtasks) ? subtasks.map(s => ({
653
+ id: s.id || genId(),
654
+ title: s.title || '',
655
+ desc: s.desc || '',
656
+ status: s.status || 'todo',
657
+ promptOverride: s.promptOverride || '',
658
+ // 保留附件元数据(仅保留基础字段,丢弃客户端临时字段)
659
+ attachments: Array.isArray(s.attachments) ? s.attachments.map(a => ({
660
+ id: a.id,
661
+ originalName: a.originalName,
662
+ mimeType: a.mimeType,
663
+ size: a.size,
664
+ ext: a.ext,
665
+ storedName: a.storedName,
666
+ absolutePath: a.absolutePath,
667
+ createdAt: a.createdAt
668
+ })) : (tasks[i].subtasks.find(x => x.id === s.id)?.attachments || [])
669
+ })) : tasks[i].subtasks,
670
+ updatedAt: now
671
+ };
672
+ await writeJson(TASKS_FILE, { tasks });
673
+ return res.json({ success: true, task: tasks[i] });
674
+ }
675
+ const task = {
676
+ id: genId(),
677
+ title,
678
+ desc: desc || '',
679
+ promptId: promptId || null,
680
+ subtasks: Array.isArray(subtasks) ? subtasks.map(s => ({
681
+ id: s.id || genId(),
682
+ title: s.title || '',
683
+ desc: s.desc || '',
684
+ status: s.status || 'todo',
685
+ promptOverride: s.promptOverride || '',
686
+ attachments: Array.isArray(s.attachments) ? s.attachments : []
687
+ })) : [],
688
+ status: 'todo',
689
+ createdAt: now,
690
+ updatedAt: now
691
+ };
692
+ tasks.push(task);
693
+ await writeJson(TASKS_FILE, { tasks });
694
+ res.json({ success: true, task });
695
+ } catch (err) {
696
+ res.status(500).json({ success: false, error: err.message });
697
+ }
698
+ });
699
+
700
+ app.delete('/api/workbench/tasks/:id', async (req, res) => {
701
+ try {
702
+ const data = await readJson(TASKS_FILE, { tasks: [] });
703
+ const tasks = (data.tasks || []).filter(t => t.id !== req.params.id);
704
+ await writeJson(TASKS_FILE, { tasks });
705
+ res.json({ success: true });
706
+ } catch (err) {
707
+ res.status(500).json({ success: false, error: err.message });
708
+ }
709
+ });
710
+
711
+ // ── 执行任务 ────────────────────────────────────────────────────────
712
+ app.post('/api/workbench/tasks/:id/run', async (req, res) => {
713
+ try {
714
+ const data = await readJson(TASKS_FILE, { tasks: [] });
715
+ const task = (data.tasks || []).find(t => t.id === req.params.id);
716
+ if (!task) return res.status(404).json({ success: false, error: '任务不存在' });
717
+ if (!task.subtasks || task.subtasks.length === 0) {
718
+ return res.status(400).json({ success: false, error: '任务没有子任务' });
719
+ }
720
+ const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
721
+ // 异步执行,立即返回
722
+ res.json({ success: true, message: '已开始执行' });
723
+ runTaskQueue(task, repoPath, '').catch(err => {
724
+ publish('task:error', { taskId: task.id, error: err.message });
725
+ });
726
+ } catch (err) {
727
+ res.status(500).json({ success: false, error: err.message });
728
+ }
729
+ });
730
+
731
+ // ── 进程状态查询(兜底,SSE 断了也能拉) ────────────────────────────
732
+ app.get('/api/workbench/jobs', (_req, res) => {
733
+ res.json({ success: true, jobs: snapshotJobs() });
734
+ });
735
+
736
+ // ── 子任务附件:上传 / 删除 / 列表 ───────────────────────────────
737
+ // 上传:POST /api/workbench/subtasks/:subId/attachments
738
+ // header: X-Original-Name, X-Mime-Type
739
+ // body: raw binary
740
+ // 删除:DELETE /api/workbench/subtasks/:subId/attachments/:attId
741
+ // 列表:GET /api/workbench/subtasks/:subId/attachments
742
+ //
743
+ // 文件存到 ~/.zen-gitsync/workbench-images/{subId}/{attId}.{ext}
744
+ // 元数据(id / originalName / mime / size / storedName)通过 sub.attachments
745
+ // 跟随 tasks.json 一起持久化。
746
+ const rawAttachment = express.raw({
747
+ type: '*/*',
748
+ limit: MAX_IMAGE_BYTES * 4 // 整体路由上限 20MB;单文件大小由业务再卡
749
+ });
750
+
751
+ app.post('/api/workbench/subtasks/:subId/attachments', rawAttachment, async (req, res) => {
752
+ try {
753
+ const { subId } = req.params;
754
+ if (!req.body || !(req.body instanceof Buffer) || req.body.length === 0) {
755
+ return res.status(400).json({ success: false, error: '请求体为空' });
756
+ }
757
+ if (req.body.length > MAX_IMAGE_BYTES) {
758
+ return res.status(413).json({ success: false, error: `单文件不得超过 ${MAX_IMAGE_BYTES / 1024 / 1024}MB` });
759
+ }
760
+ const originalName = String(req.get('X-Original-Name') || 'attachment').slice(0, 200);
761
+ const mimeType = String(req.get('X-Mime-Type') || 'application/octet-stream').slice(0, 120);
762
+ const ext = resolveExt({ originalName, mime: mimeType });
763
+ if (!ext) {
764
+ return res.status(400).json({ success: false, error: `不支持的文件类型(仅允许 ${[...ALLOWED_EXTS].join(', ')})` });
765
+ }
766
+
767
+ // 找到子任务,校验数量
768
+ const data = await readJson(TASKS_FILE, { tasks: [] });
769
+ let foundTask = null;
770
+ let foundSub = null;
771
+ for (const t of data.tasks || []) {
772
+ const s = (t.subtasks || []).find(x => x.id === subId);
773
+ if (s) { foundTask = t; foundSub = s; break; }
774
+ }
775
+ if (!foundSub) {
776
+ return res.status(404).json({ success: false, error: '子任务不存在' });
777
+ }
778
+ if (!Array.isArray(foundSub.attachments)) foundSub.attachments = [];
779
+ if (foundSub.attachments.length >= MAX_ATTACHMENTS_PER_SUBTASK) {
780
+ return res.status(400).json({
781
+ success: false,
782
+ error: `每个子任务最多 ${MAX_ATTACHMENTS_PER_SUBTASK} 个附件`
783
+ });
784
+ }
785
+
786
+ // 写入磁盘:~/.zen-gitsync/workbench-images/{subId}/{attId}.{ext}
787
+ const attId = genId();
788
+ const subDir = path.join(IMAGES_DIR, subId);
789
+ await fsp.mkdir(subDir, { recursive: true });
790
+ const storedName = `${attId}.${ext}`;
791
+ const storedPath = path.join(subDir, storedName);
792
+ await fsp.writeFile(storedPath, req.body);
793
+
794
+ const attachment = {
795
+ id: attId,
796
+ originalName,
797
+ mimeType,
798
+ size: req.body.length,
799
+ ext,
800
+ storedName,
801
+ // 绝对路径供 claude CLI 读取;同机直接读本地
802
+ absolutePath: storedPath,
803
+ createdAt: nowIso()
804
+ };
805
+ foundSub.attachments.push(attachment);
806
+ foundSub.updatedAt = nowIso();
807
+ await writeJson(TASKS_FILE, data);
808
+
809
+ res.json({ success: true, attachment });
810
+ } catch (err) {
811
+ res.status(500).json({ success: false, error: err.message });
812
+ }
813
+ });
814
+
815
+ app.delete('/api/workbench/subtasks/:subId/attachments/:attId', async (req, res) => {
816
+ try {
817
+ const { subId, attId } = req.params;
818
+ const data = await readJson(TASKS_FILE, { tasks: [] });
819
+ let foundTask = null;
820
+ let foundSub = null;
821
+ for (const t of data.tasks || []) {
822
+ const s = (t.subtasks || []).find(x => x.id === subId);
823
+ if (s) { foundTask = t; foundSub = s; break; }
824
+ }
825
+ if (!foundSub) return res.status(404).json({ success: false, error: '子任务不存在' });
826
+ const list = Array.isArray(foundSub.attachments) ? foundSub.attachments : [];
827
+ const i = list.findIndex(a => a.id === attId);
828
+ if (i < 0) return res.status(404).json({ success: false, error: '附件不存在' });
829
+ const [removed] = list.splice(i, 1);
830
+ // 删磁盘文件
831
+ try {
832
+ await fsp.unlink(path.join(IMAGES_DIR, subId, removed.storedName));
833
+ } catch { /* 文件可能已不存在,忽略 */ }
834
+ foundSub.updatedAt = nowIso();
835
+ await writeJson(TASKS_FILE, data);
836
+ res.json({ success: true });
837
+ } catch (err) {
838
+ res.status(500).json({ success: false, error: err.message });
839
+ }
840
+ });
841
+
842
+ // 附件原文件读取(前端 <img> 缩略图用)
843
+ app.get('/api/workbench/attachments/:attId/raw', async (req, res) => {
844
+ try {
845
+ const { attId } = req.params;
846
+ const data = await readJson(TASKS_FILE, { tasks: [] });
847
+ let found = null;
848
+ let parentSubId = null;
849
+ for (const t of data.tasks || []) {
850
+ for (const s of t.subtasks || []) {
851
+ const a = (s.attachments || []).find(x => x.id === attId);
852
+ if (a) { found = a; parentSubId = s.id; break; }
853
+ }
854
+ if (found) break;
855
+ }
856
+ if (!found) return res.status(404).json({ success: false, error: '附件不存在' });
857
+ const filePath = path.join(IMAGES_DIR, parentSubId, found.storedName);
858
+ try {
859
+ const stat = await fsp.stat(filePath);
860
+ res.set('Content-Type', found.mimeType || 'application/octet-stream');
861
+ res.set('Content-Length', String(stat.size));
862
+ res.set('Cache-Control', 'private, max-age=3600');
863
+ const stream = (await import('fs')).createReadStream(filePath);
864
+ stream.on('error', () => res.end());
865
+ stream.pipe(res);
866
+ } catch {
867
+ res.status(404).json({ success: false, error: '文件已丢失' });
868
+ }
869
+ } catch (err) {
870
+ res.status(500).json({ success: false, error: err.message });
871
+ }
872
+ });
873
+ }