chatccc 0.2.93 → 0.2.94

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.
@@ -1,167 +1,167 @@
1
- /**
2
- * builtin/index.ts — ChatCCC 内置 Agent 核心 API
3
- *
4
- * ChatSession 是程序化入口,既可以被 CLI 调用,也可以被其他模块(如 ToolAdapter)调用。
5
- */
6
-
7
- import { streamText } from "ai";
8
-
9
- // ---------------------------------------------------------------------------
10
- // 系统提示词 — 编译期冻结常量
11
- // ---------------------------------------------------------------------------
12
-
13
- const SYSTEM_PROMPT = [
14
- "你是 ChatCCC 内置 AI 编程助手,运行在终端环境中。",
15
- "",
16
- "## 基本规则",
17
- "- 用中文回复,但代码、命令、文件名保持原文",
18
- "- 优先给出直接可用的方案,而非长篇解释",
19
- "- 如果用户的问题涉及代码,直接给出代码并说明用法",
20
- "- 保持简洁,一次聚焦一个问题",
21
- ].join("\n");
22
-
23
- // ---------------------------------------------------------------------------
24
- // 类型定义
25
- // ---------------------------------------------------------------------------
26
-
27
- export interface ChatSessionConfig {
28
- /** DeepSeek API 兼容的服务地址,默认 https://api.deepseek.com/v1 */
29
- baseURL?: string;
30
- /** API Key(优先用 DEEPSEEK_API_KEY 环境变量) */
31
- apiKey?: string;
32
- /** 模型名称,默认 deepseek-chat */
33
- model?: string;
34
- }
35
-
36
- export interface ChatSessionOptions {
37
- /** 会话工作目录 */
38
- cwd?: string;
39
- /** 自定义系统提示词(会拼接到默认提示词之后) */
40
- systemPrompt?: string;
41
- }
42
-
43
- /**
44
- * 流式响应事件
45
- */
46
- export type ChatEvent =
47
- | { type: "text"; text: string; accumulated: string }
48
- | { type: "done"; text: string }
49
- | { type: "error"; message: string };
50
-
51
- // ---------------------------------------------------------------------------
52
- // ChatSession
53
- // ---------------------------------------------------------------------------
54
-
55
- /** 消息角色 */
56
- type MessageRole = "system" | "user" | "assistant" | "tool";
57
-
58
- /** 内部消息类型 */
59
- interface ChatMessage {
60
- role: MessageRole;
61
- content: string;
62
- }
63
-
64
- export class ChatSession {
65
- private model: any;
66
- private messages: ChatMessage[];
67
-
68
- constructor(
69
- config: ChatSessionConfig = {},
70
- options: ChatSessionOptions = {},
71
- ) {
72
- const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY;
73
- if (!apiKey) {
74
- throw new Error(
75
- "DEEPSEEK_API_KEY 未设置。请设置环境变量 DEEPSEEK_API_KEY 或传入 apiKey",
76
- );
77
- }
78
-
79
- const baseURL = config.baseURL ?? "https://api.deepseek.com/v1";
80
- const modelId = config.model ?? "deepseek-chat";
81
-
82
- // 动态 import 以支持 ESM 包在 CJS 环境下的加载(tsx 处理)
83
- const { createOpenAICompatible } = require("@ai-sdk/openai-compatible");
84
- const provider = createOpenAICompatible({
85
- name: "deepseek",
86
- baseURL,
87
- apiKey,
88
- });
89
- this.model = provider(modelId);
90
-
91
- // 构建系统提示词
92
- const systemContent = [SYSTEM_PROMPT];
93
- if (options?.systemPrompt) {
94
- systemContent.push("", options.systemPrompt);
95
- }
96
- if (options?.cwd) {
97
- systemContent.push("", `当前工作目录: ${options.cwd}`);
98
- }
99
-
100
- this.messages = [{ role: "system", content: systemContent.join("\n") }];
101
- }
102
-
103
- /**
104
- * 发送用户消息,返回异步可迭代的文本流。
105
- *
106
- * 使用方式:
107
- * ```typescript
108
- * const session = new ChatSession();
109
- * for await (const event of session.chat("帮我看看 package.json")) {
110
- * if (event.type === "text") process.stdout.write(event.text);
111
- * }
112
- * console.log("完成");
113
- * ```
114
- */
115
- async *chat(
116
- userMessage: string,
117
- signal?: AbortSignal,
118
- ): AsyncIterable<ChatEvent> {
119
- this.messages.push({ role: "user", content: userMessage });
120
-
121
- let fullText = "";
122
-
123
- try {
124
- const result = streamText({
125
- model: this.model,
126
- messages: this.messages as any,
127
- abortSignal: signal,
128
- });
129
-
130
- for await (const chunk of result.textStream) {
131
- fullText += chunk;
132
- yield { type: "text", text: chunk, accumulated: fullText };
133
- }
134
-
135
- this.messages.push({ role: "assistant", content: fullText });
136
- yield { type: "done", text: fullText };
137
- } catch (err) {
138
- const message = err instanceof Error ? err.message : String(err);
139
- if ((err as Error).name === "AbortError" || signal?.aborted) {
140
- // 被中断时,不保存不完整的助手消息
141
- if (fullText) {
142
- this.messages.push({ role: "assistant", content: fullText + "\n[已中断]" });
143
- }
144
- yield { type: "done", text: fullText };
145
- return;
146
- }
147
- yield { type: "error", message };
148
- throw err;
149
- }
150
- }
151
-
152
- /** 返回当前的会话历史(只读) */
153
- get history(): ReadonlyArray<ChatMessage> {
154
- return this.messages;
155
- }
156
-
157
- /** 返回当前轮数(不含 system 消息) */
158
- get turnCount(): number {
159
- return this.messages.length - 1;
160
- }
161
-
162
- /** 清空会话历史,保留 system 消息 */
163
- reset(): void {
164
- const system = this.messages[0];
165
- this.messages = [system];
166
- }
1
+ /**
2
+ * builtin/index.ts — ChatCCC 内置 Agent 核心 API
3
+ *
4
+ * ChatSession 是程序化入口,既可以被 CLI 调用,也可以被其他模块(如 ToolAdapter)调用。
5
+ */
6
+
7
+ import { streamText } from "ai";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // 系统提示词 — 编译期冻结常量
11
+ // ---------------------------------------------------------------------------
12
+
13
+ const SYSTEM_PROMPT = [
14
+ "你是 ChatCCC 内置 AI 编程助手,运行在终端环境中。",
15
+ "",
16
+ "## 基本规则",
17
+ "- 用中文回复,但代码、命令、文件名保持原文",
18
+ "- 优先给出直接可用的方案,而非长篇解释",
19
+ "- 如果用户的问题涉及代码,直接给出代码并说明用法",
20
+ "- 保持简洁,一次聚焦一个问题",
21
+ ].join("\n");
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // 类型定义
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export interface ChatSessionConfig {
28
+ /** DeepSeek API 兼容的服务地址,默认 https://api.deepseek.com/v1 */
29
+ baseURL?: string;
30
+ /** API Key(优先用 DEEPSEEK_API_KEY 环境变量) */
31
+ apiKey?: string;
32
+ /** 模型名称,默认 deepseek-chat */
33
+ model?: string;
34
+ }
35
+
36
+ export interface ChatSessionOptions {
37
+ /** 会话工作目录 */
38
+ cwd?: string;
39
+ /** 自定义系统提示词(会拼接到默认提示词之后) */
40
+ systemPrompt?: string;
41
+ }
42
+
43
+ /**
44
+ * 流式响应事件
45
+ */
46
+ export type ChatEvent =
47
+ | { type: "text"; text: string; accumulated: string }
48
+ | { type: "done"; text: string }
49
+ | { type: "error"; message: string };
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // ChatSession
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /** 消息角色 */
56
+ type MessageRole = "system" | "user" | "assistant" | "tool";
57
+
58
+ /** 内部消息类型 */
59
+ interface ChatMessage {
60
+ role: MessageRole;
61
+ content: string;
62
+ }
63
+
64
+ export class ChatSession {
65
+ private model: any;
66
+ private messages: ChatMessage[];
67
+
68
+ constructor(
69
+ config: ChatSessionConfig = {},
70
+ options: ChatSessionOptions = {},
71
+ ) {
72
+ const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY;
73
+ if (!apiKey) {
74
+ throw new Error(
75
+ "DEEPSEEK_API_KEY 未设置。请设置环境变量 DEEPSEEK_API_KEY 或传入 apiKey",
76
+ );
77
+ }
78
+
79
+ const baseURL = config.baseURL ?? "https://api.deepseek.com/v1";
80
+ const modelId = config.model ?? "deepseek-chat";
81
+
82
+ // 动态 import 以支持 ESM 包在 CJS 环境下的加载(tsx 处理)
83
+ const { createOpenAICompatible } = require("@ai-sdk/openai-compatible");
84
+ const provider = createOpenAICompatible({
85
+ name: "deepseek",
86
+ baseURL,
87
+ apiKey,
88
+ });
89
+ this.model = provider(modelId);
90
+
91
+ // 构建系统提示词
92
+ const systemContent = [SYSTEM_PROMPT];
93
+ if (options?.systemPrompt) {
94
+ systemContent.push("", options.systemPrompt);
95
+ }
96
+ if (options?.cwd) {
97
+ systemContent.push("", `当前工作目录: ${options.cwd}`);
98
+ }
99
+
100
+ this.messages = [{ role: "system", content: systemContent.join("\n") }];
101
+ }
102
+
103
+ /**
104
+ * 发送用户消息,返回异步可迭代的文本流。
105
+ *
106
+ * 使用方式:
107
+ * ```typescript
108
+ * const session = new ChatSession();
109
+ * for await (const event of session.chat("帮我看看 package.json")) {
110
+ * if (event.type === "text") process.stdout.write(event.text);
111
+ * }
112
+ * console.log("完成");
113
+ * ```
114
+ */
115
+ async *chat(
116
+ userMessage: string,
117
+ signal?: AbortSignal,
118
+ ): AsyncIterable<ChatEvent> {
119
+ this.messages.push({ role: "user", content: userMessage });
120
+
121
+ let fullText = "";
122
+
123
+ try {
124
+ const result = streamText({
125
+ model: this.model,
126
+ messages: this.messages as any,
127
+ abortSignal: signal,
128
+ });
129
+
130
+ for await (const chunk of result.textStream) {
131
+ fullText += chunk;
132
+ yield { type: "text", text: chunk, accumulated: fullText };
133
+ }
134
+
135
+ this.messages.push({ role: "assistant", content: fullText });
136
+ yield { type: "done", text: fullText };
137
+ } catch (err) {
138
+ const message = err instanceof Error ? err.message : String(err);
139
+ if ((err as Error).name === "AbortError" || signal?.aborted) {
140
+ // 被中断时,不保存不完整的助手消息
141
+ if (fullText) {
142
+ this.messages.push({ role: "assistant", content: fullText + "\n[已中断]" });
143
+ }
144
+ yield { type: "done", text: fullText };
145
+ return;
146
+ }
147
+ yield { type: "error", message };
148
+ throw err;
149
+ }
150
+ }
151
+
152
+ /** 返回当前的会话历史(只读) */
153
+ get history(): ReadonlyArray<ChatMessage> {
154
+ return this.messages;
155
+ }
156
+
157
+ /** 返回当前轮数(不含 system 消息) */
158
+ get turnCount(): number {
159
+ return this.messages.length - 1;
160
+ }
161
+
162
+ /** 清空会话历史,保留 system 消息 */
163
+ reset(): void {
164
+ const system = this.messages[0];
165
+ this.messages = [system];
166
+ }
167
167
  }
package/src/session.ts CHANGED
@@ -37,7 +37,14 @@ function compressWechatDisplayText(text: string): string {
37
37
  if (lines.length <= 10) return text;
38
38
  return [...lines.slice(0, 5), "...", ...lines.slice(-5)].join("\n");
39
39
  }
40
- import { readStreamState, writeStreamState, createEmptyStreamState, fixStaleStreamStates } from "./stream-state.ts";
40
+ import {
41
+ readStreamState,
42
+ writeStreamState,
43
+ createEmptyStreamState,
44
+ fixStaleStreamStates,
45
+ isFinalReplySentForTurn,
46
+ markFinalReplySent,
47
+ } from "./stream-state.ts";
41
48
  import { addCardToTurn, finalizeTurnCards, markCardDone } from "./turn-cards.ts";
42
49
  import {
43
50
  bindChatToSession,
@@ -59,6 +66,18 @@ import {
59
66
  consumeQueuePreservedChat,
60
67
  } from "./session-chat-binding.ts";
61
68
 
69
+ async function sendFinalReplyTextOnce(
70
+ platform: PlatformAdapter,
71
+ chatId: string,
72
+ sessionId: string,
73
+ turnCount: number,
74
+ text: string,
75
+ ): Promise<boolean> {
76
+ const sent = await platform.sendText(chatId, text).then((ok) => ok !== false).catch(() => false);
77
+ if (sent) await markFinalReplySent(sessionId, turnCount);
78
+ return sent;
79
+ }
80
+
62
81
  // ---------------------------------------------------------------------------
63
82
  // Shared state (imported by index.ts)
64
83
  // ---------------------------------------------------------------------------
@@ -809,27 +828,39 @@ export async function runAgentSession(
809
828
  if (display && pp) {
810
829
  // 统一 display loop 被 cardBusy 挡住或尚未 tick → 现在终结卡片
811
830
  while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
812
- const nextSeq = display.sequence + 1;
813
- const headerTitle = prevState.status === "stopped" ? "已停止" : "完成";
814
- const headerTemplate = prevState.status === "stopped" ? "red" : undefined;
815
- const cardContent = truncateContent(prevState.accumulatedContent + prevState.finalReply) || " ";
816
- const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
817
- await pp.cardUpdate(display.cardId, doneCard, nextSeq).catch(() => {});
818
- displayCards.delete(displayChatId);
819
-
820
- // 持久化:标记上一轮所有卡片为终态
821
- const finalStatus = prevState.status === "stopped" ? "stopped" : "done";
822
- finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
823
831
 
824
- if (prevState.finalReply) {
825
- await pp.sendText(displayChatId, prevState.finalReply).catch(() => {});
832
+ // 竞态防护:等待期间统一 display loop 的 tick 可能也已读到同一个
833
+ // terminal state,发送了 finalReply 并删除/替换了 displayCards 条目。
834
+ // 通过引用比较检测——若统一 loop 已处理则只补持久化和头像,不重复发。
835
+ if (displayCards.get(displayChatId) !== display) {
836
+ const finalStatus = prevState.status === "stopped" ? "stopped" : "done";
837
+ finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
838
+ pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
839
+ } else {
840
+ const nextSeq = display.sequence + 1;
841
+ const headerTitle = prevState.status === "stopped" ? "已停止" : "完成";
842
+ const headerTemplate = prevState.status === "stopped" ? "red" : undefined;
843
+ const cardContent = truncateContent(prevState.accumulatedContent + prevState.finalReply) || " ";
844
+ const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
845
+ await pp.cardUpdate(display.cardId, doneCard, nextSeq).catch(() => {});
846
+ // cardUpdate IO 期间统一 loop 可能也已处理此 display → 删前检查引用
847
+ const stillOursAfterUpdate = displayCards.get(displayChatId) === display;
848
+ displayCards.delete(displayChatId);
849
+
850
+ // 持久化:标记上一轮所有卡片为终态
851
+ const finalStatus = prevState.status === "stopped" ? "stopped" : "done";
852
+ finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
853
+
854
+ if (prevState.finalReply && stillOursAfterUpdate && !isFinalReplySentForTurn(prevState)) {
855
+ await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevState.finalReply);
856
+ }
857
+ pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
826
858
  }
827
- pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
828
- } else if (pp && prevState.finalReply) {
859
+ } else if (pp && prevState.finalReply && !isFinalReplySentForTurn(prevState)) {
829
860
  // 无 display 记录但上一轮有 finalReply(极快轮次),至少发送
830
861
  const finalStatus = prevState.status === "stopped" ? "stopped" : "done";
831
862
  finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
832
- await pp.sendText(displayChatId, prevState.finalReply).catch(() => {});
863
+ await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevState.finalReply);
833
864
  }
834
865
  // else: displayCards 无记录且无 finalReply → 无需处理
835
866
  }
@@ -1092,9 +1123,18 @@ export function startUnifiedDisplayLoop(): void {
1092
1123
  replyDelta = state.finalReply;
1093
1124
  }
1094
1125
  const remaining = (accDelta + replyDelta).trim();
1126
+
1127
+ // 若 session 仍在 activePrompts 中,说明 runAgentSession 的 finally
1128
+ // 还没执行,当前 stream state 可能是 stopSession fire-and-forget
1129
+ // 写入的,finalReply 滞后于内存态。跳过发送,等 finally 落盘后
1130
+ // 下一次 tick 再处理,避免发送过期内容或与后续发送重复。
1131
+ if (activePrompts.has(sessionId)) continue;
1132
+
1095
1133
  const tail = "━━━ 回答结束 ━━━";
1096
1134
  const finalMsg = remaining ? remaining + "\n" + tail : tail;
1097
- await p.sendText(chatId, finalMsg).catch(() => {});
1135
+ if (!isFinalReplySentForTurn(state)) {
1136
+ await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, finalMsg);
1137
+ }
1098
1138
  displayCards.delete(chatId);
