fix: 恢复 Agent 提问中断状态
This commit is contained in:
parent
410dbf6d1d
commit
e5553f1bfc
@ -339,29 +339,19 @@ async def get_active_run_by_thread(*, thread_id: str, current_uid: str, db: Asyn
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from yuxi.storage.postgres.models_business import AgentRun
|
from yuxi.storage.postgres.models_business import AgentRun
|
||||||
|
|
||||||
active_result = await db.execute(
|
# 线程内的 run 是串行的,最近一条 run 即代表线程当前状态。
|
||||||
|
# 已被回复的 interrupted run 会被更晚创建的 resume run 取代,因此不会再被当作待处理中断返回。
|
||||||
|
result = await db.execute(
|
||||||
select(AgentRun)
|
select(AgentRun)
|
||||||
.where(
|
.where(
|
||||||
AgentRun.thread_id == thread_id,
|
AgentRun.thread_id == thread_id,
|
||||||
AgentRun.uid == str(current_uid),
|
AgentRun.uid == str(current_uid),
|
||||||
AgentRun.run_type.in_(["chat", "resume"]),
|
AgentRun.run_type.in_(["chat", "resume"]),
|
||||||
AgentRun.status.in_(["pending", "running", "cancel_requested"]),
|
|
||||||
)
|
)
|
||||||
.order_by(AgentRun.created_at.desc())
|
.order_by(AgentRun.created_at.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
run = active_result.scalar_one_or_none()
|
run = result.scalar_one_or_none()
|
||||||
if not run:
|
if run and run.status in ("pending", "running", "cancel_requested", "interrupted"):
|
||||||
interrupted_result = await db.execute(
|
return {"run": run.to_dict()}
|
||||||
select(AgentRun)
|
return {"run": None}
|
||||||
.where(
|
|
||||||
AgentRun.thread_id == thread_id,
|
|
||||||
AgentRun.uid == str(current_uid),
|
|
||||||
AgentRun.run_type.in_(["chat", "resume"]),
|
|
||||||
AgentRun.status == "interrupted",
|
|
||||||
)
|
|
||||||
.order_by(AgentRun.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
run = interrupted_result.scalar_one_or_none()
|
|
||||||
return {"run": run.to_dict() if run else None}
|
|
||||||
|
|||||||
@ -0,0 +1,85 @@
|
|||||||
|
"""测试内置 ask_user_question 工具的格式契约。"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from yuxi.agents.toolkits.buildin import tools
|
||||||
|
|
||||||
|
|
||||||
|
def test_ask_user_question_interrupt_payload_and_result_format(monkeypatch):
|
||||||
|
captured_payloads = []
|
||||||
|
expected_answer = {"style": "simple"}
|
||||||
|
|
||||||
|
def fake_interrupt(payload):
|
||||||
|
captured_payloads.append(payload)
|
||||||
|
return expected_answer
|
||||||
|
|
||||||
|
monkeypatch.setattr(tools, "interrupt", fake_interrupt)
|
||||||
|
|
||||||
|
result = tools.ask_user_question.func(
|
||||||
|
questions=[
|
||||||
|
{
|
||||||
|
"question_id": "style",
|
||||||
|
"question": "选择界面风格",
|
||||||
|
"options": [
|
||||||
|
{"label": "简洁 (Recommended)", "value": "simple"},
|
||||||
|
{"label": "详细", "value": "detailed"},
|
||||||
|
],
|
||||||
|
"multi_select": False,
|
||||||
|
"allow_other": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_questions = [
|
||||||
|
{
|
||||||
|
"question_id": "style",
|
||||||
|
"question": "选择界面风格",
|
||||||
|
"options": [
|
||||||
|
{"label": "简洁 (Recommended)", "value": "simple"},
|
||||||
|
{"label": "详细", "value": "detailed"},
|
||||||
|
],
|
||||||
|
"multi_select": False,
|
||||||
|
"allow_other": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
assert captured_payloads == [{"questions": expected_questions, "source": "ask_user_question"}]
|
||||||
|
assert result == {"questions": expected_questions, "answer": expected_answer}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ask_user_question_accepts_json_string_questions(monkeypatch):
|
||||||
|
captured_payloads = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(tools, "interrupt", lambda payload: captured_payloads.append(payload) or {"q-1": "A"})
|
||||||
|
|
||||||
|
result = tools.ask_user_question.func(
|
||||||
|
questions=json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"question": "选择一个选项",
|
||||||
|
"options": ["A", "B"],
|
||||||
|
"allow_other": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured_payloads[0]["source"] == "ask_user_question"
|
||||||
|
assert captured_payloads[0]["questions"] == [
|
||||||
|
{
|
||||||
|
"question_id": "q-1",
|
||||||
|
"question": "选择一个选项",
|
||||||
|
"options": [{"label": "A", "value": "A"}, {"label": "B", "value": "B"}],
|
||||||
|
"multi_select": False,
|
||||||
|
"allow_other": False,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert result["answer"] == {"q-1": "A"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ask_user_question_rejects_empty_questions():
|
||||||
|
with pytest.raises(ValueError, match="questions 至少需要包含一个有效问题"):
|
||||||
|
tools.ask_user_question.func(questions=[])
|
||||||
@ -101,8 +101,8 @@
|
|||||||
<div class="bottom" :class="{ 'start-screen': !conversations.length }">
|
<div class="bottom" :class="{ 'start-screen': !conversations.length }">
|
||||||
<!-- 人工审批弹窗 - 放在输入框上方 -->
|
<!-- 人工审批弹窗 - 放在输入框上方 -->
|
||||||
<HumanApprovalModal
|
<HumanApprovalModal
|
||||||
:visible="approvalState.showModal"
|
:visible="currentApprovalModalVisible"
|
||||||
:questions="approvalState.questions"
|
:questions="currentApprovalQuestions"
|
||||||
@submit="handleQuestionSubmit"
|
@submit="handleQuestionSubmit"
|
||||||
@cancel="handleQuestionCancel"
|
@cancel="handleQuestionCancel"
|
||||||
/>
|
/>
|
||||||
@ -954,18 +954,38 @@ const currentThreadConfigNotice = computed(() => {
|
|||||||
return threadConfigNoticeMap.value[currentChatId.value] || null
|
return threadConfigNoticeMap.value[currentChatId.value] || null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const currentApprovalModalVisible = computed(
|
||||||
|
() =>
|
||||||
|
approvalState.showModal &&
|
||||||
|
Boolean(approvalState.threadId) &&
|
||||||
|
approvalState.threadId === currentChatId.value
|
||||||
|
)
|
||||||
|
const currentApprovalQuestions = computed(() =>
|
||||||
|
currentApprovalModalVisible.value ? approvalState.questions : []
|
||||||
|
)
|
||||||
|
|
||||||
const shouldSuppressRefsForApproval = () =>
|
const shouldSuppressRefsForApproval = () =>
|
||||||
approvalState.showModal ||
|
currentApprovalModalVisible.value ||
|
||||||
Boolean(
|
Boolean(
|
||||||
approvalState.threadId &&
|
approvalState.threadId &&
|
||||||
chatState.currentThreadId === approvalState.threadId &&
|
currentChatId.value === approvalState.threadId &&
|
||||||
isProcessing.value
|
isProcessing.value
|
||||||
)
|
)
|
||||||
|
|
||||||
// 计算是否显示Refs组件的条件
|
// 计算是否显示Refs组件的条件
|
||||||
const shouldShowRefs = computed(() => {
|
const shouldShowRefs = computed(() => {
|
||||||
|
const convs = conversations.value
|
||||||
|
const lastConv = convs.length ? convs[convs.length - 1] : null
|
||||||
return (conv) => {
|
return (conv) => {
|
||||||
return getLastMessage(conv) && conv.status !== 'streaming' && !shouldSuppressRefsForApproval()
|
if (!getLastMessage(conv) || conv.status === 'streaming' || shouldSuppressRefsForApproval()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 回复生成中(含 resume 续写的空窗期)抑制最后一个对话的 refs,避免过早出现操作栏。
|
||||||
|
// 同时看 isReplyLoading:切换/重连时 isStreaming 可能已置 false,但「正在生成回复」仍在显示。
|
||||||
|
if (conv === lastConv && (isProcessing.value || isReplyLoading.value)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -1510,6 +1530,10 @@ const startChatMainResizeObserver = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('visibilitychange', handlePageVisibilityChange)
|
||||||
|
}
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const chatMainContainer = document.querySelector('.chat-main')
|
const chatMainContainer = document.querySelector('.chat-main')
|
||||||
if (chatMainContainer) {
|
if (chatMainContainer) {
|
||||||
@ -1532,6 +1556,9 @@ onDeactivated(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.removeEventListener('visibilitychange', handlePageVisibilityChange)
|
||||||
|
}
|
||||||
scrollController.cleanup()
|
scrollController.cleanup()
|
||||||
stopChatMainResizeObserver()
|
stopChatMainResizeObserver()
|
||||||
stopStreamingStateRefresh()
|
stopStreamingStateRefresh()
|
||||||
@ -1719,11 +1746,21 @@ const handleAttachmentRemove = async (attachment) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 审批功能管理 ====================
|
// ==================== 审批功能管理 ====================
|
||||||
const { approvalState, processApprovalInStream } = useApproval({
|
const {
|
||||||
|
approvalState,
|
||||||
|
processApprovalInStream,
|
||||||
|
restoreInterruptFromThreadState,
|
||||||
|
hideApprovalState
|
||||||
|
} = useApproval({
|
||||||
getThreadState,
|
getThreadState,
|
||||||
fetchThreadMessages
|
fetchThreadMessages
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const restorePendingInterruptForThread = (threadId) => {
|
||||||
|
if (!threadId) return false
|
||||||
|
return restoreInterruptFromThreadState(threadId)
|
||||||
|
}
|
||||||
|
|
||||||
const { handleStreamChunk } = useAgentStreamHandler({
|
const { handleStreamChunk } = useAgentStreamHandler({
|
||||||
getThreadState,
|
getThreadState,
|
||||||
processApprovalInStream,
|
processApprovalInStream,
|
||||||
@ -1739,9 +1776,35 @@ const { startRunStream, resumeActiveRunForThread, stopRunStreamSubscription } =
|
|||||||
fetchAgentState,
|
fetchAgentState,
|
||||||
resetOnGoingConv,
|
resetOnGoingConv,
|
||||||
onScrollToBottom: () => scrollController.scrollToBottom(),
|
onScrollToBottom: () => scrollController.scrollToBottom(),
|
||||||
streamSmoother
|
streamSmoother,
|
||||||
|
onInterruptDetected: ({ threadId }) => {
|
||||||
|
restorePendingInterruptForThread(threadId)
|
||||||
|
},
|
||||||
|
onTerminalDetected: ({ threadId, touchedThreadIds = [] }) => {
|
||||||
|
if (approvalState.threadId === threadId || touchedThreadIds.includes(approvalState.threadId)) {
|
||||||
|
hideApprovalState()
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const resumeCurrentRunForVisiblePage = async () => {
|
||||||
|
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return
|
||||||
|
const threadId = currentChatId.value
|
||||||
|
if (!threadId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await resumeActiveRunForThread(threadId)
|
||||||
|
restorePendingInterruptForThread(threadId)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to resume current run after page became visible:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageVisibilityChange = () => {
|
||||||
|
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return
|
||||||
|
void resumeCurrentRunForVisiblePage()
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== CHAT ACTIONS ====================
|
// ==================== CHAT ACTIONS ====================
|
||||||
// 获取第一个非置顶的对话
|
// 获取第一个非置顶的对话
|
||||||
const getFirstNonPinnedChat = (chatList) => {
|
const getFirstNonPinnedChat = (chatList) => {
|
||||||
@ -1809,6 +1872,7 @@ const selectChat = async (chatId) => {
|
|||||||
await handleAgentStateRefresh(chatId)
|
await handleAgentStateRefresh(chatId)
|
||||||
syncThreadConfigSnapshot(chatId, { overwrite: false })
|
syncThreadConfigSnapshot(chatId, { overwrite: false })
|
||||||
await resumeActiveRunForThread(chatId)
|
await resumeActiveRunForThread(chatId)
|
||||||
|
restorePendingInterruptForThread(chatId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectThreadFromRoute = async (threadId) => {
|
const selectThreadFromRoute = async (threadId) => {
|
||||||
@ -1885,6 +1949,10 @@ const handleSendMessage = async ({ image } = {}) => {
|
|||||||
|
|
||||||
const threadState = getThreadState(threadId)
|
const threadState = getThreadState(threadId)
|
||||||
if (!threadState) return
|
if (!threadState) return
|
||||||
|
threadState.pendingInterrupt = null
|
||||||
|
if (approvalState.threadId === threadId) {
|
||||||
|
hideApprovalState()
|
||||||
|
}
|
||||||
|
|
||||||
const pendingAttachments = [...currentPendingThreadAttachments.value]
|
const pendingAttachments = [...currentPendingThreadAttachments.value]
|
||||||
const pendingAttachmentFileIds = pendingAttachments
|
const pendingAttachmentFileIds = pendingAttachments
|
||||||
@ -1966,6 +2034,10 @@ const handleSendOrStop = async (payload) => {
|
|||||||
if (isProcessing.value && threadState?.activeRunId) {
|
if (isProcessing.value && threadState?.activeRunId) {
|
||||||
try {
|
try {
|
||||||
await agentApi.cancelAgentRun(threadState.activeRunId)
|
await agentApi.cancelAgentRun(threadState.activeRunId)
|
||||||
|
threadState.pendingInterrupt = null
|
||||||
|
if (approvalState.threadId === threadId) {
|
||||||
|
hideApprovalState()
|
||||||
|
}
|
||||||
message.info('已发送取消请求')
|
message.info('已发送取消请求')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleChatError(error, 'stop')
|
handleChatError(error, 'stop')
|
||||||
@ -1979,6 +2051,7 @@ const handleSendOrStop = async (payload) => {
|
|||||||
// ==================== 人工审批处理 ====================
|
// ==================== 人工审批处理 ====================
|
||||||
const handleApprovalWithStream = async (answer) => {
|
const handleApprovalWithStream = async (answer) => {
|
||||||
const threadId = approvalState.threadId
|
const threadId = approvalState.threadId
|
||||||
|
const parentRunId = approvalState.parentRunId
|
||||||
if (!threadId) {
|
if (!threadId) {
|
||||||
message.error('无效的提问请求')
|
message.error('无效的提问请求')
|
||||||
approvalState.showModal = false
|
approvalState.showModal = false
|
||||||
@ -1992,14 +2065,17 @@ const handleApprovalWithStream = async (answer) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!approvalState.parentRunId) {
|
if (!parentRunId) {
|
||||||
message.error('无法找到需要恢复的运行任务')
|
message.error('无法找到需要恢复的运行任务')
|
||||||
approvalState.showModal = false
|
approvalState.showModal = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pendingInterrupt = threadState.pendingInterrupt
|
||||||
|
|
||||||
try {
|
try {
|
||||||
approvalState.showModal = false
|
hideApprovalState()
|
||||||
|
threadState.pendingInterrupt = null
|
||||||
threadState.isStreaming = true
|
threadState.isStreaming = true
|
||||||
resetOnGoingConv(threadId)
|
resetOnGoingConv(threadId)
|
||||||
const resumeRequestId = createClientRequestId()
|
const resumeRequestId = createClientRequestId()
|
||||||
@ -2009,7 +2085,7 @@ const handleApprovalWithStream = async (answer) => {
|
|||||||
thread_id: threadId,
|
thread_id: threadId,
|
||||||
meta: { request_id: resumeRequestId },
|
meta: { request_id: resumeRequestId },
|
||||||
resume: answer,
|
resume: answer,
|
||||||
parent_run_id: approvalState.parentRunId,
|
parent_run_id: parentRunId,
|
||||||
resume_request_id: resumeRequestId
|
resume_request_id: resumeRequestId
|
||||||
})
|
})
|
||||||
const runId = runResp?.run_id
|
const runId = runResp?.run_id
|
||||||
@ -2018,6 +2094,10 @@ const handleApprovalWithStream = async (answer) => {
|
|||||||
}
|
}
|
||||||
await startRunStream(threadId, runId, '0-0')
|
await startRunStream(threadId, runId, '0-0')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (pendingInterrupt) {
|
||||||
|
threadState.pendingInterrupt = pendingInterrupt
|
||||||
|
restorePendingInterruptForThread(threadId)
|
||||||
|
}
|
||||||
threadState.isStreaming = false
|
threadState.isStreaming = false
|
||||||
threadState.replyLoadingVisible = false
|
threadState.replyLoadingVisible = false
|
||||||
handleChatError(error, 'resume')
|
handleChatError(error, 'resume')
|
||||||
@ -2179,6 +2259,12 @@ const showMsgRefs = (msg) => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 回复生成中(含 resume 续写的空窗期)不在最后一条消息上过早显示操作栏/来源。
|
||||||
|
// isReplyLoading 兜底:切换/重连时 isStreaming 可能已置 false,但回复仍在生成。
|
||||||
|
if (msg.isLast && (isProcessing.value || isReplyLoading.value)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 只有真正完成的消息才显示 refs
|
// 只有真正完成的消息才显示 refs
|
||||||
if (msg.isLast && msg.status === 'finished') {
|
if (msg.isLast && msg.status === 'finished') {
|
||||||
return ['copy', 'sources']
|
return ['copy', 'sources']
|
||||||
@ -2333,6 +2419,12 @@ watch(
|
|||||||
|
|
||||||
watch(currentChatId, (threadId, oldThreadId) => {
|
watch(currentChatId, (threadId, oldThreadId) => {
|
||||||
if (threadId === oldThreadId) return
|
if (threadId === oldThreadId) return
|
||||||
|
if (!threadId || approvalState.threadId !== threadId) {
|
||||||
|
hideApprovalState()
|
||||||
|
}
|
||||||
|
if (threadId) {
|
||||||
|
restorePendingInterruptForThread(threadId)
|
||||||
|
}
|
||||||
emit('thread-change', threadId || '')
|
emit('thread-change', threadId || '')
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,11 +1,45 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseToolCall :tool-call="toolCall" hide-params>
|
<BaseToolCall
|
||||||
|
:tool-call="toolCall"
|
||||||
|
:appearance="appearance"
|
||||||
|
:default-expanded="defaultExpanded"
|
||||||
|
:force-show-result="questions.length > 0"
|
||||||
|
hide-params
|
||||||
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="sep-header">
|
<div class="sep-header">
|
||||||
<span class="note">提问</span>
|
<span class="note">提问</span>
|
||||||
<span class="separator">|</span>
|
<span class="separator">|</span>
|
||||||
<span class="description">{{ shortQuestionSummary }}</span>
|
<span class="description">{{ shortQuestionSummary }}</span>
|
||||||
<span v-if="userAnswer" class="tag tag-answered"> 已回答: {{ displayAnswer }} </span>
|
<span v-if="displayAnswer" class="tag tag-answered"> 已回答: {{ displayAnswer }} </span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #result>
|
||||||
|
<div class="ask-question-result">
|
||||||
|
<div v-if="questions.length" class="question-list">
|
||||||
|
<div
|
||||||
|
v-for="(questionItem, questionIndex) in questions"
|
||||||
|
:key="questionItem.questionId || questionIndex"
|
||||||
|
class="question-item"
|
||||||
|
>
|
||||||
|
<div class="question-title">
|
||||||
|
<span class="question-index">{{ questionIndex + 1 }}</span>
|
||||||
|
<span class="question-text">{{ questionItem.question }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="questionItem.operation" class="operation-row">
|
||||||
|
<span class="row-label">操作</span>
|
||||||
|
<span class="operation-text">{{ questionItem.operation }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="getQuestionAnswerText(questionItem)" class="answer-row">
|
||||||
|
<span class="row-label">回答</span>
|
||||||
|
<span class="answer-text">{{ getQuestionAnswerText(questionItem) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="no-question">暂无提问内容</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</BaseToolCall>
|
</BaseToolCall>
|
||||||
@ -14,51 +48,55 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import BaseToolCall from '../BaseToolCall.vue'
|
import BaseToolCall from '../BaseToolCall.vue'
|
||||||
|
import { normalizeQuestions } from '@/utils/questionUtils'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
toolCall: {
|
toolCall: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
appearance: {
|
||||||
|
type: String,
|
||||||
|
default: 'card'
|
||||||
|
},
|
||||||
|
defaultExpanded: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 解析参数
|
const parseJsonValue = (value, fallback = null) => {
|
||||||
|
if (value === undefined || value === null || value === '') return fallback
|
||||||
|
if (typeof value === 'object') return value
|
||||||
|
try {
|
||||||
|
return JSON.parse(value)
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const parsedArgs = computed(() => {
|
const parsedArgs = computed(() => {
|
||||||
const args = props.toolCall.args || props.toolCall.function?.arguments
|
const args = props.toolCall.args ?? props.toolCall.function?.arguments
|
||||||
if (!args) return {}
|
return parseJsonValue(args, {})
|
||||||
if (typeof args === 'object') return args
|
|
||||||
try {
|
|
||||||
return JSON.parse(args)
|
|
||||||
} catch {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 解析结果
|
|
||||||
const parsedResult = computed(() => {
|
const parsedResult = computed(() => {
|
||||||
const content = props.toolCall.tool_call_result?.content
|
const content = props.toolCall.tool_call_result?.content ?? props.toolCall.result
|
||||||
if (!content) return null
|
return parseJsonValue(content, null)
|
||||||
if (typeof content === 'object') return content
|
|
||||||
try {
|
|
||||||
return JSON.parse(content)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const questions = computed(() => {
|
const questions = computed(() => {
|
||||||
const rawQuestions = parsedArgs.value.questions
|
const argsQuestions = parsedArgs.value?.questions
|
||||||
if (!Array.isArray(rawQuestions)) return []
|
if (Array.isArray(argsQuestions) && argsQuestions.length) {
|
||||||
|
return normalizeQuestions(argsQuestions)
|
||||||
|
}
|
||||||
|
|
||||||
return rawQuestions
|
const resultQuestions = parsedResult.value?.questions
|
||||||
.map((item) => {
|
if (Array.isArray(resultQuestions) && resultQuestions.length) {
|
||||||
if (!item || typeof item !== 'object') return null
|
return normalizeQuestions(resultQuestions)
|
||||||
const question = String(item.question || '').trim()
|
}
|
||||||
if (!question) return null
|
|
||||||
const questionId = String(item.question_id || item.questionId || '').trim()
|
return []
|
||||||
return { questionId, question }
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const shortQuestionSummary = computed(() => {
|
const shortQuestionSummary = computed(() => {
|
||||||
@ -72,54 +110,85 @@ const shortQuestionSummary = computed(() => {
|
|||||||
return `${shortFirstQuestion} 等 ${questions.value.length} 题`
|
return `${shortFirstQuestion} 等 ${questions.value.length} 题`
|
||||||
})
|
})
|
||||||
|
|
||||||
// 用户答案
|
|
||||||
const userAnswer = computed(() => {
|
const userAnswer = computed(() => {
|
||||||
const result = parsedResult.value
|
const result = parsedResult.value
|
||||||
if (!result) return null
|
if (!result) return null
|
||||||
return result.user_answer || result.answer || null
|
return result.user_answer || result.answer || null
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatSingleAnswer = (answer) => {
|
const getOptionLabel = (value, questionItem) => {
|
||||||
|
const rawValue = String(value || '').trim()
|
||||||
|
if (!rawValue) return ''
|
||||||
|
|
||||||
|
const option = questionItem?.options?.find(
|
||||||
|
(item) => String(item.value) === rawValue || String(item.label) === rawValue
|
||||||
|
)
|
||||||
|
return option?.label || rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSingleAnswer = (answer, questionItem) => {
|
||||||
if (Array.isArray(answer)) {
|
if (Array.isArray(answer)) {
|
||||||
return answer.join(', ')
|
return answer
|
||||||
|
.map((item) => getOptionLabel(item, questionItem))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('、')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (answer && typeof answer === 'object') {
|
if (answer && typeof answer === 'object') {
|
||||||
if (answer.type === 'other') {
|
if (answer.type === 'other') {
|
||||||
return `Other: ${String(answer.text || '').trim()}`
|
const selected = Array.isArray(answer.selected)
|
||||||
|
? answer.selected.map((item) => getOptionLabel(item, questionItem)).filter(Boolean)
|
||||||
|
: []
|
||||||
|
const text = String(answer.text || '').trim()
|
||||||
|
return [...selected, text ? `其他: ${text}` : '其他'].join('、')
|
||||||
}
|
}
|
||||||
return JSON.stringify(answer)
|
return JSON.stringify(answer)
|
||||||
}
|
}
|
||||||
return String(answer)
|
|
||||||
|
return getOptionLabel(answer, questionItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示答案
|
const getQuestionAnswer = (questionItem) => {
|
||||||
const displayAnswer = computed(() => {
|
|
||||||
const answer = userAnswer.value
|
const answer = userAnswer.value
|
||||||
if (!answer) return ''
|
if (answer === null || answer === undefined || answer === '') return undefined
|
||||||
|
|
||||||
if (Array.isArray(answer)) {
|
if (questions.value.length === 1 && (typeof answer !== 'object' || Array.isArray(answer))) {
|
||||||
return answer.join(', ')
|
return answer
|
||||||
}
|
}
|
||||||
|
|
||||||
if (answer && typeof answer === 'object') {
|
if (answer && typeof answer === 'object' && !Array.isArray(answer)) {
|
||||||
if (answer.type === 'other') {
|
if (Object.prototype.hasOwnProperty.call(answer, questionItem.questionId)) {
|
||||||
return `Other: ${String(answer.text || '').trim()}`
|
return answer[questionItem.questionId]
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = Object.entries(answer)
|
if (questions.value.length === 1 && answer.type === 'other') {
|
||||||
if (!entries.length) return ''
|
return answer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const summary = entries
|
return undefined
|
||||||
.map(([questionId, value]) => {
|
}
|
||||||
const title = String(questionId || '').trim()
|
|
||||||
return `${title}: ${formatSingleAnswer(value)}`
|
|
||||||
})
|
|
||||||
.join(' | ')
|
|
||||||
|
|
||||||
|
const getQuestionAnswerText = (questionItem) => {
|
||||||
|
const answer = getQuestionAnswer(questionItem)
|
||||||
|
if (answer === undefined || answer === null || answer === '') return ''
|
||||||
|
return formatSingleAnswer(answer, questionItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayAnswer = computed(() => {
|
||||||
|
if (!userAnswer.value) return ''
|
||||||
|
|
||||||
|
const summaries = questions.value
|
||||||
|
.map((questionItem) => getQuestionAnswerText(questionItem))
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (!summaries.length) {
|
||||||
|
const text = formatSingleAnswer(userAnswer.value, questions.value[0])
|
||||||
|
return text.length > 120 ? text.slice(0, 120) + '...' : text
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = summaries.join(' | ')
|
||||||
return summary.length > 120 ? summary.slice(0, 120) + '...' : summary
|
return summary.length > 120 ? summary.slice(0, 120) + '...' : summary
|
||||||
}
|
|
||||||
|
|
||||||
return String(answer)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -127,11 +196,100 @@ const displayAnswer = computed(() => {
|
|||||||
.sep-header {
|
.sep-header {
|
||||||
.tag-answered {
|
.tag-answered {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
color: var(--green-600);
|
color: var(--color-success-700);
|
||||||
background: var(--green-50);
|
background: var(--color-success-50);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 45%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-question-result {
|
||||||
|
padding: 8px 0;
|
||||||
|
|
||||||
|
.question-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-item {
|
||||||
|
border: 1px solid var(--gray-100);
|
||||||
|
background: var(--gray-25);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--gray-800);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-index {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--main-700);
|
||||||
|
background: var(--main-50);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-text {
|
||||||
|
min-width: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-row,
|
||||||
|
.answer-row {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
color: var(--gray-500);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-text,
|
||||||
|
.answer-text {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--gray-700);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-row {
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--gray-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-text {
|
||||||
|
color: var(--color-success-700);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-question {
|
||||||
|
padding: 12px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -7,7 +7,8 @@ import {
|
|||||||
resolveRunResumeAfterSeq
|
resolveRunResumeAfterSeq
|
||||||
} from '@/utils/runStreamResume'
|
} from '@/utils/runStreamResume'
|
||||||
|
|
||||||
const RUN_TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'interrupted'])
|
const RUN_INTERRUPTED_STATUS = 'interrupted'
|
||||||
|
const RUN_TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled'])
|
||||||
const ACTIVE_RUN_STORAGE_TTL_MS = 60 * 60 * 1000
|
const ACTIVE_RUN_STORAGE_TTL_MS = 60 * 60 * 1000
|
||||||
const ACTIVE_RUN_CLIENT_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
const ACTIVE_RUN_CLIENT_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
|
||||||
@ -102,7 +103,9 @@ export function useAgentRunStream({
|
|||||||
fetchAgentState,
|
fetchAgentState,
|
||||||
resetOnGoingConv,
|
resetOnGoingConv,
|
||||||
onScrollToBottom,
|
onScrollToBottom,
|
||||||
streamSmoother
|
streamSmoother,
|
||||||
|
onInterruptDetected = null,
|
||||||
|
onTerminalDetected = null
|
||||||
}) {
|
}) {
|
||||||
const saveActiveRunSnapshot = (threadId, runId, lastSeq = '0-0') => {
|
const saveActiveRunSnapshot = (threadId, runId, lastSeq = '0-0') => {
|
||||||
if (!threadId || !runId) return
|
if (!threadId || !runId) return
|
||||||
@ -142,28 +145,91 @@ export function useAgentRunStream({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notifyInterruptDetected = (threadId, runId, run = null) => {
|
||||||
|
if (typeof onInterruptDetected !== 'function') return
|
||||||
|
onInterruptDetected({ threadId, runId, run })
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifyTerminalDetected = (threadId, runId, touchedThreadIds) => {
|
||||||
|
if (typeof onTerminalDetected !== 'function') return
|
||||||
|
onTerminalDetected({ threadId, runId, touchedThreadIds: [...touchedThreadIds] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPendingInterruptForRun = (threadState, runId) => {
|
||||||
|
const pendingInterrupt = threadState?.pendingInterrupt
|
||||||
|
if (!pendingInterrupt?.questions?.length) return false
|
||||||
|
return !pendingInterrupt.parentRunId || pendingInterrupt.parentRunId === runId
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPendingInterruptInThreads = (threadIds, runId) => {
|
||||||
|
return [...threadIds].some((id) => hasPendingInterruptForRun(getThreadState(id), runId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearPendingInterruptForRun = (threadId, runId) => {
|
||||||
|
const threadState = getThreadState(threadId)
|
||||||
|
if (hasPendingInterruptForRun(threadState, runId)) {
|
||||||
|
threadState.pendingInterrupt = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const finalizeRunStream = (
|
const finalizeRunStream = (
|
||||||
threadId,
|
threadId,
|
||||||
runId,
|
runId,
|
||||||
touchedThreadIds,
|
touchedThreadIds,
|
||||||
{ delay = 200, scroll = false } = {}
|
{ delay = 200, scroll = false, status = '' } = {}
|
||||||
) => {
|
) => {
|
||||||
const ts = getThreadState(threadId)
|
const ts = getThreadState(threadId)
|
||||||
if (!ts || ts.activeRunId !== runId) return
|
if (!ts || ts.activeRunId !== runId) return
|
||||||
|
const isInterrupted =
|
||||||
|
status === RUN_INTERRUPTED_STATUS && hasPendingInterruptInThreads(touchedThreadIds, runId)
|
||||||
touchedThreadIds.forEach((id) => streamSmoother?.flushThread(id))
|
touchedThreadIds.forEach((id) => streamSmoother?.flushThread(id))
|
||||||
ts.isStreaming = false
|
ts.isStreaming = false
|
||||||
|
if (isInterrupted) {
|
||||||
|
ts.activeRunId = runId
|
||||||
|
saveActiveRunSnapshot(threadId, runId, ts.runLastSeq)
|
||||||
|
} else {
|
||||||
ts.activeRunId = null
|
ts.activeRunId = null
|
||||||
|
clearActiveRunSnapshot(threadId)
|
||||||
|
touchedThreadIds.forEach((id) => clearPendingInterruptForRun(id, runId))
|
||||||
|
}
|
||||||
ts.lastRetryableJobTry = null
|
ts.lastRetryableJobTry = null
|
||||||
ts.replyLoadingVisible = false
|
ts.replyLoadingVisible = false
|
||||||
ts.pendingRequestId = null
|
ts.pendingRequestId = null
|
||||||
clearActiveRunSnapshot(threadId)
|
|
||||||
fetchThreadMessages({ agentId: unref(currentAgentId), threadId, delay }).finally(() => {
|
fetchThreadMessages({ agentId: unref(currentAgentId), threadId, delay }).finally(() => {
|
||||||
resetOnGoingConv(threadId)
|
resetOnGoingConv(threadId)
|
||||||
fetchAgentState(unref(currentAgentId), threadId)
|
fetchAgentState(unref(currentAgentId), threadId)
|
||||||
if (scroll) onScrollToBottom()
|
if (scroll) onScrollToBottom()
|
||||||
|
if (isInterrupted) {
|
||||||
|
notifyInterruptDetected(threadId, runId)
|
||||||
|
} else {
|
||||||
|
notifyTerminalDetected(threadId, runId, touchedThreadIds)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preserveInterruptedRun = async (threadId, run, snapshot = null) => {
|
||||||
|
const ts = getThreadState(threadId)
|
||||||
|
if (!ts || !run?.id) return false
|
||||||
|
|
||||||
|
streamSmoother?.flushThread(threadId)
|
||||||
|
ts.activeRunId = run.id
|
||||||
|
ts.runLastSeq = normalizeRunSeq(snapshot?.last_seq || ts.runLastSeq || '0-0')
|
||||||
|
ts.lastRetryableJobTry = null
|
||||||
|
ts.isStreaming = false
|
||||||
|
ts.replyLoadingVisible = false
|
||||||
|
ts.pendingRequestId = null
|
||||||
|
saveActiveRunSnapshot(threadId, run.id, ts.runLastSeq)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchThreadMessages({ agentId: unref(currentAgentId), threadId })
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to refresh messages for interrupted run:', threadId, e)
|
||||||
|
}
|
||||||
|
fetchAgentState(unref(currentAgentId), threadId)
|
||||||
|
notifyInterruptDetected(threadId, run.id, run)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const scheduleRunReconnect = (threadId, runId, delay = 500) => {
|
const scheduleRunReconnect = (threadId, runId, delay = 500) => {
|
||||||
const ts = getThreadState(threadId)
|
const ts = getThreadState(threadId)
|
||||||
if (!ts || ts.activeRunId !== runId) return
|
if (!ts || ts.activeRunId !== runId) return
|
||||||
@ -263,8 +329,10 @@ export function useAgentRunStream({
|
|||||||
|
|
||||||
if (event === 'end') {
|
if (event === 'end') {
|
||||||
sawTerminalEvent = true
|
sawTerminalEvent = true
|
||||||
if (RUN_TERMINAL_STATUSES.has(terminalStatus)) {
|
if (terminalStatus === RUN_INTERRUPTED_STATUS) {
|
||||||
finalizeRunStream(threadId, runId, touchedThreadIds)
|
finalizeRunStream(threadId, runId, touchedThreadIds, { status: terminalStatus })
|
||||||
|
} else if (RUN_TERMINAL_STATUSES.has(terminalStatus)) {
|
||||||
|
finalizeRunStream(threadId, runId, touchedThreadIds, { status: terminalStatus })
|
||||||
} else {
|
} else {
|
||||||
touchedThreadIds.forEach((id) => streamSmoother?.flushThread(id))
|
touchedThreadIds.forEach((id) => streamSmoother?.flushThread(id))
|
||||||
ts.isStreaming = false
|
ts.isStreaming = false
|
||||||
@ -281,8 +349,14 @@ export function useAgentRunStream({
|
|||||||
try {
|
try {
|
||||||
const runRes = await agentApi.getAgentRun(runId)
|
const runRes = await agentApi.getAgentRun(runId)
|
||||||
const run = runRes?.run
|
const run = runRes?.run
|
||||||
if (run && RUN_TERMINAL_STATUSES.has(run.status)) {
|
if (run?.status === RUN_INTERRUPTED_STATUS) {
|
||||||
finalizeRunStream(threadId, runId, touchedThreadIds)
|
if (hasPendingInterruptInThreads(touchedThreadIds, run.id)) {
|
||||||
|
await preserveInterruptedRun(threadId, run)
|
||||||
|
} else {
|
||||||
|
finalizeRunStream(threadId, runId, touchedThreadIds, { status: run.status })
|
||||||
|
}
|
||||||
|
} else if (run && RUN_TERMINAL_STATUSES.has(run.status)) {
|
||||||
|
finalizeRunStream(threadId, runId, touchedThreadIds, { status: run.status })
|
||||||
} else {
|
} else {
|
||||||
scheduleRunReconnect(threadId, runId)
|
scheduleRunReconnect(threadId, runId)
|
||||||
}
|
}
|
||||||
@ -319,7 +393,37 @@ export function useAgentRunStream({
|
|||||||
const resumeActiveRunForThread = async (threadId) => {
|
const resumeActiveRunForThread = async (threadId) => {
|
||||||
if (!threadId) return
|
if (!threadId) return
|
||||||
const ts = getThreadState(threadId)
|
const ts = getThreadState(threadId)
|
||||||
if (!ts || ts.runStreamAbortController) return
|
if (!ts) return
|
||||||
|
|
||||||
|
if (ts.runStreamAbortController) {
|
||||||
|
if (!ts.activeRunId) return
|
||||||
|
try {
|
||||||
|
const runRes = await agentApi.getAgentRun(ts.activeRunId)
|
||||||
|
const run = runRes?.run
|
||||||
|
if (run?.status === RUN_INTERRUPTED_STATUS) {
|
||||||
|
stopRunStreamSubscription(threadId)
|
||||||
|
const snapshot = loadActiveRunSnapshot(threadId)
|
||||||
|
if (hasPendingInterruptForRun(ts, run.id)) {
|
||||||
|
await preserveInterruptedRun(threadId, run, snapshot)
|
||||||
|
} else {
|
||||||
|
resetOnGoingConv(threadId)
|
||||||
|
await startRunStream(threadId, run.id, '0-0')
|
||||||
|
}
|
||||||
|
} else if (run && RUN_TERMINAL_STATUSES.has(run.status)) {
|
||||||
|
stopRunStreamSubscription(threadId)
|
||||||
|
ts.activeRunId = null
|
||||||
|
ts.isStreaming = false
|
||||||
|
ts.replyLoadingVisible = false
|
||||||
|
ts.pendingRequestId = null
|
||||||
|
clearPendingInterruptForRun(threadId, run.id)
|
||||||
|
clearActiveRunSnapshot(threadId)
|
||||||
|
notifyTerminalDetected(threadId, run.id, new Set([threadId]))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to refresh active run while stream is open:', threadId, e)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const snapshot = loadActiveRunSnapshot(threadId)
|
const snapshot = loadActiveRunSnapshot(threadId)
|
||||||
if (snapshot?.run_id) {
|
if (snapshot?.run_id) {
|
||||||
@ -329,7 +433,14 @@ export function useAgentRunStream({
|
|||||||
try {
|
try {
|
||||||
const runRes = await agentApi.getAgentRun(snapshot.run_id)
|
const runRes = await agentApi.getAgentRun(snapshot.run_id)
|
||||||
const run = runRes?.run
|
const run = runRes?.run
|
||||||
if (run && !RUN_TERMINAL_STATUSES.has(run.status)) {
|
if (run?.status === RUN_INTERRUPTED_STATUS) {
|
||||||
|
// 仅当本地仍持有该中断时才据快照恢复;否则不能仅凭快照重放旧中断
|
||||||
|
// (可能已被回复),交由下方 active_run 做权威判定。
|
||||||
|
if (hasPendingInterruptForRun(ts, run.id)) {
|
||||||
|
await preserveInterruptedRun(threadId, run, snapshot)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (run && !RUN_TERMINAL_STATUSES.has(run.status)) {
|
||||||
const afterSeq = resolveRunResumeAfterSeq({
|
const afterSeq = resolveRunResumeAfterSeq({
|
||||||
snapshot,
|
snapshot,
|
||||||
threadState: ts
|
threadState: ts
|
||||||
@ -350,6 +461,15 @@ export function useAgentRunStream({
|
|||||||
try {
|
try {
|
||||||
const active = await agentApi.getThreadActiveRun(threadId)
|
const active = await agentApi.getThreadActiveRun(threadId)
|
||||||
const run = active?.run
|
const run = active?.run
|
||||||
|
if (run?.status === RUN_INTERRUPTED_STATUS) {
|
||||||
|
if (hasPendingInterruptForRun(ts, run.id)) {
|
||||||
|
await preserveInterruptedRun(threadId, run)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resetOnGoingConv(threadId)
|
||||||
|
await startRunStream(threadId, run.id, '0-0')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (run && !RUN_TERMINAL_STATUSES.has(run.status)) {
|
if (run && !RUN_TERMINAL_STATUSES.has(run.status)) {
|
||||||
resetOnGoingConv(threadId)
|
resetOnGoingConv(threadId)
|
||||||
await startRunStream(threadId, run.id, '0-0')
|
await startRunStream(threadId, run.id, '0-0')
|
||||||
@ -364,7 +484,9 @@ export function useAgentRunStream({
|
|||||||
ts.isStreaming = false
|
ts.isStreaming = false
|
||||||
ts.replyLoadingVisible = false
|
ts.replyLoadingVisible = false
|
||||||
ts.pendingRequestId = null
|
ts.pendingRequestId = null
|
||||||
|
ts.pendingInterrupt = null
|
||||||
clearActiveRunSnapshot(threadId)
|
clearActiveRunSnapshot(threadId)
|
||||||
|
notifyTerminalDetected(threadId, null, new Set([threadId]))
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import { handleChatError } from '@/utils/errorHandler'
|
import { handleChatError } from '@/utils/errorHandler'
|
||||||
import { unref } from 'vue'
|
import { unref } from 'vue'
|
||||||
|
import { extractPendingInterrupt } from '@/composables/useApproval'
|
||||||
|
|
||||||
const serializeToolArgs = (args) => {
|
const serializeToolArgs = (args) => {
|
||||||
if (typeof args === 'string') return args
|
if (typeof args === 'string') return args
|
||||||
@ -158,6 +159,7 @@ export function useAgentStreamHandler({
|
|||||||
threadState.isStreaming = false
|
threadState.isStreaming = false
|
||||||
threadState.replyLoadingVisible = false
|
threadState.replyLoadingVisible = false
|
||||||
threadState.pendingRequestId = null
|
threadState.pendingRequestId = null
|
||||||
|
threadState.pendingInterrupt = null
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
||||||
@ -208,6 +210,7 @@ export function useAgentStreamHandler({
|
|||||||
threadState.isStreaming = false
|
threadState.isStreaming = false
|
||||||
threadState.replyLoadingVisible = false
|
threadState.replyLoadingVisible = false
|
||||||
threadState.pendingRequestId = null
|
threadState.pendingRequestId = null
|
||||||
|
threadState.pendingInterrupt = null
|
||||||
console.log(`${debugPrefix}[finished]`, {
|
console.log(`${debugPrefix}[finished]`, {
|
||||||
threadId,
|
threadId,
|
||||||
currentAgentId: unref(currentAgentId),
|
currentAgentId: unref(currentAgentId),
|
||||||
@ -239,6 +242,10 @@ export function useAgentStreamHandler({
|
|||||||
threadState.isStreaming = false
|
threadState.isStreaming = false
|
||||||
threadState.replyLoadingVisible = false
|
threadState.replyLoadingVisible = false
|
||||||
threadState.pendingRequestId = null
|
threadState.pendingRequestId = null
|
||||||
|
const pendingInterrupt = extractPendingInterrupt(chunk, threadId)
|
||||||
|
if (pendingInterrupt) {
|
||||||
|
threadState.pendingInterrupt = pendingInterrupt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 如果有 message 字段,显示提示(例如:敏感内容检测)
|
// 如果有 message 字段,显示提示(例如:敏感内容检测)
|
||||||
if (chunkMessage) {
|
if (chunkMessage) {
|
||||||
|
|||||||
@ -29,6 +29,7 @@ export function useAgentThreadState({
|
|||||||
lastRetryableJobTry: null,
|
lastRetryableJobTry: null,
|
||||||
replyLoadingVisible: false,
|
replyLoadingVisible: false,
|
||||||
pendingRequestId: null,
|
pendingRequestId: null,
|
||||||
|
pendingInterrupt: null,
|
||||||
onGoingConv: createOnGoingConvState(),
|
onGoingConv: createOnGoingConvState(),
|
||||||
agentState: null
|
agentState: null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { normalizeQuestions } from '@/utils/questionUtils'
|
import { normalizeQuestions } from '@/utils/questionUtils'
|
||||||
|
|
||||||
|
const APPROVAL_REQUIRED_STATUSES = new Set([
|
||||||
|
'ask_user_question_required',
|
||||||
|
'human_approval_required'
|
||||||
|
])
|
||||||
|
|
||||||
const extractQuestionPayload = (chunk) => {
|
const extractQuestionPayload = (chunk) => {
|
||||||
const interruptInfo = chunk?.interrupt_info || {}
|
const interruptInfo = chunk?.interrupt_info || {}
|
||||||
const rawQuestions = chunk?.questions || interruptInfo?.questions || []
|
const rawQuestions = chunk?.questions || interruptInfo?.questions || []
|
||||||
@ -13,6 +18,19 @@ const extractQuestionPayload = (chunk) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const extractPendingInterrupt = (chunk, threadId) => {
|
||||||
|
const payload = extractQuestionPayload(chunk)
|
||||||
|
if (!payload.questions.length) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
questions: payload.questions,
|
||||||
|
source: payload.source,
|
||||||
|
status: chunk?.status || '',
|
||||||
|
threadId: chunk?.thread_id || threadId,
|
||||||
|
parentRunId: chunk?.run_id || chunk?.parent_run_id || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useApproval({ getThreadState, fetchThreadMessages }) {
|
export function useApproval({ getThreadState, fetchThreadMessages }) {
|
||||||
const approvalState = reactive({
|
const approvalState = reactive({
|
||||||
showModal: false,
|
showModal: false,
|
||||||
@ -22,34 +40,15 @@ export function useApproval({ getThreadState, fetchThreadMessages }) {
|
|||||||
parentRunId: null
|
parentRunId: null
|
||||||
})
|
})
|
||||||
|
|
||||||
const processApprovalInStream = (chunk, threadId, currentAgentId) => {
|
const applyInterruptToApprovalState = (pendingInterrupt, fallbackThreadId) => {
|
||||||
if (
|
|
||||||
chunk.status !== 'ask_user_question_required' &&
|
|
||||||
chunk.status !== 'human_approval_required'
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const threadState = getThreadState(threadId)
|
|
||||||
if (!threadState) return false
|
|
||||||
|
|
||||||
const payload = extractQuestionPayload(chunk)
|
|
||||||
if (!payload.questions.length) return false
|
|
||||||
|
|
||||||
threadState.isStreaming = false
|
|
||||||
|
|
||||||
approvalState.showModal = true
|
approvalState.showModal = true
|
||||||
approvalState.questions = payload.questions
|
approvalState.questions = pendingInterrupt.questions
|
||||||
approvalState.status = chunk.status || ''
|
approvalState.status = pendingInterrupt.status || ''
|
||||||
approvalState.threadId = chunk.thread_id || threadId
|
approvalState.threadId = pendingInterrupt.threadId || fallbackThreadId
|
||||||
approvalState.parentRunId = chunk.run_id || null
|
approvalState.parentRunId = pendingInterrupt.parentRunId || null
|
||||||
|
|
||||||
fetchThreadMessages({ agentId: currentAgentId, threadId })
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetApprovalState = () => {
|
const clearApprovalState = () => {
|
||||||
approvalState.showModal = false
|
approvalState.showModal = false
|
||||||
approvalState.questions = []
|
approvalState.questions = []
|
||||||
approvalState.status = ''
|
approvalState.status = ''
|
||||||
@ -57,9 +56,56 @@ export function useApproval({ getThreadState, fetchThreadMessages }) {
|
|||||||
approvalState.parentRunId = null
|
approvalState.parentRunId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const processApprovalInStream = (chunk, threadId, currentAgentId) => {
|
||||||
|
if (!APPROVAL_REQUIRED_STATUSES.has(chunk.status)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadState = getThreadState(threadId)
|
||||||
|
if (!threadState) return false
|
||||||
|
|
||||||
|
const pendingInterrupt = extractPendingInterrupt(chunk, threadId)
|
||||||
|
if (!pendingInterrupt) return false
|
||||||
|
|
||||||
|
threadState.isStreaming = false
|
||||||
|
threadState.pendingInterrupt = pendingInterrupt
|
||||||
|
|
||||||
|
applyInterruptToApprovalState(pendingInterrupt, threadId)
|
||||||
|
|
||||||
|
fetchThreadMessages({ agentId: currentAgentId, threadId })
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreInterruptFromThreadState = (threadId) => {
|
||||||
|
const threadState = getThreadState(threadId)
|
||||||
|
const pendingInterrupt = threadState?.pendingInterrupt
|
||||||
|
if (!pendingInterrupt?.questions?.length) return false
|
||||||
|
|
||||||
|
threadState.isStreaming = false
|
||||||
|
threadState.replyLoadingVisible = false
|
||||||
|
threadState.pendingRequestId = null
|
||||||
|
applyInterruptToApprovalState(pendingInterrupt, threadId)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideApprovalState = () => {
|
||||||
|
clearApprovalState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetApprovalState = () => {
|
||||||
|
const threadState = getThreadState(approvalState.threadId)
|
||||||
|
if (threadState) {
|
||||||
|
threadState.pendingInterrupt = null
|
||||||
|
}
|
||||||
|
clearApprovalState()
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
approvalState,
|
approvalState,
|
||||||
processApprovalInStream,
|
processApprovalInStream,
|
||||||
|
restoreInterruptFromThreadState,
|
||||||
|
hideApprovalState,
|
||||||
resetApprovalState
|
resetApprovalState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user