diff --git a/src/claude/executor.ts b/src/claude/executor.ts index b4fbef79..d90cd95e 100644 --- a/src/claude/executor.ts +++ b/src/claude/executor.ts @@ -561,7 +561,10 @@ export class ClaudeExecutor { for (const block of message.message.content) { if ('text' in block && block.text) { // 剥离模型在普通文本中输出的 标签(Sonnet adaptive 模式下偶现) - const cleaned = (block.text as string).replace(/[\s\S]*?<\/thinking>\s*/g, ''); + // 同时处理未闭合的 标签(模型可能只输出开标签不闭合) + const cleaned = (block.text as string) + .replace(/[\s\S]*?<\/thinking>\s*/g, '') + .replace(/[\s\S]*/g, ''); if (cleaned) { output += cleaned; turnText.push(cleaned); @@ -654,9 +657,11 @@ export class ClaudeExecutor { // 解析结果消息 if (resultMessage && resultMessage.type === 'result') { if (resultMessage.subtype === 'success') { - // 如果 output 为空但 result 有文本,使用 result + // 如果 output 为空但 result 有文本,使用 result(同样需要剥离 thinking 标签) if (!output && resultMessage.result) { - output = resultMessage.result; + output = resultMessage.result + .replace(/[\s\S]*?<\/thinking>\s*/g, '') + .replace(/[\s\S]*/g, ''); } return { diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index b796bdf8..f6e3b5b8 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -34,6 +34,7 @@ import { extractMemories } from '../memory/extractor.js'; import { handleMemoryCommand, handleMemoryCardAction } from '../memory/commands.js'; import { getRepoIdentity } from '../workspace/identity.js'; import { generateQuickAck } from '../utils/quick-ack.js'; +import { checkThreadRelevance } from '../utils/thread-relevance.js'; // 注册审批通过后的消息重新入队回调(避免 approval.ts → event-handler.ts 循环依赖) setOnApproved((chatId, userId, text, messageId, rootId, threadId) => { @@ -554,10 +555,8 @@ async function handleMessageEvent(data: MessageEventData, accountId: string = 'd : undefined; // 话题内消息:话题创建者 bot 无需 @mention 即可响应后续消息 - // 前提:消息没有 @任何 bot —— 显式 @mention 是明确的意图信号, - // @了别的 bot 时话题创建者不应抢答(@人类用户不算,可能只是 tag 提醒) - // 仅限话题发起用户或 owner — 非 owner 的旁观者无 @mention 时静默忽略, - // 避免好奇路人的消息干扰 dev-bot 正在进行的工作 + // 前提:消息没有 @任何 bot —— 显式 @bot 是明确的意图信号 + // @人类用户的情况由下游 Qwen 语义判断处理(可能是指代引用,不一定是跟人说话) // allBotOpenIds 仅包含各 bot 自身 fetchBotInfo 返回的 open_id(同一 app 视角)。 // 但飞书 open_id 是 app 级别的:pm-bot 收到的 @张全栈 mention 的 open_id ≠ dev-bot 自己的 open_id。 // 补充 chatBotRegistry 中通过被动收集(sender_type=app)记录的跨 app bot open_id。 @@ -568,8 +567,15 @@ async function handleMessageEvent(data: MessageEventData, accountId: string = 'd if (threadId && !anyBotMentioned && isThreadCreatorAgent(threadId, agentId)) { const ts = sessionManager.getThreadSession(threadId, agentId); if (ts && (isOwner(userId) || ts.userId === userId)) { - threadBypass = true; - logger.debug({ threadId, agentId, accountId }, 'Thread creator bypass: responding without @mention'); + // 语义判断:用 Qwen 小模型判断无 @mention 的消息是否在跟 bot 对话 + const botDisplayName = agentRegistry.get(agentId)?.displayName ?? 'bot'; + const relevant = await checkThreadRelevance(text, botDisplayName); + if (relevant) { + threadBypass = true; + logger.debug({ threadId, agentId, accountId }, 'Thread creator bypass: responding without @mention'); + } else { + logger.info({ threadId, agentId, text: text.slice(0, 100) }, 'Thread bypass skipped — message not directed at bot'); + } } } @@ -1506,7 +1512,8 @@ async function executeDirectTask( try { // 快速确认:用小模型判断消息类型并生成短回复 // 纯问候类消息直接回复后跳过 Claude,其他类型照常走完整查询 - const quickAck = await generateQuickAck(rawPrompt); + // 话题内消息跳过 quick-ack:bot 可能是被 threadBypass 隐式触发的,不是被明确 @的 + const quickAck = eventThreadId ? null : await generateQuickAck(rawPrompt); if (quickAck) { let ackSent = false; try { @@ -1740,6 +1747,12 @@ async function sendDirectReply( result: import('../claude/types.js').ClaudeResult, threadReplyMsgId?: string, ): Promise { + // 成功但无输出(如模型 thinking 后决定不回复)→ 静默,不发 "(无输出)" + if (result.success && !result.output) { + logger.debug({ messageId }, 'Direct reply skipped — empty output (silent)'); + return; + } + const output = result.output || result.error || '(无输出)'; if (!result.success) { diff --git a/src/utils/__tests__/thread-relevance.test.ts b/src/utils/__tests__/thread-relevance.test.ts new file mode 100644 index 00000000..3bcc3f1e --- /dev/null +++ b/src/utils/__tests__/thread-relevance.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { parseRelevanceResponse } from '../thread-relevance.js'; + +// ============================================================ +// parseRelevanceResponse — JSON 解析 + fallback 逻辑 +// ============================================================ + +describe('parseRelevanceResponse', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return true for {"respond": true}', () => { + expect(parseRelevanceResponse('{"respond": true}')).toBe(true); + }); + + it('should return false for {"respond": false}', () => { + expect(parseRelevanceResponse('{"respond": false}')).toBe(false); + }); + + it('should extract JSON from markdown code block', () => { + expect(parseRelevanceResponse('```json\n{"respond": false}\n```')).toBe(false); + }); + + it('should extract JSON with extra text', () => { + expect(parseRelevanceResponse('判断结果:{"respond": true}')).toBe(true); + }); + + it('should fallback to keyword detection for "false"', () => { + expect(parseRelevanceResponse('false')).toBe(false); + }); + + it('should fallback to keyword detection for "respond: false"', () => { + expect(parseRelevanceResponse('respond: false')).toBe(false); + }); + + it('should default to true for unparseable response', () => { + expect(parseRelevanceResponse('不确定')).toBe(true); + }); + + it('should default to true for empty string', () => { + expect(parseRelevanceResponse('')).toBe(true); + }); + + it('should handle malformed JSON gracefully', () => { + expect(parseRelevanceResponse('{respond: true')).toBe(true); + }); +}); + +// ============================================================ +// checkThreadRelevance — integration(mock client) +// ============================================================ + +// Mock quick-ack getClient +const mockCreate = vi.fn(); +vi.mock('../quick-ack.js', () => ({ + getClient: vi.fn(() => Promise.resolve({ + chat: { completions: { create: (...args: unknown[]) => mockCreate(...args) } }, + })), +})); + +vi.mock('../../config.js', () => ({ + config: { + quickAck: { enabled: true, model: 'qwen3.5-flash' }, + }, +})); + +vi.mock('../logger.js', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); + +import { checkThreadRelevance } from '../thread-relevance.js'; + +describe('checkThreadRelevance', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return true when model says respond', async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: '{"respond": true}' } }], + }); + + const result = await checkThreadRelevance('帮我查一下这个bug', '土豆儿'); + expect(result).toBe(true); + }); + + it('should return false when model says do not respond', async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: '{"respond": false}' } }], + }); + + const result = await checkThreadRelevance('@赵天一 这个项目不典型了', '土豆儿'); + expect(result).toBe(false); + }); + + it('should include botName in the user message', async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: '{"respond": true}' } }], + }); + + await checkThreadRelevance('测试消息', 'DevBot'); + + const userMsg = mockCreate.mock.calls[0][0].messages[1].content; + expect(userMsg).toContain('DevBot'); + expect(userMsg).toContain('测试消息'); + }); + + it('should default to false on API error', async () => { + mockCreate.mockRejectedValue(new Error('API error')); + + const result = await checkThreadRelevance('test', 'bot'); + expect(result).toBe(false); + }); + + it('should default to false on empty response', async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: '' } }], + }); + + const result = await checkThreadRelevance('test', 'bot'); + expect(result).toBe(false); + }); + + it('should use enable_thinking: false and low temperature', async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: '{"respond": true}' } }], + }); + + await checkThreadRelevance('test', 'bot'); + + const params = mockCreate.mock.calls[0][0]; + expect(params.temperature).toBe(0); + expect(params.max_tokens).toBe(20); + }); +}); diff --git a/src/utils/quick-ack.ts b/src/utils/quick-ack.ts index 00784c20..67f75962 100644 --- a/src/utils/quick-ack.ts +++ b/src/utils/quick-ack.ts @@ -9,7 +9,8 @@ import { logger } from './logger.js'; // Lazy-initialized OpenAI client (DashScope compatible mode) let clientReady: Promise | null = null; -function getClient(): Promise { +/** Lazy-init DashScope OpenAI client(也供 thread-relevance 复用) */ +export function getClient(): Promise { if (clientReady) return clientReady; const apiKey = config.dashscope.apiKey; diff --git a/src/utils/thread-relevance.ts b/src/utils/thread-relevance.ts new file mode 100644 index 00000000..793c1420 --- /dev/null +++ b/src/utils/thread-relevance.ts @@ -0,0 +1,102 @@ +// ============================================================ +// Thread Relevance — 话题内消息是否需要 bot 回复的语义判断 +// 使用 DashScope (Qwen) 小模型快速判断,复用 quick-ack 的 client +// ============================================================ + +import { config } from '../config.js'; +import { logger } from './logger.js'; +import { getClient } from './quick-ack.js'; + +const RELEVANCE_PROMPT = `你是一个消息路由判断器。在一个群聊话题中,机器人之前参与了对话。 +现在收到一条新消息(没有 @机器人),判断这条消息是否**明确需要机器人回复**。 + +严格按 JSON 格式回复,不要输出任何其他内容: +{"respond": true} 或 {"respond": false} + +respond: true 的条件(必须满足至少一条): +- 消息**明确**在向机器人提问、请求帮助、布置任务 +- 消息提到了机器人的名字并期望它做某事 +- 消息是对机器人之前回复的追问或反馈 + +respond: false 的条件: +- 消息是在跟其他人聊天、讨论、感叹、评论 +- 消息是自言自语、告知别人状态(如"等等"、"我看看"、"稍等") +- 消息是对其他人说的话(即使话题中有机器人参与) +- 短句/语气词/感叹(如"哦"、"好的"、"噗"、"可以"、"稳了") +- 无法确定是否在跟机器人说话 → false(宁可不回)`; + +/** + * 判断话题内无 @mention 的消息是否需要 bot 回复。 + * + * 使用 Qwen 小模型快速语义判断,超时/失败默认返回 false(宁可不回,用户可 @bot 明确触发)。 + * + * @param message 用户消息文本 + * @param botName bot 显示名称 + * @returns true = 应该回复, false = 不应该回复 + */ +export async function checkThreadRelevance( + message: string, + botName: string, +): Promise { + if (!config.quickAck.enabled) return true; // 未配置小模型,默认回复 + + const client = await getClient(); + if (!client) return true; + + try { + const result = await Promise.race([ + client.chat.completions.create({ + model: config.quickAck.model, + messages: [ + { role: 'system', content: RELEVANCE_PROMPT }, + { role: 'user', content: `机器人名称:${botName}\n消息内容:${message.slice(0, 300)}` }, + ], + max_tokens: 20, + temperature: 0, + enable_thinking: false, + } as never), + new Promise((resolve) => setTimeout(() => resolve(null), 2000)), + ]); + + if (!result) { + logger.info('Thread relevance check timed out — defaulting to skip'); + return false; + } + + const raw = result.choices?.[0]?.message?.content?.trim(); + if (!raw) return false; + + return parseRelevanceResponse(raw); + } catch (err) { + logger.warn({ err }, 'Thread relevance check failed — defaulting to skip'); + return false; + } +} + +/** + * 解析 Qwen 返回的 JSON 判断结果。 + * 解析失败默认返回 true(宁可多回)。 + */ +export function parseRelevanceResponse(raw: string): boolean { + try { + const jsonMatch = raw.match(/\{[^}]+\}/); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]); + if (typeof parsed.respond === 'boolean') { + logger.info({ respond: parsed.respond, raw }, 'Thread relevance check result'); + return parsed.respond; + } + } + } catch { + // JSON parse failed + } + + // Fallback: check for keywords + if (raw.includes('false')) { + logger.info({ respond: false, raw, fallback: true }, 'Thread relevance check result (fallback)'); + return false; + } + + logger.info({ respond: true, raw, fallback: true }, 'Thread relevance check result (fallback)'); + return true; +}