1099
1139
  } else {
1100
1140
  // 发送最终结果(卡片平台)
@@ -1105,11 +1145,24 @@ export function startUnifiedDisplayLoop(): void {
1105
1145
  const cardContent = truncateContent(state.accumulatedContent + state.finalReply) || " ";
1106
1146
  const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
1107
1147
  await p.cardUpdate(display.cardId, doneCard, nextSeq).catch(() => {});
1148
+
1149
+ // 若 session 仍在 activePrompts 中,说明 runAgentSession 的 finally
1150
+ // 还没执行,当前 stream state 可能是 stopSession fire-and-forget
1151
+ // 写入的,finalReply 滞后于内存态。卡片已更新为终态外观,但不发送
1152
+ // 文本、不删除 display 条目,留给 finally 落盘后的下一次 tick 处理。
1153
+ if (activePrompts.has(sessionId)) {
1154
+ display.lastSentAccLen = state.accumulatedContent.length;
1155
+ display.lastSentFinalReply = state.finalReply;
1156
+ continue;
1157
+ }
1158
+
1108
1159
  const finalSt = state.status === "stopped" ? "stopped" : "done";
1109
1160
  finalizeTurnCards(sessionId, state.turnCount, finalSt).catch(() => {});
1110
1161
  displayCards.delete(chatId);
1111
1162
  if (state.finalReply) {
1112
- await p.sendText(chatId, state.finalReply).catch(() => {});
1163
+ if (!isFinalReplySentForTurn(state)) {
1164
+ await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, state.finalReply);
1165
+ }
1113
1166
  } else if (state.accumulatedContent.trim()) {
1114
1167
  const short = truncateContent(state.accumulatedContent, 30, 4000);
1115
1168
  await p.sendText(chatId, `[生成过程]\n${short}`).catch(() => {});
@@ -1165,7 +1218,7 @@ export function startUnifiedDisplayLoop(): void {
1165
1218
  try {
1166
1219
  const oldSeqBase = display.sequence;
1167
1220
  const oldContent = state.accumulatedContent + state.finalReply;
1168
- const oldCard = buildProgressCard(truncateContent(oldContent) || " ", { showStop: false, headerTitle: "完成" });
1221
+ const oldCard = buildProgressCard(truncateContent(oldContent) || " ", { showStop: false, headerTitle: "生成中(上轮)" });
1169
1222
  await p.cardUpdate(display.cardId, oldCard, oldSeqBase + 1).catch(() => {});
1170
1223
  markCardDone(sessionId, display.turnCount, display.cardId).catch(() => {});
1171
1224
  const newCardId = await p.cardCreate(buildProgressCard(
@@ -14,6 +14,9 @@ export interface StreamState {
14
14
  status: "running" | "done" | "stopped" | "error";
15
15
  accumulatedContent: string;
16
16
  finalReply: string;
17
+ /** The turn whose terminal text reply has already been delivered to IM. */
18
+ finalReplySentTurn?: number;
19
+ finalReplySentAt?: number;
17
20
  chunkCount: number;
18
21
  turnCount: number;
19
22
  contextTokens: number;
@@ -97,6 +100,22 @@ export async function writeStreamState(state: StreamState): Promise<void> {
97
100
  }
98
101
  }
99
102
 
103
+ export function isFinalReplySentForTurn(state: Pick<StreamState, "turnCount" | "finalReplySentTurn">): boolean {
104
+ return state.finalReplySentTurn === state.turnCount;
105
+ }
106
+
107
+ export async function markFinalReplySent(sessionId: string, turnCount: number, sentAt = Date.now()): Promise<void> {
108
+ const state = await readStreamState(sessionId);
109
+ if (!state) return;
110
+ if (state.turnCount !== turnCount) return;
111
+ if (state.status === "running") return;
112
+
113
+ state.finalReplySentTurn = turnCount;
114
+ state.finalReplySentAt = sentAt;
115
+ state.updatedAt = Date.now();
116
+ await writeStreamState(state);
117
+ }
118
+
100
119
  export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
101
120
  return {
102
121
  sessionId,
@@ -138,4 +157,4 @@ export async function fixStaleStreamStates(): Promise<void> {
138
157
  } catch (err) {
139
158
  console.error(`[${ts()}] [STREAM-STATE] fixStaleStreamStates failed: ${(err as Error).message}`);
140
159
  }
141
- }
160
+ }