feat: 使用原生工具调用优化会话问答

将会话问答 Agent 改为 OpenAI-compatible 原生 tools/tool_calls 流程,移除旧 JSON 决策主路径。

补全回答证据中的图片、表情等媒体渲染,并将发送人显示为联系人名称。

优化问答等待态文案,补充原生工具 schema 和参数解析测试。
This commit is contained in:
ILoveBingLu
2026-04-28 11:29:05 +08:00
parent f218f7a623
commit e6e94512c3
19 changed files with 1206 additions and 310 deletions
+5 -138
View File
@@ -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'
}
+305 -104
View File
@@ -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_textcontinue 后上下文几乎没变,又重复同样的输出。
// 修复:将 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 标题、列表、表格和引用,但不要输出工具调用过程。`
}
+19
View File
@@ -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 }
+95 -2
View File
@@ -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,
+31 -1
View File
@@ -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 参数
*/
+4
View File
@@ -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 })
+4
View File
@@ -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
*/
+4
View File
@@ -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 })
+4
View File
@@ -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
*/
+4
View File
@@ -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 以处理特殊的鉴权格式
*/
+4
View File
@@ -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
*/
+4
View File
@@ -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
*/
+143 -7
View File
@@ -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
}
}
}
+27
View File
@@ -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')
+28
View File
@@ -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
View File
@@ -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>