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:
@@ -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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user