cli-link 0.0.6 → 0.0.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.
- package/README.md +56 -14
- package/dist/client/assets/History-D2xDopni.js +4 -0
- package/dist/client/assets/ImageViewer-DuegU_fC.js +1 -0
- package/dist/client/assets/MarkdownRenderer-CsyizEL3.js +1 -0
- package/dist/client/assets/{PageTopBar-C8j-5s_3.js → PageTopBar-CQwjO6Af.js} +1 -1
- package/dist/client/assets/Session-B0s5zBGg.js +7 -0
- package/dist/client/assets/Settings-CfHFmJdV.js +1 -0
- package/dist/client/assets/Workspace-Cfl0mbNE.js +4 -0
- package/dist/client/assets/WorkspaceLinkedText-DCVYd9x-.js +2 -0
- package/dist/client/assets/c-BIGW1oBm.js +1 -0
- package/dist/client/assets/cpp-DIPi6g--.js +1 -0
- package/dist/client/assets/csharp-DSvCPggb.js +1 -0
- package/dist/client/assets/dart-bE4Kk8sk.js +1 -0
- package/dist/client/assets/dotenv-Da5cRb03.js +1 -0
- package/dist/client/assets/go-C27-OAKa.js +1 -0
- package/dist/client/assets/graphql-pNE0_Gx8.js +1 -0
- package/dist/client/assets/groovy-gcz8RCvz.js +1 -0
- package/dist/client/assets/index-BCg3ymV3.css +1 -0
- package/dist/client/assets/index-CrJqHlc8.js +2 -0
- package/dist/client/assets/java-VnEXKtx_.js +148 -0
- package/dist/client/assets/jsx-g9-lgVsj.js +1 -0
- package/dist/client/assets/kotlin-BdnUsdx6.js +1 -0
- package/dist/client/assets/less-B1dDrJ26.js +1 -0
- package/dist/client/assets/lua-BaeVxFsk.js +1 -0
- package/dist/client/assets/makefile-CHLpvVh8.js +1 -0
- package/dist/client/assets/php-BcCyJq-p.js +1 -0
- package/dist/client/assets/properties-DTPjHERo.js +1 -0
- package/dist/client/assets/ruby-BwImf3Ka.js +1 -0
- package/dist/client/assets/rust-B1yitclQ.js +1 -0
- package/dist/client/assets/scss-lMagJa-5.js +1 -0
- package/dist/client/assets/sql-CRqJ_cUM.js +1 -0
- package/dist/client/assets/svelte-B4a9v_or.js +1 -0
- package/dist/client/assets/swift-D82vCrfD.js +1 -0
- package/dist/client/assets/toml-vGWfd6FD.js +1 -0
- package/dist/client/assets/{vendor-icons-CNN4EKVi.js → vendor-icons-CMXJHDEv.js} +125 -65
- package/dist/client/assets/vendor-markdown--d-T3AbU.js +37 -0
- package/dist/client/assets/{vendor-motion-n6Lx6G4a.js → vendor-motion-D0ZmPdi9.js} +1 -1
- package/dist/client/assets/{vendor-react-DSV5aFEg.js → vendor-react-CcDXZHn_.js} +1 -1
- package/dist/client/assets/{vendor-virtual-CcftJrIC.js → vendor-virtual-DJI7OicV.js} +1 -1
- package/dist/client/assets/vue-DBXACu8K.js +1 -0
- package/dist/client/assets/workspace-return-FrQUv7g3.js +1 -0
- package/dist/client/index.html +4 -4
- package/dist/server/cli-manager.js +151 -26
- package/dist/server/codex-history.js +119 -17
- package/dist/server/index.js +906 -57
- package/dist/server/store.js +369 -27
- package/package.json +3 -3
- package/dist/client/assets/History-BxJVDFpN.js +0 -3
- package/dist/client/assets/MarkdownRenderer-BO-KS_L1.js +0 -1
- package/dist/client/assets/Session-CQFXA2Sr.js +0 -11
- package/dist/client/assets/Settings-DYmjRmoN.js +0 -1
- package/dist/client/assets/Workspace-D8kv9euM.js +0 -8
- package/dist/client/assets/WorkspaceLinkedText-DQyPLk-X.js +0 -2
- package/dist/client/assets/code-highlight-CEcsuMpw.js +0 -1
- package/dist/client/assets/index-BXT2BylN.css +0 -1
- package/dist/client/assets/index-DOgH1Kf3.js +0 -2
- package/dist/client/assets/vendor-markdown-BDwu-Ux6.js +0 -35
package/dist/server/index.js
CHANGED
|
@@ -3,11 +3,12 @@ import { createServer as createHttpsServer } from 'https';
|
|
|
3
3
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
4
|
import { CLIManager } from './cli-manager.js';
|
|
5
5
|
import { renderTerminalQr } from './terminal-qr.js';
|
|
6
|
+
import { syncCodexHistory } from './codex-history.js';
|
|
6
7
|
import * as store from './store.js';
|
|
7
8
|
import * as fs from 'fs';
|
|
8
9
|
import * as path from 'path';
|
|
9
10
|
import * as os from 'os';
|
|
10
|
-
import { timingSafeEqual } from 'crypto';
|
|
11
|
+
import { createHash, randomUUID, timingSafeEqual } from 'crypto';
|
|
11
12
|
import { execFileSync } from 'child_process';
|
|
12
13
|
import { fileURLToPath } from 'url';
|
|
13
14
|
const PORT = parseInt(process.env.PORT || '3101', 10);
|
|
@@ -104,6 +105,119 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
104
105
|
});
|
|
105
106
|
const cliManager = new CLIManager();
|
|
106
107
|
const clients = new Set();
|
|
108
|
+
let promptQueue = [];
|
|
109
|
+
let promptQueuePaused = false;
|
|
110
|
+
let promptQueueIdCounter = 0;
|
|
111
|
+
let promptQueueDispatching = false;
|
|
112
|
+
let promptQueueHoldUntil = 0;
|
|
113
|
+
let promptQueueDrainTimer = null;
|
|
114
|
+
const PROMPT_QUEUE_IDLE_SETTLE_MS = 800;
|
|
115
|
+
function getPromptQueuePayload() {
|
|
116
|
+
return {
|
|
117
|
+
promptQueue: promptQueue.map(item => ({ ...item })),
|
|
118
|
+
promptQueuePaused,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function broadcastPromptQueueChanged() {
|
|
122
|
+
broadcast({
|
|
123
|
+
type: 'prompt_queue_changed',
|
|
124
|
+
...getPromptQueuePayload(),
|
|
125
|
+
time: now(),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function clearPromptQueue() {
|
|
129
|
+
promptQueue = [];
|
|
130
|
+
promptQueuePaused = false;
|
|
131
|
+
promptQueueDispatching = false;
|
|
132
|
+
promptQueueHoldUntil = 0;
|
|
133
|
+
if (promptQueueDrainTimer) {
|
|
134
|
+
clearTimeout(promptQueueDrainTimer);
|
|
135
|
+
promptQueueDrainTimer = null;
|
|
136
|
+
}
|
|
137
|
+
broadcastPromptQueueChanged();
|
|
138
|
+
}
|
|
139
|
+
function hasPromptPayload(prompt) {
|
|
140
|
+
return !!prompt.content.trim() || prompt.attachments.length > 0;
|
|
141
|
+
}
|
|
142
|
+
function dispatchPromptToCli(prompt) {
|
|
143
|
+
if (!hasPromptPayload(prompt))
|
|
144
|
+
return;
|
|
145
|
+
cliManager.sendInput({
|
|
146
|
+
content: prompt.content,
|
|
147
|
+
attachments: prompt.attachments,
|
|
148
|
+
});
|
|
149
|
+
broadcast({
|
|
150
|
+
type: 'user_message',
|
|
151
|
+
content: prompt.content,
|
|
152
|
+
attachments: prompt.attachments,
|
|
153
|
+
details: prompt.attachments.length > 0 ? { attachments: prompt.attachments } : undefined,
|
|
154
|
+
time: now(),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function maybeDrainPromptQueue() {
|
|
158
|
+
const holdMs = promptQueueHoldUntil - Date.now();
|
|
159
|
+
if (holdMs > 0) {
|
|
160
|
+
schedulePromptQueueDrain(holdMs);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (promptQueueDispatching ||
|
|
164
|
+
promptQueuePaused ||
|
|
165
|
+
cliManager.status !== 'idle' ||
|
|
166
|
+
promptQueue.length === 0) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const nextPrompt = promptQueue.shift();
|
|
170
|
+
if (!nextPrompt)
|
|
171
|
+
return;
|
|
172
|
+
promptQueueDispatching = true;
|
|
173
|
+
broadcastPromptQueueChanged();
|
|
174
|
+
dispatchPromptToCli(nextPrompt);
|
|
175
|
+
}
|
|
176
|
+
function schedulePromptQueueDrain(delayMs) {
|
|
177
|
+
if (promptQueueDrainTimer)
|
|
178
|
+
clearTimeout(promptQueueDrainTimer);
|
|
179
|
+
promptQueueDrainTimer = setTimeout(() => {
|
|
180
|
+
promptQueueDrainTimer = null;
|
|
181
|
+
maybeDrainPromptQueue();
|
|
182
|
+
}, Math.max(0, delayMs));
|
|
183
|
+
}
|
|
184
|
+
function shouldQueueIncomingPrompt() {
|
|
185
|
+
return (promptQueue.length > 0 ||
|
|
186
|
+
promptQueuePaused ||
|
|
187
|
+
promptQueueDispatching ||
|
|
188
|
+
promptQueueHoldUntil > Date.now() ||
|
|
189
|
+
cliManager.status !== 'idle');
|
|
190
|
+
}
|
|
191
|
+
function enqueuePrompt(prompt) {
|
|
192
|
+
if (!hasPromptPayload(prompt))
|
|
193
|
+
return;
|
|
194
|
+
promptQueueIdCounter += 1;
|
|
195
|
+
promptQueue.push({
|
|
196
|
+
id: `queued-${Date.now()}-${promptQueueIdCounter}`,
|
|
197
|
+
content: prompt.content,
|
|
198
|
+
attachments: prompt.attachments,
|
|
199
|
+
});
|
|
200
|
+
broadcastPromptQueueChanged();
|
|
201
|
+
maybeDrainPromptQueue();
|
|
202
|
+
}
|
|
203
|
+
function removePromptFromQueue(id) {
|
|
204
|
+
const nextQueue = promptQueue.filter(item => item.id !== id);
|
|
205
|
+
if (nextQueue.length === promptQueue.length)
|
|
206
|
+
return;
|
|
207
|
+
promptQueue = nextQueue;
|
|
208
|
+
if (promptQueue.length === 0) {
|
|
209
|
+
promptQueuePaused = false;
|
|
210
|
+
promptQueueDispatching = false;
|
|
211
|
+
}
|
|
212
|
+
broadcastPromptQueueChanged();
|
|
213
|
+
}
|
|
214
|
+
function resumePromptQueue() {
|
|
215
|
+
if (!promptQueuePaused)
|
|
216
|
+
return;
|
|
217
|
+
promptQueuePaused = false;
|
|
218
|
+
broadcastPromptQueueChanged();
|
|
219
|
+
maybeDrainPromptQueue();
|
|
220
|
+
}
|
|
107
221
|
function unquoteMetaValue(value) {
|
|
108
222
|
const trimmed = value.trim();
|
|
109
223
|
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
@@ -238,6 +352,17 @@ function parseJsonField(raw) {
|
|
|
238
352
|
return undefined;
|
|
239
353
|
}
|
|
240
354
|
}
|
|
355
|
+
function toClientAttachments(raw) {
|
|
356
|
+
try {
|
|
357
|
+
return normalizePromptAttachments(raw, {
|
|
358
|
+
maxBytes: MAX_GENERATED_IMAGE_ATTACHMENT_BYTES,
|
|
359
|
+
maxCount: MAX_GENERATED_IMAGE_ATTACHMENTS_PER_TURN,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return [];
|
|
364
|
+
}
|
|
365
|
+
}
|
|
241
366
|
function getSessionConfig(session) {
|
|
242
367
|
const parsed = parseJsonField(session?.cli_config);
|
|
243
368
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
@@ -271,6 +396,7 @@ function toClientMessage(m) {
|
|
|
271
396
|
return null;
|
|
272
397
|
const parsedDetails = parseJsonField(m.details);
|
|
273
398
|
const isQuestion = mappedType === 'question' && m.type === 'ask_question';
|
|
399
|
+
const attachments = toClientAttachments(parsedDetails?.attachments);
|
|
274
400
|
return {
|
|
275
401
|
seq: m.seq,
|
|
276
402
|
id: m.id,
|
|
@@ -284,12 +410,64 @@ function toClientMessage(m) {
|
|
|
284
410
|
toolResult: m.toolResult || undefined,
|
|
285
411
|
permission: parseJsonField(m.permission),
|
|
286
412
|
details: parsedDetails,
|
|
413
|
+
attachments: attachments.length > 0 ? attachments : undefined,
|
|
287
414
|
question: isQuestion && parsedDetails ? { questions: parsedDetails.questions || [], toolUseId: parsedDetails.toolUseId } : undefined,
|
|
288
415
|
};
|
|
289
416
|
}
|
|
290
417
|
function toClientMessages(messages) {
|
|
291
418
|
return messages.map(toClientMessage).filter((m) => m !== null);
|
|
292
419
|
}
|
|
420
|
+
function cleanHistoryPreview(content, maxLength = 96) {
|
|
421
|
+
const normalized = content
|
|
422
|
+
.replace(/```[\s\S]*?```/g, ' 代码片段 ')
|
|
423
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
424
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
425
|
+
.replace(/^#+\s*/gm, '')
|
|
426
|
+
.replace(/[>*_~]/g, ' ')
|
|
427
|
+
.split(/\r?\n/)
|
|
428
|
+
.map(line => line.trim())
|
|
429
|
+
.filter(Boolean)
|
|
430
|
+
.join(' ')
|
|
431
|
+
.replace(/\s+/g, ' ')
|
|
432
|
+
.trim();
|
|
433
|
+
if (!normalized)
|
|
434
|
+
return '';
|
|
435
|
+
return normalized.length > maxLength ? `${normalized.slice(0, maxLength).trimEnd()}...` : normalized;
|
|
436
|
+
}
|
|
437
|
+
function buildHistoryPreview(messages) {
|
|
438
|
+
const recentFirst = [...messages].sort((a, b) => b.seq - a.seq);
|
|
439
|
+
const preferredTypes = new Set(['user', 'ai', 'question', 'confirm', 'error']);
|
|
440
|
+
const fallbackTypes = new Set(['tool', 'system', 'thinking']);
|
|
441
|
+
const labels = {
|
|
442
|
+
user: '你',
|
|
443
|
+
ai: 'AI',
|
|
444
|
+
question: '问题',
|
|
445
|
+
confirm: '确认',
|
|
446
|
+
error: '错误',
|
|
447
|
+
tool: '工具',
|
|
448
|
+
system: '系统',
|
|
449
|
+
thinking: '思考',
|
|
450
|
+
};
|
|
451
|
+
const pick = (allowedTypes) => {
|
|
452
|
+
for (const raw of recentFirst) {
|
|
453
|
+
const msg = toClientMessage(raw);
|
|
454
|
+
if (!msg || !allowedTypes.has(msg.type))
|
|
455
|
+
continue;
|
|
456
|
+
const attachmentCount = Array.isArray(msg.attachments) ? msg.attachments.length : 0;
|
|
457
|
+
const snippet = cleanHistoryPreview(msg.content) || (attachmentCount > 0 ? `图片附件 ${attachmentCount} 张` : '');
|
|
458
|
+
if (!snippet)
|
|
459
|
+
continue;
|
|
460
|
+
const label = labels[msg.type] || '消息';
|
|
461
|
+
return {
|
|
462
|
+
preview: `${label}:${snippet}`,
|
|
463
|
+
lastMessageType: msg.type,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
return {};
|
|
467
|
+
};
|
|
468
|
+
const preferred = pick(preferredTypes);
|
|
469
|
+
return preferred.preview ? preferred : pick(fallbackTypes);
|
|
470
|
+
}
|
|
293
471
|
function readPositiveInt(value, fallback = 0) {
|
|
294
472
|
const parsed = Number(value || 0);
|
|
295
473
|
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
@@ -389,11 +567,16 @@ function buildEditPrompt(prefixMessages, editedContent) {
|
|
|
389
567
|
}
|
|
390
568
|
function toClientHistoryTask(t, includeMessages = false) {
|
|
391
569
|
const cliSessionId = getRestorableCliSessionId(t.session_id, t.messages);
|
|
570
|
+
const summaryMessages = t.messages || (t.session_id ? store.getRecentSessionMessages(t.session_id, 32) : []);
|
|
571
|
+
const summary = buildHistoryPreview(summaryMessages);
|
|
392
572
|
const response = {
|
|
393
573
|
id: t.id,
|
|
394
574
|
workDir: t.work_dir || undefined,
|
|
395
575
|
status: t.status,
|
|
396
576
|
title: t.title,
|
|
577
|
+
preview: summary.preview,
|
|
578
|
+
lastMessageType: summary.lastMessageType,
|
|
579
|
+
messageCount: t.messages ? t.messages.length : (t.session_id ? store.getMessageCount(t.session_id) : 0),
|
|
397
580
|
confirmCount: t.confirm_count,
|
|
398
581
|
toolCount: t.tool_count,
|
|
399
582
|
duration: t.duration || undefined,
|
|
@@ -414,6 +597,26 @@ function toClientWorkDir(item) {
|
|
|
414
597
|
createdAt: item.created_at,
|
|
415
598
|
};
|
|
416
599
|
}
|
|
600
|
+
function toClientPromptHistoryItem(item) {
|
|
601
|
+
return {
|
|
602
|
+
id: item.id,
|
|
603
|
+
workDir: item.work_dir || undefined,
|
|
604
|
+
text: item.text,
|
|
605
|
+
useCount: item.use_count,
|
|
606
|
+
lastUsedAt: item.last_used_at,
|
|
607
|
+
createdAt: item.created_at,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
function normalizeHistoryScope(value, fallback = 'global') {
|
|
611
|
+
return value === 'current' || value === 'project' ? 'current' : fallback;
|
|
612
|
+
}
|
|
613
|
+
function getHistoryWorkDirForScope(scope) {
|
|
614
|
+
return scope === 'current' ? getCurrentWorkDir() : null;
|
|
615
|
+
}
|
|
616
|
+
function getCurrentHistoryWorkDir() {
|
|
617
|
+
const workDir = getCurrentWorkDir();
|
|
618
|
+
return typeof workDir === 'string' && workDir.trim() ? workDir : null;
|
|
619
|
+
}
|
|
417
620
|
function getCurrentWorkDir() {
|
|
418
621
|
return cliManager.getConfig().workDir;
|
|
419
622
|
}
|
|
@@ -436,7 +639,15 @@ const WORKSPACE_SKIP_NAMES = new Set([
|
|
|
436
639
|
]);
|
|
437
640
|
const MAX_FILE_BYTES = 512 * 1024;
|
|
438
641
|
const MAX_DIFF_BYTES = 1024 * 1024;
|
|
439
|
-
const MAX_COMMIT_CONTEXT_BYTES =
|
|
642
|
+
const MAX_COMMIT_CONTEXT_BYTES = 96 * 1024;
|
|
643
|
+
const MAX_COMMIT_DIFF_BYTES = 64 * 1024;
|
|
644
|
+
const COMMIT_MESSAGE_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
645
|
+
const MAX_IMAGE_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
646
|
+
const MAX_GENERATED_IMAGE_ATTACHMENT_BYTES = 30 * 1024 * 1024;
|
|
647
|
+
const MAX_GENERATED_IMAGE_ATTACHMENTS_PER_TURN = 8;
|
|
648
|
+
const MAX_IMAGE_ATTACHMENTS_PER_PROMPT = 4;
|
|
649
|
+
const ATTACHMENT_ROOT = path.join(os.homedir(), '.agentpilot', 'attachments');
|
|
650
|
+
const CODEX_GENERATED_IMAGES_ROOT = path.join(resolveEnvPath(process.env.CODEX_HOME || '~/.codex'), 'generated_images');
|
|
440
651
|
const PREVIEW_IMAGE_MIME_BY_EXTENSION = {
|
|
441
652
|
'.avif': 'image/avif',
|
|
442
653
|
'.bmp': 'image/bmp',
|
|
@@ -448,6 +659,281 @@ const PREVIEW_IMAGE_MIME_BY_EXTENSION = {
|
|
|
448
659
|
'.svg': 'image/svg+xml',
|
|
449
660
|
'.webp': 'image/webp',
|
|
450
661
|
};
|
|
662
|
+
const ATTACHMENT_EXTENSION_BY_MIME = {
|
|
663
|
+
'image/avif': 'avif',
|
|
664
|
+
'image/bmp': 'bmp',
|
|
665
|
+
'image/gif': 'gif',
|
|
666
|
+
'image/jpeg': 'jpg',
|
|
667
|
+
'image/png': 'png',
|
|
668
|
+
'image/webp': 'webp',
|
|
669
|
+
};
|
|
670
|
+
let generatedImageCapture = null;
|
|
671
|
+
function normalizeImageMime(raw) {
|
|
672
|
+
const mime = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
|
|
673
|
+
return ATTACHMENT_EXTENSION_BY_MIME[mime] ? mime : '';
|
|
674
|
+
}
|
|
675
|
+
function sanitizeAttachmentName(raw, fallback = 'image') {
|
|
676
|
+
const base = path.basename(typeof raw === 'string' && raw.trim() ? raw.trim() : fallback);
|
|
677
|
+
return base.replace(/[\r\n"\\/:*?<>|\x00-\x1F]+/g, '_').slice(0, 120) || fallback;
|
|
678
|
+
}
|
|
679
|
+
function ensureAttachmentRoot() {
|
|
680
|
+
fs.mkdirSync(ATTACHMENT_ROOT, { recursive: true });
|
|
681
|
+
}
|
|
682
|
+
function assertAttachmentPath(rawPath) {
|
|
683
|
+
if (typeof rawPath !== 'string' || !rawPath.trim()) {
|
|
684
|
+
throw new Error('附件路径无效');
|
|
685
|
+
}
|
|
686
|
+
ensureAttachmentRoot();
|
|
687
|
+
const resolvedRoot = fs.realpathSync(ATTACHMENT_ROOT);
|
|
688
|
+
const resolvedPath = path.resolve(rawPath);
|
|
689
|
+
const realPath = fs.realpathSync(resolvedPath);
|
|
690
|
+
if (realPath !== resolvedRoot && !realPath.startsWith(`${resolvedRoot}${path.sep}`)) {
|
|
691
|
+
throw new Error('附件路径不在允许目录内');
|
|
692
|
+
}
|
|
693
|
+
return realPath;
|
|
694
|
+
}
|
|
695
|
+
function normalizePromptAttachments(rawAttachments, options = {}) {
|
|
696
|
+
if (!Array.isArray(rawAttachments))
|
|
697
|
+
return [];
|
|
698
|
+
const maxBytes = options.maxBytes ?? MAX_IMAGE_ATTACHMENT_BYTES;
|
|
699
|
+
const maxCount = options.maxCount ?? MAX_IMAGE_ATTACHMENTS_PER_PROMPT;
|
|
700
|
+
return rawAttachments.slice(0, maxCount).map((raw, index) => {
|
|
701
|
+
if (!raw || typeof raw !== 'object') {
|
|
702
|
+
throw new Error('附件格式无效');
|
|
703
|
+
}
|
|
704
|
+
const item = raw;
|
|
705
|
+
const filePath = assertAttachmentPath(item.path);
|
|
706
|
+
const stat = fs.statSync(filePath);
|
|
707
|
+
if (!stat.isFile())
|
|
708
|
+
throw new Error('附件不是文件');
|
|
709
|
+
if (stat.size > maxBytes)
|
|
710
|
+
throw new Error('图片附件过大');
|
|
711
|
+
const mimeType = normalizeImageMime(item.mimeType) || getPreviewImageMime(filePath) || '';
|
|
712
|
+
if (!mimeType || !ATTACHMENT_EXTENSION_BY_MIME[mimeType]) {
|
|
713
|
+
throw new Error('仅支持图片附件');
|
|
714
|
+
}
|
|
715
|
+
return {
|
|
716
|
+
id: typeof item.id === 'string' && item.id ? item.id : `attachment-${index + 1}`,
|
|
717
|
+
type: 'image',
|
|
718
|
+
name: sanitizeAttachmentName(item.name, path.basename(filePath)),
|
|
719
|
+
mimeType,
|
|
720
|
+
size: stat.size,
|
|
721
|
+
path: filePath,
|
|
722
|
+
createdAt: typeof item.createdAt === 'number' && Number.isFinite(item.createdAt) ? item.createdAt : stat.mtimeMs,
|
|
723
|
+
};
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
function normalizePromptPayload(raw) {
|
|
727
|
+
const content = typeof raw === 'string'
|
|
728
|
+
? raw
|
|
729
|
+
: typeof raw?.content === 'string'
|
|
730
|
+
? raw.content
|
|
731
|
+
: '';
|
|
732
|
+
const attachments = normalizePromptAttachments(typeof raw === 'object' && raw ? raw.attachments : undefined);
|
|
733
|
+
return { content, attachments };
|
|
734
|
+
}
|
|
735
|
+
function parseImageUploadBody(body) {
|
|
736
|
+
const dataUrl = typeof body?.dataUrl === 'string' ? body.dataUrl : '';
|
|
737
|
+
const dataUrlMatch = dataUrl.match(/^data:([^;,]+);base64,(.*)$/s);
|
|
738
|
+
const name = sanitizeAttachmentName(body?.name, 'image');
|
|
739
|
+
const mimeType = normalizeImageMime(body?.mimeType || body?.type || dataUrlMatch?.[1])
|
|
740
|
+
|| normalizeImageMime(getPreviewImageMime(name));
|
|
741
|
+
const base64 = typeof body?.data === 'string' ? body.data : dataUrlMatch?.[2] || '';
|
|
742
|
+
if (!mimeType)
|
|
743
|
+
throw new Error('仅支持图片附件');
|
|
744
|
+
if (!base64)
|
|
745
|
+
throw new Error('图片数据为空');
|
|
746
|
+
const buffer = Buffer.from(base64, 'base64');
|
|
747
|
+
if (buffer.length === 0)
|
|
748
|
+
throw new Error('图片数据为空');
|
|
749
|
+
if (buffer.length > MAX_IMAGE_ATTACHMENT_BYTES)
|
|
750
|
+
throw new Error('图片不能超过 10MB');
|
|
751
|
+
return {
|
|
752
|
+
buffer,
|
|
753
|
+
mimeType,
|
|
754
|
+
name,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
function saveImageAttachment(body) {
|
|
758
|
+
const { buffer, mimeType, name } = parseImageUploadBody(body);
|
|
759
|
+
const sessionDir = currentSessionId || 'draft';
|
|
760
|
+
const dir = path.join(ATTACHMENT_ROOT, sessionDir);
|
|
761
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
762
|
+
const id = randomUUID();
|
|
763
|
+
const ext = ATTACHMENT_EXTENSION_BY_MIME[mimeType];
|
|
764
|
+
const filePath = path.join(dir, `${Date.now()}-${id}.${ext}`);
|
|
765
|
+
fs.writeFileSync(filePath, buffer, { flag: 'wx' });
|
|
766
|
+
return {
|
|
767
|
+
id,
|
|
768
|
+
type: 'image',
|
|
769
|
+
name,
|
|
770
|
+
mimeType,
|
|
771
|
+
size: buffer.length,
|
|
772
|
+
path: filePath,
|
|
773
|
+
createdAt: Date.now(),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
function listGeneratedImageFiles() {
|
|
777
|
+
const files = [];
|
|
778
|
+
function walk(dir, depth) {
|
|
779
|
+
if (depth > 4)
|
|
780
|
+
return;
|
|
781
|
+
let entries;
|
|
782
|
+
try {
|
|
783
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
784
|
+
}
|
|
785
|
+
catch {
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
for (const entry of entries) {
|
|
789
|
+
const entryPath = path.join(dir, entry.name);
|
|
790
|
+
if (entry.isDirectory()) {
|
|
791
|
+
walk(entryPath, depth + 1);
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
if (!entry.isFile())
|
|
795
|
+
continue;
|
|
796
|
+
const mimeType = getPreviewImageMime(entryPath);
|
|
797
|
+
if (mimeType && ATTACHMENT_EXTENSION_BY_MIME[mimeType]) {
|
|
798
|
+
files.push(path.resolve(entryPath));
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
walk(CODEX_GENERATED_IMAGES_ROOT, 0);
|
|
803
|
+
return files;
|
|
804
|
+
}
|
|
805
|
+
function maybeStartGeneratedImageCapture() {
|
|
806
|
+
if (generatedImageCapture)
|
|
807
|
+
return;
|
|
808
|
+
if (cliManager.getConfig().cliType !== 'codex')
|
|
809
|
+
return;
|
|
810
|
+
generatedImageCapture = {
|
|
811
|
+
startedAt: Date.now(),
|
|
812
|
+
baseline: new Set(listGeneratedImageFiles()),
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
function copyGeneratedImageToAttachment(sourcePath, index) {
|
|
816
|
+
const mimeType = getPreviewImageMime(sourcePath);
|
|
817
|
+
if (!mimeType || !ATTACHMENT_EXTENSION_BY_MIME[mimeType])
|
|
818
|
+
return null;
|
|
819
|
+
let sourceStat;
|
|
820
|
+
try {
|
|
821
|
+
sourceStat = fs.statSync(sourcePath);
|
|
822
|
+
}
|
|
823
|
+
catch {
|
|
824
|
+
return null;
|
|
825
|
+
}
|
|
826
|
+
if (!sourceStat.isFile() || sourceStat.size <= 0 || sourceStat.size > MAX_GENERATED_IMAGE_ATTACHMENT_BYTES) {
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
const id = randomUUID();
|
|
830
|
+
const ext = ATTACHMENT_EXTENSION_BY_MIME[mimeType];
|
|
831
|
+
const dir = path.join(ATTACHMENT_ROOT, currentSessionId || 'generated', 'generated');
|
|
832
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
833
|
+
const filePath = path.join(dir, `${Date.now()}-${id}.${ext}`);
|
|
834
|
+
try {
|
|
835
|
+
fs.copyFileSync(sourcePath, filePath, fs.constants.COPYFILE_EXCL);
|
|
836
|
+
}
|
|
837
|
+
catch (err) {
|
|
838
|
+
console.warn(`[Attachments] Failed to copy generated image ${sourcePath}: ${err?.message || err}`);
|
|
839
|
+
return null;
|
|
840
|
+
}
|
|
841
|
+
const stat = fs.statSync(filePath);
|
|
842
|
+
return {
|
|
843
|
+
id,
|
|
844
|
+
type: 'image',
|
|
845
|
+
name: `generated-image-${index + 1}.${ext}`,
|
|
846
|
+
mimeType,
|
|
847
|
+
size: stat.size,
|
|
848
|
+
path: filePath,
|
|
849
|
+
createdAt: Date.now(),
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
function collectGeneratedImageAttachments(finish) {
|
|
853
|
+
const capture = generatedImageCapture;
|
|
854
|
+
if (!capture)
|
|
855
|
+
return [];
|
|
856
|
+
const minMtime = capture.startedAt - 5000;
|
|
857
|
+
const candidates = listGeneratedImageFiles()
|
|
858
|
+
.map((filePath) => {
|
|
859
|
+
try {
|
|
860
|
+
return { filePath, stat: fs.statSync(filePath) };
|
|
861
|
+
}
|
|
862
|
+
catch {
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
})
|
|
866
|
+
.filter((item) => !!item &&
|
|
867
|
+
item.stat.isFile() &&
|
|
868
|
+
item.stat.size > 0 &&
|
|
869
|
+
item.stat.size <= MAX_GENERATED_IMAGE_ATTACHMENT_BYTES &&
|
|
870
|
+
!capture.baseline.has(item.filePath) &&
|
|
871
|
+
item.stat.mtimeMs >= minMtime)
|
|
872
|
+
.sort((a, b) => a.stat.mtimeMs - b.stat.mtimeMs)
|
|
873
|
+
.slice(-MAX_GENERATED_IMAGE_ATTACHMENTS_PER_TURN);
|
|
874
|
+
const attachments = candidates
|
|
875
|
+
.map((candidate, index) => copyGeneratedImageToAttachment(candidate.filePath, index))
|
|
876
|
+
.filter((item) => item !== null);
|
|
877
|
+
if (attachments.length > 0 || finish) {
|
|
878
|
+
generatedImageCapture = null;
|
|
879
|
+
}
|
|
880
|
+
return attachments;
|
|
881
|
+
}
|
|
882
|
+
function attachGeneratedImagesToEvent(event) {
|
|
883
|
+
if (event.type !== 'system' || event.details?.subtype !== 'result')
|
|
884
|
+
return [];
|
|
885
|
+
const attachments = collectGeneratedImageAttachments(false);
|
|
886
|
+
if (attachments.length === 0)
|
|
887
|
+
return [];
|
|
888
|
+
event.details = {
|
|
889
|
+
...(event.details || {}),
|
|
890
|
+
attachments: [
|
|
891
|
+
...toClientAttachments(event.details?.attachments),
|
|
892
|
+
...attachments,
|
|
893
|
+
],
|
|
894
|
+
};
|
|
895
|
+
event.attachments = event.details.attachments;
|
|
896
|
+
return attachments;
|
|
897
|
+
}
|
|
898
|
+
function emitGeneratedImagesFallback() {
|
|
899
|
+
const attachments = collectGeneratedImageAttachments(true);
|
|
900
|
+
if (attachments.length === 0)
|
|
901
|
+
return;
|
|
902
|
+
broadcast({
|
|
903
|
+
type: 'system',
|
|
904
|
+
content: '生成图片',
|
|
905
|
+
time: now(),
|
|
906
|
+
details: {
|
|
907
|
+
subtype: 'result',
|
|
908
|
+
attachments,
|
|
909
|
+
},
|
|
910
|
+
attachments,
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
function sendAttachmentFile(req, res, rawPath) {
|
|
914
|
+
const filePath = assertAttachmentPath(rawPath);
|
|
915
|
+
const stat = fs.statSync(filePath);
|
|
916
|
+
if (!stat.isFile()) {
|
|
917
|
+
sendJson(res, 404, { error: '附件不存在' });
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
const mimeType = getPreviewImageMime(filePath);
|
|
921
|
+
if (!mimeType || !ATTACHMENT_EXTENSION_BY_MIME[mimeType]) {
|
|
922
|
+
sendJson(res, 415, { error: '仅支持图片附件预览' });
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
setCorsHeaders(res);
|
|
926
|
+
res.writeHead(200, {
|
|
927
|
+
'Content-Type': mimeType,
|
|
928
|
+
'Content-Length': String(stat.size),
|
|
929
|
+
'Cache-Control': 'private, max-age=3600',
|
|
930
|
+
});
|
|
931
|
+
if (req.method === 'HEAD') {
|
|
932
|
+
res.end();
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
fs.createReadStream(filePath).pipe(res);
|
|
936
|
+
}
|
|
451
937
|
function decodeGitOctalPath(raw) {
|
|
452
938
|
return raw.replace(/(?:\\[0-7]{3})+/g, (sequence) => {
|
|
453
939
|
const bytes = sequence.match(/\\([0-7]{3})/g)?.map(item => parseInt(item.slice(1), 8)) || [];
|
|
@@ -621,6 +1107,28 @@ function runGit(args, allowExitCodes = [0]) {
|
|
|
621
1107
|
throw new Error(stderr || err?.message || 'Git 命令执行失败');
|
|
622
1108
|
}
|
|
623
1109
|
}
|
|
1110
|
+
function getCurrentGitInfo() {
|
|
1111
|
+
try {
|
|
1112
|
+
const insideWorkTree = runGit(['rev-parse', '--is-inside-work-tree'], [0, 128]).trim() === 'true';
|
|
1113
|
+
if (!insideWorkTree)
|
|
1114
|
+
return { isGitRepo: false, gitBranch: '' };
|
|
1115
|
+
const branch = runGit(['rev-parse', '--abbrev-ref', 'HEAD'], [0, 128]).trim();
|
|
1116
|
+
if (branch && branch !== 'HEAD') {
|
|
1117
|
+
return { isGitRepo: true, gitBranch: branch };
|
|
1118
|
+
}
|
|
1119
|
+
const shortHash = runGit(['rev-parse', '--short', 'HEAD'], [0, 128]).trim();
|
|
1120
|
+
return { isGitRepo: true, gitBranch: shortHash ? `HEAD ${shortHash}` : branch || 'HEAD' };
|
|
1121
|
+
}
|
|
1122
|
+
catch {
|
|
1123
|
+
return { isGitRepo: false, gitBranch: '' };
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
function getClientConfig() {
|
|
1127
|
+
return {
|
|
1128
|
+
...cliManager.getConfig(),
|
|
1129
|
+
...getCurrentGitInfo(),
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
624
1132
|
function unquoteGitPath(raw) {
|
|
625
1133
|
const trimmed = raw.trim();
|
|
626
1134
|
if (!trimmed.startsWith('"') || !trimmed.endsWith('"'))
|
|
@@ -663,6 +1171,8 @@ function parseGitStatus(output, nulTerminated = false) {
|
|
|
663
1171
|
const indexStatus = line[0] || ' ';
|
|
664
1172
|
const worktreeStatus = line[1] || ' ';
|
|
665
1173
|
const filePath = line.slice(3);
|
|
1174
|
+
const isMoved = indexStatus === 'R' || indexStatus === 'C' || worktreeStatus === 'R' || worktreeStatus === 'C';
|
|
1175
|
+
const oldPath = isMoved ? records[index + 1] : undefined;
|
|
666
1176
|
files.push({
|
|
667
1177
|
path: filePath,
|
|
668
1178
|
name: path.basename(filePath),
|
|
@@ -670,8 +1180,9 @@ function parseGitStatus(output, nulTerminated = false) {
|
|
|
670
1180
|
indexStatus,
|
|
671
1181
|
worktreeStatus,
|
|
672
1182
|
label: describeGitStatus(indexStatus, worktreeStatus),
|
|
1183
|
+
oldPath,
|
|
673
1184
|
});
|
|
674
|
-
if (
|
|
1185
|
+
if (isMoved) {
|
|
675
1186
|
index += 1;
|
|
676
1187
|
}
|
|
677
1188
|
}
|
|
@@ -683,7 +1194,9 @@ function parseGitStatus(output, nulTerminated = false) {
|
|
|
683
1194
|
const indexStatus = line[0] || ' ';
|
|
684
1195
|
const worktreeStatus = line[1] || ' ';
|
|
685
1196
|
const rawPath = line.slice(3);
|
|
686
|
-
const
|
|
1197
|
+
const movedParts = rawPath.includes(' -> ') ? rawPath.split(' -> ') : [];
|
|
1198
|
+
const displayPath = movedParts.length > 0 ? movedParts[movedParts.length - 1] || rawPath : rawPath;
|
|
1199
|
+
const oldPath = movedParts.length > 1 ? unquoteGitPath(movedParts[0] || '') : undefined;
|
|
687
1200
|
const filePath = unquoteGitPath(displayPath);
|
|
688
1201
|
return {
|
|
689
1202
|
path: filePath,
|
|
@@ -692,9 +1205,88 @@ function parseGitStatus(output, nulTerminated = false) {
|
|
|
692
1205
|
indexStatus,
|
|
693
1206
|
worktreeStatus,
|
|
694
1207
|
label: describeGitStatus(indexStatus, worktreeStatus),
|
|
1208
|
+
oldPath,
|
|
695
1209
|
};
|
|
696
1210
|
}).filter((item) => item !== null);
|
|
697
1211
|
}
|
|
1212
|
+
function emptyChangeStats() {
|
|
1213
|
+
return { additions: 0, deletions: 0 };
|
|
1214
|
+
}
|
|
1215
|
+
function normalizeGitDiffPath(raw) {
|
|
1216
|
+
const trimmed = raw.trim();
|
|
1217
|
+
if (!trimmed || trimmed === '/dev/null')
|
|
1218
|
+
return '';
|
|
1219
|
+
return unquoteGitPath(trimmed.replace(/^[ab]\//, ''));
|
|
1220
|
+
}
|
|
1221
|
+
function rememberDiffStats(map, filePath) {
|
|
1222
|
+
const normalized = normalizeGitDiffPath(filePath);
|
|
1223
|
+
if (!normalized)
|
|
1224
|
+
return null;
|
|
1225
|
+
let stats = map.get(normalized);
|
|
1226
|
+
if (!stats) {
|
|
1227
|
+
stats = emptyChangeStats();
|
|
1228
|
+
map.set(normalized, stats);
|
|
1229
|
+
}
|
|
1230
|
+
return stats;
|
|
1231
|
+
}
|
|
1232
|
+
function parseDiffStats(diff) {
|
|
1233
|
+
const statsByPath = new Map();
|
|
1234
|
+
let currentStats = null;
|
|
1235
|
+
for (const line of diff.split('\n')) {
|
|
1236
|
+
if (line.startsWith('diff --git ')) {
|
|
1237
|
+
const parts = line.split(' ');
|
|
1238
|
+
currentStats = rememberDiffStats(statsByPath, parts[3] || parts[2] || '');
|
|
1239
|
+
continue;
|
|
1240
|
+
}
|
|
1241
|
+
if (line.startsWith('+++ ')) {
|
|
1242
|
+
const nextPath = normalizeGitDiffPath(line.slice(4));
|
|
1243
|
+
if (nextPath)
|
|
1244
|
+
currentStats = rememberDiffStats(statsByPath, nextPath);
|
|
1245
|
+
continue;
|
|
1246
|
+
}
|
|
1247
|
+
if (!currentStats)
|
|
1248
|
+
continue;
|
|
1249
|
+
if (line.startsWith('--- '))
|
|
1250
|
+
continue;
|
|
1251
|
+
if (line.startsWith('+')) {
|
|
1252
|
+
currentStats.additions += 1;
|
|
1253
|
+
}
|
|
1254
|
+
else if (line.startsWith('-')) {
|
|
1255
|
+
currentStats.deletions += 1;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
return statsByPath;
|
|
1259
|
+
}
|
|
1260
|
+
function mergeStats(left, right) {
|
|
1261
|
+
return {
|
|
1262
|
+
additions: left.additions + right.additions,
|
|
1263
|
+
deletions: left.deletions + right.deletions,
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
function countSmallTextFileLines(rawPath) {
|
|
1267
|
+
try {
|
|
1268
|
+
const { target } = resolveWorkspacePath(rawPath, true);
|
|
1269
|
+
const stat = fs.statSync(target);
|
|
1270
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES)
|
|
1271
|
+
return null;
|
|
1272
|
+
const buffer = fs.readFileSync(target);
|
|
1273
|
+
if (buffer.includes(0))
|
|
1274
|
+
return null;
|
|
1275
|
+
const text = buffer.toString('utf8');
|
|
1276
|
+
if (!text)
|
|
1277
|
+
return 0;
|
|
1278
|
+
const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
1279
|
+
return normalized.endsWith('\n')
|
|
1280
|
+
? normalized.split('\n').length - 1
|
|
1281
|
+
: normalized.split('\n').length;
|
|
1282
|
+
}
|
|
1283
|
+
catch {
|
|
1284
|
+
return null;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
function sumStats(values) {
|
|
1288
|
+
return values.reduce((sum, stats) => mergeStats(sum, stats), emptyChangeStats());
|
|
1289
|
+
}
|
|
698
1290
|
function truncateText(text, maxBytes) {
|
|
699
1291
|
const buffer = Buffer.from(text, 'utf8');
|
|
700
1292
|
if (buffer.length <= maxBytes) {
|
|
@@ -855,8 +1447,23 @@ function getWorkspaceChanges(rawPath) {
|
|
|
855
1447
|
isGitRepo: false,
|
|
856
1448
|
branch: '',
|
|
857
1449
|
files: [],
|
|
1450
|
+
summary: {
|
|
1451
|
+
fileCount: 0,
|
|
1452
|
+
stagedFileCount: 0,
|
|
1453
|
+
unstagedFileCount: 0,
|
|
1454
|
+
additions: 0,
|
|
1455
|
+
deletions: 0,
|
|
1456
|
+
stagedAdditions: 0,
|
|
1457
|
+
stagedDeletions: 0,
|
|
1458
|
+
unstagedAdditions: 0,
|
|
1459
|
+
unstagedDeletions: 0,
|
|
1460
|
+
},
|
|
858
1461
|
diff: '',
|
|
1462
|
+
stagedDiff: '',
|
|
1463
|
+
unstagedDiff: '',
|
|
859
1464
|
truncated: false,
|
|
1465
|
+
stagedTruncated: false,
|
|
1466
|
+
unstagedTruncated: false,
|
|
860
1467
|
};
|
|
861
1468
|
}
|
|
862
1469
|
const selectedPath = normalizeWorkspacePath(rawPath);
|
|
@@ -865,36 +1472,84 @@ function getWorkspaceChanges(rawPath) {
|
|
|
865
1472
|
}
|
|
866
1473
|
const branch = runGit(['rev-parse', '--abbrev-ref', 'HEAD']).trim();
|
|
867
1474
|
const statusOutput = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', '.']);
|
|
868
|
-
const
|
|
1475
|
+
const rawFiles = parseGitStatus(statusOutput, true);
|
|
869
1476
|
const diffTarget = selectedPath || '.';
|
|
870
1477
|
const staged = runGit(['diff', '--cached', '--', diffTarget]);
|
|
871
|
-
|
|
1478
|
+
let unstaged = runGit(['diff', '--', diffTarget]);
|
|
872
1479
|
const parts = [];
|
|
873
1480
|
if (staged.trim())
|
|
874
1481
|
parts.push(`--- 已暂存变更 ---\n${staged}`);
|
|
875
1482
|
if (unstaged.trim())
|
|
876
1483
|
parts.push(`--- 未暂存变更 ---\n${unstaged}`);
|
|
877
1484
|
if (selectedPath && parts.length === 0) {
|
|
878
|
-
const selected =
|
|
1485
|
+
const selected = rawFiles.find(file => file.path === selectedPath);
|
|
879
1486
|
if (selected?.status === '??') {
|
|
880
1487
|
const { target } = resolveWorkspacePath(selectedPath, true);
|
|
881
1488
|
const stat = fs.statSync(target);
|
|
882
1489
|
if (stat.isFile() && stat.size <= MAX_FILE_BYTES) {
|
|
883
1490
|
const untrackedDiff = runGit(['diff', '--no-index', '--', '/dev/null', target], [0, 1]);
|
|
884
|
-
if (untrackedDiff.trim())
|
|
885
|
-
|
|
1491
|
+
if (untrackedDiff.trim()) {
|
|
1492
|
+
unstaged = untrackedDiff;
|
|
1493
|
+
parts.push(`--- 未暂存变更 ---\n${untrackedDiff}`);
|
|
1494
|
+
}
|
|
886
1495
|
}
|
|
887
1496
|
}
|
|
888
1497
|
}
|
|
1498
|
+
const stagedDiffResult = truncateText(staged, MAX_DIFF_BYTES);
|
|
1499
|
+
const unstagedDiffResult = truncateText(unstaged, MAX_DIFF_BYTES);
|
|
1500
|
+
const stagedStatsByPath = parseDiffStats(staged);
|
|
1501
|
+
const unstagedStatsByPath = parseDiffStats(unstaged);
|
|
1502
|
+
const files = rawFiles.map((file) => {
|
|
1503
|
+
const stagedStats = stagedStatsByPath.get(file.path) || emptyChangeStats();
|
|
1504
|
+
let unstagedStats = unstagedStatsByPath.get(file.path) || emptyChangeStats();
|
|
1505
|
+
if (file.status === '??' && unstagedStats.additions === 0 && unstagedStats.deletions === 0) {
|
|
1506
|
+
const additions = countSmallTextFileLines(file.path);
|
|
1507
|
+
if (additions != null) {
|
|
1508
|
+
unstagedStats = { additions, deletions: 0 };
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
const totalStats = mergeStats(stagedStats, unstagedStats);
|
|
1512
|
+
const hasStaged = file.indexStatus !== ' ' && file.indexStatus !== '?' && file.indexStatus !== '!';
|
|
1513
|
+
const hasUnstaged = file.status === '??' || (file.worktreeStatus !== ' ' && file.worktreeStatus !== '?' && file.worktreeStatus !== '!');
|
|
1514
|
+
return {
|
|
1515
|
+
...file,
|
|
1516
|
+
additions: totalStats.additions,
|
|
1517
|
+
deletions: totalStats.deletions,
|
|
1518
|
+
stagedAdditions: stagedStats.additions,
|
|
1519
|
+
stagedDeletions: stagedStats.deletions,
|
|
1520
|
+
unstagedAdditions: unstagedStats.additions,
|
|
1521
|
+
unstagedDeletions: unstagedStats.deletions,
|
|
1522
|
+
hasStaged,
|
|
1523
|
+
hasUnstaged,
|
|
1524
|
+
};
|
|
1525
|
+
});
|
|
1526
|
+
const stagedTotals = sumStats(files.map(file => ({ additions: file.stagedAdditions, deletions: file.stagedDeletions })));
|
|
1527
|
+
const unstagedTotals = sumStats(files.map(file => ({ additions: file.unstagedAdditions, deletions: file.unstagedDeletions })));
|
|
1528
|
+
const totalStats = mergeStats(stagedTotals, unstagedTotals);
|
|
889
1529
|
const diffResult = truncateText(parts.join('\n'), MAX_DIFF_BYTES);
|
|
890
1530
|
return {
|
|
891
1531
|
workDir: path.resolve(getCurrentWorkDir()),
|
|
892
1532
|
isGitRepo: true,
|
|
893
1533
|
branch,
|
|
894
1534
|
files,
|
|
1535
|
+
summary: {
|
|
1536
|
+
fileCount: files.length,
|
|
1537
|
+
stagedFileCount: files.filter(file => file.hasStaged).length,
|
|
1538
|
+
unstagedFileCount: files.filter(file => file.hasUnstaged).length,
|
|
1539
|
+
additions: totalStats.additions,
|
|
1540
|
+
deletions: totalStats.deletions,
|
|
1541
|
+
stagedAdditions: stagedTotals.additions,
|
|
1542
|
+
stagedDeletions: stagedTotals.deletions,
|
|
1543
|
+
unstagedAdditions: unstagedTotals.additions,
|
|
1544
|
+
unstagedDeletions: unstagedTotals.deletions,
|
|
1545
|
+
},
|
|
895
1546
|
diff: diffResult.text,
|
|
1547
|
+
stagedDiff: stagedDiffResult.text,
|
|
1548
|
+
unstagedDiff: unstagedDiffResult.text,
|
|
896
1549
|
diffPath: selectedPath || undefined,
|
|
897
1550
|
truncated: diffResult.truncated,
|
|
1551
|
+
stagedTruncated: stagedDiffResult.truncated,
|
|
1552
|
+
unstagedTruncated: unstagedDiffResult.truncated,
|
|
898
1553
|
};
|
|
899
1554
|
}
|
|
900
1555
|
function trackWorkspaceFile(rawPath) {
|
|
@@ -972,6 +1627,41 @@ function unstageWorkspacePath(rawPath) {
|
|
|
972
1627
|
unstaged: true,
|
|
973
1628
|
};
|
|
974
1629
|
}
|
|
1630
|
+
function isSameOrNestedWorkspacePath(pathname, targetPath) {
|
|
1631
|
+
return !!pathname && (pathname === targetPath || pathname.startsWith(`${targetPath}/`));
|
|
1632
|
+
}
|
|
1633
|
+
function discardWorkspaceChanges(rawPath) {
|
|
1634
|
+
ensureWorkspaceGitRepo();
|
|
1635
|
+
const selectedPath = normalizeWorkspacePath(rawPath);
|
|
1636
|
+
if (selectedPath) {
|
|
1637
|
+
resolveWorkspacePath(selectedPath, false);
|
|
1638
|
+
}
|
|
1639
|
+
const allStatusOutput = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', '.']);
|
|
1640
|
+
const allFiles = parseGitStatus(allStatusOutput, true);
|
|
1641
|
+
const files = selectedPath
|
|
1642
|
+
? allFiles.filter(file => isSameOrNestedWorkspacePath(file.path, selectedPath) ||
|
|
1643
|
+
isSameOrNestedWorkspacePath(file.oldPath, selectedPath))
|
|
1644
|
+
: allFiles;
|
|
1645
|
+
if (files.length === 0) {
|
|
1646
|
+
throw new Error(selectedPath ? '文件没有可清空的变更' : '没有可清空的代码变更');
|
|
1647
|
+
}
|
|
1648
|
+
const trackedFiles = files.filter(file => file.status !== '??');
|
|
1649
|
+
if (trackedFiles.length > 0) {
|
|
1650
|
+
const targets = selectedPath
|
|
1651
|
+
? Array.from(new Set(trackedFiles.flatMap(file => [file.path, file.oldPath].filter(Boolean))))
|
|
1652
|
+
: ['.'];
|
|
1653
|
+
runGit(['restore', '--source=HEAD', '--staged', '--worktree', '--', ...targets]);
|
|
1654
|
+
}
|
|
1655
|
+
const hasUntrackedFiles = files.some(file => file.status === '??');
|
|
1656
|
+
if (hasUntrackedFiles) {
|
|
1657
|
+
runGit(['clean', '-fd', '--', selectedPath || '.']);
|
|
1658
|
+
}
|
|
1659
|
+
return {
|
|
1660
|
+
workDir: path.resolve(getCurrentWorkDir()),
|
|
1661
|
+
path: selectedPath || undefined,
|
|
1662
|
+
discarded: true,
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
975
1665
|
function ensureWorkspaceGitRepo() {
|
|
976
1666
|
resolveWorkspacePath('', true);
|
|
977
1667
|
try {
|
|
@@ -993,52 +1683,98 @@ function normalizeAICommitMessage(rawMessage) {
|
|
|
993
1683
|
message = message.replace(/^["'“”‘’]+|["'“”‘’]+$/g, '').trim();
|
|
994
1684
|
return message.split('\n').map(line => line.replace(/^\s*[-*]\s+/, '').trimEnd()).join('\n').trim();
|
|
995
1685
|
}
|
|
1686
|
+
const commitMessageCache = new Map();
|
|
1687
|
+
function hashText(value) {
|
|
1688
|
+
return createHash('sha256').update(value).digest('hex');
|
|
1689
|
+
}
|
|
1690
|
+
function getCommitMessageCacheKey(workDir, diffHash) {
|
|
1691
|
+
return `${workDir}\0${diffHash}`;
|
|
1692
|
+
}
|
|
1693
|
+
function getCachedCommitMessage(cacheKey) {
|
|
1694
|
+
const entry = commitMessageCache.get(cacheKey);
|
|
1695
|
+
if (!entry)
|
|
1696
|
+
return null;
|
|
1697
|
+
if (Date.now() - entry.createdAt > COMMIT_MESSAGE_CACHE_TTL_MS) {
|
|
1698
|
+
commitMessageCache.delete(cacheKey);
|
|
1699
|
+
return null;
|
|
1700
|
+
}
|
|
1701
|
+
return entry.message;
|
|
1702
|
+
}
|
|
1703
|
+
function setCachedCommitMessage(cacheKey, message) {
|
|
1704
|
+
commitMessageCache.set(cacheKey, {
|
|
1705
|
+
message,
|
|
1706
|
+
createdAt: Date.now(),
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
996
1709
|
function buildCommitMessagePrompt() {
|
|
997
1710
|
ensureWorkspaceGitRepo();
|
|
998
|
-
const
|
|
999
|
-
const
|
|
1000
|
-
const
|
|
1001
|
-
if (
|
|
1002
|
-
throw new Error('没有可提交的代码变更');
|
|
1003
|
-
}
|
|
1004
|
-
const stagedFiles = runGit(['diff', '--cached', '--name-only']);
|
|
1005
|
-
if (!stagedFiles.trim()) {
|
|
1711
|
+
const workDir = path.resolve(getCurrentWorkDir());
|
|
1712
|
+
const branch = runGit(['rev-parse', '--abbrev-ref', 'HEAD']).trim();
|
|
1713
|
+
const stagedNameStatus = runGit(['diff', '--cached', '--name-status', '--find-renames']).trim();
|
|
1714
|
+
if (!stagedNameStatus) {
|
|
1006
1715
|
throw new Error('没有已暂存的代码变更,请先暂存要提交的文件');
|
|
1007
1716
|
}
|
|
1008
|
-
const recentCommits = runGit(['log', '--oneline', '-
|
|
1009
|
-
const
|
|
1010
|
-
const
|
|
1717
|
+
const recentCommits = runGit(['log', '--oneline', '-5'], [0, 128]).trim();
|
|
1718
|
+
const stagedNumstat = runGit(['diff', '--cached', '--numstat', '--find-renames']).trim();
|
|
1719
|
+
const stagedStat = runGit(['diff', '--cached', '--stat']).trim();
|
|
1720
|
+
const stagedSummary = runGit(['diff', '--cached', '--summary', '--find-renames']).trim();
|
|
1721
|
+
const stagedDiffForHash = runGit(['diff', '--cached', '--full-index', '--binary']);
|
|
1722
|
+
const stagedDiff = runGit(['diff', '--cached', '--find-renames', '--unified=3']);
|
|
1723
|
+
const diffHash = hashText(stagedDiffForHash || stagedDiff || stagedNameStatus);
|
|
1724
|
+
const diffContext = truncateText(stagedDiff.trim(), MAX_COMMIT_DIFF_BYTES);
|
|
1011
1725
|
const context = truncateText([
|
|
1012
|
-
`当前分支:${
|
|
1013
|
-
recentCommits ?
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1726
|
+
`当前分支:${branch}`,
|
|
1727
|
+
recentCommits ? `最近提交风格:\n${recentCommits}` : '',
|
|
1728
|
+
`已暂存文件:\n${stagedNameStatus}`,
|
|
1729
|
+
stagedNumstat ? `已暂存增删行:\n${stagedNumstat}` : '',
|
|
1730
|
+
stagedStat ? `已暂存统计:\n${stagedStat}` : '',
|
|
1731
|
+
stagedSummary ? `已暂存摘要:\n${stagedSummary}` : '',
|
|
1732
|
+
diffContext.text ? `已暂存 diff${diffContext.truncated ? ' 片段' : ''}:\n${diffContext.text}` : '',
|
|
1017
1733
|
].filter(Boolean).join('\n\n'), MAX_COMMIT_CONTEXT_BYTES);
|
|
1018
|
-
|
|
1019
|
-
'
|
|
1734
|
+
const prompt = [
|
|
1735
|
+
'你是 commit message 生成器。只基于下面的“已暂存”Git 上下文生成一条提交信息。',
|
|
1020
1736
|
'要求:',
|
|
1737
|
+
'- 不要调用命令或工具,不要读取文件。',
|
|
1021
1738
|
'- 只输出 commit message,不要解释,不要 Markdown 代码块。',
|
|
1739
|
+
'- 只描述已暂存变更,不要描述未暂存或未跟踪内容。',
|
|
1022
1740
|
'- 使用 Conventional Commits:<type>(<scope>): <subject> 或 <type>: <subject>。',
|
|
1023
1741
|
'- type 优先使用 feat/fix/docs/refactor/test/chore/style/build/ci/perf。',
|
|
1024
|
-
'-
|
|
1742
|
+
'- scope 优先从文件路径、模块名和最近提交风格判断。',
|
|
1743
|
+
'- subject 简短、具体、英文小写,尽量不超过 72 个字符;必要时可以补充 1-3 行 body。',
|
|
1025
1744
|
'- 不要提到 AI、不要提到你无法验证的内容。',
|
|
1026
1745
|
'',
|
|
1027
|
-
context.truncated ? '
|
|
1746
|
+
context.truncated || diffContext.truncated ? '注意:变更上下文已截断,请优先依据文件列表、统计和可见 diff。' : '',
|
|
1028
1747
|
'已暂存 Git 变更上下文:',
|
|
1029
1748
|
context.text,
|
|
1030
1749
|
].filter(Boolean).join('\n');
|
|
1750
|
+
return {
|
|
1751
|
+
prompt,
|
|
1752
|
+
cacheKey: getCommitMessageCacheKey(workDir, diffHash),
|
|
1753
|
+
diffHash,
|
|
1754
|
+
};
|
|
1031
1755
|
}
|
|
1032
1756
|
async function generateWorkspaceCommitMessage() {
|
|
1033
|
-
const
|
|
1034
|
-
const
|
|
1757
|
+
const payload = buildCommitMessagePrompt();
|
|
1758
|
+
const cachedMessage = getCachedCommitMessage(payload.cacheKey);
|
|
1759
|
+
if (cachedMessage) {
|
|
1760
|
+
return {
|
|
1761
|
+
workDir: path.resolve(getCurrentWorkDir()),
|
|
1762
|
+
message: cachedMessage,
|
|
1763
|
+
cached: true,
|
|
1764
|
+
diffHash: payload.diffHash,
|
|
1765
|
+
};
|
|
1766
|
+
}
|
|
1767
|
+
const output = await cliManager.runOneShot(payload.prompt, 60000);
|
|
1035
1768
|
const message = normalizeAICommitMessage(output);
|
|
1036
1769
|
if (!message) {
|
|
1037
1770
|
throw new Error('AI 未生成有效提交信息');
|
|
1038
1771
|
}
|
|
1772
|
+
setCachedCommitMessage(payload.cacheKey, message);
|
|
1039
1773
|
return {
|
|
1040
1774
|
workDir: path.resolve(getCurrentWorkDir()),
|
|
1041
1775
|
message,
|
|
1776
|
+
cached: false,
|
|
1777
|
+
diffHash: payload.diffHash,
|
|
1042
1778
|
};
|
|
1043
1779
|
}
|
|
1044
1780
|
function commitWorkspaceChanges(rawMessage) {
|
|
@@ -1394,6 +2130,10 @@ function getHistoryIdFromPath(pathname) {
|
|
|
1394
2130
|
const match = pathname.match(/^\/api\/history\/([^/]+)$/);
|
|
1395
2131
|
return match ? decodeURIComponent(match[1]) : null;
|
|
1396
2132
|
}
|
|
2133
|
+
function getPromptHistoryIdFromPath(pathname) {
|
|
2134
|
+
const match = pathname.match(/^\/api\/prompt-history\/([^/]+)$/);
|
|
2135
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
2136
|
+
}
|
|
1397
2137
|
function listSystemDir(rawDir, includeFiles) {
|
|
1398
2138
|
const baseDir = typeof rawDir === 'string' && rawDir ? rawDir : '~';
|
|
1399
2139
|
const dir = baseDir.startsWith('~') ? baseDir.replace('~', os.homedir()) : baseDir;
|
|
@@ -1476,7 +2216,7 @@ async function handleHttpRequest(req, res) {
|
|
|
1476
2216
|
try {
|
|
1477
2217
|
if (req.method === 'GET' && pathname === '/api/config') {
|
|
1478
2218
|
sendJson(res, 200, {
|
|
1479
|
-
config:
|
|
2219
|
+
config: getClientConfig(),
|
|
1480
2220
|
status: cliManager.status,
|
|
1481
2221
|
sessionId: currentSessionId,
|
|
1482
2222
|
time: now(),
|
|
@@ -1516,24 +2256,58 @@ async function handleHttpRequest(req, res) {
|
|
|
1516
2256
|
return;
|
|
1517
2257
|
}
|
|
1518
2258
|
if (req.method === 'GET' && pathname === '/api/history') {
|
|
1519
|
-
const
|
|
2259
|
+
const workDir = getCurrentHistoryWorkDir();
|
|
2260
|
+
const tasks = workDir ? store.getHistoryTasks({ workDir }) : [];
|
|
1520
2261
|
sendJson(res, 200, { tasks: tasks.map(task => toClientHistoryTask(task)) });
|
|
1521
2262
|
return;
|
|
1522
2263
|
}
|
|
2264
|
+
if (req.method === 'GET' && pathname === '/api/prompt-history') {
|
|
2265
|
+
const scope = normalizeHistoryScope(url.searchParams.get('scope'), 'current');
|
|
2266
|
+
const prompts = store.getPromptHistory({
|
|
2267
|
+
workDir: getHistoryWorkDirForScope(scope),
|
|
2268
|
+
query: url.searchParams.get('query') || undefined,
|
|
2269
|
+
limit: url.searchParams.get('limit') ? Number(url.searchParams.get('limit')) : undefined,
|
|
2270
|
+
});
|
|
2271
|
+
sendJson(res, 200, { prompts: prompts.map(toClientPromptHistoryItem) });
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
if (req.method === 'POST' && pathname === '/api/codex-history/sync') {
|
|
2275
|
+
const body = await readJsonBody(req);
|
|
2276
|
+
const result = syncCodexHistory({
|
|
2277
|
+
codexHome: process.env.AGENTPILOT_CODEX_HOME,
|
|
2278
|
+
limit: body.limit,
|
|
2279
|
+
workDir: getCurrentWorkDir(),
|
|
2280
|
+
});
|
|
2281
|
+
sendJson(res, 200, { result });
|
|
2282
|
+
broadcast({ type: 'history_changed', time: now() });
|
|
2283
|
+
return;
|
|
2284
|
+
}
|
|
2285
|
+
const promptHistoryId = getPromptHistoryIdFromPath(pathname);
|
|
2286
|
+
if (promptHistoryId && req.method === 'DELETE') {
|
|
2287
|
+
const scope = normalizeHistoryScope(url.searchParams.get('scope'), 'current');
|
|
2288
|
+
store.deletePromptHistory(promptHistoryId, getHistoryWorkDirForScope(scope));
|
|
2289
|
+
sendJson(res, 200, { id: promptHistoryId });
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
1523
2292
|
const historyId = getHistoryIdFromPath(pathname);
|
|
1524
2293
|
if (historyId && req.method === 'GET') {
|
|
1525
|
-
const
|
|
2294
|
+
const workDir = getCurrentHistoryWorkDir();
|
|
2295
|
+
const task = workDir ? store.getHistoryTask(historyId, workDir) : null;
|
|
1526
2296
|
sendJson(res, 200, { task: task ? toClientHistoryTask(task, true) : null });
|
|
1527
2297
|
return;
|
|
1528
2298
|
}
|
|
1529
2299
|
if (historyId && req.method === 'DELETE') {
|
|
1530
|
-
|
|
2300
|
+
const workDir = getCurrentHistoryWorkDir();
|
|
2301
|
+
if (workDir)
|
|
2302
|
+
store.deleteHistoryTask(historyId, workDir);
|
|
1531
2303
|
sendJson(res, 200, { id: historyId });
|
|
1532
2304
|
broadcast({ type: 'history_changed', time: now() });
|
|
1533
2305
|
return;
|
|
1534
2306
|
}
|
|
1535
2307
|
if (req.method === 'DELETE' && pathname === '/api/history') {
|
|
1536
|
-
|
|
2308
|
+
const workDir = getCurrentHistoryWorkDir();
|
|
2309
|
+
if (workDir)
|
|
2310
|
+
store.clearHistory(workDir);
|
|
1537
2311
|
sendJson(res, 200, { success: true });
|
|
1538
2312
|
broadcast({ type: 'history_changed', time: now() });
|
|
1539
2313
|
return;
|
|
@@ -1549,6 +2323,15 @@ async function handleHttpRequest(req, res) {
|
|
|
1549
2323
|
sendJson(res, 200, listSystemDir(url.searchParams.get('dir'), includeFiles));
|
|
1550
2324
|
return;
|
|
1551
2325
|
}
|
|
2326
|
+
if (req.method === 'POST' && pathname === '/api/attachments/image') {
|
|
2327
|
+
const body = await readJsonBody(req, MAX_IMAGE_ATTACHMENT_BYTES * 2);
|
|
2328
|
+
sendJson(res, 200, { attachment: saveImageAttachment(body) });
|
|
2329
|
+
return;
|
|
2330
|
+
}
|
|
2331
|
+
if ((req.method === 'GET' || req.method === 'HEAD') && pathname === '/api/attachments/file') {
|
|
2332
|
+
sendAttachmentFile(req, res, url.searchParams.get('path') || '');
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
1552
2335
|
if (req.method === 'GET' && pathname === '/api/workdir/tree') {
|
|
1553
2336
|
sendJson(res, 200, listWorkspaceDir(url.searchParams.get('path') || ''));
|
|
1554
2337
|
return;
|
|
@@ -1588,6 +2371,11 @@ async function handleHttpRequest(req, res) {
|
|
|
1588
2371
|
sendJson(res, 200, unstageWorkspacePath(body.path));
|
|
1589
2372
|
return;
|
|
1590
2373
|
}
|
|
2374
|
+
if (req.method === 'POST' && pathname === '/api/workdir/discard') {
|
|
2375
|
+
const body = await readJsonBody(req);
|
|
2376
|
+
sendJson(res, 200, discardWorkspaceChanges(body.path));
|
|
2377
|
+
return;
|
|
2378
|
+
}
|
|
1591
2379
|
if (req.method === 'POST' && pathname === '/api/workdir/commit-message') {
|
|
1592
2380
|
sendJson(res, 200, await generateWorkspaceCommitMessage());
|
|
1593
2381
|
return;
|
|
@@ -1677,6 +2465,14 @@ const PERSISTABLE_TYPES = new Set([
|
|
|
1677
2465
|
'status',
|
|
1678
2466
|
]);
|
|
1679
2467
|
function broadcast(event) {
|
|
2468
|
+
if (event.type === 'status' && event.status === 'running') {
|
|
2469
|
+
maybeStartGeneratedImageCapture();
|
|
2470
|
+
}
|
|
2471
|
+
attachGeneratedImagesToEvent(event);
|
|
2472
|
+
if (event.type === 'status' && event.status !== 'idle') {
|
|
2473
|
+
promptQueueDispatching = false;
|
|
2474
|
+
promptQueueHoldUntil = 0;
|
|
2475
|
+
}
|
|
1680
2476
|
// Persist CLI session ID when we get it from system init or result
|
|
1681
2477
|
if (currentSessionId && event.type === 'system' && event.details?.session_id) {
|
|
1682
2478
|
try {
|
|
@@ -1757,6 +2553,14 @@ function broadcast(event) {
|
|
|
1757
2553
|
if (!event.id)
|
|
1758
2554
|
event.id = persisted.id;
|
|
1759
2555
|
event.seq = persisted.seq;
|
|
2556
|
+
if (event.type === 'user_message') {
|
|
2557
|
+
store.recordPromptHistory({
|
|
2558
|
+
text: event.content || '',
|
|
2559
|
+
workDir: getCurrentWorkDir(),
|
|
2560
|
+
sessionId: currentSessionId,
|
|
2561
|
+
messageId: persisted.id,
|
|
2562
|
+
});
|
|
2563
|
+
}
|
|
1760
2564
|
}
|
|
1761
2565
|
}
|
|
1762
2566
|
catch (err) {
|
|
@@ -1773,16 +2577,25 @@ function broadcast(event) {
|
|
|
1773
2577
|
catch { }
|
|
1774
2578
|
}
|
|
1775
2579
|
}
|
|
2580
|
+
if (event.type === 'status' && event.status === 'idle') {
|
|
2581
|
+
emitGeneratedImagesFallback();
|
|
2582
|
+
promptQueueHoldUntil = Date.now() + PROMPT_QUEUE_IDLE_SETTLE_MS;
|
|
2583
|
+
schedulePromptQueueDrain(PROMPT_QUEUE_IDLE_SETTLE_MS);
|
|
2584
|
+
}
|
|
1776
2585
|
}
|
|
1777
2586
|
cliManager.setEventHandler(broadcast);
|
|
2587
|
+
if (cliManager.status === 'disconnected') {
|
|
2588
|
+
cliManager.restoreReady();
|
|
2589
|
+
}
|
|
1778
2590
|
wss.on('connection', (ws) => {
|
|
1779
2591
|
clients.add(ws);
|
|
1780
2592
|
console.log(`[WS] Client connected (total: ${clients.size})`);
|
|
1781
2593
|
ws.send(JSON.stringify({
|
|
1782
2594
|
type: 'connected',
|
|
1783
|
-
config:
|
|
2595
|
+
config: getClientConfig(),
|
|
1784
2596
|
status: cliManager.status,
|
|
1785
2597
|
sessionId: currentSessionId,
|
|
2598
|
+
...getPromptQueuePayload(),
|
|
1786
2599
|
}));
|
|
1787
2600
|
ws.on('message', (data) => {
|
|
1788
2601
|
let msg;
|
|
@@ -1813,6 +2626,7 @@ wss.on('connection', (ws) => {
|
|
|
1813
2626
|
function handleMessage(ws, msg) {
|
|
1814
2627
|
switch (msg.type) {
|
|
1815
2628
|
case 'start_cli': {
|
|
2629
|
+
clearPromptQueue();
|
|
1816
2630
|
// Archive current session
|
|
1817
2631
|
if (currentSessionId) {
|
|
1818
2632
|
store.archiveSession(currentSessionId, 'completed');
|
|
@@ -1836,20 +2650,40 @@ function handleMessage(ws, msg) {
|
|
|
1836
2650
|
broadcast({
|
|
1837
2651
|
type: 'session_reset',
|
|
1838
2652
|
sessionId: currentSessionId,
|
|
1839
|
-
config:
|
|
2653
|
+
config: getClientConfig(),
|
|
1840
2654
|
time: now(),
|
|
1841
2655
|
});
|
|
1842
2656
|
broadcast({ type: 'history_changed', time: now() });
|
|
1843
2657
|
broadcast({ type: 'workdirs_changed', current: getCurrentWorkDir(), time: now() });
|
|
1844
2658
|
break;
|
|
1845
2659
|
}
|
|
1846
|
-
case 'send_message':
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
type: '
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
2660
|
+
case 'send_message': {
|
|
2661
|
+
const prompt = normalizePromptPayload(msg);
|
|
2662
|
+
if (cliManager.status === 'disconnected') {
|
|
2663
|
+
broadcast({ type: 'error', content: 'CLI 未连接,无法发送', time: now() });
|
|
2664
|
+
}
|
|
2665
|
+
else if (!hasPromptPayload(prompt)) {
|
|
2666
|
+
broadcast({ type: 'error', content: '消息不能为空', time: now() });
|
|
2667
|
+
}
|
|
2668
|
+
else if (shouldQueueIncomingPrompt()) {
|
|
2669
|
+
enqueuePrompt(prompt);
|
|
2670
|
+
}
|
|
2671
|
+
else {
|
|
2672
|
+
dispatchPromptToCli(prompt);
|
|
2673
|
+
}
|
|
2674
|
+
break;
|
|
2675
|
+
}
|
|
2676
|
+
case 'queue_prompt':
|
|
2677
|
+
enqueuePrompt(normalizePromptPayload(msg));
|
|
2678
|
+
break;
|
|
2679
|
+
case 'remove_queued_prompt':
|
|
2680
|
+
removePromptFromQueue(msg.id);
|
|
2681
|
+
break;
|
|
2682
|
+
case 'clear_prompt_queue':
|
|
2683
|
+
clearPromptQueue();
|
|
2684
|
+
break;
|
|
2685
|
+
case 'resume_prompt_queue':
|
|
2686
|
+
resumePromptQueue();
|
|
1853
2687
|
break;
|
|
1854
2688
|
case 'confirm_response':
|
|
1855
2689
|
cliManager.confirmResponse(msg.approved);
|
|
@@ -1858,9 +2692,14 @@ function handleMessage(ws, msg) {
|
|
|
1858
2692
|
cliManager.questionResponse(msg.answer, msg.toolUseId);
|
|
1859
2693
|
break;
|
|
1860
2694
|
case 'interrupt':
|
|
2695
|
+
if (promptQueue.length > 0) {
|
|
2696
|
+
promptQueuePaused = true;
|
|
2697
|
+
broadcastPromptQueueChanged();
|
|
2698
|
+
}
|
|
1861
2699
|
cliManager.interrupt();
|
|
1862
2700
|
break;
|
|
1863
2701
|
case 'restart_cli': {
|
|
2702
|
+
clearPromptQueue();
|
|
1864
2703
|
// Archive current session before restarting
|
|
1865
2704
|
if (currentSessionId) {
|
|
1866
2705
|
const taskStatus = cliManager.status === 'running' || cliManager.status === 'confirm' ? 'running' :
|
|
@@ -1874,7 +2713,7 @@ function handleMessage(ws, msg) {
|
|
|
1874
2713
|
broadcast({
|
|
1875
2714
|
type: 'session_reset',
|
|
1876
2715
|
sessionId: currentSessionId,
|
|
1877
|
-
config:
|
|
2716
|
+
config: getClientConfig(),
|
|
1878
2717
|
time: now(),
|
|
1879
2718
|
});
|
|
1880
2719
|
broadcast({ type: 'history_changed', time: now() });
|
|
@@ -1892,7 +2731,7 @@ function handleMessage(ws, msg) {
|
|
|
1892
2731
|
case 'get_config':
|
|
1893
2732
|
ws.send(JSON.stringify({
|
|
1894
2733
|
type: 'config',
|
|
1895
|
-
config:
|
|
2734
|
+
config: getClientConfig(),
|
|
1896
2735
|
status: cliManager.status,
|
|
1897
2736
|
sessionId: currentSessionId,
|
|
1898
2737
|
time: now(),
|
|
@@ -1935,7 +2774,8 @@ function handleMessage(ws, msg) {
|
|
|
1935
2774
|
}
|
|
1936
2775
|
// --- New protocol: History ---
|
|
1937
2776
|
case 'get_history': {
|
|
1938
|
-
const
|
|
2777
|
+
const workDir = getCurrentHistoryWorkDir();
|
|
2778
|
+
const tasks = workDir ? store.getHistoryTasks({ workDir }) : [];
|
|
1939
2779
|
ws.send(JSON.stringify({
|
|
1940
2780
|
type: 'history_data',
|
|
1941
2781
|
requestId: msg.requestId,
|
|
@@ -1944,7 +2784,8 @@ function handleMessage(ws, msg) {
|
|
|
1944
2784
|
break;
|
|
1945
2785
|
}
|
|
1946
2786
|
case 'get_history_task': {
|
|
1947
|
-
const
|
|
2787
|
+
const workDir = getCurrentHistoryWorkDir();
|
|
2788
|
+
const task = workDir ? store.getHistoryTask(msg.id, workDir) : null;
|
|
1948
2789
|
ws.send(JSON.stringify({
|
|
1949
2790
|
type: 'history_task_data',
|
|
1950
2791
|
requestId: msg.requestId,
|
|
@@ -1962,7 +2803,8 @@ function handleMessage(ws, msg) {
|
|
|
1962
2803
|
}));
|
|
1963
2804
|
break;
|
|
1964
2805
|
}
|
|
1965
|
-
const
|
|
2806
|
+
const workDir = getCurrentHistoryWorkDir();
|
|
2807
|
+
const task = workDir ? store.getHistoryTask(msg.id, workDir) : null;
|
|
1966
2808
|
const cliSessionId = task ? getRestorableCliSessionId(task.session_id, task.messages) : undefined;
|
|
1967
2809
|
if (!task || !task.session_id || !cliSessionId) {
|
|
1968
2810
|
ws.send(JSON.stringify({
|
|
@@ -1973,7 +2815,7 @@ function handleMessage(ws, msg) {
|
|
|
1973
2815
|
}));
|
|
1974
2816
|
break;
|
|
1975
2817
|
}
|
|
1976
|
-
const restored = store.resumeHistoryTask(msg.id, currentSessionId,
|
|
2818
|
+
const restored = store.resumeHistoryTask(msg.id, currentSessionId, workDir);
|
|
1977
2819
|
if (!restored) {
|
|
1978
2820
|
ws.send(JSON.stringify({
|
|
1979
2821
|
type: 'history_resumed',
|
|
@@ -1988,6 +2830,9 @@ function handleMessage(ws, msg) {
|
|
|
1988
2830
|
...getSessionConfig(restored.session),
|
|
1989
2831
|
cliSessionId,
|
|
1990
2832
|
};
|
|
2833
|
+
if (typeof restoredConfig.workDir !== 'string' && typeof restored.work_dir === 'string') {
|
|
2834
|
+
restoredConfig.workDir = restored.work_dir;
|
|
2835
|
+
}
|
|
1991
2836
|
cliManager.restoreConfig(restoredConfig);
|
|
1992
2837
|
cliManager.restoreSessionId(cliSessionId);
|
|
1993
2838
|
if (typeof restoredConfig.workDir === 'string') {
|
|
@@ -2003,7 +2848,7 @@ function handleMessage(ws, msg) {
|
|
|
2003
2848
|
broadcast({
|
|
2004
2849
|
type: 'session_restored',
|
|
2005
2850
|
sessionId: currentSessionId,
|
|
2006
|
-
config:
|
|
2851
|
+
config: getClientConfig(),
|
|
2007
2852
|
status: cliManager.status,
|
|
2008
2853
|
messages: sessionMessages,
|
|
2009
2854
|
lastSeq,
|
|
@@ -2025,7 +2870,7 @@ function handleMessage(ws, msg) {
|
|
|
2025
2870
|
requestId: msg.requestId,
|
|
2026
2871
|
success: true,
|
|
2027
2872
|
sessionId: currentSessionId,
|
|
2028
|
-
config:
|
|
2873
|
+
config: getClientConfig(),
|
|
2029
2874
|
messages: sessionMessages,
|
|
2030
2875
|
lastSeq,
|
|
2031
2876
|
sent: !!followUp,
|
|
@@ -2081,7 +2926,7 @@ function handleMessage(ws, msg) {
|
|
|
2081
2926
|
broadcast({
|
|
2082
2927
|
type: 'session_restored',
|
|
2083
2928
|
sessionId: currentSessionId,
|
|
2084
|
-
config:
|
|
2929
|
+
config: getClientConfig(),
|
|
2085
2930
|
status: cliManager.status,
|
|
2086
2931
|
messages: sessionMessages,
|
|
2087
2932
|
lastSeq,
|
|
@@ -2103,7 +2948,9 @@ function handleMessage(ws, msg) {
|
|
|
2103
2948
|
break;
|
|
2104
2949
|
}
|
|
2105
2950
|
case 'delete_history': {
|
|
2106
|
-
|
|
2951
|
+
const workDir = getCurrentHistoryWorkDir();
|
|
2952
|
+
if (workDir)
|
|
2953
|
+
store.deleteHistoryTask(msg.id, workDir);
|
|
2107
2954
|
ws.send(JSON.stringify({
|
|
2108
2955
|
type: 'history_deleted',
|
|
2109
2956
|
requestId: msg.requestId,
|
|
@@ -2114,7 +2961,9 @@ function handleMessage(ws, msg) {
|
|
|
2114
2961
|
break;
|
|
2115
2962
|
}
|
|
2116
2963
|
case 'clear_history': {
|
|
2117
|
-
|
|
2964
|
+
const workDir = getCurrentHistoryWorkDir();
|
|
2965
|
+
if (workDir)
|
|
2966
|
+
store.clearHistory(workDir);
|
|
2118
2967
|
ws.send(JSON.stringify({
|
|
2119
2968
|
type: 'history_cleared',
|
|
2120
2969
|
requestId: msg.requestId,
|
|
@@ -2275,7 +3124,7 @@ function handleMessage(ws, msg) {
|
|
|
2275
3124
|
}
|
|
2276
3125
|
}
|
|
2277
3126
|
function now() {
|
|
2278
|
-
return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
3127
|
+
return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
|
|
2279
3128
|
}
|
|
2280
3129
|
function formatHostForUrl(host) {
|
|
2281
3130
|
return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|