chatccc 0.2.95 → 0.2.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/feishu-platform.test.ts +2 -1
- package/src/adapters/adapter-interface.ts +5 -0
- package/src/adapters/codex-adapter.ts +9 -3
- package/src/adapters/cursor-adapter.ts +19 -5
- package/src/cards.ts +43 -0
- package/src/config.ts +24 -0
- package/src/feishu-api.ts +126 -1
- package/src/feishu-platform.ts +5 -0
- package/src/index.ts +2 -0
- package/src/orchestrator.ts +150 -8
- package/src/session.ts +58 -14
- package/src/sim-platform.ts +6 -0
- package/src/sim-store.ts +1 -1
- package/src/stream-state.ts +36 -33
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
-
import { getPlatform, setPlatform, getTenantAccessToken, getChatInfo, createGroupChat, updateChatInfo, sendTextReply, sendCardReply, sendRawCard, extractSessionInfo, formatDelayNotice, reportPermissionResults, verifyAllPermissions, addReaction, recallMessage, updateCardMessage, setChatAvatar, disbandChat, sendRestartCard } from "../feishu-platform.ts";
|
|
2
|
+
import { getPlatform, setPlatform, getTenantAccessToken, getChatInfo, createGroupChat, updateChatInfo, sendTextReply, sendCardReply, sendRawCard, sendPostMessage, extractSessionInfo, formatDelayNotice, reportPermissionResults, verifyAllPermissions, addReaction, recallMessage, updateCardMessage, setChatAvatar, disbandChat, sendRestartCard } from "../feishu-platform.ts";
|
|
3
3
|
import type { FeishuPlatform } from "../feishu-platform.ts";
|
|
4
4
|
|
|
5
5
|
const realPlatform = getPlatform();
|
|
@@ -17,6 +17,7 @@ describe("feishu-platform", () => {
|
|
|
17
17
|
sendTextReply: async () => true,
|
|
18
18
|
sendCardReply: async () => true,
|
|
19
19
|
sendRawCard: async () => true,
|
|
20
|
+
sendPostMessage: async () => true,
|
|
20
21
|
sendImageReply: async () => true,
|
|
21
22
|
sendFileReply: async () => true,
|
|
22
23
|
addReaction: async () => {},
|
|
@@ -31,6 +31,11 @@ export interface UnifiedTextBlock {
|
|
|
31
31
|
* 由 pickFinalReply 在两者之间挑选。
|
|
32
32
|
*
|
|
33
33
|
* 对没有 partial/final 双轨的适配器(如 Claude SDK),永远不会 emit 此类型。
|
|
34
|
+
*
|
|
35
|
+
* 命名注意:此处的 "final" 指"完整/最终版本"(与 partial delta 相对),
|
|
36
|
+
* 不等于 stream-state / AccumulatorState 中的 finalReply / finalText。
|
|
37
|
+
* 后者命名含 "final" 但实际语义是"本轮所有文本的累积",属历史遗留命名,
|
|
38
|
+
* 详见 session.ts 的 AccumulatorState 注释和 stream-state.ts 的 StreamState 注释。
|
|
34
39
|
*/
|
|
35
40
|
export interface UnifiedTextFinalBlock {
|
|
36
41
|
type: "text_final";
|
|
@@ -150,9 +150,10 @@ function spawnCodex(
|
|
|
150
150
|
args: string[],
|
|
151
151
|
cwd?: string,
|
|
152
152
|
stdinText?: string,
|
|
153
|
+
modelOverride?: string,
|
|
153
154
|
): ChildProcess {
|
|
154
155
|
const allArgs = [...args];
|
|
155
|
-
const model = resolveCodexModel();
|
|
156
|
+
const model = modelOverride ?? resolveCodexModel();
|
|
156
157
|
if (model) {
|
|
157
158
|
// 把 -m 插在 exec 后面、其他参数前面
|
|
158
159
|
const execIdx = allArgs.indexOf("exec");
|
|
@@ -222,9 +223,11 @@ class CodexAdapter implements ToolAdapter {
|
|
|
222
223
|
readonly displayName = "Codex";
|
|
223
224
|
readonly sessionDescPrefix = "Codex Session:";
|
|
224
225
|
private metaStore: CodexSessionMetaStore;
|
|
226
|
+
private modelOverride: string | undefined;
|
|
225
227
|
|
|
226
|
-
constructor(metaStore: CodexSessionMetaStore) {
|
|
228
|
+
constructor(metaStore: CodexSessionMetaStore, modelOverride?: string) {
|
|
227
229
|
this.metaStore = metaStore;
|
|
230
|
+
this.modelOverride = modelOverride;
|
|
228
231
|
}
|
|
229
232
|
|
|
230
233
|
// createSession: 生成 sessionId,记录 cwd,不创建 Codex 线程(延迟到首次 prompt)
|
|
@@ -251,7 +254,7 @@ class CodexAdapter implements ToolAdapter {
|
|
|
251
254
|
? [...CODEX_BASE_ARGS, "-C", cwd, "-"]
|
|
252
255
|
: [...CODEX_BASE_ARGS, "resume", threadId, "-"];
|
|
253
256
|
|
|
254
|
-
const proc = spawnCodex(args, cwd, userText);
|
|
257
|
+
const proc = spawnCodex(args, cwd, userText, this.modelOverride);
|
|
255
258
|
if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
|
|
256
259
|
|
|
257
260
|
// 关键:spawn 用了 shell:true,proc.pid 指向的是壳进程(cmd.exe / sh)。
|
|
@@ -304,6 +307,8 @@ class CodexAdapter implements ToolAdapter {
|
|
|
304
307
|
|
|
305
308
|
export interface CreateCodexAdapterOptions {
|
|
306
309
|
metaStore?: CodexSessionMetaStore;
|
|
310
|
+
/** per-session 模型覆盖(/model 命令);传了就用,不传走全局 codex.model */
|
|
311
|
+
model?: string;
|
|
307
312
|
}
|
|
308
313
|
|
|
309
314
|
export function createCodexAdapter(
|
|
@@ -311,5 +316,6 @@ export function createCodexAdapter(
|
|
|
311
316
|
): ToolAdapter {
|
|
312
317
|
return new CodexAdapter(
|
|
313
318
|
options.metaStore ?? defaultCodexSessionMetaStore,
|
|
319
|
+
options.model,
|
|
314
320
|
);
|
|
315
321
|
}
|
|
@@ -260,8 +260,18 @@ function spawnAgent(
|
|
|
260
260
|
extraArgs: string[],
|
|
261
261
|
cwd?: string,
|
|
262
262
|
stdinText?: string,
|
|
263
|
+
modelOverride?: string,
|
|
263
264
|
): ChildProcess {
|
|
264
|
-
|
|
265
|
+
let allArgs = [...CURSOR_AGENT_ARGS, ...extraArgs];
|
|
266
|
+
if (modelOverride) {
|
|
267
|
+
// 替换全局 --model 为 per-session override
|
|
268
|
+
const modelIdx = allArgs.findIndex((a, i) => a === "--model" && i + 1 < allArgs.length);
|
|
269
|
+
if (modelIdx >= 0) {
|
|
270
|
+
allArgs[modelIdx + 1] = modelOverride;
|
|
271
|
+
} else {
|
|
272
|
+
allArgs.push("--model", modelOverride);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
265
275
|
const proc = spawn(CURSOR_AGENT_COMMAND, allArgs, {
|
|
266
276
|
cwd,
|
|
267
277
|
stdio: [stdinText !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
|
|
@@ -324,13 +334,15 @@ class CursorAdapter implements ToolAdapter {
|
|
|
324
334
|
readonly sessionDescPrefix = "Cursor Session:";
|
|
325
335
|
private activeProcs = new Set<ChildProcess>();
|
|
326
336
|
private metaStore: CursorSessionMetaStore;
|
|
337
|
+
private modelOverride: string | undefined;
|
|
327
338
|
|
|
328
|
-
constructor(metaStore: CursorSessionMetaStore) {
|
|
339
|
+
constructor(metaStore: CursorSessionMetaStore, modelOverride?: string) {
|
|
329
340
|
this.metaStore = metaStore;
|
|
341
|
+
this.modelOverride = modelOverride;
|
|
330
342
|
}
|
|
331
343
|
|
|
332
344
|
async createSession(cwd: string): Promise<CreateSessionResult> {
|
|
333
|
-
const proc = spawnAgent(["ok"], cwd);
|
|
345
|
+
const proc = spawnAgent(["ok"], cwd, undefined, this.modelOverride);
|
|
334
346
|
this.activeProcs.add(proc);
|
|
335
347
|
|
|
336
348
|
for await (const msg of readJsonLines(proc, undefined, "createSession")) {
|
|
@@ -358,7 +370,7 @@ class CursorAdapter implements ToolAdapter {
|
|
|
358
370
|
options?: ToolPromptOptions,
|
|
359
371
|
): AsyncIterable<UnifiedStreamMessage> {
|
|
360
372
|
console.log(`[Cursor debug] prompt start: sessionId=${sessionId}, cwd=${cwd}, userTextLen=${userText.length}`);
|
|
361
|
-
const proc = spawnAgent(["--resume", sessionId], cwd, userText);
|
|
373
|
+
const proc = spawnAgent(["--resume", sessionId], cwd, userText, this.modelOverride);
|
|
362
374
|
this.activeProcs.add(proc);
|
|
363
375
|
if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
|
|
364
376
|
|
|
@@ -414,10 +426,12 @@ class CursorAdapter implements ToolAdapter {
|
|
|
414
426
|
export interface CreateCursorAdapterOptions {
|
|
415
427
|
/** 注入自定义 meta 持久化 store(测试用);未提供时使用全局默认实例。 */
|
|
416
428
|
metaStore?: CursorSessionMetaStore;
|
|
429
|
+
/** per-session 模型覆盖(/model 命令);传了就用,不传走全局 cursor.model */
|
|
430
|
+
model?: string;
|
|
417
431
|
}
|
|
418
432
|
|
|
419
433
|
export function createCursorAdapter(
|
|
420
434
|
options: CreateCursorAdapterOptions = {},
|
|
421
435
|
): ToolAdapter {
|
|
422
|
-
return new CursorAdapter(options.metaStore ?? defaultCursorSessionMetaStore);
|
|
436
|
+
return new CursorAdapter(options.metaStore ?? defaultCursorSessionMetaStore, options.model);
|
|
423
437
|
}
|
package/src/cards.ts
CHANGED
|
@@ -407,3 +407,46 @@ export function buildStatusCard(statusText: string, template = "blue"): string {
|
|
|
407
407
|
],
|
|
408
408
|
});
|
|
409
409
|
}
|
|
410
|
+
|
|
411
|
+
/** 模型切换卡片(/model 命令,飞书专用,含切换按钮) */
|
|
412
|
+
export function buildModelCard(
|
|
413
|
+
currentModel: string,
|
|
414
|
+
models: string[],
|
|
415
|
+
tool?: string,
|
|
416
|
+
): string {
|
|
417
|
+
const toolLabel = tool ? ` (${tool})` : "";
|
|
418
|
+
const currentLine = currentModel
|
|
419
|
+
? `**当前模型:** \`${currentModel}\``
|
|
420
|
+
: "**当前模型:** 未指定";
|
|
421
|
+
|
|
422
|
+
const lines: string[] = [currentLine];
|
|
423
|
+
if (models.length > 0) {
|
|
424
|
+
lines.push("", "**可切换模型:**");
|
|
425
|
+
for (const m of models) {
|
|
426
|
+
lines.push(`- \`${m}\``);
|
|
427
|
+
}
|
|
428
|
+
lines.push("", "点击按钮切换模型,或输入 `/model clear` 恢复默认");
|
|
429
|
+
} else {
|
|
430
|
+
lines.push("", "没有可切换的模型。请在 config.json 中配置模型字段。");
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const buttons: ButtonDef[] = [];
|
|
434
|
+
for (const m of models.slice(0, 20)) {
|
|
435
|
+
const shortName = m.includes("/") ? m.slice(m.lastIndexOf("/") + 1) : m;
|
|
436
|
+
buttons.push({
|
|
437
|
+
text: `/model ${shortName}`,
|
|
438
|
+
value: JSON.stringify({ cmd: `/model ${m}` }),
|
|
439
|
+
type: "primary",
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return JSON.stringify({
|
|
444
|
+
config: { wide_screen_mode: true },
|
|
445
|
+
header: { template: "blue", title: { content: `模型切换${toolLabel}`, tag: "plain_text" } },
|
|
446
|
+
elements: [
|
|
447
|
+
{ tag: "div", text: { tag: "lark_md", content: lines.join("\n") } },
|
|
448
|
+
{ tag: "hr" },
|
|
449
|
+
buildButtons(buttons),
|
|
450
|
+
],
|
|
451
|
+
});
|
|
452
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -120,6 +120,25 @@ export interface AppConfig {
|
|
|
120
120
|
export type AgentTool = "claude" | "cursor" | "codex";
|
|
121
121
|
export const AGENT_TOOLS: AgentTool[] = ["claude", "cursor", "codex"];
|
|
122
122
|
|
|
123
|
+
/** 获取指定 agent 配置中所有模型相关的值(最多 100 个,去重) */
|
|
124
|
+
export function getAllModelsForTool(tool: AgentTool, cfg: AppConfig = config): string[] {
|
|
125
|
+
const seen = new Set<string>();
|
|
126
|
+
const collect = (v: unknown) => {
|
|
127
|
+
if (typeof v === "string" && v.trim()) seen.add(v.trim());
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
if (tool === "claude") {
|
|
131
|
+
collect(cfg.claude.model);
|
|
132
|
+
collect(cfg.claude.subagentModel);
|
|
133
|
+
} else if (tool === "cursor") {
|
|
134
|
+
collect(cfg.cursor.model);
|
|
135
|
+
} else if (tool === "codex") {
|
|
136
|
+
collect(cfg.codex.model);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return Array.from(seen).slice(0, 100);
|
|
140
|
+
}
|
|
141
|
+
|
|
123
142
|
const CONFIG_FILE = join(USER_DATA_DIR, "config.json");
|
|
124
143
|
const CONFIG_SAMPLE_FILE = join(PROJECT_ROOT, "config.sample.json");
|
|
125
144
|
|
|
@@ -511,6 +530,11 @@ export let CLAUDE_API_KEY = config.claude.apiKey;
|
|
|
511
530
|
/** Anthropic 兼容网关的 base URL(仅经 SDK 子进程 env 传递,从不写入主进程 process.env) */
|
|
512
531
|
export let CLAUDE_BASE_URL = config.claude.baseUrl;
|
|
513
532
|
|
|
533
|
+
/** 返回当前生效的 Claude 模型(per-session 覆盖由 session.ts 管理,此处仅返回全局配置) */
|
|
534
|
+
export function getEffectiveClaudeModel(): string {
|
|
535
|
+
return CLAUDE_MODEL;
|
|
536
|
+
}
|
|
537
|
+
|
|
514
538
|
// ---------------------------------------------------------------------------
|
|
515
539
|
// /git 超时配置(实际值来自 config.json,纯函数与常量见 ./config-utils.ts)
|
|
516
540
|
// ---------------------------------------------------------------------------
|
package/src/feishu-api.ts
CHANGED
|
@@ -532,15 +532,92 @@ export function formatDelayNotice(createTimeMs: number, messageText?: string, no
|
|
|
532
532
|
return `> ⚠️ 延迟送达提醒:此消息于 ${sendTimeStr} 发送,因服务离线,延迟约 ${delayStr}后送达${contentLine}`;
|
|
533
533
|
}
|
|
534
534
|
|
|
535
|
+
/**
|
|
536
|
+
* 检测文本中的 markdown 表格,用代码块包裹。
|
|
537
|
+
*
|
|
538
|
+
* 飞书 markdown 渲染对表格支持不稳定(列对齐易错乱),用代码块包裹可保证
|
|
539
|
+
* 等宽字体显示、列对齐正确。只处理不在已有代码块内的表格。
|
|
540
|
+
*
|
|
541
|
+
* 检测规则:至少两行连续的 "|col|col|" 模式,其中必须包含一行分隔行
|
|
542
|
+
* (如 |---|----|),避免将单行含 | 的普通文本误判为表格。
|
|
543
|
+
*/
|
|
544
|
+
function wrapMarkdownTables(text: string): string {
|
|
545
|
+
const lines = text.split("\n");
|
|
546
|
+
const result: string[] = [];
|
|
547
|
+
let inCodeBlock = false;
|
|
548
|
+
let tableRows: string[] = [];
|
|
549
|
+
let foundSeparator = false;
|
|
550
|
+
|
|
551
|
+
const isTableRow = (line: string): boolean =>
|
|
552
|
+
/^\s*\|.+\|/.test(line);
|
|
553
|
+
|
|
554
|
+
const isSeparatorRow = (line: string): boolean =>
|
|
555
|
+
/^\s*\|[\s\-:]+\|/.test(line) && /-/.test(line);
|
|
556
|
+
|
|
557
|
+
const flushTable = (): void => {
|
|
558
|
+
if (foundSeparator && tableRows.length >= 2) {
|
|
559
|
+
result.push("```");
|
|
560
|
+
result.push(...tableRows);
|
|
561
|
+
result.push("```");
|
|
562
|
+
} else {
|
|
563
|
+
result.push(...tableRows);
|
|
564
|
+
}
|
|
565
|
+
tableRows = [];
|
|
566
|
+
foundSeparator = false;
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
for (const line of lines) {
|
|
570
|
+
const trimmed = line.trim();
|
|
571
|
+
|
|
572
|
+
if (trimmed.startsWith("```")) {
|
|
573
|
+
flushTable();
|
|
574
|
+
inCodeBlock = !inCodeBlock;
|
|
575
|
+
result.push(line);
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (inCodeBlock) {
|
|
580
|
+
result.push(line);
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (!foundSeparator) {
|
|
585
|
+
if (isTableRow(line)) {
|
|
586
|
+
tableRows.push(line);
|
|
587
|
+
} else if (tableRows.length > 0 && isSeparatorRow(line)) {
|
|
588
|
+
tableRows.push(line);
|
|
589
|
+
foundSeparator = true;
|
|
590
|
+
} else {
|
|
591
|
+
if (tableRows.length > 0) {
|
|
592
|
+
result.push(...tableRows);
|
|
593
|
+
tableRows = [];
|
|
594
|
+
}
|
|
595
|
+
result.push(line);
|
|
596
|
+
}
|
|
597
|
+
} else {
|
|
598
|
+
if (isTableRow(line)) {
|
|
599
|
+
tableRows.push(line);
|
|
600
|
+
} else {
|
|
601
|
+
flushTable();
|
|
602
|
+
result.push(line);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
flushTable();
|
|
608
|
+
return result.join("\n");
|
|
609
|
+
}
|
|
610
|
+
|
|
535
611
|
export async function sendTextReply(
|
|
536
612
|
token: string,
|
|
537
613
|
chatId: string,
|
|
538
614
|
text: string
|
|
539
615
|
): Promise<boolean> {
|
|
540
616
|
const safeText = applyPrivacy(text);
|
|
617
|
+
const wrappedText = wrapMarkdownTables(safeText);
|
|
541
618
|
const card = JSON.stringify({
|
|
542
619
|
config: { wide_screen_mode: true },
|
|
543
|
-
elements: [{ tag: "markdown", content:
|
|
620
|
+
elements: [{ tag: "markdown", content: wrappedText }],
|
|
544
621
|
});
|
|
545
622
|
try {
|
|
546
623
|
const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
@@ -915,3 +992,51 @@ export async function updateCardMessage(token: string, messageId: string, conten
|
|
|
915
992
|
}
|
|
916
993
|
return true;
|
|
917
994
|
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* 发送飞书 post 类型消息(非卡片富文本消息)。
|
|
998
|
+
*
|
|
999
|
+
* post 消息支持 table、text、a、at、img 等富文本元素,
|
|
1000
|
+
* 与 interactive 卡片消息不同,post 直接在消息流中渲染。
|
|
1001
|
+
*
|
|
1002
|
+
* @param postContent 飞书 post content 格式的二维数组,每个元素是一个 paragraph
|
|
1003
|
+
*/
|
|
1004
|
+
export async function sendPostMessage(
|
|
1005
|
+
token: string,
|
|
1006
|
+
chatId: string,
|
|
1007
|
+
title: string,
|
|
1008
|
+
postContent: unknown[][],
|
|
1009
|
+
): Promise<boolean> {
|
|
1010
|
+
const content = JSON.stringify({
|
|
1011
|
+
post: {
|
|
1012
|
+
zh_cn: {
|
|
1013
|
+
title,
|
|
1014
|
+
content: postContent,
|
|
1015
|
+
},
|
|
1016
|
+
},
|
|
1017
|
+
});
|
|
1018
|
+
try {
|
|
1019
|
+
const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
1020
|
+
method: "POST",
|
|
1021
|
+
headers: {
|
|
1022
|
+
Authorization: `Bearer ${token}`,
|
|
1023
|
+
"Content-Type": "application/json",
|
|
1024
|
+
},
|
|
1025
|
+
body: JSON.stringify({
|
|
1026
|
+
receive_id: chatId,
|
|
1027
|
+
msg_type: "post",
|
|
1028
|
+
content,
|
|
1029
|
+
}),
|
|
1030
|
+
});
|
|
1031
|
+
const data = (await resp.json().catch(() => ({}))) as { code: number; msg?: string; data?: { message_id?: string } };
|
|
1032
|
+
if (data.code !== 0) {
|
|
1033
|
+
console.error(`[${ts()}] [SEND] post FAIL: chatId=${chatId} code=${data.code} msg="${data.msg ?? ""}"`);
|
|
1034
|
+
return false;
|
|
1035
|
+
}
|
|
1036
|
+
console.log(`[${ts()}] [SEND] post OK: chatId=${chatId} msgId=${data.data?.message_id ?? "N/A"}`);
|
|
1037
|
+
return true;
|
|
1038
|
+
} catch (err) {
|
|
1039
|
+
console.error(`[${ts()}] [SEND] post FAIL: chatId=${chatId} ${(err as Error).message}`);
|
|
1040
|
+
return false;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
package/src/feishu-platform.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface FeishuPlatform {
|
|
|
19
19
|
sendTextReply: typeof realApi.sendTextReply;
|
|
20
20
|
sendCardReply: typeof realApi.sendCardReply;
|
|
21
21
|
sendRawCard: typeof realApi.sendRawCard;
|
|
22
|
+
sendPostMessage: typeof realApi.sendPostMessage;
|
|
22
23
|
sendImageReply: typeof realApi.sendImageReply;
|
|
23
24
|
sendFileReply: typeof realApi.sendFileReply;
|
|
24
25
|
addReaction: typeof realApi.addReaction;
|
|
@@ -134,6 +135,10 @@ export function formatDelayNotice(...args: Parameters<typeof realApi.formatDelay
|
|
|
134
135
|
return _impl.formatDelayNotice(...args);
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
export function sendPostMessage(...args: Parameters<typeof realApi.sendPostMessage>): ReturnType<typeof realApi.sendPostMessage> {
|
|
139
|
+
return _impl.sendPostMessage(...args);
|
|
140
|
+
}
|
|
141
|
+
|
|
137
142
|
export function sendRestartCard(...args: Parameters<typeof realApi.sendRestartCard>): ReturnType<typeof realApi.sendRestartCard> {
|
|
138
143
|
return _impl.sendRestartCard(...args);
|
|
139
144
|
}
|
package/src/index.ts
CHANGED
|
@@ -291,6 +291,8 @@ function parseCardAction(data: unknown): CardActionResult | null {
|
|
|
291
291
|
const path = (action.value as Record<string, string>).path;
|
|
292
292
|
if (path) text = `/cd ${path}`;
|
|
293
293
|
}
|
|
294
|
+
// cmd 本身就是以 / 开头的完整指令时,直接使用(如 /model <name> 动态按钮)
|
|
295
|
+
if (!text && cmd.startsWith("/")) text = cmd;
|
|
294
296
|
if (!text) return null;
|
|
295
297
|
|
|
296
298
|
const chatId =
|
package/src/orchestrator.ts
CHANGED
|
@@ -13,10 +13,13 @@ import { makeTraceId, logTrace } from "./trace.ts";
|
|
|
13
13
|
import {
|
|
14
14
|
CLAUDE_EFFORT,
|
|
15
15
|
CLAUDE_MODEL,
|
|
16
|
+
CLAUDE_SUBAGENT_MODEL,
|
|
16
17
|
GIT_TIMEOUT_MS,
|
|
17
18
|
PROJECT_ROOT,
|
|
18
19
|
anthropicConfigDisplay,
|
|
20
|
+
config,
|
|
19
21
|
fileLog,
|
|
22
|
+
getAllModelsForTool,
|
|
20
23
|
getDefaultCwd,
|
|
21
24
|
setDefaultCwd,
|
|
22
25
|
getRecentDirs,
|
|
@@ -25,9 +28,11 @@ import {
|
|
|
25
28
|
sessionPrefixForTool,
|
|
26
29
|
toolDisplayName,
|
|
27
30
|
ts,
|
|
31
|
+
type AgentTool,
|
|
28
32
|
} from "./config.ts";
|
|
29
33
|
import {
|
|
30
34
|
buildHelpCard,
|
|
35
|
+
buildModelCard,
|
|
31
36
|
buildStatusCard,
|
|
32
37
|
buildCdContent,
|
|
33
38
|
buildCdCard,
|
|
@@ -41,15 +46,18 @@ import {
|
|
|
41
46
|
runGitCommand,
|
|
42
47
|
} from "./git-command.ts";
|
|
43
48
|
import {
|
|
49
|
+
clearSessionModelOverride,
|
|
44
50
|
getSessionStatus,
|
|
45
51
|
getAllSessionsStatus,
|
|
46
52
|
initClaudeSession,
|
|
47
53
|
lastMsgTimestamps,
|
|
48
54
|
resumeAndPrompt,
|
|
49
55
|
sessionInfoMap,
|
|
56
|
+
setSessionModelOverride,
|
|
50
57
|
switchChatBinding,
|
|
51
58
|
recordSessionRegistry,
|
|
52
59
|
getAdapterForTool,
|
|
60
|
+
getEffectiveModelForTool,
|
|
53
61
|
stopSession,
|
|
54
62
|
loadSessionRegistryForBinding,
|
|
55
63
|
removeSessionRegistryRecord,
|
|
@@ -65,6 +73,7 @@ import {
|
|
|
65
73
|
enqueueMessage,
|
|
66
74
|
cancelQueuedMessage,
|
|
67
75
|
} from "./session-chat-binding.ts";
|
|
76
|
+
import { getTenantAccessToken, sendPostMessage } from "./feishu-platform.ts";
|
|
68
77
|
export { type PlatformAdapter } from "./platform-adapter.ts";
|
|
69
78
|
import type { PlatformAdapter } from "./platform-adapter.ts";
|
|
70
79
|
|
|
@@ -81,6 +90,21 @@ export function sessionChatName(left: string, cwd: string): string {
|
|
|
81
90
|
return `${left}-${cwdDisplayName(cwd)}`;
|
|
82
91
|
}
|
|
83
92
|
|
|
93
|
+
/** 模型模糊匹配:精确匹配优先,否则找子串匹配(模型名越短越优先) */
|
|
94
|
+
function findModelMatch(input: string, models: string[]): string | null {
|
|
95
|
+
if (models.length === 0) return null;
|
|
96
|
+
const inputLower = input.toLowerCase();
|
|
97
|
+
// 1) 精确匹配(忽略大小写)
|
|
98
|
+
for (const m of models) {
|
|
99
|
+
if (m.toLowerCase() === inputLower) return m;
|
|
100
|
+
}
|
|
101
|
+
// 2) 子串匹配:模型全名包含输入,按模型名长度升序(越短越优先)
|
|
102
|
+
const candidates = models
|
|
103
|
+
.filter(m => m.toLowerCase().includes(inputLower))
|
|
104
|
+
.sort((a, b) => a.length - b.length);
|
|
105
|
+
return candidates[0] ?? null;
|
|
106
|
+
}
|
|
107
|
+
|
|
84
108
|
function isUntitledSessionChatName(name: string): boolean {
|
|
85
109
|
return name === "新会话" || name.startsWith("新会话-");
|
|
86
110
|
}
|
|
@@ -141,7 +165,7 @@ export async function handleCommand(
|
|
|
141
165
|
chatInfo.description,
|
|
142
166
|
);
|
|
143
167
|
if (sessionInfoResult) {
|
|
144
|
-
const adapter = getAdapterForTool(sessionInfoResult.tool);
|
|
168
|
+
const adapter = getAdapterForTool(sessionInfoResult.tool, sessionInfoResult.sessionId);
|
|
145
169
|
const info = await adapter.getSessionInfo(sessionInfoResult.sessionId);
|
|
146
170
|
sessionCwd = info?.cwd;
|
|
147
171
|
}
|
|
@@ -243,6 +267,34 @@ export async function handleCommand(
|
|
|
243
267
|
return;
|
|
244
268
|
}
|
|
245
269
|
|
|
270
|
+
if (textLower === "/model") {
|
|
271
|
+
logTrace(tid, "BRANCH", { cmd: "/model" });
|
|
272
|
+
|
|
273
|
+
const defaultTool = resolveDefaultAgentTool();
|
|
274
|
+
const models = getAllModelsForTool(defaultTool);
|
|
275
|
+
let currentModel = "";
|
|
276
|
+
if (defaultTool === "cursor") currentModel = config.cursor.model;
|
|
277
|
+
else if (defaultTool === "codex") currentModel = config.codex.model;
|
|
278
|
+
else currentModel = CLAUDE_MODEL;
|
|
279
|
+
|
|
280
|
+
if (platform.kind === "wechat") {
|
|
281
|
+
const lines = [currentModel ? `当前模型 (${defaultTool}): ${currentModel}` : `当前模型 (${defaultTool}): 未指定`];
|
|
282
|
+
if (models.length > 0) {
|
|
283
|
+
lines.push("", "可切换模型:");
|
|
284
|
+
for (const m of models) lines.push(` ${m}`);
|
|
285
|
+
lines.push("", "在会话中输入 /model <模型名> 切换模型");
|
|
286
|
+
} else {
|
|
287
|
+
lines.push("", "没有可切换的模型。请在 config.json 中配置模型字段。");
|
|
288
|
+
}
|
|
289
|
+
await platform.sendText(chatId, lines.join("\n")).catch(() => {});
|
|
290
|
+
} else {
|
|
291
|
+
const card = buildModelCard(currentModel, models, defaultTool);
|
|
292
|
+
await platform.sendRawCard(chatId, card);
|
|
293
|
+
}
|
|
294
|
+
logTrace(tid, "DONE", { outcome: "model_query", defaultTool });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
246
298
|
if (textLower === "/new" || textLower.startsWith("/new ")) {
|
|
247
299
|
const toolArg = text.slice(5).trim().toLowerCase();
|
|
248
300
|
const tool = toolArg || resolveDefaultAgentTool();
|
|
@@ -339,6 +391,7 @@ export async function handleCommand(
|
|
|
339
391
|
`**工作目录:** \`${cwd}\`\n\n` +
|
|
340
392
|
`直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
|
|
341
393
|
`发送 **/cd** 切换新建会话的默认目录。\n` +
|
|
394
|
+
`发送 **/model** 查看或切换当前会话的模型。\n` +
|
|
342
395
|
`发送 **/new** 创建新会话,**/newh** 重置当前会话(沿用工作目录)。\n` +
|
|
343
396
|
`发送 **/sessions** 查看所有会话状态。\n` +
|
|
344
397
|
`发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。`,
|
|
@@ -424,6 +477,7 @@ export async function handleCommand(
|
|
|
424
477
|
`**工作目录:** \`${cwd}\`\n\n` +
|
|
425
478
|
`直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
|
|
426
479
|
`发送 **/cd** 切换新建会话的默认目录。\n` +
|
|
480
|
+
`发送 **/model** 查看或切换当前会话的模型。\n` +
|
|
427
481
|
`发送 **/new** 创建新会话,**/newh** 重置当前会话(沿用工作目录)。\n` +
|
|
428
482
|
`发送 **/sessions** 查看所有会话状态。\n` +
|
|
429
483
|
`发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。`,
|
|
@@ -510,7 +564,7 @@ export async function handleCommand(
|
|
|
510
564
|
) {
|
|
511
565
|
const MAX_PREFIX = 10;
|
|
512
566
|
const prefix = text.slice(0, MAX_PREFIX);
|
|
513
|
-
const adapter = getAdapterForTool(descriptionTool);
|
|
567
|
+
const adapter = getAdapterForTool(descriptionTool, sessionId);
|
|
514
568
|
const info = await adapter
|
|
515
569
|
.getSessionInfo(sessionId)
|
|
516
570
|
.catch(() => undefined);
|
|
@@ -553,7 +607,7 @@ export async function handleCommand(
|
|
|
553
607
|
) {
|
|
554
608
|
const MAX_PREFIX = 10;
|
|
555
609
|
const prefix = text.slice(0, MAX_PREFIX);
|
|
556
|
-
const adapter = getAdapterForTool(descriptionTool
|
|
610
|
+
const adapter = getAdapterForTool(descriptionTool!, sessionId);
|
|
557
611
|
const info = await adapter
|
|
558
612
|
.getSessionInfo(sessionId)
|
|
559
613
|
.catch(() => undefined);
|
|
@@ -608,6 +662,43 @@ export async function handleCommand(
|
|
|
608
662
|
return;
|
|
609
663
|
}
|
|
610
664
|
|
|
665
|
+
if (textLower === "/test") {
|
|
666
|
+
logTrace(tid, "BRANCH", { cmd: "/test" });
|
|
667
|
+
const tableHeaders = ["名称", "版本", "状态"];
|
|
668
|
+
const tableRows = [
|
|
669
|
+
["ChatCCC", "0.2.96", "运行中"],
|
|
670
|
+
["Claude SDK", "0.50.0", "已连接"],
|
|
671
|
+
["Feishu API", "v1", "正常"],
|
|
672
|
+
];
|
|
673
|
+
const mdTable = [
|
|
674
|
+
`| ${tableHeaders.join(" | ")} |`,
|
|
675
|
+
`| ${tableHeaders.map(() => "---").join(" | ")} |`,
|
|
676
|
+
...tableRows.map((row) => `| ${row.join(" | ")} |`),
|
|
677
|
+
].join("\n");
|
|
678
|
+
|
|
679
|
+
if (platform.kind === "feishu") {
|
|
680
|
+
try {
|
|
681
|
+
const token = await getTenantAccessToken();
|
|
682
|
+
const postContent: unknown[][] = [
|
|
683
|
+
// 先尝试富文本表格
|
|
684
|
+
[{ tag: "table", cells: [tableHeaders, ...tableRows] }],
|
|
685
|
+
// 再用代码块包起来
|
|
686
|
+
[{ tag: "text", text: `\n表格(代码块格式):\n\`\`\`\n${mdTable}\n\`\`\`` }],
|
|
687
|
+
];
|
|
688
|
+
await sendPostMessage(token, chatId, "测试表格", postContent);
|
|
689
|
+
} catch (err) {
|
|
690
|
+
console.error(`[${ts()}] [TEST] post message failed: ${(err as Error).message}`);
|
|
691
|
+
// Fallback to markdown card
|
|
692
|
+
await platform.sendText(chatId, `表格(代码块格式):\n\`\`\`\n${mdTable}\n\`\`\``).catch(() => {});
|
|
693
|
+
}
|
|
694
|
+
} else {
|
|
695
|
+
// WeChat / other platforms: just send code block
|
|
696
|
+
await platform.sendText(chatId, `表格(代码块格式):\n\`\`\`\n${mdTable}\n\`\`\``).catch(() => {});
|
|
697
|
+
}
|
|
698
|
+
logTrace(tid, "DONE", { outcome: "test" });
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
611
702
|
if (textLower === "/status") {
|
|
612
703
|
logTrace(tid, "BRANCH", { cmd: "/status" });
|
|
613
704
|
const status = await getSessionStatus(chatId);
|
|
@@ -674,7 +765,7 @@ export async function handleCommand(
|
|
|
674
765
|
|
|
675
766
|
if (textLower === "/newh") {
|
|
676
767
|
logTrace(tid, "BRANCH", { cmd: "/newh" });
|
|
677
|
-
const adapter = getAdapterForTool(descriptionTool);
|
|
768
|
+
const adapter = getAdapterForTool(descriptionTool, sessionId);
|
|
678
769
|
let cwd: string;
|
|
679
770
|
try {
|
|
680
771
|
const info = await adapter.getSessionInfo(sessionId);
|
|
@@ -747,7 +838,8 @@ export async function handleCommand(
|
|
|
747
838
|
`**Session ID:** ${newSessionId}\n` +
|
|
748
839
|
`**工作目录:** \`${cwd}\`(沿用当前会话目录)\n\n` +
|
|
749
840
|
`直接在这里发消息即可继续对话。\n` +
|
|
750
|
-
`发送 **/cd**
|
|
841
|
+
`发送 **/cd** 可切换新建会话的默认目录。\n` +
|
|
842
|
+
`发送 **/model** 查看或切换当前会话的模型。`,
|
|
751
843
|
"green",
|
|
752
844
|
);
|
|
753
845
|
|
|
@@ -849,7 +941,7 @@ export async function handleCommand(
|
|
|
849
941
|
return;
|
|
850
942
|
}
|
|
851
943
|
|
|
852
|
-
const targetAdapter = getAdapterForTool(target.tool);
|
|
944
|
+
const targetAdapter = getAdapterForTool(target.tool, target.sessionId);
|
|
853
945
|
let cwd2: string;
|
|
854
946
|
try {
|
|
855
947
|
const targetInfo = await targetAdapter.getSessionInfo(
|
|
@@ -907,7 +999,8 @@ export async function handleCommand(
|
|
|
907
999
|
`**序号:** ${index + 1}\n` +
|
|
908
1000
|
`**Session ID:** ${target.sessionId}\n` +
|
|
909
1001
|
`**工作目录:** \`${cwd2}\`\n\n` +
|
|
910
|
-
|
|
1002
|
+
`直接在这里发消息即可继续对话。\n` +
|
|
1003
|
+
`发送 **/model** 查看或切换当前会话的模型。${busyNote}`,
|
|
911
1004
|
"green",
|
|
912
1005
|
);
|
|
913
1006
|
|
|
@@ -920,6 +1013,55 @@ export async function handleCommand(
|
|
|
920
1013
|
return;
|
|
921
1014
|
}
|
|
922
1015
|
|
|
1016
|
+
// /model clear — 清除当前 session 的模型覆盖
|
|
1017
|
+
if (textLower === "/model clear") {
|
|
1018
|
+
logTrace(tid, "BRANCH", { cmd: "/model clear", sessionId });
|
|
1019
|
+
clearSessionModelOverride(sessionId);
|
|
1020
|
+
const defaultModel = getEffectiveModelForTool(descriptionTool);
|
|
1021
|
+
const toolLabel = toolDisplayName(descriptionTool);
|
|
1022
|
+
const msg = `已清除当前 ${toolLabel} 会话的模型覆盖,恢复使用: \`${defaultModel || "(未指定)"}\``;
|
|
1023
|
+
await (platform.kind === "wechat"
|
|
1024
|
+
? platform.sendText(chatId, msg)
|
|
1025
|
+
: platform.sendCard(chatId, "模型切换", msg, "green")
|
|
1026
|
+
).catch(() => {});
|
|
1027
|
+
logTrace(tid, "DONE", { outcome: "model_cleared", sessionId, tool: descriptionTool });
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// /model <name> — 切换当前 session 的模型(支持所有 agent,模糊匹配)
|
|
1032
|
+
if (textLower.startsWith("/model ")) {
|
|
1033
|
+
const modelArg = text.slice(7).trim();
|
|
1034
|
+
if (!modelArg) return; // 纯 "/model " 不处理,交给上面的 /model 分支
|
|
1035
|
+
logTrace(tid, "BRANCH", { cmd: "/model", arg: modelArg, sessionId, tool: descriptionTool });
|
|
1036
|
+
|
|
1037
|
+
const models = getAllModelsForTool(descriptionTool as AgentTool);
|
|
1038
|
+
const toolLabel = toolDisplayName(descriptionTool);
|
|
1039
|
+
|
|
1040
|
+
// 查找目标模型:精确匹配优先,否则子串匹配(模型名越短越优先)
|
|
1041
|
+
const target = findModelMatch(modelArg, models);
|
|
1042
|
+
|
|
1043
|
+
if (!target) {
|
|
1044
|
+
const msg = models.length > 0
|
|
1045
|
+
? `未找到匹配 "${modelArg}" 的模型。当前 ${toolLabel} 可选模型:\n${models.map(m => ` \`${m}\``).join("\n")}`
|
|
1046
|
+
: `当前 ${toolLabel} 没有可切换的模型。请在 config.json 中配置模型字段。`;
|
|
1047
|
+
await (platform.kind === "wechat"
|
|
1048
|
+
? platform.sendText(chatId, msg)
|
|
1049
|
+
: platform.sendCard(chatId, "模型切换", msg, "red")
|
|
1050
|
+
).catch(() => {});
|
|
1051
|
+
logTrace(tid, "DONE", { outcome: "model_not_found", arg: modelArg, tool: descriptionTool });
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
setSessionModelOverride(sessionId, target);
|
|
1056
|
+
const msg = `已切换当前 ${toolLabel} 会话模型为: \`${target}\``;
|
|
1057
|
+
await (platform.kind === "wechat"
|
|
1058
|
+
? platform.sendText(chatId, msg)
|
|
1059
|
+
: platform.sendCard(chatId, "模型切换", msg, "green")
|
|
1060
|
+
).catch(() => {});
|
|
1061
|
+
logTrace(tid, "DONE", { outcome: "model_switched", arg: modelArg, target, sessionId, tool: descriptionTool });
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
923
1065
|
// /git <args>:在「当前会话工作目录」执行 git 命令
|
|
924
1066
|
if (textLower.startsWith("/git ") || textLower === "/git") {
|
|
925
1067
|
const args = text === "/git" ? "" : text.slice(5).trim();
|
|
@@ -935,7 +1077,7 @@ export async function handleCommand(
|
|
|
935
1077
|
return;
|
|
936
1078
|
}
|
|
937
1079
|
|
|
938
|
-
const adapter = getAdapterForTool(descriptionTool);
|
|
1080
|
+
const adapter = getAdapterForTool(descriptionTool, sessionId);
|
|
939
1081
|
let cwd: string | undefined;
|
|
940
1082
|
try {
|
|
941
1083
|
const info = await adapter.getSessionInfo(sessionId);
|
package/src/session.ts
CHANGED
|
@@ -307,23 +307,60 @@ export function resetState(): void {
|
|
|
307
307
|
// 是 onReady/onReconnected 取代 resetState 的正确入口。
|
|
308
308
|
|
|
309
309
|
// ---------------------------------------------------------------------------
|
|
310
|
-
// Adapter: 按 tool 创建并缓存
|
|
310
|
+
// Adapter: 按 tool + effectiveModel 创建并缓存
|
|
311
311
|
// ---------------------------------------------------------------------------
|
|
312
312
|
|
|
313
313
|
const adapterCache = new Map<string, ToolAdapter>();
|
|
314
314
|
|
|
315
|
-
|
|
316
|
-
|
|
315
|
+
// Per-session 模型覆盖(/model 命令设置,不持久化)
|
|
316
|
+
const sessionModelOverrides = new Map<string, string>();
|
|
317
|
+
|
|
318
|
+
/** 返回 session 的生效模型:优先 per-session 覆盖,其次全局配置(Claude) */
|
|
319
|
+
function getModelForSession(sessionId?: string): string {
|
|
320
|
+
if (sessionId) {
|
|
321
|
+
const override = sessionModelOverrides.get(sessionId);
|
|
322
|
+
if (override) return override;
|
|
323
|
+
}
|
|
324
|
+
return CLAUDE_MODEL;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** 返回指定 tool 的生效模型:优先 per-session 覆盖,其次 tool 默认配置 */
|
|
328
|
+
export function getEffectiveModelForTool(tool: string, sessionId?: string): string {
|
|
329
|
+
if (sessionId) {
|
|
330
|
+
const override = sessionModelOverrides.get(sessionId);
|
|
331
|
+
if (override) return override;
|
|
332
|
+
}
|
|
333
|
+
if (tool === "cursor") return config.cursor.model;
|
|
334
|
+
if (tool === "codex") return config.codex.model;
|
|
335
|
+
return CLAUDE_MODEL;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** 为指定 session 设置模型覆盖(/model <name>) */
|
|
339
|
+
export function setSessionModelOverride(sessionId: string, model: string): void {
|
|
340
|
+
sessionModelOverrides.set(sessionId, model);
|
|
341
|
+
adapterCache.clear();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** 清除指定 session 的模型覆盖(/model clear) */
|
|
345
|
+
export function clearSessionModelOverride(sessionId: string): void {
|
|
346
|
+
sessionModelOverrides.delete(sessionId);
|
|
347
|
+
adapterCache.clear();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function getAdapterForTool(tool: string, sessionId?: string): ToolAdapter {
|
|
351
|
+
const effectiveModel = getEffectiveModelForTool(tool, sessionId);
|
|
352
|
+
const cacheKey = effectiveModel ? `${tool}:${effectiveModel}` : tool;
|
|
353
|
+
const cached = adapterCache.get(cacheKey);
|
|
317
354
|
if (cached) return cached;
|
|
318
355
|
|
|
319
356
|
let adapter: ToolAdapter;
|
|
320
357
|
if (tool === "cursor") {
|
|
321
|
-
adapter = createCursorAdapter();
|
|
358
|
+
adapter = createCursorAdapter({ model: effectiveModel || undefined });
|
|
322
359
|
} else if (tool === "codex") {
|
|
323
|
-
adapter = createCodexAdapter();
|
|
360
|
+
adapter = createCodexAdapter({ model: effectiveModel || undefined });
|
|
324
361
|
} else {
|
|
325
362
|
adapter = createClaudeAdapter({
|
|
326
|
-
model:
|
|
363
|
+
model: effectiveModel,
|
|
327
364
|
subagentModel: CLAUDE_SUBAGENT_MODEL,
|
|
328
365
|
effort: CLAUDE_EFFORT,
|
|
329
366
|
isEmpty: isAnthropicConfigEmpty,
|
|
@@ -331,7 +368,7 @@ export function getAdapterForTool(tool: string): ToolAdapter {
|
|
|
331
368
|
baseUrl: CLAUDE_BASE_URL,
|
|
332
369
|
});
|
|
333
370
|
}
|
|
334
|
-
adapterCache.set(
|
|
371
|
+
adapterCache.set(cacheKey, adapter);
|
|
335
372
|
return adapter;
|
|
336
373
|
}
|
|
337
374
|
|
|
@@ -508,9 +545,13 @@ export function _resetSessionRegistryFileForTest(): void {
|
|
|
508
545
|
// accumulateBlockContent — 将 UnifiedBlock 累积到渲染状态(纯函数,可测试)
|
|
509
546
|
// ---------------------------------------------------------------------------
|
|
510
547
|
|
|
548
|
+
// 注意:以下字段命名含 "final",但实际语义是"本轮所有文本的累积",并非仅
|
|
549
|
+
// "最后一段回复"。历史原因得名——当时以为 finalReply 只包含最后一轮输出,实则
|
|
550
|
+
// 所有 text block(工具调用前、调用间、调用后)都累加在这里。
|
|
551
|
+
// accumulatedContent + finalReply 拼接后才是完整流式输出的全部内容。
|
|
511
552
|
export interface AccumulatorState {
|
|
512
553
|
accumulatedContent: string;
|
|
513
|
-
/**
|
|
554
|
+
/** 本轮所有 text block 的累加(流式文本 delta),包括工具调用前后的全部文本 */
|
|
514
555
|
finalText: string;
|
|
515
556
|
/**
|
|
516
557
|
* 适配器明确给出的"完整最终文本"(覆盖语义)。
|
|
@@ -748,7 +789,7 @@ export async function switchChatBinding(args: SwitchChatBindingArgs): Promise<Sw
|
|
|
748
789
|
* Claude 显示 model/effort(来自环境变量);Cursor 显示 model(运行时由
|
|
749
790
|
* cursor-agent 决定,初次创建时尚未学习到,故显示占位)。
|
|
750
791
|
*/
|
|
751
|
-
function formatToolConfigForLog(tool: string, sessionModel?: string): string {
|
|
792
|
+
function formatToolConfigForLog(tool: string, sessionModel?: string, sessionId?: string): string {
|
|
752
793
|
if (tool === "cursor") {
|
|
753
794
|
return `model=${sessionModel ?? "(由 cursor-agent 决定,init 事件后学习)"}`;
|
|
754
795
|
}
|
|
@@ -761,7 +802,7 @@ function formatToolConfigForLog(tool: string, sessionModel?: string): string {
|
|
|
761
802
|
: "effort=(由 codex config.toml 决定)";
|
|
762
803
|
return `model=${modelStr}, ${effortStr}`;
|
|
763
804
|
}
|
|
764
|
-
return `model=${anthropicConfigDisplay(
|
|
805
|
+
return `model=${anthropicConfigDisplay(getModelForSession(sessionId))}, subagentModel=${anthropicConfigDisplay(CLAUDE_SUBAGENT_MODEL)}, effort=${anthropicConfigDisplay(CLAUDE_EFFORT)}`;
|
|
765
806
|
}
|
|
766
807
|
|
|
767
808
|
export async function initClaudeSession(tool: string, overrideCwd?: string, chatId?: string): Promise<{ sessionId: string; cwd: string }> {
|
|
@@ -841,12 +882,12 @@ export async function runAgentSession(
|
|
|
841
882
|
let info: Awaited<ReturnType<ToolAdapter["getSessionInfo"]>>;
|
|
842
883
|
let cwd: string;
|
|
843
884
|
try {
|
|
844
|
-
adapter = getAdapterForTool(tool);
|
|
885
|
+
adapter = getAdapterForTool(tool, sessionId);
|
|
845
886
|
info = await adapter.getSessionInfo(sessionId);
|
|
846
887
|
cwd = info?.cwd ?? (await getDefaultCwd(_chatId));
|
|
847
888
|
if (tid) logTrace(tid, "SESSION_START", { sessionId, tool, cwd, turn: (sessionInfoMap.get(_chatId)?.turnCount ?? 0) + 1 });
|
|
848
889
|
console.log(
|
|
849
|
-
`[${ts()}] Running ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model)}, cwd=${cwd})`
|
|
890
|
+
`[${ts()}] Running ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model, sessionId)}, cwd=${cwd})`
|
|
850
891
|
);
|
|
851
892
|
|
|
852
893
|
// 构建 IM skills prompt(sessionId 方式,无 token)
|
|
@@ -1541,7 +1582,7 @@ async function resolveModelEffort(
|
|
|
1541
1582
|
if (tool === "cursor") {
|
|
1542
1583
|
let model = UNKNOWN_MODEL_PLACEHOLDER;
|
|
1543
1584
|
try {
|
|
1544
|
-
const adapter = getAdapterForTool(tool);
|
|
1585
|
+
const adapter = getAdapterForTool(tool, sessionId);
|
|
1545
1586
|
const info = await adapter.getSessionInfo(sessionId);
|
|
1546
1587
|
if (info?.model) model = info.model;
|
|
1547
1588
|
} catch {
|
|
@@ -1558,7 +1599,7 @@ async function resolveModelEffort(
|
|
|
1558
1599
|
};
|
|
1559
1600
|
}
|
|
1560
1601
|
return {
|
|
1561
|
-
model: anthropicConfigDisplay(
|
|
1602
|
+
model: anthropicConfigDisplay(getModelForSession(sessionId)),
|
|
1562
1603
|
effort: anthropicConfigDisplay(CLAUDE_EFFORT),
|
|
1563
1604
|
};
|
|
1564
1605
|
}
|
|
@@ -1665,6 +1706,9 @@ export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
|
|
|
1665
1706
|
|
|
1666
1707
|
export function _setAdapterForToolForTest(tool: string, adapter: ToolAdapter): void {
|
|
1667
1708
|
adapterCache.set(tool, adapter);
|
|
1709
|
+
// 同时设置当前配置模型对应的 key(getAdapterForTool 会优先 lookup 含 model 的 key)
|
|
1710
|
+
const effective = getEffectiveModelForTool(tool);
|
|
1711
|
+
if (effective) adapterCache.set(`${tool}:${effective}`, adapter);
|
|
1668
1712
|
}
|
|
1669
1713
|
|
|
1670
1714
|
export function clearAdapterCache(): void {
|
package/src/sim-platform.ts
CHANGED
|
@@ -78,6 +78,12 @@ export const SimulatedPlatform: FeishuPlatform = {
|
|
|
78
78
|
return true;
|
|
79
79
|
},
|
|
80
80
|
|
|
81
|
+
async sendPostMessage(_token, chatId, title, postContent) {
|
|
82
|
+
console.log(`[${ts()}] [SIM:SEND] post → ${chatId}: title="${title}" paragraphs=${postContent.length}`);
|
|
83
|
+
simStore.sendReply(chatId, "post", `[${title}] ${postContent.length} paragraphs`);
|
|
84
|
+
return true;
|
|
85
|
+
},
|
|
86
|
+
|
|
81
87
|
// ---- 消息管理 ----
|
|
82
88
|
async addReaction(_token, _messageId, _emojiType) {
|
|
83
89
|
// 模拟模式不需要表情回应
|
package/src/sim-store.ts
CHANGED
|
@@ -30,7 +30,7 @@ export interface SimMessage {
|
|
|
30
30
|
id: string; // UUID
|
|
31
31
|
chatId: string;
|
|
32
32
|
senderId: string; // SimAccount.id
|
|
33
|
-
type: "text" | "card" | "image" | "file" | "raw_card" | "system";
|
|
33
|
+
type: "text" | "card" | "image" | "file" | "raw_card" | "post" | "system";
|
|
34
34
|
content: string; // 文本内容或 JSON 字符串
|
|
35
35
|
timestamp: number;
|
|
36
36
|
}
|
package/src/stream-state.ts
CHANGED
|
@@ -9,17 +9,20 @@ import { USER_DATA_DIR, ts } from "./config.ts";
|
|
|
9
9
|
|
|
10
10
|
export const STREAMS_DIR = join(USER_DATA_DIR, "state", "streams");
|
|
11
11
|
|
|
12
|
-
export interface StreamState {
|
|
13
|
-
sessionId: string;
|
|
14
|
-
status: "running" | "done" | "stopped" | "error";
|
|
15
|
-
accumulatedContent: string;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
12
|
+
export interface StreamState {
|
|
13
|
+
sessionId: string;
|
|
14
|
+
status: "running" | "done" | "stopped" | "error";
|
|
15
|
+
accumulatedContent: string;
|
|
16
|
+
/** 本轮会话中 LLM 输出的全部文本内容(所有 text block 的累加)。
|
|
17
|
+
* 命名含 "final" 但实为"全部累积文本",并非仅"最终一段回复"。
|
|
18
|
+
* 参见 session.ts 的 AccumulatorState 注释。 */
|
|
19
|
+
finalReply: string;
|
|
20
|
+
/** The turn whose terminal text reply has already been delivered to IM. */
|
|
21
|
+
finalReplySentTurn?: number;
|
|
22
|
+
finalReplySentAt?: number;
|
|
23
|
+
chunkCount: number;
|
|
24
|
+
turnCount: number;
|
|
25
|
+
contextTokens: number;
|
|
23
26
|
updatedAt: number;
|
|
24
27
|
cwd: string;
|
|
25
28
|
tool: string;
|
|
@@ -74,7 +77,7 @@ export function _resetRenameForTest(): void {
|
|
|
74
77
|
* 真文件,这一帧可能被 reader 读到半截,但 readStreamState 的 try/catch 兜底
|
|
75
78
|
* 会让 loop 跳过这一帧,2 秒后下次 write 大概率成功——比写盘失败丢数据好。
|
|
76
79
|
*/
|
|
77
|
-
export async function writeStreamState(state: StreamState): Promise<void> {
|
|
80
|
+
export async function writeStreamState(state: StreamState): Promise<void> {
|
|
78
81
|
const filePath = getStreamStatePath(state.sessionId);
|
|
79
82
|
const payload = JSON.stringify(state, null, 2);
|
|
80
83
|
try {
|
|
@@ -98,26 +101,26 @@ export async function writeStreamState(state: StreamState): Promise<void> {
|
|
|
98
101
|
} catch (err) {
|
|
99
102
|
console.error(`[${ts()}] Failed to write stream-state for ${state.sessionId}: ${(err as Error).message}`);
|
|
100
103
|
}
|
|
101
|
-
}
|
|
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
|
-
|
|
119
|
-
export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
|
|
120
|
-
return {
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function isFinalReplySentForTurn(state: Pick<StreamState, "turnCount" | "finalReplySentTurn">): boolean {
|
|
107
|
+
return state.finalReplySentTurn === state.turnCount;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function markFinalReplySent(sessionId: string, turnCount: number, sentAt = Date.now()): Promise<void> {
|
|
111
|
+
const state = await readStreamState(sessionId);
|
|
112
|
+
if (!state) return;
|
|
113
|
+
if (state.turnCount !== turnCount) return;
|
|
114
|
+
if (state.status === "running") return;
|
|
115
|
+
|
|
116
|
+
state.finalReplySentTurn = turnCount;
|
|
117
|
+
state.finalReplySentAt = sentAt;
|
|
118
|
+
state.updatedAt = Date.now();
|
|
119
|
+
await writeStreamState(state);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
|
|
123
|
+
return {
|
|
121
124
|
sessionId,
|
|
122
125
|
status: "running",
|
|
123
126
|
accumulatedContent: "",
|
|
@@ -157,4 +160,4 @@ export async function fixStaleStreamStates(): Promise<void> {
|
|
|
157
160
|
} catch (err) {
|
|
158
161
|
console.error(`[${ts()}] [STREAM-STATE] fixStaleStreamStates failed: ${(err as Error).message}`);
|
|
159
162
|
}
|
|
160
|
-
}
|
|
163
|
+
}
|