Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions src/claude/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,10 @@ export class ClaudeExecutor {
for (const block of message.message.content) {
if ('text' in block && block.text) {
// 剥离模型在普通文本中输出的 <thinking> 标签(Sonnet adaptive 模式下偶现)
const cleaned = (block.text as string).replace(/<thinking>[\s\S]*?<\/thinking>\s*/g, '');
// 同时处理未闭合的 <thinking> 标签(模型可能只输出开标签不闭合)
const cleaned = (block.text as string)
.replace(/<thinking>[\s\S]*?<\/thinking>\s*/g, '')
.replace(/<thinking>[\s\S]*/g, '');
if (cleaned) {
output += cleaned;
turnText.push(cleaned);
Expand Down Expand Up @@ -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(/<thinking>[\s\S]*?<\/thinking>\s*/g, '')
.replace(/<thinking>[\s\S]*/g, '');
}

return {
Expand Down
27 changes: 20 additions & 7 deletions src/feishu/event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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。
Expand All @@ -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');
}
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1740,6 +1747,12 @@ async function sendDirectReply(
result: import('../claude/types.js').ClaudeResult,
threadReplyMsgId?: string,
): Promise<void> {
// 成功但无输出(如模型 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) {
Expand Down
136 changes: 136 additions & 0 deletions src/utils/__tests__/thread-relevance.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
3 changes: 2 additions & 1 deletion src/utils/quick-ack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { logger } from './logger.js';
// Lazy-initialized OpenAI client (DashScope compatible mode)
let clientReady: Promise<import('openai').default | null> | null = null;

function getClient(): Promise<import('openai').default | null> {
/** Lazy-init DashScope OpenAI client(也供 thread-relevance 复用) */
export function getClient(): Promise<import('openai').default | null> {
if (clientReady) return clientReady;

const apiKey = config.dashscope.apiKey;
Expand Down
102 changes: 102 additions & 0 deletions src/utils/thread-relevance.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<null>((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;
}
Loading