-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(feishu): add /model command for per-chat model switching #2149
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -145,6 +145,29 @@ async function handleIncomingMessage(event) { | |
| const identity = incomingIdentity(event); | ||
| if (!identity.chatId) return; | ||
|
|
||
| // Store the incoming message ID so sendText() can reply inside the same | ||
| // Feishu thread/topic — without this, every bot message creates a new | ||
| // standalone topic in thread-enabled groups. | ||
| // / 缓存入站消息 ID,让 sendText 能通过 reply API 在同一话题内回复。 | ||
| // / 否则每条 bot 消息都会在话题群中创建独立的新话题(见 #1710)。 | ||
| if (identity.messageId) { | ||
| const existing = await threadStore.getChat(identity.chatId); | ||
| if (existing) { | ||
| await threadStore.patchChat(identity.chatId, { | ||
| replyToMessageId: identity.messageId, | ||
| updatedAt: new Date().toISOString() | ||
| }); | ||
| } else { | ||
| await threadStore.setChat(identity.chatId, { | ||
| replyToMessageId: identity.messageId, | ||
| threadId: null, | ||
| lastSeq: 0, | ||
| activeTurnId: null, | ||
| updatedAt: new Date().toISOString() | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| if (identity.messageType && identity.messageType !== "text") { | ||
| await sendText(identity.chatId, "Only text messages are supported in this first bridge."); | ||
| return; | ||
|
|
@@ -208,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; | ||
|
|
@@ -220,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; | ||
|
Comment on lines
+249
to
+251
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. State Overwrite Bug in
|
||
|
|
||
| const thread = await runtimeJson("/v1/threads", { | ||
| method: "POST", | ||
| body: { | ||
| model: config.model, | ||
| model: effectiveModel, | ||
| workspace: config.workspace, | ||
| mode: config.mode, | ||
| allow_shell: config.allowShell, | ||
|
|
@@ -251,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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`); | ||
| const activeBlock = activeTurnBlock(detail, state); | ||
| if (activeBlock) { | ||
|
|
@@ -273,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, | ||
|
|
@@ -530,22 +564,59 @@ 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. | ||
| // / 优先使用 reply API,确保 bot 回复留在话题群的同一条话题内。 | ||
| const state = await threadStore.getChat(chatId); | ||
| const replyToMessageId = state?.replyToMessageId || null; | ||
|
|
||
| const replyMessage = | ||
| replyToMessageId | ||
| ? client.im?.v1?.message?.reply?.bind(client.im.v1.message) || | ||
| client.im?.message?.reply?.bind(client.im.message) | ||
| : null; | ||
| const createMessage = | ||
| client.im?.v1?.message?.create?.bind(client.im.v1.message) || | ||
| client.im?.message?.create?.bind(client.im.message); | ||
| if (!createMessage) { | ||
| throw new Error("Lark SDK client does not expose im message create API"); | ||
| } | ||
|
|
||
| for (const chunk of splitMessage(text, config.maxReplyChars)) { | ||
| await createMessage({ | ||
| params: { receive_id_type: "chat_id" }, | ||
| data: { | ||
| receive_id: chatId, | ||
| msg_type: "text", | ||
| content: JSON.stringify({ text: chunk }) | ||
| } | ||
| }); | ||
| const body = { | ||
| msg_type: "text", | ||
| content: JSON.stringify({ text: chunk }) | ||
| }; | ||
| if (replyMessage) { | ||
| await replyMessage({ | ||
| path: { message_id: replyToMessageId }, | ||
| data: body | ||
| }); | ||
|
Comment on lines
+610
to
+613
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
After this change, Useful? React with 👍 / 👎. |
||
| } else { | ||
| await createMessage({ | ||
| params: { receive_id_type: "chat_id" }, | ||
| data: { ...body, receive_id: chatId } | ||
| }); | ||
| } | ||
|
Comment on lines
+609
to
+619
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If let sent = false;
if (replyMessage) {
try {
await replyMessage({
path: { message_id: replyToMessageId },
data: body
});
sent = true;
} catch (error) {
console.error(`Failed to reply to message ${replyToMessageId}, falling back to direct message:`, error);
}
}
if (!sent) {
await createMessage({
params: { receive_id_type: "chat_id" },
data: { ...body, receive_id: chatId }
});
} |
||
| } | ||
| } | ||
|
Comment on lines
604
to
621
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When |
||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can simplify the logic for storing the incoming message ID by using
patchChatdirectly. SincepatchChathandles merging with the existing state (or initializing an empty object if none exists), we don't need to check if the chat exists or duplicate theupdatedAttimestamp and other fields.