chatccc 0.2.45 → 0.2.47
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/im-skills/feishu-skill/receive-send-file.md +3 -11
- package/im-skills/feishu-skill/receive-send-image.md +3 -11
- package/im-skills/feishu-skill/send-file.mjs +5 -6
- package/im-skills/feishu-skill/send-image.mjs +5 -6
- package/im-skills/feishu-skill/skill.md +2 -3
- package/package.json +1 -1
- package/src/__tests__/agent-image-rpc.test.ts +17 -52
- package/src/__tests__/session.test.ts +49 -3
- package/src/agent-file-rpc.ts +108 -106
- package/src/agent-image-rpc.ts +78 -113
- package/src/index.ts +162 -109
- package/src/session-chat-binding.ts +87 -0
- package/src/session.ts +329 -264
- package/src/stream-state.ts +94 -0
- package/src/agent-grants-rpc.ts +0 -71
package/src/session.ts
CHANGED
|
@@ -32,16 +32,18 @@ import type { ToolAdapter } from "./adapters/adapter-interface.ts";
|
|
|
32
32
|
import { createClaudeAdapter } from "./adapters/claude-adapter.ts";
|
|
33
33
|
import { createCursorAdapter } from "./adapters/cursor-adapter.ts";
|
|
34
34
|
import { createCodexAdapter } from "./adapters/codex-adapter.ts";
|
|
35
|
-
import {
|
|
36
|
-
createAgentImageGrant,
|
|
37
|
-
revokeAgentImageGrant,
|
|
38
|
-
} from "./agent-image-rpc.ts";
|
|
39
|
-
import {
|
|
40
|
-
createAgentFileGrant,
|
|
41
|
-
revokeAgentFileGrant,
|
|
42
|
-
} from "./agent-file-rpc.ts";
|
|
43
|
-
import { setSessionGrants, clearSessionGrants, AGENT_SESSION_GRANTS_PATH } from "./agent-grants-rpc.ts";
|
|
44
35
|
import { buildImSkillsPrompt, exportSkillSubDocs } from "./im-skills.ts";
|
|
36
|
+
import { readStreamState, writeStreamState, createEmptyStreamState, fixStaleStreamStates } from "./stream-state.ts";
|
|
37
|
+
import {
|
|
38
|
+
bindChatToSession,
|
|
39
|
+
unbindChatFromSession,
|
|
40
|
+
getChatsForSession,
|
|
41
|
+
isSessionRunning,
|
|
42
|
+
activePrompts,
|
|
43
|
+
displayCards,
|
|
44
|
+
displayLoops,
|
|
45
|
+
rebuildSessionChatsFromRegistry,
|
|
46
|
+
} from "./session-chat-binding.ts";
|
|
45
47
|
|
|
46
48
|
// ---------------------------------------------------------------------------
|
|
47
49
|
// Shared state (imported by index.ts)
|
|
@@ -54,6 +56,7 @@ export const MAX_PROCESSED = 5000;
|
|
|
54
56
|
export const lastMsgTimestamps = new Map<string, number>();
|
|
55
57
|
|
|
56
58
|
export let sessionGen = 0;
|
|
59
|
+
/** @deprecated 使用 activePrompts (session-chat-binding.ts) + displayCards 替代 */
|
|
57
60
|
export const chatSessionMap = new Map<string, {
|
|
58
61
|
gen: number;
|
|
59
62
|
close: () => void;
|
|
@@ -68,17 +71,8 @@ export const chatSessionMap = new Map<string, {
|
|
|
68
71
|
}>();
|
|
69
72
|
|
|
70
73
|
/**
|
|
71
|
-
* sessionInfoMap 记录每个 chatId
|
|
72
|
-
*
|
|
73
|
-
* 注意此处**不**保存 model / effort:
|
|
74
|
-
* - Claude 会话:model/effort 由 ChatCCC 启动时的环境变量决定(CLAUDE_MODEL/EFFORT),
|
|
75
|
-
* getSessionStatus 直接读全局配置即可。
|
|
76
|
-
* - Cursor 会话:model 是 cursor-agent 自报的运行时值(如 Composer 2 Fast),
|
|
77
|
-
* 由 cursor-adapter 持久化到 cursor-session-meta.json,
|
|
78
|
-
* getSessionStatus 通过 adapter.getSessionInfo 实时获取;effort 概念不适用。
|
|
79
|
-
*
|
|
80
|
-
* 把 model/effort 从 sessionInfoMap 移除是为了消除"硬塞 CLAUDE_* 给 Cursor"
|
|
81
|
-
* 的不一致 bug——/status、/sessions 必须显示真实工具的真实信息。
|
|
74
|
+
* sessionInfoMap 记录每个 chatId 当前绑定的会话元数据。
|
|
75
|
+
* 同一 session 可被多个 chatId 共享;model/effort 不在其中(按 tool 动态解析)。
|
|
82
76
|
*/
|
|
83
77
|
export const sessionInfoMap = new Map<string, {
|
|
84
78
|
sessionId: string;
|
|
@@ -97,7 +91,12 @@ export function resetState(): void {
|
|
|
97
91
|
sessionInfoMap.clear();
|
|
98
92
|
processedMessages.clear();
|
|
99
93
|
lastMsgTimestamps.clear();
|
|
100
|
-
|
|
94
|
+
// 清理新的全局状态
|
|
95
|
+
activePrompts.clear();
|
|
96
|
+
displayCards.clear();
|
|
97
|
+
for (const stop of displayLoops.values()) stop();
|
|
98
|
+
displayLoops.clear();
|
|
99
|
+
console.log(`[${ts()}] [RESET] State cleared (dedup + active sessions + bindings)`);
|
|
101
100
|
}
|
|
102
101
|
|
|
103
102
|
// ---------------------------------------------------------------------------
|
|
@@ -211,6 +210,11 @@ async function loadSessionRegistry(): Promise<SessionRegistryData> {
|
|
|
211
210
|
}
|
|
212
211
|
}
|
|
213
212
|
|
|
213
|
+
/** 供 session-chat-binding.ts 重建映射 */
|
|
214
|
+
export async function loadSessionRegistryForBinding(): Promise<SessionRegistryData> {
|
|
215
|
+
return loadSessionRegistry();
|
|
216
|
+
}
|
|
217
|
+
|
|
214
218
|
async function saveSessionRegistry(data: SessionRegistryData): Promise<void> {
|
|
215
219
|
try {
|
|
216
220
|
await mkdir(dirname(sessionRegistryFile), { recursive: true });
|
|
@@ -400,44 +404,55 @@ export async function resumeAndPrompt(
|
|
|
400
404
|
msgTimestamp: number,
|
|
401
405
|
tool: string,
|
|
402
406
|
traceId?: string,
|
|
407
|
+
): Promise<void> {
|
|
408
|
+
return runAgentSession(sessionId, userText, token, chatId, msgTimestamp, tool, traceId);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// runAgentSession — session 中心的 agent prompt(文件持久化 + display 解耦)
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
export async function runAgentSession(
|
|
416
|
+
sessionId: string,
|
|
417
|
+
userText: string,
|
|
418
|
+
token: string,
|
|
419
|
+
_chatId: string,
|
|
420
|
+
msgTimestamp: number,
|
|
421
|
+
tool: string,
|
|
422
|
+
traceId?: string,
|
|
403
423
|
): Promise<void> {
|
|
404
424
|
const tid = traceId ?? "";
|
|
425
|
+
|
|
426
|
+
// 并发检查:同一 session 只能有一个活跃 prompt
|
|
427
|
+
if (activePrompts.has(sessionId)) {
|
|
428
|
+
if (tid) logTrace(tid, "BLOCKED", { outcome: "session_busy", sessionId });
|
|
429
|
+
console.log(`[${ts()}] [BLOCKED] Session ${sessionId} is already generating`);
|
|
430
|
+
await sendTextReply(token, _chatId, "该会话正在生成回复中,请等待完成后再发送消息。").catch(() => {});
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
405
434
|
const adapter = getAdapterForTool(tool);
|
|
406
435
|
const info = await adapter.getSessionInfo(sessionId);
|
|
407
|
-
const cwd = info?.cwd ?? (await getDefaultCwd(
|
|
408
|
-
if (tid) logTrace(tid, "SESSION_START", { sessionId, tool, cwd, turn: (sessionInfoMap.get(
|
|
436
|
+
const cwd = info?.cwd ?? (await getDefaultCwd(_chatId));
|
|
437
|
+
if (tid) logTrace(tid, "SESSION_START", { sessionId, tool, cwd, turn: (sessionInfoMap.get(_chatId)?.turnCount ?? 0) + 1 });
|
|
409
438
|
console.log(
|
|
410
|
-
`[${ts()}]
|
|
439
|
+
`[${ts()}] Running ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model)}, cwd=${cwd})`
|
|
411
440
|
);
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
sessionId,
|
|
415
|
-
cwd,
|
|
416
|
-
port: CHATCCC_PORT,
|
|
417
|
-
traceId: tid || undefined,
|
|
418
|
-
});
|
|
419
|
-
const fileGrant = createAgentFileGrant({
|
|
420
|
-
chatId,
|
|
421
|
-
sessionId,
|
|
422
|
-
cwd,
|
|
423
|
-
port: CHATCCC_PORT,
|
|
424
|
-
traceId: tid || undefined,
|
|
425
|
-
});
|
|
441
|
+
|
|
442
|
+
// 构建 IM skills prompt(sessionId 方式,无 token)
|
|
426
443
|
const feishuSkillDir = join(PROJECT_ROOT, "im-skills", "feishu-skill");
|
|
427
444
|
const imSkillsCacheDir = join(USER_DATA_DIR, "im-skills");
|
|
428
445
|
const skillVariables = {
|
|
429
446
|
cwd,
|
|
430
447
|
session_id: sessionId,
|
|
431
448
|
im_skills_cache_dir: imSkillsCacheDir,
|
|
432
|
-
|
|
433
|
-
|
|
449
|
+
send_image_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-image`,
|
|
450
|
+
send_file_url: `http://127.0.0.1:${CHATCCC_PORT}/api/agent/send-file`,
|
|
434
451
|
send_image_script: join(feishuSkillDir, "send-image.mjs"),
|
|
435
452
|
send_file_script: join(feishuSkillDir, "send-file.mjs"),
|
|
436
453
|
download_video_script: join(feishuSkillDir, "download-video.mjs"),
|
|
437
454
|
};
|
|
438
|
-
setSessionGrants(sessionId, imageGrant, fileGrant);
|
|
439
455
|
const imSkillsPrompt = await buildImSkillsPrompt({ variables: skillVariables });
|
|
440
|
-
// 渲染子文档到缓存目录,供 Agent 按需读取
|
|
441
456
|
await exportSkillSubDocs({ variables: skillVariables }, imSkillsCacheDir);
|
|
442
457
|
const userTextWithCapabilities = [
|
|
443
458
|
...(imSkillsPrompt ? [imSkillsPrompt, ""] : []),
|
|
@@ -446,37 +461,43 @@ export async function resumeAndPrompt(
|
|
|
446
461
|
"[/User message]",
|
|
447
462
|
].join("\n");
|
|
448
463
|
|
|
464
|
+
// 设置活跃 prompt
|
|
449
465
|
const controller = new AbortController();
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
close: () => controller.abort(),
|
|
454
|
-
cardId: null,
|
|
466
|
+
const now = Date.now();
|
|
467
|
+
activePrompts.set(sessionId, {
|
|
468
|
+
controller,
|
|
455
469
|
stopped: false,
|
|
456
|
-
|
|
457
|
-
finalText: "",
|
|
458
|
-
spinnerTimer: null,
|
|
459
|
-
msgTimestamp,
|
|
460
|
-
sequence: 0,
|
|
461
|
-
cardBusy: false,
|
|
470
|
+
startTime: now,
|
|
462
471
|
});
|
|
463
|
-
const myGen = sessionGen;
|
|
464
|
-
|
|
465
|
-
setChatAvatar(token, chatId, tool, "busy").catch(() => {});
|
|
466
472
|
|
|
467
|
-
|
|
468
|
-
const existingInfo = sessionInfoMap.get(
|
|
473
|
+
// 更新 sessionInfoMap(所有绑定群共用)
|
|
474
|
+
const existingInfo = sessionInfoMap.get(_chatId);
|
|
469
475
|
const nextTurnCount = (existingInfo?.turnCount ?? 0) + 1;
|
|
470
476
|
const nextContextTokens = existingInfo?.lastContextTokens ?? 0;
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
477
|
+
// 对所有绑定的 chatId 更新 sessionInfoMap
|
|
478
|
+
for (const cid of getChatsForSession(sessionId)) {
|
|
479
|
+
const ei = sessionInfoMap.get(cid);
|
|
480
|
+
sessionInfoMap.set(cid, {
|
|
481
|
+
sessionId,
|
|
482
|
+
turnCount: nextTurnCount,
|
|
483
|
+
lastContextTokens: nextContextTokens,
|
|
484
|
+
startTime: now,
|
|
485
|
+
tool,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
// 确保触发群也在 map 中
|
|
489
|
+
if (!sessionInfoMap.has(_chatId)) {
|
|
490
|
+
sessionInfoMap.set(_chatId, {
|
|
491
|
+
sessionId,
|
|
492
|
+
turnCount: nextTurnCount,
|
|
493
|
+
lastContextTokens: nextContextTokens,
|
|
494
|
+
startTime: now,
|
|
495
|
+
tool,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
478
499
|
await recordSessionRegistry({
|
|
479
|
-
chatId,
|
|
500
|
+
chatId: _chatId,
|
|
480
501
|
sessionId,
|
|
481
502
|
tool,
|
|
482
503
|
turnCount: nextTurnCount,
|
|
@@ -485,28 +506,16 @@ export async function resumeAndPrompt(
|
|
|
485
506
|
running: true,
|
|
486
507
|
});
|
|
487
508
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
if (cEntry) { cEntry.cardId = cardId; cEntry.sequence = 1; }
|
|
499
|
-
const sendOk = await sendCardKitMessage(token, chatId, cardId).catch((err) => {
|
|
500
|
-
if (tid) logTrace(tid, "CARD_SEND_FAIL", { cardId, error: (err as Error).message });
|
|
501
|
-
console.error(`[${ts()}] [CARDIKT] sendMessage FAIL: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
|
|
502
|
-
fileLog.flush();
|
|
503
|
-
return false;
|
|
504
|
-
});
|
|
505
|
-
if (!sendOk) {
|
|
506
|
-
sendTextReply(token, chatId, "⚠️ 卡片发送失败,将使用文本回复。").catch(() => {});
|
|
507
|
-
cardId = null;
|
|
508
|
-
if (cEntry) { cEntry.cardId = null; cEntry.sequence = 0; }
|
|
509
|
-
}
|
|
509
|
+
// 初始化 stream-state.json
|
|
510
|
+
const initialState = createEmptyStreamState(sessionId, cwd, tool, nextTurnCount);
|
|
511
|
+
await writeStreamState(initialState);
|
|
512
|
+
|
|
513
|
+
// 启动 display loop
|
|
514
|
+
ensureDisplayLoop(sessionId);
|
|
515
|
+
|
|
516
|
+
// 设置所有绑定群头像为 busy
|
|
517
|
+
for (const cid of getChatsForSession(sessionId)) {
|
|
518
|
+
setChatAvatar(token, cid, tool, "busy").catch(() => {});
|
|
510
519
|
}
|
|
511
520
|
|
|
512
521
|
const state: AccumulatorState = {
|
|
@@ -516,96 +525,21 @@ export async function resumeAndPrompt(
|
|
|
516
525
|
chunkCount: 0,
|
|
517
526
|
};
|
|
518
527
|
|
|
519
|
-
let
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
let dotCount = 0;
|
|
523
|
-
let lastSentContent = "";
|
|
524
|
-
let streamErrorNotified = false;
|
|
525
|
-
let healthLogTicks = 0;
|
|
526
|
-
// 兜底:setInterval 不 await 异步回调,回调内任何漏接的异常都会变成
|
|
527
|
-
// unhandledRejection 进而(在 Node 默认策略下)让进程崩。这里用 IIFE + .catch
|
|
528
|
-
// 整体兜一层,配合内部两个细粒度 try/catch 一起守住。
|
|
529
|
-
const sendInterval = cardId ? setInterval(() => {
|
|
530
|
-
void (async () => {
|
|
531
|
-
const cEntry = chatSessionMap.get(chatId);
|
|
532
|
-
if (!cEntry || cEntry.stopped || cEntry.cardBusy) return;
|
|
533
|
-
if (cEntry.cardId !== cardId) return;
|
|
534
|
-
|
|
535
|
-
if (Date.now() - cardCreatedAt > CARD_ROTATE_MS) {
|
|
536
|
-
cEntry.cardBusy = true;
|
|
537
|
-
try {
|
|
538
|
-
const oldSeqBase = cEntry.sequence;
|
|
539
|
-
const oldDisplay = truncateContent(state.accumulatedContent + pickFinalReply(state)) || "处理中...";
|
|
540
|
-
const oldCard = buildProgressCard(oldDisplay, { showStop: false, headerTitle: "生成中...(上轮)" });
|
|
541
|
-
await updateCardKitCard(token, cardId!, oldCard, oldSeqBase + 1).catch(() => {});
|
|
542
|
-
const newCardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." }));
|
|
543
|
-
if (!newCardId) throw new Error("createCardKitCard returned empty");
|
|
544
|
-
await sendCardKitMessage(token, chatId, newCardId);
|
|
545
|
-
cardId = newCardId;
|
|
546
|
-
cEntry.cardId = newCardId;
|
|
547
|
-
cEntry.sequence = 1;
|
|
548
|
-
cardCreatedAt = Date.now();
|
|
549
|
-
lastSentContent = "";
|
|
550
|
-
streamErrorNotified = false;
|
|
551
|
-
console.log(`[${ts()}] [CARDIKT] rotated: old=${oldSeqBase} new=${newCardId} (9min timeout)`);
|
|
552
|
-
} catch (err) {
|
|
553
|
-
console.error(`[${ts()}] [CARDIKT] rotation FAIL: ${(err as Error).message}`);
|
|
554
|
-
} finally {
|
|
555
|
-
cEntry.cardBusy = false;
|
|
556
|
-
}
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
dotCount = (dotCount % 9) + 1;
|
|
561
|
-
const content = truncateContent(state.accumulatedContent + pickFinalReply(state) + "\n" + "。".repeat(dotCount));
|
|
562
|
-
if (content === lastSentContent) return;
|
|
563
|
-
|
|
564
|
-
lastSentContent = content;
|
|
565
|
-
cEntry.cardBusy = true;
|
|
566
|
-
const mySeq = cEntry.sequence + 1;
|
|
567
|
-
try {
|
|
568
|
-
const card = buildProgressCard(content, { showStop: true, headerTitle: "生成中..." });
|
|
569
|
-
await updateCardKitCard(token, cardId!, card, mySeq);
|
|
570
|
-
cEntry.sequence = mySeq;
|
|
571
|
-
cEntry.accumulatedContent = state.accumulatedContent;
|
|
572
|
-
streamErrorNotified = false;
|
|
573
|
-
healthLogTicks++;
|
|
574
|
-
if (healthLogTicks % 10 === 0) {
|
|
575
|
-
console.log(`[${ts()}] [CARDIKT] update health: seq=${mySeq} content=${state.accumulatedContent.length}chars text=${state.finalText.length}chars cardAge=${Math.round((Date.now() - cardCreatedAt) / 1000)}s`);
|
|
576
|
-
}
|
|
577
|
-
} catch (err) {
|
|
578
|
-
console.error(`[${ts()}] CardKit update error: chatId=${chatId} cardId=${cardId} seq=${mySeq} ${(err as Error).message}`);
|
|
579
|
-
if (!streamErrorNotified) {
|
|
580
|
-
streamErrorNotified = true;
|
|
581
|
-
sendTextReply(token, chatId, "⚠️ 卡片更新失败,结果将以文本形式发送。").catch(() => {});
|
|
582
|
-
}
|
|
583
|
-
} finally {
|
|
584
|
-
cEntry.cardBusy = false;
|
|
585
|
-
}
|
|
586
|
-
})().catch((err: unknown) => {
|
|
587
|
-
const e = err instanceof Error ? err : new Error(String(err));
|
|
588
|
-
console.error(`[${ts()}] [CARDIKT] spinner tick uncaught: ${e.message}\n${e.stack ?? ""}`);
|
|
589
|
-
const entry = chatSessionMap.get(chatId);
|
|
590
|
-
if (entry) entry.cardBusy = false;
|
|
591
|
-
});
|
|
592
|
-
}, 3000) : null;
|
|
593
|
-
if (sendInterval) {
|
|
594
|
-
const entry = chatSessionMap.get(chatId);
|
|
595
|
-
if (entry) entry.spinnerTimer = sendInterval;
|
|
596
|
-
}
|
|
528
|
+
let lastFileWrite = Date.now();
|
|
529
|
+
const FILE_WRITE_INTERVAL_MS = 2000;
|
|
597
530
|
|
|
598
531
|
try {
|
|
599
532
|
for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal)) {
|
|
600
533
|
for (const block of unifiedMsg.blocks) {
|
|
601
534
|
accumulateBlockContent(block, state);
|
|
602
535
|
|
|
603
|
-
// 更新持久化上下文 token 数(compact_boundary 事件)
|
|
604
536
|
if (block.type === "compact_boundary" && block.post_tokens) {
|
|
605
|
-
const
|
|
606
|
-
|
|
537
|
+
for (const cid of getChatsForSession(sessionId)) {
|
|
538
|
+
const sinfo = sessionInfoMap.get(cid);
|
|
539
|
+
if (sinfo) sinfo.lastContextTokens = block.post_tokens;
|
|
540
|
+
}
|
|
607
541
|
await recordSessionRegistry({
|
|
608
|
-
chatId,
|
|
542
|
+
chatId: _chatId,
|
|
609
543
|
sessionId,
|
|
610
544
|
tool,
|
|
611
545
|
lastContextTokens: block.post_tokens,
|
|
@@ -613,110 +547,234 @@ export async function resumeAndPrompt(
|
|
|
613
547
|
});
|
|
614
548
|
}
|
|
615
549
|
}
|
|
550
|
+
|
|
551
|
+
// 定时写入文件
|
|
552
|
+
const now2 = Date.now();
|
|
553
|
+
if (now2 - lastFileWrite >= FILE_WRITE_INTERVAL_MS) {
|
|
554
|
+
lastFileWrite = now2;
|
|
555
|
+
await writeStreamState({
|
|
556
|
+
sessionId,
|
|
557
|
+
status: "running",
|
|
558
|
+
accumulatedContent: state.accumulatedContent,
|
|
559
|
+
finalReply: pickFinalReply(state),
|
|
560
|
+
chunkCount: state.chunkCount,
|
|
561
|
+
turnCount: nextTurnCount,
|
|
562
|
+
contextTokens: existingInfo?.lastContextTokens ?? 0,
|
|
563
|
+
updatedAt: now2,
|
|
564
|
+
cwd,
|
|
565
|
+
tool,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
616
568
|
}
|
|
617
569
|
} catch (streamErr) {
|
|
618
|
-
console.error(`[${ts()}] [STREAM] Error in stream loop: ${(streamErr as Error).message}`);
|
|
570
|
+
console.error(`[${ts()}] [STREAM] Error in stream loop for ${sessionId}: ${(streamErr as Error).message}`);
|
|
619
571
|
} finally {
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
572
|
+
// 标记 prompt 结束
|
|
573
|
+
const prompt = activePrompts.get(sessionId);
|
|
574
|
+
const wasStopped = prompt?.stopped ?? false;
|
|
575
|
+
activePrompts.delete(sessionId);
|
|
576
|
+
|
|
577
|
+
// 写最终状态
|
|
578
|
+
const finalStatus = wasStopped ? "stopped" : "done";
|
|
579
|
+
const finalReply = pickFinalReply(state).trim();
|
|
580
|
+
await writeStreamState({
|
|
581
|
+
sessionId,
|
|
582
|
+
status: finalStatus,
|
|
583
|
+
accumulatedContent: state.accumulatedContent,
|
|
584
|
+
finalReply,
|
|
585
|
+
chunkCount: state.chunkCount,
|
|
586
|
+
turnCount: nextTurnCount,
|
|
587
|
+
contextTokens: existingInfo?.lastContextTokens ?? 0,
|
|
588
|
+
updatedAt: Date.now(),
|
|
589
|
+
cwd,
|
|
590
|
+
tool,
|
|
591
|
+
});
|
|
624
592
|
|
|
625
|
-
|
|
626
|
-
if (!cEntry || cEntry.gen !== myGen) return;
|
|
627
|
-
clearSessionGrants(sessionId);
|
|
628
|
-
const wasStopped = cEntry.stopped;
|
|
629
|
-
const prevTs = lastMsgTimestamps.get(chatId);
|
|
630
|
-
if (prevTs === undefined || cEntry.msgTimestamp > prevTs) {
|
|
631
|
-
lastMsgTimestamps.set(chatId, cEntry.msgTimestamp);
|
|
632
|
-
}
|
|
633
|
-
chatSessionMap.delete(chatId);
|
|
634
|
-
setChatAvatar(token, chatId, tool, "idle").catch(() => {});
|
|
593
|
+
// display loop 下一轮会读到最终状态并发送消息
|
|
635
594
|
|
|
636
|
-
const finalCardContent = state.accumulatedContent || " ";
|
|
637
|
-
if (cardId) {
|
|
638
|
-
while (cEntry.cardBusy) {
|
|
639
|
-
await new Promise(r => setTimeout(r, 20));
|
|
640
|
-
}
|
|
641
|
-
const nextSeq = cEntry.sequence + 1;
|
|
642
595
|
if (wasStopped) {
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
596
|
+
for (const cid of getChatsForSession(sessionId)) {
|
|
597
|
+
const finfo = sessionInfoMap.get(cid);
|
|
598
|
+
await recordSessionRegistry({
|
|
599
|
+
chatId: cid,
|
|
600
|
+
sessionId,
|
|
601
|
+
tool,
|
|
602
|
+
turnCount: finfo?.turnCount ?? nextTurnCount,
|
|
603
|
+
lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
|
|
604
|
+
startTime: finfo?.startTime ?? now,
|
|
605
|
+
running: false,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
|
|
609
|
+
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
|
|
648
610
|
} else {
|
|
649
|
-
const
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
611
|
+
for (const cid of getChatsForSession(sessionId)) {
|
|
612
|
+
const finfo = sessionInfoMap.get(cid);
|
|
613
|
+
await recordSessionRegistry({
|
|
614
|
+
chatId: cid,
|
|
615
|
+
sessionId,
|
|
616
|
+
tool,
|
|
617
|
+
turnCount: finfo?.turnCount ?? nextTurnCount,
|
|
618
|
+
lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
|
|
619
|
+
startTime: finfo?.startTime ?? now,
|
|
620
|
+
running: false,
|
|
621
|
+
});
|
|
622
|
+
setChatAvatar(token, cid, tool, "idle").catch(() => {});
|
|
623
|
+
}
|
|
624
|
+
console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
|
|
625
|
+
if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
|
|
655
626
|
}
|
|
656
627
|
}
|
|
628
|
+
}
|
|
657
629
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
// ensureDisplayLoop — 每个 session 一个 display 循环,读文件更新所有绑定群的卡片
|
|
632
|
+
// ---------------------------------------------------------------------------
|
|
661
633
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
634
|
+
const CARD_ROTATE_MS = 9 * 60 * 1000;
|
|
635
|
+
|
|
636
|
+
export function ensureDisplayLoop(sessionId: string): void {
|
|
637
|
+
if (displayLoops.has(sessionId)) return;
|
|
638
|
+
|
|
639
|
+
let dotCount = 0;
|
|
640
|
+
|
|
641
|
+
const interval = setInterval(() => {
|
|
642
|
+
void (async () => {
|
|
643
|
+
const state = await readStreamState(sessionId);
|
|
644
|
+
if (!state) return;
|
|
645
|
+
|
|
646
|
+
const chats = getChatsForSession(sessionId);
|
|
647
|
+
if (chats.length === 0) {
|
|
648
|
+
// 无绑定群,若 session 已结束则停止 loop
|
|
649
|
+
if (state.status !== "running") {
|
|
650
|
+
clearInterval(interval);
|
|
651
|
+
displayLoops.delete(sessionId);
|
|
652
|
+
}
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const isTerminal = state.status !== "running";
|
|
657
|
+
|
|
658
|
+
for (const chatId of chats) {
|
|
659
|
+
try {
|
|
660
|
+
// getTenantAccessToken 有缓存,多次调用开销低
|
|
661
|
+
const tokenModule = await import("./feishu-platform.ts");
|
|
662
|
+
const token = await tokenModule.getTenantAccessToken();
|
|
663
|
+
const display = displayCards.get(chatId);
|
|
664
|
+
|
|
665
|
+
if (isTerminal) {
|
|
666
|
+
// 发送最终结果
|
|
667
|
+
if (display) {
|
|
668
|
+
while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
|
|
669
|
+
const nextSeq = display.sequence + 1;
|
|
670
|
+
const headerTitle = state.status === "stopped" ? "已停止" : "完成";
|
|
671
|
+
const headerTemplate = state.status === "stopped" ? "red" : undefined;
|
|
672
|
+
const cardContent = state.accumulatedContent || " ";
|
|
673
|
+
const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
|
|
674
|
+
await updateCardKitCard(token, display.cardId, doneCard, nextSeq).catch(() => {});
|
|
675
|
+
displayCards.delete(chatId);
|
|
676
|
+
}
|
|
677
|
+
if (state.finalReply) {
|
|
678
|
+
await sendTextReply(token, chatId, state.finalReply).catch(() => {});
|
|
679
|
+
} else if (!display && state.accumulatedContent.trim()) {
|
|
680
|
+
const short = truncateContent(state.accumulatedContent, 30, 4000);
|
|
681
|
+
await sendTextReply(token, chatId, `[生成过程]\n${short}`).catch(() => {});
|
|
682
|
+
}
|
|
683
|
+
setChatAvatar(token, chatId, state.tool, "idle").catch(() => {});
|
|
684
|
+
} else {
|
|
685
|
+
// running: 创建或更新卡片
|
|
686
|
+
if (!display) {
|
|
687
|
+
const cardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." })).catch(() => null);
|
|
688
|
+
if (cardId) {
|
|
689
|
+
await sendCardKitMessage(token, chatId, cardId).catch(() => null);
|
|
690
|
+
displayCards.set(chatId, {
|
|
691
|
+
cardId,
|
|
692
|
+
sequence: 1,
|
|
693
|
+
cardBusy: false,
|
|
694
|
+
cardCreatedAt: Date.now(),
|
|
695
|
+
lastSentContent: "",
|
|
696
|
+
streamErrorNotified: false,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
} else {
|
|
700
|
+
if (display.cardBusy) continue;
|
|
701
|
+
|
|
702
|
+
// 卡片轮转
|
|
703
|
+
if (Date.now() - display.cardCreatedAt > CARD_ROTATE_MS) {
|
|
704
|
+
display.cardBusy = true;
|
|
705
|
+
try {
|
|
706
|
+
const oldSeqBase = display.sequence;
|
|
707
|
+
const oldDisplayContent = truncateContent(state.accumulatedContent + state.finalReply) || "处理中...";
|
|
708
|
+
const oldCard = buildProgressCard(oldDisplayContent, { showStop: false, headerTitle: "生成中...(上轮)" });
|
|
709
|
+
await updateCardKitCard(token, display.cardId, oldCard, oldSeqBase + 1).catch(() => {});
|
|
710
|
+
const newCardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." }));
|
|
711
|
+
if (newCardId) {
|
|
712
|
+
await sendCardKitMessage(token, chatId, newCardId);
|
|
713
|
+
display.cardId = newCardId;
|
|
714
|
+
display.sequence = 1;
|
|
715
|
+
display.cardCreatedAt = Date.now();
|
|
716
|
+
display.lastSentContent = "";
|
|
717
|
+
display.streamErrorNotified = false;
|
|
718
|
+
}
|
|
719
|
+
} catch (err) {
|
|
720
|
+
console.error(`[${ts()}] [CARDIKT] rotation FAIL for ${chatId}: ${(err as Error).message}`);
|
|
721
|
+
} finally {
|
|
722
|
+
display.cardBusy = false;
|
|
723
|
+
}
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
dotCount = (dotCount % 9) + 1;
|
|
728
|
+
const content = truncateContent(state.accumulatedContent + state.finalReply + "\n" + "。".repeat(dotCount));
|
|
729
|
+
if (content === display.lastSentContent) continue;
|
|
730
|
+
|
|
731
|
+
display.lastSentContent = content;
|
|
732
|
+
display.cardBusy = true;
|
|
733
|
+
const mySeq = display.sequence + 1;
|
|
734
|
+
try {
|
|
735
|
+
const card = buildProgressCard(content, { showStop: true, headerTitle: "生成中..." });
|
|
736
|
+
await updateCardKitCard(token, display.cardId, card, mySeq);
|
|
737
|
+
display.sequence = mySeq;
|
|
738
|
+
} catch (err) {
|
|
739
|
+
console.error(`[${ts()}] CardKit update error: chatId=${chatId} ${(err as Error).message}`);
|
|
740
|
+
if (!display.streamErrorNotified) {
|
|
741
|
+
display.streamErrorNotified = true;
|
|
742
|
+
sendTextReply(token, chatId, "卡片更新失败,结果将以文本形式发送。").catch(() => {});
|
|
743
|
+
}
|
|
744
|
+
} finally {
|
|
745
|
+
display.cardBusy = false;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
} catch (err) {
|
|
750
|
+
console.error(`[${ts()}] Display loop error for ${chatId}: ${(err as Error).message}`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (isTerminal) {
|
|
755
|
+
clearInterval(interval);
|
|
756
|
+
displayLoops.delete(sessionId);
|
|
757
|
+
}
|
|
758
|
+
})().catch((err: unknown) => {
|
|
759
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
760
|
+
console.error(`[${ts()}] Display loop uncaught for ${sessionId}: ${e.message}`);
|
|
672
761
|
});
|
|
673
|
-
|
|
674
|
-
await sendTextReply(token, chatId, finalReply).catch((err) =>
|
|
675
|
-
console.error(`[${ts()}] Failed to send partial text: ${(err as Error).message}`)
|
|
676
|
-
);
|
|
677
|
-
}
|
|
678
|
-
console.log(`[${ts()}] Session ${sessionId} stopped by user (content chunks: ${state.chunkCount})`);
|
|
679
|
-
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
|
|
680
|
-
return;
|
|
681
|
-
}
|
|
762
|
+
}, 3000);
|
|
682
763
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
const shortContent = truncateContent(state.accumulatedContent, 30, 4000);
|
|
686
|
-
await sendTextReply(token, chatId, `[生成过程]\n${shortContent}`).catch((err) =>
|
|
687
|
-
console.error(`[${ts()}] Failed to send content fallback: ${(err as Error).message}`)
|
|
688
|
-
);
|
|
689
|
-
}
|
|
690
|
-
if (finalReply) {
|
|
691
|
-
await sendTextReply(token, chatId, finalReply).catch((err) =>
|
|
692
|
-
console.error(`[${ts()}] Failed to send text fallback: ${(err as Error).message}`)
|
|
693
|
-
);
|
|
694
|
-
}
|
|
695
|
-
} else {
|
|
696
|
-
if (finalReply) {
|
|
697
|
-
await sendTextReply(token, chatId, finalReply).catch((err) =>
|
|
698
|
-
console.error(`[${ts()}] Failed to send final text: ${(err as Error).message}`)
|
|
699
|
-
);
|
|
700
|
-
} else if (!cardId && state.accumulatedContent.trim()) {
|
|
701
|
-
const shortContent = truncateContent(state.accumulatedContent, 30, 4000);
|
|
702
|
-
await sendTextReply(token, chatId, `[生成过程]\n${shortContent}`).catch((err) =>
|
|
703
|
-
console.error(`[${ts()}] Failed to send content text: ${(err as Error).message}`)
|
|
704
|
-
);
|
|
705
|
-
}
|
|
706
|
-
}
|
|
764
|
+
displayLoops.set(sessionId, () => clearInterval(interval));
|
|
765
|
+
}
|
|
707
766
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
|
|
767
|
+
// ---------------------------------------------------------------------------
|
|
768
|
+
// stopSession — 停止指定 session 的活跃 prompt
|
|
769
|
+
// ---------------------------------------------------------------------------
|
|
770
|
+
|
|
771
|
+
export function stopSession(sessionId: string): boolean {
|
|
772
|
+
const prompt = activePrompts.get(sessionId);
|
|
773
|
+
if (!prompt) return false;
|
|
774
|
+
prompt.stopped = true;
|
|
775
|
+
prompt.controller.abort();
|
|
776
|
+
console.log(`[${ts()}] [STOP] Session ${sessionId} aborted`);
|
|
777
|
+
return true;
|
|
720
778
|
}
|
|
721
779
|
|
|
722
780
|
// ---------------------------------------------------------------------------
|
|
@@ -783,22 +841,29 @@ export async function getSessionStatus(chatId: string): Promise<SessionStatus |
|
|
|
783
841
|
const info = sessionInfoMap.get(chatId);
|
|
784
842
|
if (!info) return null;
|
|
785
843
|
|
|
786
|
-
const
|
|
844
|
+
const isActive = activePrompts.has(info.sessionId) && !(activePrompts.get(info.sessionId)?.stopped);
|
|
787
845
|
const { model, effort } = await resolveModelEffort(info.tool, info.sessionId);
|
|
788
846
|
|
|
789
847
|
const registry = await loadSessionRegistry();
|
|
790
848
|
const chatName = registry[chatId]?.chatName ?? "";
|
|
791
849
|
|
|
850
|
+
// 从 stream-state.json 获取当前累积长度
|
|
851
|
+
let accumulatedLength = 0;
|
|
852
|
+
const streamState = await readStreamState(info.sessionId);
|
|
853
|
+
if (streamState) {
|
|
854
|
+
accumulatedLength = streamState.accumulatedContent.length + streamState.finalReply.length;
|
|
855
|
+
}
|
|
856
|
+
|
|
792
857
|
return {
|
|
793
858
|
sessionId: info.sessionId,
|
|
794
859
|
chatName,
|
|
795
|
-
running:
|
|
860
|
+
running: isActive,
|
|
796
861
|
turnCount: info.turnCount,
|
|
797
862
|
lastContextTokens: info.lastContextTokens,
|
|
798
863
|
startTime: info.startTime,
|
|
799
864
|
model,
|
|
800
865
|
effort,
|
|
801
|
-
accumulatedLength
|
|
866
|
+
accumulatedLength,
|
|
802
867
|
};
|
|
803
868
|
}
|
|
804
869
|
|
|
@@ -829,7 +894,7 @@ export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
|
|
|
829
894
|
chatId: info.chatId,
|
|
830
895
|
sessionId: info.sessionId,
|
|
831
896
|
chatName: info.chatName || "",
|
|
832
|
-
active: info.
|
|
897
|
+
active: activePrompts.has(info.sessionId) && !(activePrompts.get(info.sessionId)?.stopped),
|
|
833
898
|
turnCount: info.turnCount,
|
|
834
899
|
startTime: info.startTime,
|
|
835
900
|
model,
|