feat: 统一工具调用的规范化处理,优化消息解析逻辑

This commit is contained in:
Wenjie Zhang 2026-06-02 00:08:38 +08:00
parent 5310fdaa09
commit 14daa43e28
12 changed files with 120 additions and 207 deletions

View File

@ -17,6 +17,7 @@ from yuxi.repositories.agent_repository import AgentRepository
from yuxi.repositories.agent_run_repository import TERMINAL_RUN_STATUSES, AgentRunRepository
from yuxi.repositories.conversation_repository import ConversationRepository
from yuxi.services.run_queue_service import (
build_run_event_envelope,
get_arq_pool,
get_last_run_stream_seq,
list_run_stream_events,
@ -273,14 +274,13 @@ async def stream_agent_run_events(
if terminal_seq in {"", "0-0"}:
terminal_seq = None
yield _format_sse(
{
"schema_version": 1,
"run_id": run_id,
"thread_id": run.thread_id,
"event": "end",
"payload": {"status": run.status},
"created_at": utc_now_naive().isoformat(),
},
build_run_event_envelope(
run_id=run_id,
thread_id=run.thread_id,
event_type="end",
payload={"status": run.status},
created_at=utc_now_naive().isoformat(),
),
event="end",
event_id=terminal_seq,
)

View File

@ -55,6 +55,32 @@ def normalize_after_seq(after_seq: str | None) -> str:
return "0-0"
def build_run_event_envelope(
*,
run_id: str,
event_type: str,
payload: dict | None = None,
thread_id: str | None = None,
created_at: str | None = None,
) -> dict:
return {
"schema_version": 1,
"run_id": run_id,
"thread_id": thread_id,
"event": event_type,
"payload": payload or {},
"created_at": created_at or datetime.now(tz=UTC).isoformat(),
}
def _payload_thread_id(payload: dict | None) -> str | None:
chunk = payload.get("chunk") if isinstance(payload, dict) else None
if not isinstance(chunk, dict):
return None
thread_id = chunk.get("thread_id")
return thread_id.strip() if isinstance(thread_id, str) and thread_id.strip() else None
async def get_redis_client():
global _redis_client
if _redis_client is not None:
@ -164,14 +190,14 @@ async def append_run_stream_event(run_id: str, event_type: str, payload: dict, *
key = _event_stream_key(run_id)
now = datetime.now(tz=UTC)
now_ms = int(now.timestamp() * 1000)
envelope = {
"schema_version": 1,
"run_id": run_id,
"thread_id": thread_id,
"event": event_type,
"payload": payload or {},
"created_at": now.isoformat(),
}
event_thread_id = thread_id or _payload_thread_id(payload)
envelope = build_run_event_envelope(
run_id=run_id,
event_type=event_type,
payload=payload or {},
thread_id=event_thread_id,
created_at=now.isoformat(),
)
fields = {
"event_type": event_type,
"payload": json.dumps(envelope, ensure_ascii=False),

View File

@ -97,7 +97,11 @@ async def test_run_stream_event_roundtrip(monkeypatch: pytest.MonkeyPatch):
run_id = "run-1"
seq1 = await run_queue_service.append_run_stream_event(run_id, "loading", {"items": [1]})
seq2 = await run_queue_service.append_run_stream_event(run_id, "finished", {"chunk": {"status": "finished"}})
seq2 = await run_queue_service.append_run_stream_event(
run_id,
"finished",
{"chunk": {"status": "finished", "thread_id": "child-thread"}},
)
assert seq1 < seq2
@ -106,6 +110,7 @@ async def test_run_stream_event_roundtrip(monkeypatch: pytest.MonkeyPatch):
assert events[0]["payload"]["schema_version"] == 1
assert events[0]["payload"]["run_id"] == run_id
assert events[0]["payload"]["payload"] == {"items": [1]}
assert events[1]["payload"]["thread_id"] == "child-thread"
next_events = await run_queue_service.list_run_stream_events(run_id, after_seq=seq1, limit=100)
assert len(next_events) == 1

View File

@ -67,7 +67,7 @@
- 收敛用户身份命名:原业务登录标识统一改为 `uid`Agent/LangGraph runtime、conversation、agent_run、sandbox 路径和前端用户态均使用字符串 `uid``user_id` 仅保留给外部响应中的数值 `users.id` 或真实外键场景。
- 工作区知识库分类显示:知识库侧边栏按创建者分组为“我的知识库”和“共享知识库”,自己创建的知识库显示在“我的知识库”下,非自己创建的显示在“共享知识库”下;`knowledge_bases` 表新增 `created_by` 字段记录创建者 uid。
- 聊天附件新增 MinIO tmp 临时上传、可选 PDF/图片解析、确认后加入线程附件的流程;前端改为弹窗内上传、解析与确认。
- 标准化 Agent run/SSE 执行链路run 创建时持久化输入消息并提交后入队worker 统一写入 Redis Stream envelopeSSE 输出 `event/data/id`、心跳注释、`Last-Event-ID` 回放和终止 `end` 事件;前端强制使用 run API 并支持 ask_user_question 中断后以 resume run 恢复。
- 标准化 Agent run/SSE 执行链路run 创建时持久化输入消息并提交后入队worker 统一写入 Redis Stream envelopeSSE 输出 `event/data/id`、心跳注释、`Last-Event-ID` 回放和终止 `end` 事件;前端强制使用 run API 并支持 ask_user_question 中断后以 resume run 恢复;事件 envelope 构造收敛到统一 helper前端优先使用 envelope 一级 `thread_id` 路由
- 收敛后端模块边界:文档解析从 `plugins.parser` 移动到 `knowledge.parser`,内容审查从 `plugins.guard` 移动到 `services.guard`
- 收敛文件服务边界文件预览判断抽为独立服务Viewer 文件系统的 workspace 分支复用用户 workspace 服务,线程运行时上下文解析从泛化 `filesystem_service` 拆出为 agent runtime helper。
- 升级 DeepAgents 到 0.6.7 并适配新版文件系统协议SubAgentMiddleware 改为显式 subagent specSkills prompt 补齐新版占位符sandbox/skills backend 复用新版 `ReadResult`、`GlobResult`、`GrepResult` 等协议类型,文件权限在 backend 层明确区分 skills、uploads、outputs 与 workspace保留最小 `CustomCompositeBackend` 以避免非 route glob 误扫其他 routeAgent 上下文压缩改为复用 DeepAgents SummarizationMiddleware历史摘要与大工具结果统一 offload 到 outputs。

View File

@ -45,10 +45,7 @@
<div class="chat-box">
<template v-for="row in conversationRows" :key="row.key">
<div v-if="row.type === 'conversation'" class="conv-box">
<template
v-for="(displayItem, itemIndex) in getConversationDisplayItems(row.conv)"
:key="displayItem.key"
>
<template v-for="(displayItem, itemIndex) in row.displayItems" :key="displayItem.key">
<AgentMessageComponent
v-if="displayItem.type === 'message'"
:message="displayItem.message"
@ -62,9 +59,7 @@
<ToolCallsGroupComponent
v-else
:tool-calls="displayItem.toolCalls"
:is-active="
isToolGroupActive(row.conv, itemIndex, getConversationDisplayItems(row.conv))
"
:is-active="isToolGroupActive(row.conv, itemIndex, row.displayItems)"
/>
</template>
<AgentArtifactsCard
@ -366,6 +361,7 @@ import { useAgentMentionConfig } from '@/composables/useAgentMentionConfig'
import AgentArtifactsCard from '@/components/AgentArtifactsCard.vue'
import AgentPanel from '@/components/AgentPanel.vue'
import AttachmentTmpUploadModal from '@/components/AttachmentTmpUploadModal.vue'
import { normalizeToolCalls } from '@/components/ToolCallingResult/toolRegistry'
// ==================== PROPS & EMITS ====================
const props = defineProps({
@ -821,7 +817,8 @@ const conversationRows = computed(() => {
const rows = conversations.value.map((conv, index) => ({
type: 'conversation',
key: conv.status === 'streaming' ? 'ongoing-conversation' : `history-${index}`,
conv
conv,
displayItems: getConversationDisplayItems(conv)
}))
if (currentThreadConfigNotice.value) {
@ -1769,27 +1766,10 @@ const handleResizingChange = (isResizingState, clientX = 0) => {
}
// ==================== HELPER FUNCTIONS ====================
const extractAssistantMessageBody = (message) => {
let content = typeof message?.content === 'string' ? message.content.trim() : ''
let reasoningContent = message?.additional_kwargs?.reasoning_content || ''
if (!reasoningContent && content) {
const thinkRegex = /<think>(.*?)<\/think>|<think>(.*?)$/s
const thinkMatch = content.match(thinkRegex)
if (thinkMatch) {
reasoningContent = (thinkMatch[1] || thinkMatch[2] || '').trim()
content = content.replace(thinkMatch[0], '').trim()
}
}
return { content, reasoningContent }
}
const hasVisibleAssistantBody = (message) => {
if (!message || message.type !== 'ai') return true
const { content, reasoningContent } = extractAssistantMessageBody(message)
const { content, reasoningContent } = MessageProcessor.parseAssistantMessageBody(message)
return Boolean(
content ||
reasoningContent ||
@ -1800,19 +1780,8 @@ const hasVisibleAssistantBody = (message) => {
}
const getMessageToolCalls = (message) => {
if (!Array.isArray(message?.tool_calls)) return []
return message.tool_calls
.filter((toolCall) => {
return (
toolCall &&
(toolCall.id || toolCall.name || toolCall.function?.name) &&
(toolCall.args !== undefined ||
toolCall.function?.arguments !== undefined ||
toolCall.tool_call_result !== undefined)
)
})
.map((toolCall) => {
return normalizeToolCalls(message?.tool_calls, {
mapToolCall: (toolCall) => {
const subagentRun = toolCall.id ? currentSubagentRunById.value.get(String(toolCall.id)) : null
if (!subagentRun) return toolCall
@ -1821,7 +1790,8 @@ const getMessageToolCalls = (message) => {
subagent_run: subagentRun,
display_label: subagentRun.subagent_name || subagentRun.subagent_type || undefined
}
})
}
})
}
// AI

View File

@ -148,6 +148,7 @@ import { storeToRefs } from 'pinia'
import { MessageProcessor } from '@/utils/messageProcessor'
import { normalizeAttachmentPreviews } from '@/utils/file_utils'
import { buildMentionDisplayLabels } from '@/utils/mention_utils'
import { normalizeToolCalls } from '@/components/ToolCallingResult/toolRegistry'
const props = defineProps({
// 'user'|'assistant'|'sent'|'received'
@ -281,50 +282,13 @@ const messageSources = computed(() => {
return { knowledgeChunks: [], webSources: [] }
})
//
const validToolCalls = computed(() => {
if (!props.message.tool_calls || !Array.isArray(props.message.tool_calls)) {
return []
}
return props.message.tool_calls.filter((toolCall) => {
//
return (
toolCall &&
(toolCall.id || toolCall.name || toolCall.function?.name) &&
(toolCall.args !== undefined ||
toolCall.function?.arguments !== undefined ||
toolCall.tool_call_result !== undefined)
)
})
})
const validToolCalls = computed(() => normalizeToolCalls(props.message.tool_calls))
const parsedData = computed(() => {
// Start with default values from the prop to avoid mutation.
let content = props.message.content.trim() || ''
let reasoning_content = props.message.additional_kwargs?.reasoning_content || ''
if (reasoning_content) {
return {
content,
reasoning_content
}
}
// Regex to find <think>...</think> or an unclosed <think>... at the end of the string.
const thinkRegex = /<think>(.*?)<\/think>|<think>(.*?)$/s
const thinkMatch = content.match(thinkRegex)
if (thinkMatch) {
// The captured reasoning is in either group 1 (closed tag) or 2 (unclosed tag).
reasoning_content = (thinkMatch[1] || thinkMatch[2] || '').trim()
// Remove the entire matched <think> block from the original content.
content = content.replace(thinkMatch[0], '').trim()
}
const { content, reasoningContent } = MessageProcessor.parseAssistantMessageBody(props.message)
return {
content,
reasoning_content
reasoning_content: reasoningContent
}
})
</script>

View File

@ -41,7 +41,7 @@ import MysqlDescribeTableTool from './tools/MysqlDescribeTableTool.vue'
import MysqlListTablesTool from './tools/MysqlListTablesTool.vue'
import AskUserQuestionTool from './tools/AskUserQuestionTool.vue'
import ExecuteTool from './tools/ExecuteTool.vue'
import { getToolCallId, HIDDEN_TOOL_CALL_IDS } from './toolRegistry'
import { getToolCallId, isHiddenToolCall } from './toolRegistry'
const props = defineProps({
toolCall: {
@ -91,7 +91,7 @@ const TOOL_RENDERERS = {
}
const currentRenderer = computed(() => TOOL_RENDERERS[toolId.value] || null)
const isHidden = computed(() => HIDDEN_TOOL_CALL_IDS.includes(toolId.value))
const isHidden = computed(() => isHiddenToolCall(props.toolCall))
const toolRendererRef = ref(null)
const refreshGraph = () => {

View File

@ -53,4 +53,27 @@ export const HIDDEN_TOOL_CALL_IDS = ['present_artifacts']
export const getToolCallId = (toolCall) => toolCall?.name || toolCall?.function?.name || ''
export const isHiddenToolCall = (toolCall) => HIDDEN_TOOL_CALL_IDS.includes(getToolCallId(toolCall))
export const isValidToolCall = (toolCall) => {
return Boolean(
toolCall &&
(toolCall.id || toolCall.name || toolCall.function?.name) &&
(toolCall.args !== undefined ||
toolCall.function?.arguments !== undefined ||
toolCall.tool_call_result !== undefined)
)
}
export const normalizeToolCalls = (toolCalls, { includeHidden = false, mapToolCall } = {}) => {
if (!Array.isArray(toolCalls)) return []
return toolCalls
.filter((toolCall) => {
if (!isValidToolCall(toolCall)) return false
return includeHidden || !isHiddenToolCall(toolCall)
})
.map((toolCall) => (mapToolCall ? mapToolCall(toolCall) : toolCall))
}
export const getToolIcon = (toolId) => TOOL_ICON_MAP[toolId] || null

View File

@ -42,7 +42,7 @@
import { computed, ref, watch } from 'vue'
import { ChevronDown, ChevronRight, Wrench } from 'lucide-vue-next'
import { ToolCallRenderer } from '@/components/ToolCallingResult'
import { getToolCallId, HIDDEN_TOOL_CALL_IDS } from '@/components/ToolCallingResult/toolRegistry'
import { getToolCallId, normalizeToolCalls } from '@/components/ToolCallingResult/toolRegistry'
const props = defineProps({
toolCalls: {
@ -55,20 +55,7 @@ const props = defineProps({
}
})
const normalizedToolCalls = computed(() => {
return (props.toolCalls || []).filter((toolCall) => {
const toolId = getToolCallId(toolCall)
return (
toolCall &&
!HIDDEN_TOOL_CALL_IDS.includes(toolId) &&
(toolCall.id || toolCall.name || toolCall.function?.name) &&
(toolCall.args !== undefined ||
toolCall.function?.arguments !== undefined ||
toolCall.tool_call_result !== undefined)
)
})
})
const normalizedToolCalls = computed(() => normalizeToolCalls(props.toolCalls))
const shouldCollapseToolCalls = computed(() => normalizedToolCalls.value.length > 0)
const areToolCallsExpanded = ref(false)

View File

@ -55,9 +55,9 @@ const getThreadIdFromObject = (value) => {
const resolveChunkThreadId = ({ envelope, payload, chunk, fallbackThreadId }) => {
return (
getThreadIdFromObject(chunk) ||
getThreadIdFromObject(payload) ||
getThreadIdFromObject(envelope) ||
getThreadIdFromObject(payload) ||
getThreadIdFromObject(chunk) ||
fallbackThreadId
)
}

View File

@ -109,6 +109,12 @@ const run = () => {
assert.equal(conversations[0].messages.at(-1).isLast, true)
assert.equal(conversations[0].status, 'finished')
const assistantBody = MessageProcessor.parseAssistantMessageBody({
type: 'ai',
content: '<think>推理过程</think>最终答案'
})
assert.deepEqual(assistantBody, { content: '最终答案', reasoningContent: '推理过程' })
console.log('messageProcessor extractKnowledgeChunksFromConversation: all assertions passed')
}

View File

@ -283,6 +283,28 @@ export class MessageProcessor {
}
}
/**
* 解析助手消息正文与推理内容保持渲染和列表拆分使用同一套规则
* @param {Object} message - AI 消息对象
* @returns {{content: string, reasoningContent: string}}
*/
static parseAssistantMessageBody(message) {
let content = typeof message?.content === 'string' ? message.content.trim() : ''
let reasoningContent = message?.additional_kwargs?.reasoning_content || ''
if (!reasoningContent && content) {
const thinkRegex = /<think>(.*?)<\/think>|<think>(.*?)$/s
const thinkMatch = content.match(thinkRegex)
if (thinkMatch) {
reasoningContent = (thinkMatch[1] || thinkMatch[2] || '').trim()
content = content.replace(thinkMatch[0], '').trim()
}
}
return { content, reasoningContent }
}
/**
* 合并消息块
* @param {Array} chunks - 消息块数组
@ -398,96 +420,6 @@ export class MessageProcessor {
}
}
/**
* 处理流式响应数据块
* @param {Object} data - 响应数据
* @param {Object} onGoingConv - 进行中的对话对象
* @param {Object} state - 状态对象
* @param {Function} getAgentHistory - 获取历史记录函数
* @param {Function} handleError - 错误处理函数
*/
static async processResponseChunk(data, onGoingConv, state, getAgentHistory, handleError) {
try {
switch (data.status) {
case 'init':
// 代表服务端收到请求并返回第一个响应
state.waitingServerResponse = false
onGoingConv.msgChunks[data.request_id] = [data.msg]
break
case 'loading':
if (data.msg.id) {
if (!onGoingConv.msgChunks[data.msg.id]) {
onGoingConv.msgChunks[data.msg.id] = []
}
onGoingConv.msgChunks[data.msg.id].push(data.msg)
}
break
case 'error':
console.error('流式处理出错:', data.message)
handleError(new Error(data.message), 'stream')
break
case 'finished':
await getAgentHistory()
break
default:
console.warn('未知的响应状态:', data.status)
}
} catch (error) {
handleError(error, 'stream')
}
}
/**
* 处理流式响应
* @param {Response} response - 响应对象
* @param {Function} processChunk - 处理块的函数
* @param {Function} scrollToBottom - 滚动到底部函数
* @param {Function} handleError - 错误处理函数
*/
static async handleStreamResponse(response, processChunk, scrollToBottom, handleError) {
try {
const reader = response.body.getReader()
let buffer = ''
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || '' // 保留最后一行可能不完整的内容
for (const line of lines) {
if (line.trim()) {
try {
const data = JSON.parse(line.trim())
await processChunk(data)
} catch (e) {
console.debug('解析JSON出错:', e.message)
}
}
}
await scrollToBottom()
}
// 处理缓冲区中可能剩余的内容
if (buffer.trim()) {
try {
const data = JSON.parse(buffer.trim())
await processChunk(data)
} catch {
console.warn('最终缓冲区内容无法解析:', buffer)
}
}
} catch (error) {
handleError(error, 'stream')
}
}
}
export default MessageProcessor