Merge pull request #2149 from yuanchenglu/fix/feishu-perchat-model

feat(feishu): add /model command for per-chat model switching
This commit is contained in:
Hunter Bown
2026-05-31 00:10:32 -07:00
committed by GitHub
3 changed files with 94 additions and 12 deletions
+51 -12
View File
@@ -16,6 +16,7 @@ import {
parseList,
parseApprovalDecisionArgs,
parseTextContent,
preservedChatStateFields,
splitMessage,
stripGroupPrefix
} from "./lib.mjs";
@@ -231,6 +232,9 @@ async function handleCommand(chatId, command) {
case "approval":
await decideApproval(chatId, action);
return;
case "set_model":
await setChatModel(chatId, action.modelName);
return;
case "prompt":
await runPrompt(chatId, action.prompt);
return;
@@ -243,10 +247,14 @@ async function ensureThread(chatId, { forceNew = false } = {}) {
const existing = await threadStore.getChat(chatId);
if (existing?.threadId && !forceNew) return existing;
// Use per-chat model if set, fall back to bridge-level default.
// / 优先使用 per-chat 模型(/model 命令设置),否则用桥接级别的默认模型。
const effectiveModel = existing?.model || config.model;
const thread = await runtimeJson("/v1/threads", {
method: "POST",
body: {
model: config.model,
model: effectiveModel,
workspace: config.workspace,
mode: config.mode,
allow_shell: config.allowShell,
@@ -259,6 +267,7 @@ async function ensureThread(chatId, { forceNew = false } = {}) {
});
const state = {
...preservedChatStateFields(existing),
threadId: thread.id,
lastSeq: 0,
activeTurnId: null,
@@ -274,6 +283,10 @@ async function runPrompt(chatId, prompt) {
return;
}
const state = await ensureThread(chatId);
// Use per-chat model for this turn (may differ from the thread's
// creation model if the user ran /model after the thread was created).
// / 使用 per-chat 模型执行本轮对话(如果用户在创建线程后切换过模型)。
const effectiveModel = state?.model || config.model;
const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
const activeBlock = activeTurnBlock(detail, state);
if (activeBlock) {
@@ -296,7 +309,7 @@ async function runPrompt(chatId, prompt) {
body: {
prompt,
input_summary: prompt.slice(0, 200),
model: config.model,
model: effectiveModel,
mode: config.mode,
allow_shell: config.allowShell,
trust_mode: config.trustMode,
@@ -494,7 +507,9 @@ async function resumeThread(chatId, args) {
return;
}
const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(threadId)}`);
const existing = await threadStore.getChat(chatId);
await threadStore.setChat(chatId, {
...preservedChatStateFields(existing),
threadId,
lastSeq: Number(detail.latest_seq || 0),
activeTurnId: null,
@@ -553,6 +568,24 @@ async function decideApproval(chatId, action) {
await sendText(chatId, `Approval ${approvalId}: ${decision}${remember ? " and remember" : ""}`);
}
async function setChatModel(chatId, modelName) {
// /model <name> — set per-chat model; "default" or empty resets to bridge default.
// / /model "default" 或空参数 — 恢复桥接级别的默认模型。
if (!modelName || modelName === "default") {
await threadStore.patchChat(chatId, {
model: null,
updatedAt: new Date().toISOString()
});
await sendText(chatId, `Reset per-chat model. Using bridge default: ${config.model}`);
return;
}
await threadStore.patchChat(chatId, {
model: modelName,
updatedAt: new Date().toISOString()
});
await sendText(chatId, `Per-chat model set to: ${modelName}`);
}
async function sendText(chatId, text) {
// Try reply API first — keeps bot responses inside the same Feishu
// thread/topic instead of spawning new standalone topics.
@@ -572,22 +605,28 @@ async function sendText(chatId, text) {
throw new Error("Lark SDK client does not expose im message create API");
}
let canReply = Boolean(replyMessage);
for (const chunk of splitMessage(text, config.maxReplyChars)) {
const body = {
msg_type: "text",
content: JSON.stringify({ text: chunk })
};
if (replyMessage) {
await replyMessage({
path: { message_id: replyToMessageId },
data: body
});
} else {
await createMessage({
params: { receive_id_type: "chat_id" },
data: { ...body, receive_id: chatId }
});
if (canReply) {
try {
await replyMessage({
path: { message_id: replyToMessageId },
data: body
});
continue;
} catch (error) {
canReply = false;
console.warn("Feishu reply API failed; falling back to message create", error);
}
}
await createMessage({
params: { receive_id_type: "chat_id" },
data: { ...body, receive_id: chatId }
});
}
}
+17
View File
@@ -147,6 +147,11 @@ export function commandAction(command) {
return { kind: "interrupt" };
case "compact":
return { kind: "compact" };
case "model":
// /model <model_name> — switch per-chat default model.
// Stored in thread store and used for future threads/turns.
// Pass "default" to reset to the bridge-level default.
return { kind: "set_model", modelName: command.args };
case "allow":
return { kind: "approval", decision: "allow", ...parseApprovalDecisionArgs(command.args) };
case "deny":
@@ -161,6 +166,17 @@ export function commandAction(command) {
}
}
export function preservedChatStateFields(state = {}) {
const preserved = {};
if (Object.prototype.hasOwnProperty.call(state || {}, "model")) {
preserved.model = state.model || null;
}
if (state?.replyToMessageId) {
preserved.replyToMessageId = state.replyToMessageId;
}
return preserved;
}
export function splitMessage(text, maxChars = 3500) {
const value = String(text || "");
const chars = Array.from(value);
@@ -341,6 +357,7 @@ export function helpText() {
"/threads - recent runtime threads",
"/new - create a new thread for this chat",
"/resume <thread_id> - bind this chat to an existing thread",
"/model <name|default> - set or reset this chat's model",
"/interrupt - interrupt the active turn",
"/compact - compact the current thread",
"/allow <approval_id> [remember] - approve a pending tool call",
@@ -12,8 +12,10 @@ import {
parseCommand,
parseList,
parseTextContent,
preservedChatStateFields,
splitMessage,
stripGroupPrefix,
helpText,
validateBridgeConfig
} from "../src/lib.mjs";
@@ -89,12 +91,36 @@ test("commandAction maps bridge commands and falls back to prompts", () => {
kind: "resume",
threadId: "thread-1"
});
assert.deepEqual(commandAction(parseCommand("/model deepseek-v4-pro")), {
kind: "set_model",
modelName: "deepseek-v4-pro"
});
assert.deepEqual(commandAction(parseCommand("/unknown value")), {
kind: "prompt",
prompt: "/unknown value"
});
});
test("helpText documents per-chat model switching", () => {
assert.match(helpText(), /\/model <name\|default>/);
});
test("preservedChatStateFields carries model across state replacement", () => {
assert.deepEqual(
preservedChatStateFields({
threadId: "old-thread",
model: "deepseek-v4-flash",
replyToMessageId: "om_123",
activeTurnId: "turn-1"
}),
{
model: "deepseek-v4-flash",
replyToMessageId: "om_123"
}
);
assert.deepEqual(preservedChatStateFields({ model: null }), { model: null });
});
test("parseApprovalDecisionArgs extracts remember flag", () => {
assert.deepEqual(parseApprovalDecisionArgs("ap_123 remember"), {
approvalId: "ap_123",