feat(feishu): add /model command for per-chat model switching

The bridge only supported a single global model (DEEPSEEK_MODEL env /
default_text_model in config.toml).  Users who wanted a different model
for a particular Feishu group had to restart the bridge with a different
env var — impractical and disruptive.

This commit adds per-chat model switching so users in a group chat can
run "/model <name>" to switch the model for all future threads and
turns in that chat, without affecting other chats.

Changes:

- **lib.mjs — commandAction()**: handle "model" command → { kind:
  "set_model", modelName }.

- **index.mjs**:
  - setChatModel(chatId, modelName): store/clear per-chat model in the
    thread store.  "/model default" resets to the bridge-level default.
  - ensureThread(): read per-chat model from store when creating a
    new runtime thread; fall back to config.model.
  - runPrompt(): read per-chat model for each turn submission
    (independent of the thread-creation model), so a /model change
    takes effect on the very next message.

Usage:
  /model deepseek-v4-flash      — switch this chat to Flash
  /model deepseek-v4-pro        — switch this chat to Pro
  /model default                — reset to bridge default

Resolution priority (per-chat): global default < per-chat /model.

/ 新增 /model 命令,支持按飞书群设置独立模型。
/ 群内输入 /model <name> 切换,/model default 恢复全局默认。
This commit is contained in:
yuanchenglu
2026-05-26 09:52:53 +08:00
parent c0b82b6ec0
commit 82499e1c20
2 changed files with 36 additions and 2 deletions
+31 -2
View File
@@ -231,6 +231,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 +246,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,
@@ -274,6 +281,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 +307,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,
@@ -553,6 +564,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.
+5
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":