mirror of
https://github.com/ILoveBingLu/CipherTalk.git
synced 2026-05-11 14:01:48 +08:00
feat: 使用原生工具调用优化会话问答
将会话问答 Agent 改为 OpenAI-compatible 原生 tools/tool_calls 流程,移除旧 JSON 决策主路径。 补全回答证据中的图片、表情等媒体渲染,并将发送人显示为联系人名称。 优化问答等待态文案,补充原生工具 schema 和参数解析测试。
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Agent 自主决策:解析模型输出、选择下一步动作
|
||||
* Agent 工具参数规范化。
|
||||
*
|
||||
* 旧版这里会解析模型输出的 JSON action;现在主循环改为原生
|
||||
* tools/tool_calls,本文件只保留本地安全校验和参数标准化。
|
||||
*/
|
||||
import type { AIProvider } from '../../ai/providers/base'
|
||||
import type {
|
||||
AutonomousAgentAction,
|
||||
ToolLoopAction
|
||||
} from './types'
|
||||
import {
|
||||
@@ -11,11 +12,9 @@ import {
|
||||
SEARCH_CONTEXT_BEFORE,
|
||||
SEARCH_CONTEXT_AFTER
|
||||
} from './types'
|
||||
import { compactText, isRecord, clampToolLimit, stripThinkBlocks, stripJsonFence } from './utils/text'
|
||||
import { compactText, isRecord, clampToolLimit } from './utils/text'
|
||||
import { normalizeSearchQuery } from './utils/search'
|
||||
import { getFirstConcreteQuery } from './utils/search'
|
||||
import { normalizeStringArray } from './utils/text'
|
||||
import { buildAutonomousAgentPrompt, type BuildDecisionPromptInput } from './prompts/decision'
|
||||
|
||||
/**
|
||||
* 将模型原始输出规范化为 ToolLoopAction
|
||||
@@ -138,135 +137,3 @@ export function normalizeToolAction(raw: unknown): ToolLoopAction | null {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模型返回的 Agent 动作 JSON
|
||||
*/
|
||||
export function parseAutonomousAgentAction(value: string, finalAnswerContentCharLimit = 4000): AutonomousAgentAction | null {
|
||||
try {
|
||||
const parsed = JSON.parse(stripJsonFence(stripThinkBlocks(value))) as Record<string, unknown>
|
||||
const action = String(parsed.action || '').trim()
|
||||
const compactPreservingLines = (content: unknown, limit = finalAnswerContentCharLimit): string | undefined => {
|
||||
const text = String(content || '').trim()
|
||||
if (!text) return undefined
|
||||
return text.length > limit ? `${text.slice(0, limit - 3)}...` : text
|
||||
}
|
||||
// 提取可选的进度文字(tool_call 和 final_answer 都可携带)
|
||||
const assistantText = compactText(String(parsed.assistantText || parsed.assistant_text || ''), 500) || undefined
|
||||
|
||||
if (action === 'assistant_text') {
|
||||
const content = compactText(String(parsed.content || ''), 500)
|
||||
// 如果 assistant_text 同时携带了 toolName,说明模型想"说一句话+调工具"
|
||||
// 将其转化为带 assistantText 的 tool_call
|
||||
const toolName = String(parsed.toolName || parsed.tool || parsed.nextTool || '').trim()
|
||||
if (toolName && content) {
|
||||
const args = isRecord(parsed.args) ? parsed.args : {}
|
||||
const tool = normalizeToolAction({ ...args, action: toolName, reason: parsed.reason || args.reason })
|
||||
if (tool) {
|
||||
return { action: 'tool_call', tool, reason: compactText(String(parsed.reason || ''), 160) || undefined, assistantText: content }
|
||||
}
|
||||
}
|
||||
return content ? { action: 'assistant_text', content } : null
|
||||
}
|
||||
|
||||
if (action === 'final_answer') {
|
||||
return {
|
||||
action: 'final_answer',
|
||||
content: compactPreservingLines(parsed.content),
|
||||
reason: compactText(String(parsed.reason || ''), 160) || undefined,
|
||||
assistantText
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'tool_call') {
|
||||
const toolName = String(parsed.toolName || parsed.tool || parsed.name || '').trim()
|
||||
if (!toolName || toolName === 'answer') {
|
||||
return {
|
||||
action: 'final_answer',
|
||||
reason: compactText(String(parsed.reason || ''), 160) || undefined,
|
||||
assistantText
|
||||
}
|
||||
}
|
||||
|
||||
const args = isRecord(parsed.args) ? parsed.args : {}
|
||||
const tool = normalizeToolAction({
|
||||
...args,
|
||||
action: toolName,
|
||||
reason: parsed.reason || args.reason
|
||||
})
|
||||
return tool ? {
|
||||
action: 'tool_call',
|
||||
tool,
|
||||
reason: compactText(String(parsed.reason || ''), 160) || undefined,
|
||||
assistantText
|
||||
} : null
|
||||
}
|
||||
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用模型选择下一个 Agent 动作
|
||||
*/
|
||||
export async function chooseNextAutonomousAgentAction(
|
||||
provider: AIProvider,
|
||||
model: string,
|
||||
input: BuildDecisionPromptInput,
|
||||
options: {
|
||||
decisionMaxTokens: number
|
||||
finalAnswerContentCharLimit: number
|
||||
}
|
||||
): Promise<{ action: AutonomousAgentAction; prompt: string }> {
|
||||
const prompt = buildAutonomousAgentPrompt(input)
|
||||
|
||||
try {
|
||||
const response = await provider.chat([
|
||||
{
|
||||
role: 'system',
|
||||
content: '你是自主工具编排 Agent。你必须输出可解析的严格 JSON 对象。你可以在 JSON 的字符串值内部使用 Markdown 进行排版,但整体响应不能被 Markdown 代码块包裹,也不能包含任何解释性文本。'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt
|
||||
}
|
||||
], {
|
||||
model,
|
||||
temperature: 0.2,
|
||||
maxTokens: options.decisionMaxTokens,
|
||||
enableThinking: false
|
||||
})
|
||||
|
||||
const parsed = parseAutonomousAgentAction(response, options.finalAnswerContentCharLimit)
|
||||
if (parsed) return { action: parsed, prompt }
|
||||
} catch {
|
||||
// 失败时走本地兜底,避免问答卡死。
|
||||
}
|
||||
|
||||
if (input.route.intent === 'direct_answer') {
|
||||
return {
|
||||
action: {
|
||||
action: 'final_answer',
|
||||
content: '我在。你可以问我这段聊天里的内容、统计互动,或者让我帮你总结。'
|
||||
},
|
||||
prompt
|
||||
}
|
||||
}
|
||||
|
||||
if (input.evidenceQuality === 'none') {
|
||||
const firstQuery = getFirstConcreteQuery(input.question, input.route.searchQueries)
|
||||
return {
|
||||
action: firstQuery
|
||||
? { action: 'tool_call', tool: { action: 'search_messages', query: firstQuery, reason: '模型动作解析失败,使用关键词检索兜底' } }
|
||||
: { action: 'tool_call', tool: { action: 'read_latest', limit: MAX_CONTEXT_MESSAGES, reason: '模型动作解析失败,读取最近上下文兜底' } },
|
||||
prompt
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
action: { action: 'final_answer', reason: '模型动作解析失败,但已有可用证据,进入最终回答' },
|
||||
prompt
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* OpenAI-compatible 原生 tools/tool_calls 定义与参数校验。
|
||||
*/
|
||||
import type { NativeToolDefinition } from '../../ai/providers/base'
|
||||
import type { SessionQAToolName, ToolLoopAction } from './types'
|
||||
import { normalizeToolAction } from './agentDecision'
|
||||
import { isRecord, stripJsonFence } from './utils/text'
|
||||
|
||||
const NATIVE_SESSION_QA_TOOL_NAMES = [
|
||||
'read_summary_facts',
|
||||
'search_messages',
|
||||
'read_context',
|
||||
'read_latest',
|
||||
'read_by_time_range',
|
||||
'resolve_participant',
|
||||
'get_session_statistics',
|
||||
'get_keyword_statistics',
|
||||
'aggregate_messages',
|
||||
'answer'
|
||||
] as const
|
||||
|
||||
const NATIVE_TOOL_NAME_SET = new Set<string>(NATIVE_SESSION_QA_TOOL_NAMES)
|
||||
|
||||
export type NativeSessionQAToolName = typeof NATIVE_SESSION_QA_TOOL_NAMES[number]
|
||||
|
||||
export interface NativeToolArgumentParseResult {
|
||||
args: Record<string, unknown>
|
||||
action: ToolLoopAction | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function isNativeSessionQAToolName(name: string): name is NativeSessionQAToolName {
|
||||
return NATIVE_TOOL_NAME_SET.has(name)
|
||||
}
|
||||
|
||||
function objectSchema(properties: Record<string, unknown>, required: string[] = []): Record<string, unknown> {
|
||||
return {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties,
|
||||
required
|
||||
}
|
||||
}
|
||||
|
||||
export function getNativeSessionQATools(): NativeToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'read_summary_facts',
|
||||
description: '读取当前会话摘要和结构化摘要。适合先判断摘要是否已覆盖用户问题。',
|
||||
parameters: objectSchema({})
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_messages',
|
||||
description: '用关键词和语义检索搜索当前会话中的相关消息、对话块和记忆证据。适合具体实体、项目名、原话、是否提到某事等问题。',
|
||||
parameters: objectSchema({
|
||||
query: {
|
||||
type: 'string',
|
||||
description: '短关键词、短语或实体名。保留用户问题中的专有名词、人名、项目名。'
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description: '调用原因,简短说明为什么搜索这个词。'
|
||||
}
|
||||
}, ['query'])
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'read_context',
|
||||
description: '围绕 search_messages 返回的命中读取前后文。必须先有已知命中 h1/h2 后再调用。',
|
||||
parameters: objectSchema({
|
||||
hitId: {
|
||||
type: 'string',
|
||||
description: '已知搜索命中 ID,例如 h1、h2。'
|
||||
},
|
||||
beforeLimit: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 12,
|
||||
default: 6,
|
||||
description: '读取命中前多少条消息。'
|
||||
},
|
||||
afterLimit: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 12,
|
||||
default: 6,
|
||||
description: '读取命中后多少条消息。'
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description: '调用原因。'
|
||||
}
|
||||
}, ['hitId'])
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'read_latest',
|
||||
description: '读取当前会话最近消息。只适合最近进展、寒暄上下文,或其它检索策略都无法提供证据时兜底。',
|
||||
parameters: objectSchema({
|
||||
limit: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 40,
|
||||
default: 40,
|
||||
description: '读取最近消息数量。'
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description: '调用原因。'
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'read_by_time_range',
|
||||
description: '按时间范围、关键词或参与者读取当前会话消息。适合用户问题带有昨天、上周、某日期、某人说过什么等范围线索。',
|
||||
parameters: objectSchema({
|
||||
startTime: {
|
||||
type: 'integer',
|
||||
description: '开始时间,秒级 Unix 时间戳。没有明确范围时可省略。'
|
||||
},
|
||||
endTime: {
|
||||
type: 'integer',
|
||||
description: '结束时间,秒级 Unix 时间戳。没有明确范围时可省略。'
|
||||
},
|
||||
label: {
|
||||
type: 'string',
|
||||
description: '人类可读的时间范围标签,例如 昨晚、2025-01-01。'
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
default: 80,
|
||||
description: '读取消息数量上限。'
|
||||
},
|
||||
keyword: {
|
||||
type: 'string',
|
||||
description: '可选关键词过滤。'
|
||||
},
|
||||
senderUsername: {
|
||||
type: 'string',
|
||||
description: '已解析出的发送者 username。通常先调用 resolve_participant。'
|
||||
},
|
||||
participantName: {
|
||||
type: 'string',
|
||||
description: '问题中的参与者昵称或备注。'
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description: '调用原因。'
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'resolve_participant',
|
||||
description: '把用户问题中的昵称、备注、人名解析为会话内 senderUsername,供后续按发送者过滤。',
|
||||
parameters: objectSchema({
|
||||
name: {
|
||||
type: 'string',
|
||||
description: '需要解析的参与者名称。'
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description: '调用原因。'
|
||||
}
|
||||
}, ['name'])
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_session_statistics',
|
||||
description: '统计当前会话消息量、发送者分布、消息类型和可选样例。',
|
||||
parameters: objectSchema({
|
||||
startTime: {
|
||||
type: 'integer',
|
||||
description: '可选开始时间,秒级 Unix 时间戳。'
|
||||
},
|
||||
endTime: {
|
||||
type: 'integer',
|
||||
description: '可选结束时间,秒级 Unix 时间戳。'
|
||||
},
|
||||
label: {
|
||||
type: 'string',
|
||||
description: '统计范围标签。'
|
||||
},
|
||||
participantLimit: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 50,
|
||||
default: 20,
|
||||
description: '发送者排行数量上限。'
|
||||
},
|
||||
includeSamples: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: '是否返回少量样例消息作为证据。'
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description: '调用原因。'
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_keyword_statistics',
|
||||
description: '统计关键词或短语的命中消息数、出现次数、发送者分布、时间分布和样例。',
|
||||
parameters: objectSchema({
|
||||
keywords: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
maxItems: 6,
|
||||
items: { type: 'string' },
|
||||
description: '要统计的关键词或短语。'
|
||||
},
|
||||
startTime: {
|
||||
type: 'integer',
|
||||
description: '可选开始时间,秒级 Unix 时间戳。'
|
||||
},
|
||||
endTime: {
|
||||
type: 'integer',
|
||||
description: '可选结束时间,秒级 Unix 时间戳。'
|
||||
},
|
||||
label: {
|
||||
type: 'string',
|
||||
description: '统计范围标签。'
|
||||
},
|
||||
matchMode: {
|
||||
type: 'string',
|
||||
enum: ['substring', 'exact'],
|
||||
default: 'substring',
|
||||
description: 'substring 表示包含匹配,exact 表示整条消息归一化后完全等于关键词。'
|
||||
},
|
||||
participantLimit: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 50,
|
||||
default: 20,
|
||||
description: '参与者排行数量上限。'
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description: '调用原因。'
|
||||
}
|
||||
}, ['keywords'])
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'aggregate_messages',
|
||||
description: '对已读取的上下文消息进行聚合整理。适合统计、摘要、时间线或消息类型整理。',
|
||||
parameters: objectSchema({
|
||||
metric: {
|
||||
type: 'string',
|
||||
enum: ['speaker_count', 'message_count', 'kind_count', 'timeline', 'summary'],
|
||||
default: 'summary',
|
||||
description: '聚合类型。'
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description: '调用原因。'
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'answer',
|
||||
description: '当证据已经足够,或工具观察明确显示证据不足时,调用此工具进入最终回答。',
|
||||
parameters: objectSchema({
|
||||
reason: {
|
||||
type: 'string',
|
||||
description: '为什么现在可以回答。'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function parseNativeToolCallArguments(
|
||||
toolName: string,
|
||||
rawArguments: string | Record<string, unknown> | null | undefined
|
||||
): NativeToolArgumentParseResult {
|
||||
if (!isNativeSessionQAToolName(toolName)) {
|
||||
return {
|
||||
args: {},
|
||||
action: null,
|
||||
error: `未知工具:${toolName}`
|
||||
}
|
||||
}
|
||||
|
||||
let args: Record<string, unknown> = {}
|
||||
|
||||
try {
|
||||
if (typeof rawArguments === 'string') {
|
||||
const text = stripJsonFence(rawArguments.trim())
|
||||
if (text) {
|
||||
const parsed = JSON.parse(text)
|
||||
if (!isRecord(parsed)) {
|
||||
return { args: {}, action: null, error: `${toolName} 参数必须是 JSON object。` }
|
||||
}
|
||||
args = parsed
|
||||
}
|
||||
} else if (isRecord(rawArguments)) {
|
||||
args = rawArguments
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
args: {},
|
||||
action: null,
|
||||
error: `${toolName} 参数 JSON 解析失败:${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
}
|
||||
|
||||
const action = normalizeToolAction({ ...args, action: toolName })
|
||||
if (!action) {
|
||||
return {
|
||||
args,
|
||||
action: null,
|
||||
error: `${toolName} 参数未通过本地安全校验。`
|
||||
}
|
||||
}
|
||||
|
||||
return { args, action }
|
||||
}
|
||||
|
||||
export function toSessionQAToolName(toolName: string): SessionQAToolName {
|
||||
return isNativeSessionQAToolName(toolName) ? toolName : 'answer'
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import type {
|
||||
ToolLoopAction,
|
||||
ContextWindow,
|
||||
SessionQAToolCall,
|
||||
IntentRoute
|
||||
IntentRoute,
|
||||
NativeToolExecutionResult
|
||||
} from './types'
|
||||
import {
|
||||
MAX_CONTEXT_MESSAGES,
|
||||
@@ -37,14 +38,15 @@ import { loadSessionContactMap } from './utils/contacts'
|
||||
import { routeFromHeuristics, enforceConcreteEvidenceRoute, getRouteLabel } from './intent/router'
|
||||
import { emitProgress } from './progress'
|
||||
import { AgentContext, AgentAbortError } from './agentContext'
|
||||
import { chooseNextAutonomousAgentAction } from './agentDecision'
|
||||
import { assessEvidenceQuality, hasConclusiveSearchFailure, findKnownHitForAction, summarizeSearchObservation, getSearchDiagnostics, interpretSearchFailure } from './evidence'
|
||||
import { hasConclusiveSearchFailure, findKnownHitForAction, summarizeSearchObservation, getSearchDiagnostics, interpretSearchFailure } from './evidence'
|
||||
import { searchSessionMessages, loadLatestContext, loadContextAroundMessage } from './tools/search'
|
||||
import { loadSessionStatistics, loadKeywordStatistics, loadMessagesByTimeRange, loadMessagesByTimeRangeAll, formatSessionStatisticsText, formatKeywordStatisticsText } from './tools/statistics'
|
||||
import { resolveParticipantName, findResolvedSenderUsername } from './tools/participant'
|
||||
import { aggregateMessages } from './tools/aggregate'
|
||||
import { buildAutonomousAgentPrompt } from './prompts/decision'
|
||||
import { buildAnswerPrompt } from './prompts/answer'
|
||||
import { classifyAgentError, shouldRetryToolCall, getRetryDelayMs } from './errors'
|
||||
import { getNativeSessionQATools, parseNativeToolCallArguments, toSessionQAToolName } from './nativeTools'
|
||||
import { NATIVE_TOOL_CALLING_UNSUPPORTED_MESSAGE, isNativeToolCallingUnsupportedError } from '../../ai/providers/base'
|
||||
import type { SummaryEvidenceRef } from '../types/analysis'
|
||||
import { getAgentNodeName } from './nodeNames'
|
||||
|
||||
@@ -251,7 +253,7 @@ async function executeSearchMessages(ctx: AgentContext, action: ToolLoopAction &
|
||||
ctx.toolCallsUsed += 1
|
||||
const progressId = `tool-loop-${ctx.toolCallsUsed}-search`
|
||||
emitProgress(ctx.options, { id: progressId, stage: 'tool', status: 'completed', title: '搜索相关消息', detail: `已跳过重复关键词:${query}`, toolName: 'search_messages', query, count: 0 })
|
||||
ctx.observations.push({ title: '跳过重复搜索', detail: `关键词 "${query}" 已经搜索过,请更换关键词,或进入 final_answer。` })
|
||||
ctx.observations.push({ title: '跳过重复搜索', detail: `关键词 "${query}" 已经搜索过,请更换关键词,或进入回答。` })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -369,7 +371,7 @@ async function executeReadLatest(ctx: AgentContext, action: ToolLoopAction & { a
|
||||
ctx.toolCallsUsed += 1
|
||||
const progressId = `tool-loop-${ctx.toolCallsUsed}-latest`
|
||||
emitProgress(ctx.options, { id: progressId, stage: 'tool', status: 'completed', title: '读取最近上下文', detail: '已跳过重复读取', toolName: 'read_latest', count: 0 })
|
||||
ctx.observations.push({ title: '跳过重复读取', detail: '已读取过最近上下文,请进入 final_answer 进行回答。' })
|
||||
ctx.observations.push({ title: '跳过重复读取', detail: '已读取过最近上下文,请进入回答。' })
|
||||
return
|
||||
}
|
||||
ctx.toolCallsUsed += 1
|
||||
@@ -412,10 +414,146 @@ async function dispatchToolAction(ctx: AgentContext, action: ToolLoopAction): Pr
|
||||
}
|
||||
}
|
||||
|
||||
// ─── assistant_text 自动推进:根据当前状态选择下一个工具 ────────
|
||||
function stringifyAssistantContent(content: OpenAI.Chat.ChatCompletionMessage['content'] | null | undefined): string {
|
||||
if (!content) return ''
|
||||
if (typeof content === 'string') return stripThinkBlocks(content).trim()
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part: any) => typeof part?.text === 'string' ? part.text : '')
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildNativeToolState(ctx: AgentContext): NativeToolExecutionResult['state'] {
|
||||
return {
|
||||
toolCallsUsed: ctx.toolCallsUsed,
|
||||
knownHitIds: ctx.knownHits.slice(0, 12).map((hit) => hit.hitId),
|
||||
searchPayloadCount: ctx.searchPayloads.length,
|
||||
contextWindowCount: ctx.contextWindows.length,
|
||||
contextMessageCount: ctx.contextWindows.reduce((sum, window) => sum + window.messages.length, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function compactToolObservationForModel(result: NativeToolExecutionResult): string {
|
||||
return JSON.stringify({
|
||||
ok: result.ok,
|
||||
toolName: result.toolName,
|
||||
summary: compactText(result.summary, 1200),
|
||||
evidenceQuality: result.evidenceQuality,
|
||||
observations: result.observations.slice(-3).map((item) => ({
|
||||
title: item.title,
|
||||
detail: compactText(item.detail, 1800)
|
||||
})),
|
||||
state: result.state,
|
||||
error: result.error
|
||||
})
|
||||
}
|
||||
|
||||
function createFailedNativeToolResult(
|
||||
ctx: AgentContext,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
error: string
|
||||
): NativeToolExecutionResult {
|
||||
const safeToolName = toSessionQAToolName(toolName)
|
||||
const toolCall = buildToolCall({
|
||||
toolName: safeToolName,
|
||||
args,
|
||||
summary: error,
|
||||
status: 'failed'
|
||||
})
|
||||
|
||||
ctx.toolCallsUsed += 1
|
||||
ctx.toolCalls.push(toolCall)
|
||||
ctx.observations.push({ title: '工具参数错误', detail: error })
|
||||
emitProgress(ctx.options, {
|
||||
id: `tool-loop-${ctx.toolCallsUsed}-invalid`,
|
||||
stage: 'tool',
|
||||
status: 'failed',
|
||||
title: getAgentNodeName({ toolName: safeToolName }),
|
||||
detail: error,
|
||||
toolName: safeToolName
|
||||
})
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
toolName: safeToolName,
|
||||
args,
|
||||
summary: error,
|
||||
observations: [{ title: '工具参数错误', detail: error }],
|
||||
toolCalls: [toolCall],
|
||||
evidenceQuality: ctx.evidenceQuality,
|
||||
error,
|
||||
state: buildNativeToolState(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
async function executeNativeToolAction(
|
||||
ctx: AgentContext,
|
||||
action: ToolLoopAction,
|
||||
args: Record<string, unknown>
|
||||
): Promise<NativeToolExecutionResult> {
|
||||
if (action.action === 'answer') {
|
||||
const summary = action.reason || '模型判断可以进入最终回答。'
|
||||
ctx.observations.push({ title: '开始回答', detail: summary })
|
||||
return {
|
||||
ok: true,
|
||||
toolName: 'answer',
|
||||
args,
|
||||
summary,
|
||||
observations: [{ title: '开始回答', detail: summary }],
|
||||
toolCalls: [],
|
||||
evidenceQuality: ctx.evidenceQuality,
|
||||
state: buildNativeToolState(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
const beforeObservationCount = ctx.observations.length
|
||||
const beforeToolCallCount = ctx.toolCalls.length
|
||||
await dispatchToolAction(ctx, action)
|
||||
|
||||
const observations = ctx.observations.slice(beforeObservationCount)
|
||||
const toolCalls = ctx.toolCalls.slice(beforeToolCallCount)
|
||||
const summary = observations.map((item) => `${item.title}: ${item.detail}`).join('\n\n')
|
||||
|| toolCalls.map((item) => item.summary).join('\n')
|
||||
|| `${action.action} 执行完成。`
|
||||
const hasFailedToolCall = toolCalls.some((item) => item.status === 'failed')
|
||||
|
||||
return {
|
||||
ok: !hasFailedToolCall,
|
||||
toolName: action.action,
|
||||
args,
|
||||
summary,
|
||||
observations,
|
||||
toolCalls,
|
||||
evidenceQuality: ctx.evidenceQuality,
|
||||
state: buildNativeToolState(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
function shouldAcceptPlainAssistantAnswer(ctx: AgentContext, route: IntentRoute): boolean {
|
||||
return ctx.evidenceQuality !== 'none'
|
||||
|| route.intent === 'direct_answer'
|
||||
|| hasConclusiveSearchFailure(ctx.searchPayloads)
|
||||
}
|
||||
|
||||
function appendLocalFallbackResultMessage(
|
||||
messages: OpenAI.Chat.ChatCompletionMessageParam[],
|
||||
action: ToolLoopAction,
|
||||
result: NativeToolExecutionResult
|
||||
) {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: `系统已自动执行本地补充工具 ${action.action},结果如下。请基于这些观察继续:\n${compactToolObservationForModel(result)}`
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 普通文本自动推进:根据当前状态选择下一个工具 ────────
|
||||
|
||||
/**
|
||||
* 当模型输出纯 assistant_text 时,根据当前 Agent 状态自动选择一个合理的工具执行,
|
||||
* 当模型输出普通文本但当前证据不足时,根据当前 Agent 状态自动选择一个合理的工具执行,
|
||||
* 避免 continue 空转导致重复输出。
|
||||
*
|
||||
* 选择策略按优先级:
|
||||
@@ -429,12 +567,12 @@ function pickFallbackToolAction(ctx: AgentContext, route: IntentRoute): ToolLoop
|
||||
// 优先用启发式路由建议的搜索关键词
|
||||
const nextSearchQuery = route.searchQueries.find((q) => !ctx.searchedQueries.has(q.toLowerCase()))
|
||||
if (nextSearchQuery && ctx.toolCallsUsed < MAX_TOOL_CALLS - 2) {
|
||||
return { action: 'search_messages', query: nextSearchQuery, reason: '模型输出进度文字,自动执行路由推荐的搜索' }
|
||||
return { action: 'search_messages', query: nextSearchQuery, reason: '模型输出普通文本但证据不足,自动执行路由推荐的搜索' }
|
||||
}
|
||||
|
||||
// 还没读过摘要
|
||||
if (!ctx.summaryFactsRead && !ctx.observations.some((o) => o.title === '读取摘要事实')) {
|
||||
return { action: 'read_summary_facts', reason: '模型输出进度文字,自动检查摘要事实' }
|
||||
return { action: 'read_summary_facts', reason: '模型输出普通文本但证据不足,自动检查摘要事实' }
|
||||
}
|
||||
|
||||
// 路由有时间范围线索
|
||||
@@ -444,17 +582,17 @@ function pickFallbackToolAction(ctx: AgentContext, route: IntentRoute): ToolLoop
|
||||
startTime: route.timeRange.startTime,
|
||||
endTime: route.timeRange.endTime,
|
||||
label: route.timeRange.label,
|
||||
reason: '模型输出进度文字,自动按时间范围读取'
|
||||
reason: '模型输出普通文本但证据不足,自动按时间范围读取'
|
||||
}
|
||||
}
|
||||
|
||||
// 路由有参与者线索但尚未解析
|
||||
if (route.participantHints.length > 0 && ctx.resolvedParticipants.length === 0) {
|
||||
return { action: 'resolve_participant', name: route.participantHints[0], reason: '模型输出进度文字,自动解析参与者' }
|
||||
return { action: 'resolve_participant', name: route.participantHints[0], reason: '模型输出普通文本但证据不足,自动解析参与者' }
|
||||
}
|
||||
|
||||
if (!ctx.contextWindows.some((w) => w.source === 'latest')) {
|
||||
return { action: 'read_latest', limit: MAX_CONTEXT_MESSAGES, reason: '模型输出进度文字,自动读取最近上下文兜底' }
|
||||
return { action: 'read_latest', limit: MAX_CONTEXT_MESSAGES, reason: '模型输出普通文本但证据不足,自动读取最近上下文兜底' }
|
||||
}
|
||||
|
||||
return { action: 'answer', reason: '所有自动探测工具已执行完毕,强制进入总结环节。' }
|
||||
@@ -476,118 +614,181 @@ export async function answerSessionQuestionWithAgent(
|
||||
|
||||
const agentDecisionMaxTokens = clampTokenBudget(options.agentDecisionMaxTokens, DEFAULT_AGENT_DECISION_MAX_TOKENS, 512, MAX_AGENT_DECISION_MAX_TOKENS)
|
||||
const agentAnswerMaxTokens = clampTokenBudget(options.agentAnswerMaxTokens, DEFAULT_AGENT_ANSWER_MAX_TOKENS, 512, MAX_AGENT_ANSWER_MAX_TOKENS)
|
||||
const finalAnswerContentCharLimit = Math.max(4000, agentAnswerMaxTokens * 4)
|
||||
const nativeTools = getNativeSessionQATools()
|
||||
const initialAgentPrompt = buildAutonomousAgentPrompt({
|
||||
sessionName: ctx.sessionName, question: ctx.question, route,
|
||||
summaryText: options.summaryText, structuredContext, historyText,
|
||||
observations: ctx.observations, knownHits: ctx.knownHits,
|
||||
resolvedParticipants: ctx.resolvedParticipants, aggregateText: ctx.aggregateText,
|
||||
summaryFactsRead: ctx.summaryFactsRead, toolCallsUsed: ctx.toolCallsUsed,
|
||||
evidenceQuality: ctx.evidenceQuality, searchRetries: ctx.searchRetries,
|
||||
searchPayloads: ctx.searchPayloads, contextWindows: ctx.contextWindows
|
||||
})
|
||||
ctx.lastAgentPrompt = initialAgentPrompt
|
||||
|
||||
const toolLoopMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content: '你是 CipherTalk 的本地聊天记录问答 Agent。必须使用 OpenAI-compatible 原生 tools/tool_calls 调用工具;不要输出 JSON action。证据不足时继续调用工具或明确说明证据不足。'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: initialAgentPrompt
|
||||
}
|
||||
]
|
||||
|
||||
const emitAssistantThought = (text?: string) => {
|
||||
const content = compactText(text || '', 500)
|
||||
if (!content) return
|
||||
const lastThought = ctx.observations.filter((o) => o.title === 'Agent 输出').pop()
|
||||
if (lastThought && lastThought.detail === content) return
|
||||
emitProgress(ctx.options, {
|
||||
id: `thought-${Date.now()}-${ctx.decisionAttempts}`,
|
||||
stage: 'thought',
|
||||
status: 'completed',
|
||||
title: content,
|
||||
detail: content
|
||||
})
|
||||
ctx.observations.push({ title: 'Agent 输出', detail: content })
|
||||
}
|
||||
|
||||
try {
|
||||
// ── 主决策循环 ──
|
||||
if (typeof options.provider.chatWithTools !== 'function') {
|
||||
throw new Error(NATIVE_TOOL_CALLING_UNSUPPORTED_MESSAGE)
|
||||
}
|
||||
|
||||
// ── 原生 tools/tool_calls 主循环 ──
|
||||
while (ctx.shouldContinueLoop()) {
|
||||
ctx.checkAbort()
|
||||
ctx.decisionAttempts += 1
|
||||
|
||||
const evidenceQuality = ctx.evidenceQuality
|
||||
const conclusiveSearchFailure = hasConclusiveSearchFailure(ctx.searchPayloads)
|
||||
|
||||
const agentDecision = await chooseNextAutonomousAgentAction(options.provider, options.model, {
|
||||
sessionName: ctx.sessionName, question: ctx.question, route,
|
||||
summaryText: options.summaryText, structuredContext, historyText,
|
||||
observations: ctx.observations, knownHits: ctx.knownHits,
|
||||
resolvedParticipants: ctx.resolvedParticipants, aggregateText: ctx.aggregateText,
|
||||
summaryFactsRead: ctx.summaryFactsRead, toolCallsUsed: ctx.toolCallsUsed,
|
||||
evidenceQuality, searchRetries: ctx.searchRetries,
|
||||
searchPayloads: ctx.searchPayloads, contextWindows: ctx.contextWindows
|
||||
}, { decisionMaxTokens: agentDecisionMaxTokens, finalAnswerContentCharLimit })
|
||||
ctx.lastAgentPrompt = agentDecision.prompt
|
||||
|
||||
// ── 辅助:发射 assistantText 思考气泡(如果有的话)──
|
||||
const maybeEmitAssistantText = (text?: string) => {
|
||||
if (!text) return
|
||||
// 去重:和上一条相同内容则不发射
|
||||
const lastThought = ctx.observations.filter((o) => o.title === 'Agent 输出').pop()
|
||||
if (lastThought && lastThought.detail === text) return
|
||||
emitProgress(ctx.options, {
|
||||
id: `thought-${Date.now()}-${ctx.decisionAttempts}`,
|
||||
stage: 'thought',
|
||||
status: 'completed',
|
||||
title: text,
|
||||
detail: text
|
||||
let nativeResponse
|
||||
try {
|
||||
nativeResponse = await options.provider.chatWithTools(toolLoopMessages, {
|
||||
model: options.model,
|
||||
temperature: 0.2,
|
||||
maxTokens: agentDecisionMaxTokens,
|
||||
enableThinking: false,
|
||||
tools: nativeTools,
|
||||
toolChoice: 'auto'
|
||||
})
|
||||
ctx.observations.push({ title: 'Agent 输出', detail: text })
|
||||
} catch (error) {
|
||||
if ((error instanceof Error && error.message === NATIVE_TOOL_CALLING_UNSUPPORTED_MESSAGE) || isNativeToolCallingUnsupportedError(error)) {
|
||||
throw new Error(NATIVE_TOOL_CALLING_UNSUPPORTED_MESSAGE)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// 处理 assistant_text → 不再空转 continue,直接推进到工具调用
|
||||
// 根本原因:模型想表达"说句话+调工具",但格式限制只能三选一,
|
||||
// 导致它先输出 assistant_text,continue 后上下文几乎没变,又重复同样的输出。
|
||||
// 修复:将 assistant_text 视为"附带进度文字的隐含工具调用",自动推进。
|
||||
if (agentDecision.action.action === 'assistant_text') {
|
||||
maybeEmitAssistantText(agentDecision.action.content)
|
||||
// 不再 continue 空转。根据当前状态自动选择一个工具执行,确保每轮都有实质推进。
|
||||
const autoAction = pickFallbackToolAction(ctx, route)
|
||||
ctx.observations.push({ title: '自动推进', detail: `模型输出进度文字后自动执行:${autoAction.action}` })
|
||||
await dispatchToolAction(ctx, autoAction)
|
||||
const assistantMessage = nativeResponse.message
|
||||
const assistantText = stringifyAssistantContent(assistantMessage.content)
|
||||
const nativeToolCalls = Array.isArray((assistantMessage as any).tool_calls)
|
||||
? (assistantMessage as any).tool_calls as Array<{ id: string; type: string; function?: { name?: string; arguments?: string } }>
|
||||
: []
|
||||
const decisionTrace = JSON.stringify({
|
||||
content: compactText(assistantText, 800),
|
||||
toolCalls: nativeToolCalls.map((call) => ({
|
||||
id: call.id,
|
||||
name: call.function?.name,
|
||||
arguments: compactText(call.function?.arguments || '', 800)
|
||||
})),
|
||||
finishReason: nativeResponse.finishReason
|
||||
})
|
||||
ctx.trackDecisionTokens(ctx.lastAgentPrompt, decisionTrace)
|
||||
|
||||
toolLoopMessages.push(assistantMessage as OpenAI.Chat.ChatCompletionMessageParam)
|
||||
|
||||
if (nativeToolCalls.length > 0) {
|
||||
emitAssistantThought(assistantText)
|
||||
let shouldGenerateFinalAnswer = false
|
||||
|
||||
for (const toolCall of nativeToolCalls) {
|
||||
ctx.checkAbort()
|
||||
const toolName = String(toolCall.function?.name || '')
|
||||
const toolCallId = toolCall.id || `tool-${Date.now()}-${ctx.toolCallsUsed}`
|
||||
const parsed = parseNativeToolCallArguments(toolName, toolCall.function?.arguments)
|
||||
|
||||
let result: NativeToolExecutionResult
|
||||
if (!parsed.action || parsed.error) {
|
||||
result = createFailedNativeToolResult(ctx, toolName || 'unknown', parsed.args, parsed.error || '工具参数无效。')
|
||||
toolLoopMessages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCallId,
|
||||
content: compactToolObservationForModel(result)
|
||||
} as OpenAI.Chat.ChatCompletionMessageParam)
|
||||
continue
|
||||
}
|
||||
|
||||
let action = parsed.action
|
||||
const evidenceQuality = ctx.evidenceQuality
|
||||
const conclusiveSearchFailure = hasConclusiveSearchFailure(ctx.searchPayloads)
|
||||
|
||||
if (evidenceQuality === 'none' && ctx.toolCallsUsed >= MAX_TOOL_CALLS - 1 && action.action !== 'read_latest' && action.action !== 'answer') {
|
||||
action = { action: 'read_latest', limit: MAX_CONTEXT_MESSAGES, reason: '工具预算即将耗尽' }
|
||||
}
|
||||
|
||||
if (action.action === 'answer') {
|
||||
if (evidenceQuality === 'none' && route.intent !== 'direct_answer' && !conclusiveSearchFailure) {
|
||||
action = ctx.summaryFactsRead
|
||||
? { action: 'read_latest', limit: MAX_CONTEXT_MESSAGES, reason: '模型准备回答但仍缺少证据,读取最近上下文兜底' }
|
||||
: { action: 'read_summary_facts', reason: '模型准备回答但尚无证据,先检查摘要事实' }
|
||||
} else if (evidenceQuality === 'weak' && ctx.toolCallsUsed < MAX_TOOL_CALLS - 2) {
|
||||
const hasSearchedWithNoHits = ctx.searchPayloads.length > 0 && ctx.searchPayloads.every((i) => i.payload.hits.length === 0)
|
||||
if (hasSearchedWithNoHits && ctx.searchRetries < MAX_SEARCH_RETRIES) {
|
||||
const nextQuery = route.searchQueries.find((q) => !ctx.searchedQueries.has(q.toLowerCase()))
|
||||
action = nextQuery
|
||||
? { action: 'search_messages', query: nextQuery, reason: '之前的搜索没有命中,换关键词重试' }
|
||||
: { action: 'read_latest', limit: MAX_CONTEXT_MESSAGES, reason: '搜索没有命中且缺少新关键词' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = await executeNativeToolAction(ctx, action, parsed.args)
|
||||
toolLoopMessages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCallId,
|
||||
content: compactToolObservationForModel(result)
|
||||
} as OpenAI.Chat.ChatCompletionMessageParam)
|
||||
|
||||
if (action.action === 'answer') {
|
||||
shouldGenerateFinalAnswer = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldGenerateFinalAnswer) {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// tool_call / final_answer 如果携带了 assistantText,先展示
|
||||
if ('assistantText' in agentDecision.action && agentDecision.action.assistantText) {
|
||||
maybeEmitAssistantText(agentDecision.action.assistantText)
|
||||
}
|
||||
|
||||
// 处理 final_answer
|
||||
if (agentDecision.action.action === 'final_answer') {
|
||||
if (evidenceQuality === 'none' && route.intent !== 'direct_answer' && !conclusiveSearchFailure) {
|
||||
const hasTriedSummary = ctx.observations.some((i) => i.title === '读取摘要事实')
|
||||
const fallbackAction: ToolLoopAction = ctx.summaryFactsRead || hasTriedSummary
|
||||
? { action: 'read_latest', limit: MAX_CONTEXT_MESSAGES, reason: '模型准备回答但仍缺少证据,读取最近上下文兜底' }
|
||||
: { action: 'read_summary_facts', reason: '模型准备回答但尚无证据,先检查摘要事实' }
|
||||
ctx.observations.push({ title: '延后回答', detail: agentDecision.action.reason || '当前没有证据,继续收集上下文。' })
|
||||
agentDecision.action = { action: 'tool_call', tool: fallbackAction }
|
||||
} else if (agentDecision.action.content) {
|
||||
if (assistantText) {
|
||||
if (shouldAcceptPlainAssistantAnswer(ctx, route)) {
|
||||
emitProgress(options, { id: 'answer', stage: 'answer', status: 'running', title: '生成回答', detail: 'Agent 已决定直接回答' })
|
||||
ctx.emitVisibleText(agentDecision.action.content)
|
||||
ctx.emitVisibleText(assistantText)
|
||||
ctx.trackAnswerTokens(ctx.lastAgentPrompt, assistantText)
|
||||
emitProgress(options, { id: 'answer', stage: 'answer', status: 'completed', title: '生成回答', detail: '回答生成完成' })
|
||||
ctx.logger.lifecycle('Agent 直接回答完成', { ...ctx.getTokenUsage() })
|
||||
return { answerText: stripThinkBlocks(ctx.answerText), evidenceRefs: dedupeEvidenceRefs(ctx.evidenceCandidates), toolCalls: ctx.toolCalls, promptText: ctx.lastAgentPrompt, tokenUsage: ctx.getTokenUsage() }
|
||||
} else {
|
||||
ctx.observations.push({ title: '开始回答', detail: agentDecision.action.reason || 'Agent 判断已有可用证据,进入最终回答。' })
|
||||
}
|
||||
|
||||
emitAssistantThought(assistantText)
|
||||
const fallbackAction = pickFallbackToolAction(ctx, route)
|
||||
if (fallbackAction.action === 'answer') {
|
||||
ctx.observations.push({ title: '开始回答', detail: fallbackAction.reason || '本地补充工具已执行完毕,进入最终回答。' })
|
||||
break
|
||||
}
|
||||
const fallbackResult = await executeNativeToolAction(ctx, fallbackAction, { ...fallbackAction })
|
||||
appendLocalFallbackResultMessage(toolLoopMessages, fallbackAction, fallbackResult)
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理 tool_call
|
||||
let action: ToolLoopAction = agentDecision.action.action === 'tool_call'
|
||||
? agentDecision.action.tool
|
||||
: { action: 'read_latest', limit: MAX_CONTEXT_MESSAGES, reason: 'Agent 动作无效,读取最近上下文兜底' }
|
||||
|
||||
// 预算即将耗尽时强制读取最近消息
|
||||
if (evidenceQuality === 'none' && ctx.toolCallsUsed >= MAX_TOOL_CALLS - 1 && action.action !== 'read_latest') {
|
||||
action = { action: 'read_latest', limit: MAX_CONTEXT_MESSAGES, reason: '工具预算即将耗尽' }
|
||||
const fallbackAction = pickFallbackToolAction(ctx, route)
|
||||
if (fallbackAction.action === 'answer') {
|
||||
ctx.observations.push({ title: '开始回答', detail: fallbackAction.reason || '模型未返回工具调用,进入最终回答。' })
|
||||
break
|
||||
}
|
||||
|
||||
// answer 动作的证据检查
|
||||
if (action.action === 'answer') {
|
||||
if (evidenceQuality === 'none' && !conclusiveSearchFailure) {
|
||||
action = ctx.summaryFactsRead
|
||||
? { action: 'read_latest', limit: MAX_CONTEXT_MESSAGES, reason: '摘要事实不足,回答前读取最近上下文' }
|
||||
: { action: 'read_summary_facts', reason: '尚无可用证据,先检查摘要事实' }
|
||||
} else if (evidenceQuality === 'weak' && ctx.toolCallsUsed < MAX_TOOL_CALLS - 2) {
|
||||
const hasSearchedWithNoHits = ctx.searchPayloads.length > 0 && ctx.searchPayloads.every((i) => i.payload.hits.length === 0)
|
||||
if (hasSearchedWithNoHits && ctx.searchRetries < MAX_SEARCH_RETRIES) {
|
||||
const nextQuery = route.searchQueries.find((q) => !ctx.searchedQueries.has(q.toLowerCase()))
|
||||
action = nextQuery
|
||||
? { action: 'search_messages', query: nextQuery, reason: '之前的搜索没有命中,换关键词重试' }
|
||||
: { action: 'read_latest', limit: MAX_CONTEXT_MESSAGES, reason: '搜索没有命中且缺少新关键词' }
|
||||
} else {
|
||||
ctx.observations.push({ title: '开始回答', detail: '证据有限但已尝试多种策略,进入回答生成。' })
|
||||
break
|
||||
}
|
||||
} else {
|
||||
ctx.observations.push({ title: '开始回答', detail: action.reason || '已有可用证据,进入回答生成。' })
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 分发工具执行
|
||||
await dispatchToolAction(ctx, action)
|
||||
const fallbackResult = await executeNativeToolAction(ctx, fallbackAction, { ...fallbackAction })
|
||||
appendLocalFallbackResultMessage(toolLoopMessages, fallbackAction, fallbackResult)
|
||||
}
|
||||
|
||||
// ── 循环结束后的兜底读取 ──
|
||||
|
||||
@@ -19,50 +19,24 @@ function buildAvailableToolSchemaText(): string {
|
||||
return `可调用工具:
|
||||
1. read_summary_facts args={}
|
||||
读取当前摘要和结构化摘要。适合先判断摘要是否已经覆盖问题。
|
||||
2. read_latest args={"limit":40}
|
||||
读取最近消息。只适合最近进展或其它工具无法提供线索时兜底。
|
||||
3. read_by_time_range args={"startTime":秒级时间戳,"endTime":秒级时间戳,"label":"昨天晚上","limit":80,"keyword":"可选关键词","participantName":"可选昵称"}
|
||||
按时间/关键词/参与者读取消息。
|
||||
4. resolve_participant args={"name":"张三"}
|
||||
把昵称、备注或问题中的人名解析为 senderUsername。
|
||||
5. search_messages args={"query":"关键词或短语"}
|
||||
2. search_messages args={"query":"关键词或短语"}
|
||||
混合检索消息、对话块和记忆证据。适合具体词、原话、实体、产品名、技术名、是否提到过等问题。
|
||||
6. read_context args={"hitId":"h1","beforeLimit":6,"afterLimit":6}
|
||||
3. read_context args={"hitId":"h1","beforeLimit":6,"afterLimit":6}
|
||||
围绕 search_messages 命中读取前后文。必须已有命中 h1/h2 后再用。
|
||||
4. read_latest args={"limit":40}
|
||||
读取最近消息。只适合最近进展或其它工具无法提供线索时兜底。
|
||||
5. read_by_time_range args={"startTime":秒级时间戳,"endTime":秒级时间戳,"label":"昨天晚上","limit":80,"keyword":"可选关键词","participantName":"可选昵称"}
|
||||
按时间/关键词/参与者读取消息。
|
||||
6. resolve_participant args={"name":"张三"}
|
||||
把昵称、备注或问题中的人名解析为 senderUsername。
|
||||
7. get_session_statistics args={"startTime":秒级时间戳,"endTime":秒级时间戳,"participantLimit":20,"includeSamples":true}
|
||||
统计当前会话消息量、发送者、类型和样例。
|
||||
8. get_keyword_statistics args={"keywords":["关键词"],"matchMode":"substring","startTime":秒级时间戳,"endTime":秒级时间戳}
|
||||
统计某些词/短语出现次数、发送者分布和样例。
|
||||
9. aggregate_messages args={"metric":"speaker_count|message_count|kind_count|timeline|summary"}
|
||||
对已读取消息做聚合整理。`
|
||||
}
|
||||
|
||||
function buildOutputFormatText(): string {
|
||||
// 使用 JSON.stringify 生成正确的 JSON 示例,避免手动转义
|
||||
const example1 = JSON.stringify({
|
||||
action: 'tool_call',
|
||||
toolName: 'search_messages',
|
||||
args: { query: '关键词' },
|
||||
reason: '为什么调用',
|
||||
assistantText: '我先查一下相关记录。'
|
||||
})
|
||||
const example2 = JSON.stringify({
|
||||
action: 'final_answer',
|
||||
content: '最终回答文本',
|
||||
reason: '为什么现在可以回答'
|
||||
})
|
||||
const example3 = JSON.stringify({
|
||||
action: 'tool_call',
|
||||
toolName: 'search_messages',
|
||||
args: { query: '关键词' },
|
||||
reason: '为什么调用'
|
||||
})
|
||||
return `输出格式必须是严格 JSON,推荐以下格式:
|
||||
1. ${example1}
|
||||
2. ${example2}
|
||||
3. ${example3}
|
||||
|
||||
注意:如果你想给用户一句进度文字(如"好的,我来查一下"),请把它写在 tool_call 的 assistantText 字段中,不要单独用 assistant_text。这样可以一步完成"说话+调工具"。`
|
||||
对已读取消息做聚合整理。
|
||||
10. answer args={"reason":"为什么现在可以回答"}
|
||||
证据已经足够,或工具观察明确显示内容不存在/证据不足时,进入最终回答。`
|
||||
}
|
||||
|
||||
export interface BuildDecisionPromptInput {
|
||||
@@ -96,7 +70,7 @@ export function buildAutonomousAgentPrompt(input: BuildDecisionPromptInput): str
|
||||
: '无'
|
||||
const searchHints = input.route.searchQueries.join('、') || '无'
|
||||
|
||||
return `你是 CipherTalk 的本地聊天记录问答 Agent。你要自主决定下一步:调用一个本地工具,或给出最终答案。
|
||||
return `你是 CipherTalk 的本地聊天记录问答 Agent。你要通过原生 tool calling 自主决定下一步:调用一个本地工具,或直接给出最终答案。
|
||||
|
||||
当前时间:${formatTime(Date.now())}
|
||||
会话:${input.sessionName}
|
||||
@@ -144,16 +118,14 @@ ${buildObservationText(input.observations)}
|
||||
|
||||
${buildAvailableToolSchemaText()}
|
||||
|
||||
${buildOutputFormatText()}
|
||||
|
||||
决策规则:
|
||||
- 优先使用带 assistantText 的 tool_call,一步完成进度提示和工具调用。不要使用独立的 assistant_text 动作。
|
||||
- 寒暄、感谢、能力询问等不需要聊天记录的问题,可以直接 final_answer,不要调用工具。
|
||||
- 事实类、证据类、原话类、是否提到某词、统计类问题,必须先有证据再 final_answer。
|
||||
- 证据质量为 none 时,不要 final_answer;优先 search_messages、get_session_statistics、get_keyword_statistics、read_by_time_range 或 read_summary_facts。例外:工具观察明确为 content_not_found 时,应 final_answer 说明证据不足。
|
||||
- 使用 API 提供的 tools/tool_calls,不要输出 JSON action,不要把工具调用过程写成自然语言给用户。
|
||||
- 寒暄、感谢、能力询问等不需要聊天记录的问题,可以直接用普通文本回答,不要调用工具。
|
||||
- 事实类、证据类、原话类、是否提到某词、统计类问题,必须先调用工具取得证据;证据足够后可以直接回答,或调用 answer。
|
||||
- 证据质量为 none 时,不要直接回答;优先 search_messages、get_session_statistics、get_keyword_statistics、read_by_time_range 或 read_summary_facts。例外:工具观察明确为 content_not_found 时,应调用 answer 或直接回答证据不足。
|
||||
- 搜索 0 命中时先看工具观察里的失败原因:content_not_found 表示关键词和语义检索都无证据,应回答证据不足;vector_unavailable 或 keyword_miss_only 才换更核心/同义关键词或按时间读取。
|
||||
- read_context 只能在已有搜索命中 h1/h2 后调用。
|
||||
- 同一个 read_context 命中如果已读取过或返回 0 条,不要重复调用;换其它 hitId、换搜索词,或进入 answer。
|
||||
- 工具预算接近用完时,若仍无证据,可以调用 read_latest 兜底。
|
||||
- final_answer.content 是给用户看的最终答案,可以在 JSON 字符串内使用 Markdown 标题、列表、表格和引用;但整个响应本身仍必须是可解析 JSON。
|
||||
- 不要输出 Markdown 代码块,不要解释 JSON 之外的内容。`
|
||||
- 最终答案可以使用 Markdown 标题、列表、表格和引用,但不要输出工具调用过程。`
|
||||
}
|
||||
|
||||
@@ -175,6 +175,25 @@ export type ToolLoopAction =
|
||||
| { action: 'get_keyword_statistics'; keywords: string[]; startTime?: number; endTime?: number; label?: string; matchMode?: 'substring' | 'exact'; participantLimit?: number; reason?: string }
|
||||
| { action: 'answer'; reason?: string }
|
||||
|
||||
export interface NativeToolExecutionResult {
|
||||
ok: boolean
|
||||
toolName: SessionQAToolName
|
||||
args: Record<string, unknown>
|
||||
summary: string
|
||||
observations: ToolObservation[]
|
||||
toolCalls: SessionQAToolCall[]
|
||||
evidenceQuality: EvidenceQuality
|
||||
error?: string
|
||||
state: {
|
||||
toolCallsUsed: number
|
||||
knownHitIds: string[]
|
||||
searchPayloadCount: number
|
||||
contextWindowCount: number
|
||||
contextMessageCount: number
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated 旧 JSON action 决策模式已被原生 tools/tool_calls 替代。 */
|
||||
export type AutonomousAgentAction =
|
||||
| { action: 'assistant_text'; content: string }
|
||||
| { action: 'tool_call'; tool: ToolLoopAction; reason?: string; assistantText?: string }
|
||||
|
||||
@@ -18,6 +18,14 @@ export interface AIProvider {
|
||||
*/
|
||||
chat(messages: OpenAI.Chat.ChatCompletionMessageParam[], options?: ChatOptions): Promise<string>
|
||||
|
||||
/**
|
||||
* 原生工具调用(OpenAI-compatible Chat Completions tools/tool_calls)
|
||||
*/
|
||||
chatWithTools(
|
||||
messages: OpenAI.Chat.ChatCompletionMessageParam[],
|
||||
options: ChatWithToolsOptions
|
||||
): Promise<NativeToolCallResult>
|
||||
|
||||
/**
|
||||
* 流式聊天
|
||||
*/
|
||||
@@ -43,6 +51,53 @@ export interface ChatOptions {
|
||||
enableThinking?: boolean // 是否启用思考模式(处理 reasoning_content)
|
||||
}
|
||||
|
||||
export type NativeToolDefinition = OpenAI.Chat.ChatCompletionTool
|
||||
|
||||
export interface ChatWithToolsOptions extends ChatOptions {
|
||||
tools: NativeToolDefinition[]
|
||||
toolChoice?: OpenAI.Chat.ChatCompletionToolChoiceOption
|
||||
parallelToolCalls?: boolean
|
||||
}
|
||||
|
||||
export interface NativeToolCallResult {
|
||||
message: OpenAI.Chat.ChatCompletionMessage
|
||||
finishReason?: string | null
|
||||
}
|
||||
|
||||
export const NATIVE_TOOL_CALLING_UNSUPPORTED_MESSAGE = '当前模型/服务商不支持原生工具调用,请切换支持 tools 的 OpenAI-compatible 模型'
|
||||
|
||||
export function isNativeToolCallingUnsupportedError(error: unknown): boolean {
|
||||
const status = typeof error === 'object' && error && 'status' in error
|
||||
? Number((error as { status?: unknown }).status)
|
||||
: undefined
|
||||
const message = error instanceof Error ? error.message : String(error || '')
|
||||
const lower = message.toLowerCase()
|
||||
|
||||
return (
|
||||
status === 400
|
||||
|| status === 404
|
||||
|| status === 422
|
||||
) && (
|
||||
lower.includes('tool')
|
||||
|| lower.includes('tool_choice')
|
||||
|| lower.includes('tool_calls')
|
||||
|| lower.includes('function_call')
|
||||
|| lower.includes('function calling')
|
||||
|| lower.includes('functions')
|
||||
|| lower.includes('unsupported parameter')
|
||||
|| lower.includes('unknown parameter')
|
||||
|| lower.includes('unrecognized request argument')
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeNativeToolCallingError(error: unknown): Error {
|
||||
if (isNativeToolCallingUnsupportedError(error)) {
|
||||
return new Error(NATIVE_TOOL_CALLING_UNSUPPORTED_MESSAGE)
|
||||
}
|
||||
|
||||
return error instanceof Error ? error : new Error(String(error || '模型工具调用失败'))
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 提供商抽象基类
|
||||
*/
|
||||
@@ -89,11 +144,16 @@ export abstract class BaseAIProvider implements AIProvider {
|
||||
return new OpenAI(clientConfig)
|
||||
}
|
||||
|
||||
protected resolveModelId(displayName: string): string {
|
||||
return displayName
|
||||
}
|
||||
|
||||
async chat(messages: OpenAI.Chat.ChatCompletionMessageParam[], options?: ChatOptions): Promise<string> {
|
||||
const client = await this.getClient()
|
||||
const model = this.resolveModelId(options?.model || this.models[0])
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: options?.model || this.models[0],
|
||||
model,
|
||||
messages: messages,
|
||||
temperature: options?.temperature || 0.7,
|
||||
max_tokens: options?.maxTokens,
|
||||
@@ -103,6 +163,38 @@ export abstract class BaseAIProvider implements AIProvider {
|
||||
return response.choices[0]?.message?.content || ''
|
||||
}
|
||||
|
||||
async chatWithTools(
|
||||
messages: OpenAI.Chat.ChatCompletionMessageParam[],
|
||||
options: ChatWithToolsOptions
|
||||
): Promise<NativeToolCallResult> {
|
||||
const client = await this.getClient()
|
||||
const model = this.resolveModelId(options?.model || this.models[0])
|
||||
|
||||
const requestParams: any = {
|
||||
model,
|
||||
messages,
|
||||
temperature: options?.temperature ?? 0.2,
|
||||
max_tokens: options?.maxTokens,
|
||||
stream: false,
|
||||
tools: options.tools,
|
||||
tool_choice: options.toolChoice ?? 'auto'
|
||||
}
|
||||
|
||||
if (typeof options.parallelToolCalls === 'boolean') {
|
||||
requestParams.parallel_tool_calls = options.parallelToolCalls
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.chat.completions.create(requestParams)
|
||||
return {
|
||||
message: response.choices[0]?.message || { role: 'assistant', content: '' },
|
||||
finishReason: response.choices[0]?.finish_reason || null
|
||||
}
|
||||
} catch (error) {
|
||||
throw normalizeNativeToolCallingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async streamChat(
|
||||
messages: OpenAI.Chat.ChatCompletionMessageParam[],
|
||||
options: ChatOptions,
|
||||
@@ -110,10 +202,11 @@ export abstract class BaseAIProvider implements AIProvider {
|
||||
): Promise<void> {
|
||||
const client = await this.getClient()
|
||||
const enableThinking = options?.enableThinking !== false // 默认启用
|
||||
const model = this.resolveModelId(options?.model || this.models[0])
|
||||
|
||||
// 构建请求参数
|
||||
const requestParams: any = {
|
||||
model: options?.model || this.models[0],
|
||||
model,
|
||||
messages: messages,
|
||||
temperature: options?.temperature || 0.7,
|
||||
max_tokens: options?.maxTokens,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type OpenAI from 'openai'
|
||||
import { BaseAIProvider, ChatOptions } from './base'
|
||||
import { BaseAIProvider, ChatOptions, ChatWithToolsOptions, NativeToolCallResult, normalizeNativeToolCallingError } from './base'
|
||||
|
||||
/**
|
||||
* DeepSeek 提供商元数据
|
||||
@@ -47,6 +47,10 @@ export class DeepSeekProvider extends BaseAIProvider {
|
||||
return MODEL_MAPPING[displayName] || displayName
|
||||
}
|
||||
|
||||
protected resolveModelId(displayName: string): string {
|
||||
return this.getModelId(displayName)
|
||||
}
|
||||
|
||||
private buildRequestParams(
|
||||
messages: OpenAI.Chat.ChatCompletionMessageParam[],
|
||||
options: ChatOptions | undefined,
|
||||
@@ -84,6 +88,32 @@ export class DeepSeekProvider extends BaseAIProvider {
|
||||
return response.choices[0]?.message?.content || ''
|
||||
}
|
||||
|
||||
async chatWithTools(
|
||||
messages: OpenAI.Chat.ChatCompletionMessageParam[],
|
||||
options: ChatWithToolsOptions
|
||||
): Promise<NativeToolCallResult> {
|
||||
const client = await this.getClient()
|
||||
const requestParams: any = {
|
||||
...this.buildRequestParams(messages, { ...options, enableThinking: false }, false),
|
||||
tools: options.tools,
|
||||
tool_choice: options.toolChoice ?? 'auto'
|
||||
}
|
||||
|
||||
if (typeof options.parallelToolCalls === 'boolean') {
|
||||
requestParams.parallel_tool_calls = options.parallelToolCalls
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.chat.completions.create(requestParams)
|
||||
return {
|
||||
message: response.choices[0]?.message || { role: 'assistant', content: '' },
|
||||
finishReason: response.choices[0]?.finish_reason || null
|
||||
}
|
||||
} catch (error) {
|
||||
throw normalizeNativeToolCallingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重写 streamChat 方法以使用新版 DeepSeek V4 模型和 thinking 参数
|
||||
*/
|
||||
|
||||
@@ -48,6 +48,10 @@ export class DoubaoProvider extends BaseAIProvider {
|
||||
return MODEL_MAPPING[displayName] || displayName
|
||||
}
|
||||
|
||||
protected resolveModelId(displayName: string): string {
|
||||
return this.getModelId(displayName)
|
||||
}
|
||||
|
||||
async chat(messages: any[], options?: any): Promise<string> {
|
||||
const modelId = this.getModelId(options?.model || this.models[0])
|
||||
return super.chat(messages, { ...options, model: modelId })
|
||||
|
||||
@@ -61,6 +61,10 @@ export class GeminiProvider extends BaseAIProvider {
|
||||
return MODEL_MAPPING[displayName] || displayName
|
||||
}
|
||||
|
||||
protected resolveModelId(displayName: string): string {
|
||||
return this.getModelId(displayName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重写 chat 方法以使用映射后的模型ID
|
||||
*/
|
||||
|
||||
@@ -51,6 +51,10 @@ export class KimiProvider extends BaseAIProvider {
|
||||
return MODEL_MAPPING[displayName] || displayName
|
||||
}
|
||||
|
||||
protected resolveModelId(displayName: string): string {
|
||||
return this.getModelId(displayName)
|
||||
}
|
||||
|
||||
async chat(messages: any[], options?: any): Promise<string> {
|
||||
const modelId = this.getModelId(options?.model || this.models[0])
|
||||
return super.chat(messages, { ...options, model: modelId })
|
||||
|
||||
@@ -58,6 +58,10 @@ export class OpenAIProvider extends BaseAIProvider {
|
||||
return MODEL_MAPPING[displayName] || displayName
|
||||
}
|
||||
|
||||
protected resolveModelId(displayName: string): string {
|
||||
return this.getModelId(displayName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重写 chat 方法以使用映射后的模型ID
|
||||
*/
|
||||
|
||||
@@ -72,6 +72,10 @@ export class QwenProvider extends BaseAIProvider {
|
||||
return MODEL_MAPPING[displayName] || displayName
|
||||
}
|
||||
|
||||
protected resolveModelId(displayName: string): string {
|
||||
return this.getModelId(displayName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重写 chat 方法以使用映射后的模型ID
|
||||
*/
|
||||
|
||||
@@ -58,6 +58,10 @@ export class TencentProvider extends BaseAIProvider {
|
||||
return MODEL_MAPPING[displayName] || displayName
|
||||
}
|
||||
|
||||
protected resolveModelId(displayName: string): string {
|
||||
return this.getModelId(displayName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重写 getClient 以处理特殊的鉴权格式
|
||||
*/
|
||||
|
||||
@@ -61,6 +61,10 @@ export class XAIProvider extends BaseAIProvider {
|
||||
return MODEL_MAPPING[displayName] || displayName
|
||||
}
|
||||
|
||||
protected resolveModelId(displayName: string): string {
|
||||
return this.getModelId(displayName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重写 chat 方法以使用映射后的模型ID
|
||||
*/
|
||||
|
||||
@@ -56,6 +56,10 @@ export class ZhipuProvider extends BaseAIProvider {
|
||||
return MODEL_MAPPING[displayName] || displayName
|
||||
}
|
||||
|
||||
protected resolveModelId(displayName: string): string {
|
||||
return this.getModelId(displayName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重写 chat 方法以使用映射后的模型ID
|
||||
*/
|
||||
|
||||
@@ -5090,13 +5090,132 @@ class ChatService extends EventEmitter {
|
||||
*/
|
||||
public async getMessageByLocalId(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> {
|
||||
const dbTablePairs = this.findSessionTables(sessionId)
|
||||
const myWxid = this.configService.get('myWxid')
|
||||
const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : ''
|
||||
|
||||
for (const { db, tableName } of dbTablePairs) {
|
||||
for (const { db, tableName, dbPath } of dbTablePairs) {
|
||||
try {
|
||||
const row = db.prepare(`SELECT * FROM ${tableName} WHERE local_id = ?`).get(localId) as any
|
||||
const hasName2IdTable = this.checkTableExists(db, 'Name2Id')
|
||||
let myRowId: number | null = null
|
||||
|
||||
if (myWxid && hasName2IdTable) {
|
||||
const cacheKeyOriginal = `${dbPath}:${myWxid}`
|
||||
const cachedRowIdOriginal = this.myRowIdCache.get(cacheKeyOriginal)
|
||||
|
||||
if (cachedRowIdOriginal !== undefined) {
|
||||
myRowId = cachedRowIdOriginal
|
||||
} else {
|
||||
const row = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(myWxid) as any
|
||||
if (row?.rowid) {
|
||||
myRowId = row.rowid
|
||||
this.myRowIdCache.set(cacheKeyOriginal, myRowId)
|
||||
} else if (cleanedMyWxid && cleanedMyWxid !== myWxid) {
|
||||
const cacheKeyCleaned = `${dbPath}:${cleanedMyWxid}`
|
||||
const cachedRowIdCleaned = this.myRowIdCache.get(cacheKeyCleaned)
|
||||
|
||||
if (cachedRowIdCleaned !== undefined) {
|
||||
myRowId = cachedRowIdCleaned
|
||||
} else {
|
||||
const row2 = db.prepare('SELECT rowid FROM Name2Id WHERE user_name = ?').get(cleanedMyWxid) as any
|
||||
myRowId = row2?.rowid ?? null
|
||||
this.myRowIdCache.set(cacheKeyCleaned, myRowId)
|
||||
}
|
||||
} else {
|
||||
this.myRowIdCache.set(cacheKeyOriginal, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let row: any
|
||||
if (hasName2IdTable && myRowId !== null) {
|
||||
row = db.prepare(`SELECT m.*,
|
||||
CASE WHEN m.real_sender_id = ? THEN 1 ELSE 0 END AS computed_is_send,
|
||||
n.user_name AS sender_username
|
||||
FROM ${tableName} m
|
||||
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
|
||||
WHERE m.local_id = ?`).get(myRowId, localId) as any
|
||||
} else if (hasName2IdTable) {
|
||||
row = db.prepare(`SELECT m.*, n.user_name AS sender_username
|
||||
FROM ${tableName} m
|
||||
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
|
||||
WHERE m.local_id = ?`).get(localId) as any
|
||||
} else {
|
||||
row = db.prepare(`SELECT * FROM ${tableName} WHERE local_id = ?`).get(localId) as any
|
||||
}
|
||||
|
||||
if (row) {
|
||||
const content = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||
const localType = row.local_type || row.type || 1
|
||||
const isSend = row.computed_is_send ?? row.is_send ?? null
|
||||
|
||||
let emojiCdnUrl: string | undefined
|
||||
let emojiMd5: string | undefined
|
||||
let emojiProductId: string | undefined
|
||||
let quotedContent: string | undefined
|
||||
let quotedSender: string | undefined
|
||||
let quotedImageMd5: string | undefined
|
||||
let quotedEmojiMd5: string | undefined
|
||||
let quotedEmojiCdnUrl: string | undefined
|
||||
let imageMd5: string | undefined
|
||||
let imageDatName: string | undefined
|
||||
let isLivePhoto: boolean | undefined
|
||||
let videoMd5: string | undefined
|
||||
let videoDuration: number | undefined
|
||||
let voiceDuration: number | undefined
|
||||
|
||||
if (localType === 47 && content) {
|
||||
const emojiInfo = this.parseEmojiInfo(content)
|
||||
emojiCdnUrl = emojiInfo.cdnUrl
|
||||
emojiMd5 = emojiInfo.md5
|
||||
emojiProductId = emojiInfo.productId
|
||||
} else if (localType === 3 && content) {
|
||||
const imageInfo = this.parseImageInfo(content)
|
||||
imageMd5 = imageInfo.md5
|
||||
imageDatName = this.parseImageDatNameFromRow(row)
|
||||
isLivePhoto = imageInfo.isLivePhoto
|
||||
} else if (localType === 43 && content) {
|
||||
videoMd5 = this.parseVideoMd5(content)
|
||||
videoDuration = this.parseVideoDuration(content)
|
||||
} else if (localType === 34 && content) {
|
||||
voiceDuration = this.parseVoiceDuration(content)
|
||||
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
|
||||
const quoteInfo = this.parseQuoteMessage(content)
|
||||
quotedContent = quoteInfo.content
|
||||
quotedSender = quoteInfo.sender
|
||||
quotedImageMd5 = quoteInfo.imageMd5
|
||||
quotedEmojiMd5 = quoteInfo.emojiMd5
|
||||
quotedEmojiCdnUrl = quoteInfo.emojiCdnUrl
|
||||
}
|
||||
|
||||
let fileName: string | undefined
|
||||
let fileSize: number | undefined
|
||||
let fileExt: string | undefined
|
||||
let fileMd5: string | undefined
|
||||
if (localType === 49 && content) {
|
||||
const fileInfo = this.parseFileInfo(content)
|
||||
fileName = fileInfo.fileName
|
||||
fileSize = fileInfo.fileSize
|
||||
fileExt = fileInfo.fileExt
|
||||
fileMd5 = fileInfo.fileMd5
|
||||
}
|
||||
|
||||
let chatRecordList: ChatRecordItem[] | undefined
|
||||
if (content) {
|
||||
const xmlType = this.extractXmlValue(content, 'type')
|
||||
if (xmlType === '19' || localType === 49) {
|
||||
chatRecordList = this.parseChatHistory(content)
|
||||
}
|
||||
}
|
||||
|
||||
let transferPayerUsername: string | undefined
|
||||
let transferReceiverUsername: string | undefined
|
||||
if ((localType === 49 || localType === 8589934592049) && content) {
|
||||
const xmlType = this.extractXmlValue(content, 'type')
|
||||
if (xmlType === '2000') {
|
||||
transferPayerUsername = this.extractXmlValue(content, 'payer_username') || undefined
|
||||
transferReceiverUsername = this.extractXmlValue(content, 'receiver_username') || undefined
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -5106,14 +5225,31 @@ class ChatService extends EventEmitter {
|
||||
localType,
|
||||
createTime: row.create_time || 0,
|
||||
sortSeq: row.sort_seq || 0,
|
||||
isSend: row.is_send ?? null,
|
||||
isSend,
|
||||
senderUsername: row.sender_username || null,
|
||||
parsedContent: this.parseMessageContent(content, localType),
|
||||
rawContent: content,
|
||||
chatRecordList: content ? (() => {
|
||||
const xmlType = this.extractXmlValue(content, 'type')
|
||||
return (xmlType === '19' || localType === 49) ? this.parseChatHistory(content) : undefined
|
||||
})() : undefined
|
||||
emojiCdnUrl,
|
||||
emojiMd5,
|
||||
productId: emojiProductId,
|
||||
quotedContent,
|
||||
quotedSender,
|
||||
quotedImageMd5,
|
||||
quotedEmojiMd5,
|
||||
quotedEmojiCdnUrl,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
isLivePhoto,
|
||||
videoMd5,
|
||||
videoDuration,
|
||||
voiceDuration,
|
||||
fileName,
|
||||
fileSize,
|
||||
fileExt,
|
||||
fileMd5,
|
||||
chatRecordList,
|
||||
transferPayerUsername,
|
||||
transferReceiverUsername
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,33 @@ async function main() {
|
||||
assert.equal(progressEvent.nodeName, '语义搜索')
|
||||
assert.equal(progressEvent.displayName, '语义搜索')
|
||||
|
||||
const nativeTools = require(fromRoot('electron', 'services', 'ai-agent', 'qa', 'nativeTools.ts'))
|
||||
const tools = nativeTools.getNativeSessionQATools()
|
||||
const toolNames = tools.map((tool) => tool.function.name)
|
||||
assert.deepEqual(toolNames, [
|
||||
'read_summary_facts',
|
||||
'search_messages',
|
||||
'read_context',
|
||||
'read_latest',
|
||||
'read_by_time_range',
|
||||
'resolve_participant',
|
||||
'get_session_statistics',
|
||||
'get_keyword_statistics',
|
||||
'aggregate_messages',
|
||||
'answer'
|
||||
])
|
||||
const searchTool = tools.find((tool) => tool.function.name === 'search_messages')
|
||||
assert.deepEqual(searchTool.function.parameters.required, ['query'])
|
||||
const contextTool = tools.find((tool) => tool.function.name === 'read_context')
|
||||
assert.deepEqual(contextTool.function.parameters.required, ['hitId'])
|
||||
assert.equal(contextTool.function.parameters.properties.beforeLimit.default, 6)
|
||||
const parsedToolCall = nativeTools.parseNativeToolCallArguments('search_messages', '{"query":"Falcon","reason":"查项目"}')
|
||||
assert.equal(parsedToolCall.action.action, 'search_messages')
|
||||
assert.equal(parsedToolCall.action.query, 'Falcon')
|
||||
const invalidToolCall = nativeTools.parseNativeToolCallArguments('search_messages', '{bad json')
|
||||
assert.equal(invalidToolCall.action, null)
|
||||
assert.match(invalidToolCall.error, /JSON/)
|
||||
|
||||
const textParser = require(fromRoot('electron', 'services', 'ai-agent', 'qa', 'data', 'textParser.ts'))
|
||||
assert.equal(textParser.parseMessageContent('hello', 1), 'hello')
|
||||
assert.equal(textParser.parseMessageContent('<msg><appmsg><type>6</type><title>报价.xlsx</title></appmsg></msg>', 49), '[文件] 报价.xlsx')
|
||||
|
||||
@@ -1054,6 +1054,13 @@
|
||||
span:first-child {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.qa-sender-name {
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.qa-evidence-card-preview {
|
||||
@@ -1306,6 +1313,13 @@
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
|
||||
.qa-sender-name {
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
@@ -1529,6 +1543,13 @@
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
|
||||
.qa-sender-name {
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
@@ -2248,6 +2269,13 @@
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
|
||||
.qa-sender-name {
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-preview {
|
||||
|
||||
+153
-12
@@ -203,8 +203,51 @@ function getRiskSeverityLabel(severity: 'low' | 'medium' | 'high') {
|
||||
}
|
||||
}
|
||||
|
||||
function getEvidenceSender(ref: SummaryEvidenceRef) {
|
||||
return ref.senderDisplayName || ref.senderUsername || '未知发送人'
|
||||
const senderDisplayNameCache = new Map<string, string>()
|
||||
|
||||
function isTechnicalSenderName(value?: string | null) {
|
||||
const text = String(value || '').trim()
|
||||
return !text || /^wxid_/i.test(text)
|
||||
}
|
||||
|
||||
function getReadableSenderName(input: {
|
||||
isSelf?: boolean
|
||||
senderUsername?: string | null
|
||||
senderDisplayName?: string | null
|
||||
sessionName?: string
|
||||
isGroup?: boolean
|
||||
}) {
|
||||
if (input.isSelf) return '我'
|
||||
|
||||
const cached = input.senderUsername ? senderDisplayNameCache.get(input.senderUsername) : ''
|
||||
if (cached && !isTechnicalSenderName(cached)) return cached
|
||||
|
||||
if (input.senderDisplayName && !isTechnicalSenderName(input.senderDisplayName)) {
|
||||
return input.senderDisplayName
|
||||
}
|
||||
|
||||
if (input.senderUsername && !isTechnicalSenderName(input.senderUsername)) {
|
||||
return input.senderUsername
|
||||
}
|
||||
|
||||
if (!input.isGroup && input.sessionName) {
|
||||
return input.sessionName
|
||||
}
|
||||
|
||||
return '未知发送人'
|
||||
}
|
||||
|
||||
function getEvidenceSender(ref: SummaryEvidenceRef, message?: Message, sessionName?: string, sessionId?: string) {
|
||||
const isSelf = message?.isSend === 1 || ref.senderDisplayName === '我'
|
||||
const senderUsername = message?.senderUsername || ref.senderUsername || ''
|
||||
const effectiveSessionId = sessionId || ref.sessionId
|
||||
return getReadableSenderName({
|
||||
isSelf,
|
||||
senderUsername,
|
||||
senderDisplayName: ref.senderDisplayName,
|
||||
sessionName,
|
||||
isGroup: effectiveSessionId.includes('@chatroom')
|
||||
})
|
||||
}
|
||||
|
||||
function getAvatarLetter(name: string) {
|
||||
@@ -226,6 +269,71 @@ function isSameEvidenceMessage(message: Message, ref: SummaryEvidenceRef) {
|
||||
&& message.sortSeq === ref.sortSeq
|
||||
}
|
||||
|
||||
function EvidenceSenderName({
|
||||
refItem,
|
||||
message,
|
||||
sessionId,
|
||||
sessionName
|
||||
}: {
|
||||
refItem: SummaryEvidenceRef
|
||||
message?: Message
|
||||
sessionId: string
|
||||
sessionName?: string
|
||||
}) {
|
||||
const isSelf = message?.isSend === 1 || refItem.senderDisplayName === '我'
|
||||
const senderUsername = message?.senderUsername || refItem.senderUsername || ''
|
||||
const isGroup = sessionId.includes('@chatroom')
|
||||
const [displayName, setDisplayName] = useState(() => getReadableSenderName({
|
||||
isSelf,
|
||||
senderUsername,
|
||||
senderDisplayName: refItem.senderDisplayName,
|
||||
sessionName,
|
||||
isGroup
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setDisplayName(getReadableSenderName({
|
||||
isSelf,
|
||||
senderUsername,
|
||||
senderDisplayName: refItem.senderDisplayName,
|
||||
sessionName,
|
||||
isGroup
|
||||
}))
|
||||
|
||||
if (isSelf || !senderUsername) return
|
||||
|
||||
const cached = senderDisplayNameCache.get(senderUsername)
|
||||
if (cached && !isTechnicalSenderName(cached)) {
|
||||
setDisplayName(cached)
|
||||
return
|
||||
}
|
||||
|
||||
window.electronAPI.chat.getContactAvatar(senderUsername).then((result) => {
|
||||
if (cancelled) return
|
||||
const resolvedName = result?.displayName || ''
|
||||
if (resolvedName) senderDisplayNameCache.set(senderUsername, resolvedName)
|
||||
setDisplayName(getReadableSenderName({
|
||||
isSelf,
|
||||
senderUsername,
|
||||
senderDisplayName: resolvedName || refItem.senderDisplayName,
|
||||
sessionName,
|
||||
isGroup
|
||||
}))
|
||||
}).catch(() => {})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isGroup, isSelf, refItem.senderDisplayName, senderUsername, sessionName])
|
||||
|
||||
return (
|
||||
<span className="qa-sender-name" title={senderUsername && displayName !== senderUsername ? senderUsername : undefined}>
|
||||
{displayName}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function compareMessagesByTime(a: Message, b: Message) {
|
||||
if (a.sortSeq !== b.sortSeq) return a.sortSeq - b.sortSeq
|
||||
if (a.createTime !== b.createTime) return a.createTime - b.createTime
|
||||
@@ -277,6 +385,7 @@ function EvidenceAvatar({
|
||||
if (cancelled) return
|
||||
setContactAvatar(result?.avatarUrl || '')
|
||||
setContactName(result?.displayName || '')
|
||||
if (result?.displayName) senderDisplayNameCache.set(senderUsername, result.displayName)
|
||||
}).catch(() => {})
|
||||
|
||||
return () => {
|
||||
@@ -286,7 +395,12 @@ function EvidenceAvatar({
|
||||
|
||||
const displayName = isSelf
|
||||
? '我'
|
||||
: contactName || refItem.senderDisplayName || refItem.senderUsername || sessionName || '未知'
|
||||
: getReadableSenderName({
|
||||
senderUsername,
|
||||
senderDisplayName: contactName || refItem.senderDisplayName,
|
||||
sessionName,
|
||||
isGroup
|
||||
})
|
||||
const avatarSrc = isSelf ? myAvatarUrl : (contactAvatar || (!isGroup ? sessionAvatarUrl : ''))
|
||||
|
||||
return (
|
||||
@@ -742,7 +856,11 @@ function AISummaryWindow() {
|
||||
>
|
||||
<div className="evidence-meta">
|
||||
<span>{formatEvidenceTime(ref.createTime)}</span>
|
||||
<span>{getEvidenceSender(ref)}</span>
|
||||
<EvidenceSenderName
|
||||
refItem={ref}
|
||||
sessionId={ref.sessionId || sessionId}
|
||||
sessionName={sessionName}
|
||||
/>
|
||||
</div>
|
||||
<div className="evidence-preview">{ref.previewText}</div>
|
||||
</div>
|
||||
@@ -753,15 +871,23 @@ function AISummaryWindow() {
|
||||
|
||||
const buildEvidenceCopyText = (ref: SummaryEvidenceRef) => [
|
||||
`时间:${formatEvidenceFullTime(ref.createTime)}`,
|
||||
`发送人:${getEvidenceSender(ref)}`,
|
||||
`发送人:${getEvidenceSender(ref, evidenceMessageMap[getEvidenceKey(ref)], sessionName, ref.sessionId || sessionId)}`,
|
||||
`内容:${ref.previewText}`,
|
||||
`消息游标:sessionId=${ref.sessionId}, localId=${ref.localId}, createTime=${ref.createTime}, sortSeq=${ref.sortSeq}`
|
||||
].join('\n')
|
||||
|
||||
const getContextMessageSender = (message: Message, fallbackRef?: SummaryEvidenceRef) => {
|
||||
if (message.isSend === 1) return '我'
|
||||
if (message.senderUsername) return message.senderUsername
|
||||
if (fallbackRef && isSameEvidenceMessage(message, fallbackRef)) return getEvidenceSender(fallbackRef)
|
||||
if (fallbackRef && isSameEvidenceMessage(message, fallbackRef)) {
|
||||
return getEvidenceSender(fallbackRef, message, sessionName, fallbackRef.sessionId || sessionId)
|
||||
}
|
||||
if (message.senderUsername) {
|
||||
return getReadableSenderName({
|
||||
senderUsername: message.senderUsername,
|
||||
sessionName,
|
||||
isGroup: (fallbackRef?.sessionId || sessionId).includes('@chatroom')
|
||||
})
|
||||
}
|
||||
return sessionName || '未知发送人'
|
||||
}
|
||||
|
||||
@@ -795,7 +921,7 @@ function AISummaryWindow() {
|
||||
setQaInput([
|
||||
'为什么这条消息能支持你的结论?请结合上下文解释。',
|
||||
'',
|
||||
`证据:${formatEvidenceFullTime(ref.createTime)} ${getEvidenceSender(ref)}:${ref.previewText}`
|
||||
`证据:${formatEvidenceFullTime(ref.createTime)} ${getEvidenceSender(ref, evidenceMessageMap[getEvidenceKey(ref)], sessionName, ref.sessionId || sessionId)}:${ref.previewText}`
|
||||
].join('\n'))
|
||||
|
||||
window.setTimeout(() => {
|
||||
@@ -908,7 +1034,12 @@ function AISummaryWindow() {
|
||||
<div className="qa-evidence-card-meta">
|
||||
<span>#{index + 1}</span>
|
||||
<span>{formatEvidenceTime(ref.createTime)}</span>
|
||||
<span>{getEvidenceSender(ref)}</span>
|
||||
<EvidenceSenderName
|
||||
refItem={ref}
|
||||
message={evidenceMessage}
|
||||
sessionId={ref.sessionId || sessionId}
|
||||
sessionName={sessionName}
|
||||
/>
|
||||
</div>
|
||||
<div className="qa-evidence-card-preview">
|
||||
<MessageContent content={ref.previewText} disableLinks />
|
||||
@@ -1123,7 +1254,7 @@ function AISummaryWindow() {
|
||||
return (
|
||||
<div className="qa-streaming-placeholder">
|
||||
<Loader2 size={14} className="spinner" />
|
||||
<span>正在检索上下文...</span>
|
||||
<span>正在规划下一步...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1190,7 +1321,12 @@ function AISummaryWindow() {
|
||||
|
||||
<div className="qa-context-anchor">
|
||||
<span>{formatEvidenceFullTime(evidenceContext.ref.createTime)}</span>
|
||||
<span>{getEvidenceSender(evidenceContext.ref)}</span>
|
||||
<EvidenceSenderName
|
||||
refItem={evidenceContext.ref}
|
||||
message={evidenceMessageMap[getEvidenceKey(evidenceContext.ref)]}
|
||||
sessionId={evidenceContext.ref.sessionId || sessionId}
|
||||
sessionName={sessionName}
|
||||
/>
|
||||
<span>localId {evidenceContext.ref.localId}</span>
|
||||
</div>
|
||||
|
||||
@@ -1236,7 +1372,12 @@ function AISummaryWindow() {
|
||||
<div className="qa-context-message-content">
|
||||
<div className="qa-context-message-meta">
|
||||
<span>{formatEvidenceTime(message.createTime)}</span>
|
||||
<span>{getContextMessageSender(message, evidenceContext.ref)}</span>
|
||||
<EvidenceSenderName
|
||||
refItem={contextRef}
|
||||
message={message}
|
||||
sessionId={evidenceContext.ref.sessionId || sessionId}
|
||||
sessionName={sessionName}
|
||||
/>
|
||||
{isAnchor && <strong>原消息</strong>}
|
||||
</div>
|
||||
<p>
|
||||
|
||||
Reference in New Issue
Block a user