feat: 添加子智能体消息渲染
This commit is contained in:
parent
6a5926ba41
commit
206ddd8ff0
@ -85,7 +85,7 @@ def _preview_text(text: str, limit: int = 500) -> str:
|
||||
|
||||
|
||||
def _tool_result_with_thread_id(child_thread_id: str, content: str) -> str:
|
||||
return f"子智能体线程 ID: {child_thread_id}\n\n{content}"
|
||||
return f"> 子智能体线程 ID: {child_thread_id}\n\n---\n\n{content}"
|
||||
|
||||
|
||||
def _new_child_thread_id(
|
||||
|
||||
@ -31,7 +31,7 @@ WEB_SEARCH_SYSTEM_PROMPT = """你是「网页检索」子智能体,由主智
|
||||
|
||||
输出要求:
|
||||
- 返回一份结构化的摘要资料,按主题或要点组织。
|
||||
- 每条关键结论后使用 <cite source="$URL" type="url">$INDEX</cite> 标注引用来源,$INDEX 从 1 开始递增。
|
||||
- 每条关键结论后使用 <cite source="$URL" type="url">$INDEX</cite> 标注引用来源,$INDEX 从 1 开始递增。不单独成行,直接跟在结论后面。
|
||||
- 在结尾汇总「参考来源」列表,逐条列出标题与 URL。
|
||||
- 不要编造来源或链接;无法验证的信息要明确标注。"""
|
||||
ACCESS_LEVELS = {"global", "department", "user"}
|
||||
|
||||
@ -67,7 +67,8 @@ export const agentApi = {
|
||||
* @param {string} threadId - 会话ID
|
||||
* @returns {Promise} - AgentState
|
||||
*/
|
||||
getAgentState: (threadId) => apiGet(`/api/chat/thread/${threadId}/state`),
|
||||
getAgentState: (threadId, { includeMessages = false } = {}) =>
|
||||
apiGet(`/api/chat/thread/${threadId}/state${includeMessages ? '?include_messages=true' : ''}`),
|
||||
|
||||
/**
|
||||
* Submit feedback for a message
|
||||
|
||||
@ -288,13 +288,15 @@
|
||||
<section class="state-section">
|
||||
<div class="state-section-header">
|
||||
<span class="state-section-title">子智能体</span>
|
||||
<span class="state-section-meta">{{ currentSubagentRuns.length }}</span>
|
||||
<span class="state-section-meta">{{ displaySubagentRuns.length }}</span>
|
||||
</div>
|
||||
<div v-if="currentSubagentRuns.length" class="state-list">
|
||||
<div v-if="displaySubagentRuns.length" class="state-list">
|
||||
<div
|
||||
v-for="(run, index) in currentSubagentRuns"
|
||||
v-for="(run, index) in displaySubagentRuns"
|
||||
:key="run.id || `${run.subagent_type || 'subagent'}-${index}`"
|
||||
class="state-list-item"
|
||||
:class="{ 'is-clickable': run.child_thread_id }"
|
||||
@click="run.child_thread_id && openSubagentThread(run)"
|
||||
>
|
||||
<img
|
||||
v-if="getSubagentIconSrc(run)"
|
||||
@ -333,6 +335,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SubagentThreadModal
|
||||
v-model:open="subagentThreadModal.open"
|
||||
:child-thread-id="subagentThreadModal.childThreadId"
|
||||
:subagent-name="subagentThreadModal.subagentName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -344,6 +352,7 @@ import {
|
||||
watch,
|
||||
nextTick,
|
||||
computed,
|
||||
provide,
|
||||
onUnmounted,
|
||||
onActivated,
|
||||
onDeactivated
|
||||
@ -383,7 +392,10 @@ import { useAgentMentionConfig } from '@/composables/useAgentMentionConfig'
|
||||
import AgentArtifactsCard from '@/components/AgentArtifactsCard.vue'
|
||||
import AgentPanel from '@/components/AgentPanel.vue'
|
||||
import AttachmentTmpUploadModal from '@/components/AttachmentTmpUploadModal.vue'
|
||||
import { enrichTaskToolCalls } from '@/components/ToolCallingResult/toolRegistry'
|
||||
import SubagentThreadModal from '@/components/SubagentThreadModal.vue'
|
||||
import { enrichTaskToolCalls, parseToolCallArgs } from '@/components/ToolCallingResult/toolRegistry'
|
||||
import { getConversationDisplayItems } from '@/utils/messageGrouping'
|
||||
import { makeChildThreadId } from '@/utils/subagentThread'
|
||||
|
||||
// ==================== PROPS & EMITS ====================
|
||||
const props = defineProps({
|
||||
@ -422,8 +434,17 @@ const randomGreeting = greetingMessages[Math.floor(Math.random() * greetingMessa
|
||||
const chatState = reactive({
|
||||
currentThreadId: null,
|
||||
// 以threadId为键的线程状态
|
||||
threadStates: {}
|
||||
threadStates: {},
|
||||
// 流式期间记录 父 task 工具调用 id → 子智能体 child_thread_id(首次运行时前端无法推算该 id)
|
||||
subagentThreadByToolCall: {}
|
||||
})
|
||||
const recordSubagentThread = (toolCallId, childThreadId) => {
|
||||
if (!toolCallId || !childThreadId) return
|
||||
if (chatState.subagentThreadByToolCall[toolCallId] === childThreadId) return
|
||||
chatState.subagentThreadByToolCall[toolCallId] = childThreadId
|
||||
}
|
||||
const getSubagentThreadIdByToolCall = (toolCallId) =>
|
||||
(toolCallId && chatState.subagentThreadByToolCall[String(toolCallId)]) || ''
|
||||
const setCurrentThreadId = (threadId) => {
|
||||
chatState.currentThreadId = threadId || null
|
||||
chatThreadsStore.setCurrentThreadId(threadId || null)
|
||||
@ -728,6 +749,18 @@ const currentSubagentOptionBySlug = computed(() => {
|
||||
})
|
||||
return optionBySlug
|
||||
})
|
||||
|
||||
const subagentThreadModal = reactive({
|
||||
open: false,
|
||||
childThreadId: '',
|
||||
subagentName: ''
|
||||
})
|
||||
const openSubagentThread = (run) => {
|
||||
if (!run?.child_thread_id) return
|
||||
subagentThreadModal.childThreadId = String(run.child_thread_id)
|
||||
subagentThreadModal.subagentName = getSubagentRunName(run)
|
||||
subagentThreadModal.open = true
|
||||
}
|
||||
const currentStateFiles = computed(() => {
|
||||
const files = []
|
||||
const seenPaths = new Set()
|
||||
@ -770,7 +803,7 @@ const stateSummaryLabel = computed(() => {
|
||||
totalTodoCount.value +
|
||||
currentStateFiles.value.length +
|
||||
currentArtifactFiles.value.length +
|
||||
currentSubagentRuns.value.length
|
||||
displaySubagentRuns.value.length
|
||||
return total ? `${total} 项` : '暂无内容'
|
||||
})
|
||||
|
||||
@ -828,6 +861,122 @@ const getThreadOngoingMessages = (threadId) => {
|
||||
|
||||
const onGoingConvMessages = computed(() => getThreadOngoingMessages(currentChatId.value))
|
||||
|
||||
// 供深层 TaskTool 读取子线程实时轨迹 / 首次运行时定位 child_thread_id
|
||||
provide('getThreadOngoingMessages', getThreadOngoingMessages)
|
||||
provide('getSubagentThreadIdByToolCall', getSubagentThreadIdByToolCall)
|
||||
|
||||
// 解析父级 ongoing 里的全部 task 工具调用(按消息顺序),统一供面板与状态判定使用。
|
||||
// 注意:ongoing 期间 task 的工具结果不流式(只有 message_delta/tool_call 事件),因此这里的
|
||||
// hasResult 在流式阶段恒为 false,状态判定不能依赖它。
|
||||
const ongoingTaskCalls = computed(() => {
|
||||
const calls = []
|
||||
onGoingConvMessages.value.forEach((message, messageIndex) => {
|
||||
if (message?.type !== 'ai' || !Array.isArray(message.tool_calls)) return
|
||||
message.tool_calls.forEach((toolCall) => {
|
||||
const name = toolCall?.name || toolCall?.function?.name
|
||||
if (name !== 'task') return
|
||||
const id = toolCall?.id ? String(toolCall.id) : ''
|
||||
if (!id) return
|
||||
const args = parseToolCallArgs(toolCall)
|
||||
calls.push({
|
||||
id,
|
||||
messageIndex,
|
||||
hasResult: Boolean(toolCall.tool_call_result || toolCall.result),
|
||||
subagentType: args.subagent_type || '',
|
||||
description: args.description || '',
|
||||
childThreadId: args.thread_id ? String(args.thread_id) : getSubagentThreadIdByToolCall(id)
|
||||
})
|
||||
})
|
||||
})
|
||||
return calls
|
||||
})
|
||||
|
||||
// 当前活跃(真正在执行)的 task 调用 = 最后一条「含未完成 task 调用」的 AI 消息中的那些调用。
|
||||
// steer 顺序进行 → 只有最后一条消息的调用在执行;并行 → 同一条消息的多个调用都在执行。
|
||||
// 用消息顺序判定,不依赖异步推算的 child_thread_id,避免首次运行哈希未就绪导致的状态错乱。
|
||||
const activeSubagentToolCallIds = computed(() => {
|
||||
const pending = ongoingTaskCalls.value.filter((call) => !call.hasResult)
|
||||
if (!pending.length) return new Set()
|
||||
const lastMessageIndex = pending[pending.length - 1].messageIndex
|
||||
return new Set(
|
||||
pending.filter((call) => call.messageIndex === lastMessageIndex).map((call) => call.id)
|
||||
)
|
||||
})
|
||||
provide('activeSubagentToolCallIds', activeSubagentToolCallIds)
|
||||
|
||||
// agent_state.subagent_runs 仅在 task 返回(完成态)时写入;面板的运行中条目只取「活跃」调用,
|
||||
// 避免已完成的 steer 历史调用在面板里重复成额外条目。
|
||||
const runningSubagentRunsFromStream = computed(() => {
|
||||
const activeIds = activeSubagentToolCallIds.value
|
||||
return ongoingTaskCalls.value
|
||||
.filter((call) => activeIds.has(call.id))
|
||||
.map((call) => {
|
||||
const option = call.subagentType ? currentSubagentOptionBySlug.value.get(call.subagentType) : null
|
||||
return {
|
||||
id: call.id,
|
||||
subagent_type: call.subagentType,
|
||||
subagent_name: option?.name || call.subagentType || '子智能体',
|
||||
description: call.description,
|
||||
child_thread_id: call.childThreadId || '',
|
||||
status: 'running'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 与后端 merge_subagent_runs 一致:按 child_thread_id / id 合并,运行中条目覆盖同一线程的完成态,
|
||||
// 保证每个子线程恒为一行并反映当前状态(含续跑/steer)。
|
||||
const displaySubagentRuns = computed(() => {
|
||||
const merged = currentSubagentRuns.value.map((run) => ({ ...run }))
|
||||
const childIndex = new Map()
|
||||
const idIndex = new Map()
|
||||
merged.forEach((run, index) => {
|
||||
if (run.child_thread_id) childIndex.set(String(run.child_thread_id), index)
|
||||
if (run.id) idIndex.set(String(run.id), index)
|
||||
})
|
||||
runningSubagentRunsFromStream.value.forEach((run) => {
|
||||
let position
|
||||
if (run.child_thread_id && childIndex.has(run.child_thread_id)) {
|
||||
position = childIndex.get(run.child_thread_id)
|
||||
} else if (idIndex.has(run.id)) {
|
||||
position = idIndex.get(run.id)
|
||||
}
|
||||
if (position === undefined) {
|
||||
position = merged.length
|
||||
merged.push(run)
|
||||
} else {
|
||||
merged[position] = { ...merged[position], ...run }
|
||||
}
|
||||
if (run.child_thread_id) childIndex.set(run.child_thread_id, position)
|
||||
idIndex.set(run.id, position)
|
||||
})
|
||||
return merged
|
||||
})
|
||||
|
||||
// 首次运行的子智能体:前端按后端同样的哈希推算 child_thread_id,缓存到映射里供面板/轨迹定位。
|
||||
watch(
|
||||
onGoingConvMessages,
|
||||
(messages) => {
|
||||
const parentThreadId = currentChatId.value
|
||||
if (!parentThreadId) return
|
||||
messages.forEach((message) => {
|
||||
if (message?.type !== 'ai' || !Array.isArray(message.tool_calls)) return
|
||||
message.tool_calls.forEach((toolCall) => {
|
||||
const name = toolCall?.name || toolCall?.function?.name
|
||||
if (name !== 'task') return
|
||||
if (toolCall.tool_call_result || toolCall.result) return
|
||||
const id = toolCall?.id ? String(toolCall.id) : ''
|
||||
if (!id || chatState.subagentThreadByToolCall[id]) return
|
||||
const args = parseToolCallArgs(toolCall)
|
||||
if (args.thread_id || !args.subagent_type) return
|
||||
makeChildThreadId(parentThreadId, String(args.subagent_type), id).then((childThreadId) => {
|
||||
recordSubagentThread(id, childThreadId)
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const historyConversations = computed(() => {
|
||||
return MessageProcessor.convertServerHistoryToMessages(currentThreadMessages.value)
|
||||
})
|
||||
@ -855,7 +1004,7 @@ const conversationRows = computed(() => {
|
||||
type: 'conversation',
|
||||
key: conv.status === 'streaming' ? 'ongoing-conversation' : `history-${index}`,
|
||||
conv,
|
||||
displayItems: getConversationDisplayItems(conv)
|
||||
displayItems: getDisplayItems(conv)
|
||||
}))
|
||||
|
||||
if (currentThreadConfigNotice.value) {
|
||||
@ -1808,19 +1957,6 @@ const handleResizingChange = (isResizingState, clientX = 0) => {
|
||||
}
|
||||
|
||||
// ==================== HELPER FUNCTIONS ====================
|
||||
const hasVisibleAssistantBody = (message) => {
|
||||
if (!message || message.type !== 'ai') return true
|
||||
|
||||
const { content, reasoningContent } = MessageProcessor.parseAssistantMessageBody(message)
|
||||
return Boolean(
|
||||
content ||
|
||||
reasoningContent ||
|
||||
message.error_type ||
|
||||
message.extra_metadata?.error_type ||
|
||||
message.isStoppedByUser
|
||||
)
|
||||
}
|
||||
|
||||
const getMessageToolCalls = (message) => {
|
||||
return enrichTaskToolCalls(message?.tool_calls, {
|
||||
subagentRunById: currentSubagentRunById.value,
|
||||
@ -1829,58 +1965,8 @@ const getMessageToolCalls = (message) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 将 AI 消息拆成“正文块”和“工具块”,再跨消息合并相邻工具块。
|
||||
const getConversationDisplayItems = (conv) => {
|
||||
if (!Array.isArray(conv?.messages) || conv.messages.length === 0) return []
|
||||
|
||||
const items = []
|
||||
let pendingToolGroup = null
|
||||
|
||||
const flushToolGroup = () => {
|
||||
if (pendingToolGroup && pendingToolGroup.toolCalls.length > 0) {
|
||||
items.push(pendingToolGroup)
|
||||
}
|
||||
pendingToolGroup = null
|
||||
}
|
||||
|
||||
conv.messages.forEach((message, index) => {
|
||||
if (message.type !== 'ai') {
|
||||
flushToolGroup()
|
||||
items.push({
|
||||
type: 'message',
|
||||
key: message.id || `message-${index}`,
|
||||
message,
|
||||
sourceIndex: index
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (hasVisibleAssistantBody(message)) {
|
||||
flushToolGroup()
|
||||
items.push({
|
||||
type: 'message',
|
||||
key: message.id || `message-${index}`,
|
||||
message,
|
||||
sourceIndex: index
|
||||
})
|
||||
}
|
||||
|
||||
const toolCalls = getMessageToolCalls(message)
|
||||
if (toolCalls.length === 0) return
|
||||
|
||||
if (!pendingToolGroup) {
|
||||
pendingToolGroup = {
|
||||
type: 'tool-group',
|
||||
key: `tool-group-${message.id || index}`,
|
||||
toolCalls: []
|
||||
}
|
||||
}
|
||||
pendingToolGroup.toolCalls.push(...toolCalls)
|
||||
})
|
||||
|
||||
flushToolGroup()
|
||||
return items
|
||||
}
|
||||
const getDisplayItems = (conv) =>
|
||||
getConversationDisplayItems(conv, { enrichToolCalls: getMessageToolCalls })
|
||||
|
||||
const isDisplayMessageProcessing = (conv, displayItem) => {
|
||||
return (
|
||||
@ -2807,11 +2893,16 @@ watch(currentChatId, (threadId, oldThreadId) => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.state-list-item--button:hover {
|
||||
.state-list-item--button:hover,
|
||||
.state-list-item.is-clickable:hover {
|
||||
border-color: var(--main-200);
|
||||
background: var(--gray-0);
|
||||
}
|
||||
|
||||
.state-list-item.is-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.state-list-item-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 17px;
|
||||
|
||||
104
web/src/components/SubagentThreadModal.vue
Normal file
104
web/src/components/SubagentThreadModal.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="subagentName || '子智能体'"
|
||||
:footer="null"
|
||||
:width="800"
|
||||
:destroyOnClose="true"
|
||||
@cancel="$emit('update:open', false)"
|
||||
>
|
||||
<div class="subagent-thread-modal-body">
|
||||
<div v-if="loading" class="subagent-thread-modal-state">正在加载子智能体消息...</div>
|
||||
<div v-else-if="error" class="subagent-thread-modal-state is-error">{{ error }}</div>
|
||||
<ThreadMessageList v-else :messages="messages" />
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { agentApi } from '@/apis'
|
||||
import { MessageProcessor } from '@/utils/messageProcessor'
|
||||
import ThreadMessageList from '@/components/ThreadMessageList.vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
childThreadId: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
subagentName: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['update:open'])
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const messages = ref([])
|
||||
|
||||
// LangChain 内容块数组 → 纯文本(仅保留 text 块)
|
||||
const flattenContent = (content) => {
|
||||
if (typeof content === 'string') return content
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter((block) => block && block.type === 'text')
|
||||
.map((block) => block.text || '')
|
||||
.join('')
|
||||
}
|
||||
return content ?? ''
|
||||
}
|
||||
|
||||
const loadHistory = async (threadId) => {
|
||||
if (!threadId) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
messages.value = []
|
||||
try {
|
||||
// 子智能体消息存于 LangGraph checkpoint,需走 state 接口并把 tool 结果嵌入 AI 消息。
|
||||
const response = await agentApi.getAgentState(threadId, { includeMessages: true })
|
||||
// checkpoint 的 content 可能是 LangChain 内容块数组,扁平成文本供 MarkdownPreview 渲染。
|
||||
const normalized = (response.messages || []).map((msg) => ({
|
||||
...msg,
|
||||
content: flattenContent(msg.content)
|
||||
}))
|
||||
messages.value = MessageProcessor.convertToolResultToMessages(normalized)
|
||||
} catch (e) {
|
||||
error.value = '加载子智能体消息失败'
|
||||
console.error('Failed to load subagent thread messages:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.open, props.childThreadId],
|
||||
([isOpen, threadId]) => {
|
||||
if (isOpen && threadId) loadHistory(threadId)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.subagent-thread-modal-body {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.subagent-thread-modal-state {
|
||||
padding: 32px 0;
|
||||
text-align: center;
|
||||
color: var(--gray-500);
|
||||
font-size: 13px;
|
||||
|
||||
&.is-error {
|
||||
color: var(--color-error-600);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
56
web/src/components/ThreadMessageList.vue
Normal file
56
web/src/components/ThreadMessageList.vue
Normal file
@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="thread-message-list">
|
||||
<template v-for="(conv, convIndex) in conversations" :key="`conv-${convIndex}`">
|
||||
<template v-for="displayItem in getDisplayItems(conv)" :key="displayItem.key">
|
||||
<AgentMessageComponent
|
||||
v-if="displayItem.type === 'message'"
|
||||
:message="displayItem.message"
|
||||
:show-refs="false"
|
||||
:hide-tool-calls="true"
|
||||
:mention="{}"
|
||||
/>
|
||||
<ToolCallsGroupComponent v-else :tool-calls="displayItem.toolCalls" />
|
||||
</template>
|
||||
</template>
|
||||
<div v-if="conversations.length === 0" class="thread-message-list-empty">暂无消息</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import AgentMessageComponent from '@/components/AgentMessageComponent.vue'
|
||||
import ToolCallsGroupComponent from '@/components/ToolCallsGroupComponent.vue'
|
||||
import { MessageProcessor } from '@/utils/messageProcessor'
|
||||
import { getConversationDisplayItems } from '@/utils/messageGrouping'
|
||||
|
||||
const props = defineProps({
|
||||
messages: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
enrichToolCalls: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const conversations = computed(() => MessageProcessor.convertServerHistoryToMessages(props.messages))
|
||||
|
||||
const getDisplayItems = (conv) =>
|
||||
getConversationDisplayItems(conv, props.enrichToolCalls ? { enrichToolCalls: props.enrichToolCalls } : {})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.thread-message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.thread-message-list-empty {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
color: var(--gray-500);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@ -6,11 +6,11 @@
|
||||
<!-- Header Slot -->
|
||||
<div class="tool-header" @click="toggleExpand">
|
||||
<!-- Fixed Status Icon -->
|
||||
<span v-if="toolCall.status === 'success' || toolCall.tool_call_result">
|
||||
<span v-if="effectiveStatus === 'completed'">
|
||||
<component v-if="toolIcon" :is="toolIcon" size="16" class="tool-loader tool-success" />
|
||||
<CheckCircle v-else size="16" class="tool-loader tool-success" />
|
||||
</span>
|
||||
<span v-else-if="toolCall.status === 'error'">
|
||||
<span v-else-if="effectiveStatus === 'error'">
|
||||
<XCircle size="16" class="tool-loader tool-error" />
|
||||
</span>
|
||||
<span v-else>
|
||||
@ -71,7 +71,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Result Slot -->
|
||||
<div class="tool-result" style="opacity: 0.8" v-if="hasResult">
|
||||
<div class="tool-result" style="opacity: 0.8" v-if="hasResult || forceShowResult">
|
||||
<slot name="result" :tool-call="toolCall" :result-content="resultContent">
|
||||
<div class="tool-result-content" :data-tool-call-id="toolCall.id">
|
||||
<!-- Default rendering -->
|
||||
@ -112,6 +112,16 @@ const props = defineProps({
|
||||
appearance: {
|
||||
type: String,
|
||||
default: 'card'
|
||||
},
|
||||
// 外部可覆盖状态以驱动图标:'running' | 'completed' | 'failed'(用于结果不随流式返回的工具,如 task)
|
||||
status: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 即使没有 tool_call_result 也展示结果区(配合外部提供的结果内容,如 task 的 result_preview)
|
||||
forceShowResult: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
@ -125,6 +135,14 @@ const toggleExpand = () => {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
|
||||
// 图标状态:优先用外部传入的 status,否则按 tool_call_result/status 推断
|
||||
const effectiveStatus = computed(() => {
|
||||
if (props.status) return props.status
|
||||
if (props.toolCall.status === 'success' || props.toolCall.tool_call_result) return 'completed'
|
||||
if (props.toolCall.status === 'error') return 'failed'
|
||||
return 'running'
|
||||
})
|
||||
|
||||
// Tool Name Logic
|
||||
const toolId = computed(() => getToolCallId(props.toolCall))
|
||||
|
||||
|
||||
@ -1,35 +1,35 @@
|
||||
<template>
|
||||
<BaseToolCall :tool-call="toolCall">
|
||||
<BaseToolCall :tool-call="toolCall" :status="baseStatus" :force-show-result="Boolean(displayResult)">
|
||||
<template #header>
|
||||
<div class="sep-header">
|
||||
<span class="note">{{ subagentDisplayName }}</span>
|
||||
<span v-if="runStatusLabel" class="run-status" :class="runStatusClass">
|
||||
{{ runStatusLabel }}
|
||||
</span>
|
||||
<span class="separator" v-if="shortDescription">|</span>
|
||||
<span class="description" v-if="shortDescription">{{ shortDescription }}</span>
|
||||
<span class="separator" v-if="headerDetail">|</span>
|
||||
<span class="description" :class="{ 'is-live': isRunning && liveStep }" v-if="headerDetail">
|
||||
{{ headerDetail }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #params>
|
||||
<div v-if="description" class="task-description">{{ description }}</div>
|
||||
<div v-if="childThreadId" class="task-thread-id">
|
||||
子智能体线程 ID:{{ childThreadId }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #result="{ resultContent }">
|
||||
<template #result>
|
||||
<div class="task-result">
|
||||
<MarkdownPreview compact :content="String(resultContent)" class="md-preview-wrapper" />
|
||||
<MarkdownPreview compact :content="String(displayResult)" class="md-preview-wrapper" />
|
||||
</div>
|
||||
</template>
|
||||
</BaseToolCall>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, inject } from 'vue'
|
||||
import BaseToolCall from '../BaseToolCall.vue'
|
||||
import MarkdownPreview from '@/components/common/MarkdownPreview.vue'
|
||||
import { MessageProcessor } from '@/utils/messageProcessor'
|
||||
|
||||
const props = defineProps({
|
||||
toolCall: {
|
||||
@ -38,6 +38,10 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const getThreadOngoingMessages = inject('getThreadOngoingMessages', null)
|
||||
const getSubagentThreadIdByToolCall = inject('getSubagentThreadIdByToolCall', null)
|
||||
const activeSubagentToolCallIds = inject('activeSubagentToolCallIds', null)
|
||||
|
||||
const parsedArgs = computed(() => {
|
||||
const args = props.toolCall.args || props.toolCall.function?.arguments
|
||||
if (!args) return {}
|
||||
@ -56,12 +60,22 @@ const subagentDisplayName = computed(
|
||||
const description = computed(
|
||||
() => parsedArgs.value.description || subagentRun.value?.description || ''
|
||||
)
|
||||
const childThreadId = computed(() => subagentRun.value?.child_thread_id || parsedArgs.value.thread_id || '')
|
||||
const childThreadId = computed(
|
||||
() =>
|
||||
subagentRun.value?.child_thread_id ||
|
||||
parsedArgs.value.thread_id ||
|
||||
(getSubagentThreadIdByToolCall ? getSubagentThreadIdByToolCall(props.toolCall.id) : '') ||
|
||||
''
|
||||
)
|
||||
const hasToolResult = computed(() => Boolean(props.toolCall.tool_call_result || props.toolCall.result))
|
||||
// 是否为当前真正在执行的子智能体调用(同一子线程的多次 steer 中只有最后一个为活跃)。
|
||||
const isActiveRun = computed(() => Boolean(activeSubagentToolCallIds?.value?.has(String(props.toolCall.id))))
|
||||
const runStatus = computed(() => {
|
||||
if (props.toolCall.status === 'error') return 'failed'
|
||||
if (props.toolCall.status !== 'success' && !hasToolResult.value) return 'running'
|
||||
if (subagentRun.value?.status) return subagentRun.value.status
|
||||
// ongoing 期间工具结果不流式:有结果说明是历史/已落库,按结果展示;
|
||||
// 没有结果时,只有「活跃」调用算运行中,其余 steer 历史调用视为已完成(结果待整轮结束后回填)。
|
||||
if (hasToolResult.value) return subagentRun.value?.status === 'failed' ? 'failed' : 'completed'
|
||||
if (isActiveRun.value) return 'running'
|
||||
return 'completed'
|
||||
})
|
||||
const runStatusLabel = computed(() => {
|
||||
@ -75,11 +89,65 @@ const runStatusClass = computed(() => ({
|
||||
'is-completed': runStatus.value === 'completed',
|
||||
'is-failed': runStatus.value === 'failed'
|
||||
}))
|
||||
// 映射到 BaseToolCall 的图标状态(failed → error)
|
||||
const baseStatus = computed(() => (runStatus.value === 'failed' ? 'error' : runStatus.value))
|
||||
// ongoing 期间 task 结果不流式:优先用工具结果;回退到 subagent_run.result_preview 时必须 id 精确匹配,
|
||||
// 否则 steer 历史卡片会错显 agent_state 里最新一次(被 reducer 覆盖后的)运行结果。
|
||||
const displayResult = computed(() => {
|
||||
const toolResult = props.toolCall.tool_call_result?.content || props.toolCall.result
|
||||
if (toolResult) return toolResult
|
||||
if (
|
||||
runStatus.value !== 'running' &&
|
||||
subagentRun.value?.id &&
|
||||
String(subagentRun.value.id) === String(props.toolCall.id)
|
||||
) {
|
||||
return subagentRun.value.result_preview || ''
|
||||
}
|
||||
return ''
|
||||
})
|
||||
const shortDescription = computed(() => {
|
||||
const desc = description.value
|
||||
if (!desc) return ''
|
||||
return desc.length > 50 ? desc.slice(0, 50) + '...' : desc
|
||||
})
|
||||
|
||||
const isRunning = computed(() => runStatus.value === 'running')
|
||||
|
||||
const truncate = (text, limit = 50) => {
|
||||
const value = String(text || '').replace(/\s+/g, ' ').trim()
|
||||
if (!value) return ''
|
||||
return value.length > limit ? value.slice(0, limit) + '...' : value
|
||||
}
|
||||
|
||||
const formatToolCall = (toolCall) => {
|
||||
const name = toolCall?.name || toolCall?.function?.name || 'tool'
|
||||
const rawArgs = toolCall?.args ?? toolCall?.function?.arguments
|
||||
const args = rawArgs && typeof rawArgs === 'object' ? JSON.stringify(rawArgs) : String(rawArgs ?? '')
|
||||
return `Call(${name}): ${args}`
|
||||
}
|
||||
|
||||
// 子线程实时轨迹:最新一条 ongoing 消息——优先展示工具调用,否则展示正文。
|
||||
const liveStep = computed(() => {
|
||||
if (!isRunning.value || !childThreadId.value || typeof getThreadOngoingMessages !== 'function') {
|
||||
return ''
|
||||
}
|
||||
const messages = getThreadOngoingMessages(childThreadId.value)
|
||||
const last = messages[messages.length - 1]
|
||||
if (!last) return ''
|
||||
const toolCalls = last.tool_calls
|
||||
if (Array.isArray(toolCalls) && toolCalls.length) {
|
||||
return truncate(formatToolCall(toolCalls[toolCalls.length - 1]), 80)
|
||||
}
|
||||
const { content, reasoningContent } = MessageProcessor.parseAssistantMessageBody(last)
|
||||
const body = content || reasoningContent
|
||||
if (body) return truncate(body)
|
||||
return ''
|
||||
})
|
||||
|
||||
const headerDetail = computed(() => {
|
||||
if (isRunning.value && liveStep.value) return liveStep.value
|
||||
return shortDescription.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@ -116,6 +184,16 @@ const shortDescription = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.sep-header .description {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.is-live {
|
||||
color: var(--color-primary-700);
|
||||
}
|
||||
}
|
||||
|
||||
.task-description {
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
@ -124,15 +202,11 @@ const shortDescription = computed(() => {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.task-thread-id {
|
||||
margin-top: 6px;
|
||||
color: var(--gray-500);
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.task-result {
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
max-height: min(600px, 40vh);
|
||||
overflow: auto;
|
||||
|
||||
.md-preview-wrapper {
|
||||
color: var(--gray-800);
|
||||
|
||||
@ -39,11 +39,23 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref, watch, inject } from 'vue'
|
||||
import { ChevronDown, ChevronRight, Wrench } from 'lucide-vue-next'
|
||||
import { ToolCallRenderer } from '@/components/ToolCallingResult'
|
||||
import { getToolCallId, normalizeToolCalls } from '@/components/ToolCallingResult/toolRegistry'
|
||||
|
||||
const activeSubagentToolCallIds = inject('activeSubagentToolCallIds', null)
|
||||
|
||||
// task 工具结果不随流式返回,不能用 tool_call_result 判断运行中:只有「活跃」的 task 才算运行中。
|
||||
const toolRunState = (toolCall) => {
|
||||
if (toolCall.status === 'error') return 'error'
|
||||
if (toolCall.tool_call_result || toolCall.status === 'success') return 'completed'
|
||||
if (getToolCallId(toolCall) === 'task') {
|
||||
return activeSubagentToolCallIds?.value?.has(String(toolCall.id)) ? 'running' : 'completed'
|
||||
}
|
||||
return 'running'
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
toolCalls: {
|
||||
type: Array,
|
||||
@ -110,16 +122,10 @@ const toolCallsNamesMeta = computed(() => {
|
||||
})
|
||||
|
||||
const statusSummary = computed(() => {
|
||||
const successCount = normalizedToolCalls.value.filter(
|
||||
(toolCall) => toolCall.status === 'success' || toolCall.tool_call_result
|
||||
).length
|
||||
const runningCount = normalizedToolCalls.value.filter(
|
||||
(toolCall) =>
|
||||
toolCall.status !== 'success' && toolCall.status !== 'error' && !toolCall.tool_call_result
|
||||
).length
|
||||
const errorCount = normalizedToolCalls.value.filter(
|
||||
(toolCall) => toolCall.status === 'error'
|
||||
).length
|
||||
const states = normalizedToolCalls.value.map(toolRunState)
|
||||
const successCount = states.filter((state) => state === 'completed').length
|
||||
const runningCount = states.filter((state) => state === 'running').length
|
||||
const errorCount = states.filter((state) => state === 'error').length
|
||||
|
||||
const parts = []
|
||||
if (successCount > 0 && successCount === normalizedToolCalls.value.length) {
|
||||
@ -140,7 +146,6 @@ const toggleToolCallsExpanded = () => {
|
||||
<style lang="less" scoped>
|
||||
.tool-calls-container {
|
||||
width: 100%;
|
||||
margin: 10px 0;
|
||||
padding: 0;
|
||||
|
||||
.tool-calls-summary {
|
||||
@ -222,9 +227,8 @@ const toggleToolCallsExpanded = () => {
|
||||
}
|
||||
|
||||
.tool-calls-panel {
|
||||
padding: 4px 0 4px 12px;
|
||||
border-left: 1px solid var(--gray-100);
|
||||
margin-left: 8px;
|
||||
border-top: 1px solid var(--gray-100);
|
||||
padding-top: 4px;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
70
web/src/utils/messageGrouping.js
Normal file
70
web/src/utils/messageGrouping.js
Normal file
@ -0,0 +1,70 @@
|
||||
import MessageProcessor from '@/utils/messageProcessor'
|
||||
import { enrichTaskToolCalls } from '@/components/ToolCallingResult/toolRegistry'
|
||||
|
||||
const hasVisibleAssistantBody = (message) => {
|
||||
if (!message || message.type !== 'ai') return true
|
||||
|
||||
const { content, reasoningContent } = MessageProcessor.parseAssistantMessageBody(message)
|
||||
return Boolean(
|
||||
content ||
|
||||
reasoningContent ||
|
||||
message.error_type ||
|
||||
message.extra_metadata?.error_type ||
|
||||
message.isStoppedByUser
|
||||
)
|
||||
}
|
||||
|
||||
const defaultEnrichToolCalls = (message) => enrichTaskToolCalls(message?.tool_calls)
|
||||
|
||||
// 将 AI 消息拆成“正文块”和“工具块”,再跨消息合并相邻工具块。
|
||||
export const getConversationDisplayItems = (conv, { enrichToolCalls = defaultEnrichToolCalls } = {}) => {
|
||||
if (!Array.isArray(conv?.messages) || conv.messages.length === 0) return []
|
||||
|
||||
const items = []
|
||||
let pendingToolGroup = null
|
||||
|
||||
const flushToolGroup = () => {
|
||||
if (pendingToolGroup && pendingToolGroup.toolCalls.length > 0) {
|
||||
items.push(pendingToolGroup)
|
||||
}
|
||||
pendingToolGroup = null
|
||||
}
|
||||
|
||||
conv.messages.forEach((message, index) => {
|
||||
if (message.type !== 'ai') {
|
||||
flushToolGroup()
|
||||
items.push({
|
||||
type: 'message',
|
||||
key: message.id || `message-${index}`,
|
||||
message,
|
||||
sourceIndex: index
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (hasVisibleAssistantBody(message)) {
|
||||
flushToolGroup()
|
||||
items.push({
|
||||
type: 'message',
|
||||
key: message.id || `message-${index}`,
|
||||
message,
|
||||
sourceIndex: index
|
||||
})
|
||||
}
|
||||
|
||||
const toolCalls = enrichToolCalls(message)
|
||||
if (toolCalls.length === 0) return
|
||||
|
||||
if (!pendingToolGroup) {
|
||||
pendingToolGroup = {
|
||||
type: 'tool-group',
|
||||
key: `tool-group-${message.id || index}`,
|
||||
toolCalls: []
|
||||
}
|
||||
}
|
||||
pendingToolGroup.toolCalls.push(...toolCalls)
|
||||
})
|
||||
|
||||
flushToolGroup()
|
||||
return items
|
||||
}
|
||||
15
web/src/utils/subagentThread.js
Normal file
15
web/src/utils/subagentThread.js
Normal file
@ -0,0 +1,15 @@
|
||||
// 复刻后端 yuxi/utils/subagent_thread_utils.py 的 make_child_thread_id:
|
||||
// child_thread_id = "subagent_" + sha256("{parent}:{slug}:{tool_call_id}")[:55]
|
||||
// 子智能体由 graph.ainvoke 独立调用,流式事件不带 tool_call_id,前端据此自行推算 child_thread_id。
|
||||
const PREFIX = 'subagent_'
|
||||
const DIGEST_LENGTH = 64 - PREFIX.length
|
||||
|
||||
export async function makeChildThreadId(parentThreadId, agentSlug, toolCallId) {
|
||||
if (!parentThreadId || !agentSlug || !toolCallId) return ''
|
||||
const data = new TextEncoder().encode(`${parentThreadId}:${agentSlug}:${toolCallId}`)
|
||||
const buffer = await crypto.subtle.digest('SHA-256', data)
|
||||
const hex = Array.from(new Uint8Array(buffer))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
return `${PREFIX}${hex.slice(0, DIGEST_LENGTH)}`
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user