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 yuxi.storage.postgres.models_business import AgentRun
|
||||
|
||||
active_result = await db.execute(
|
||||
# 线程内的 run 是串行的,最近一条 run 即代表线程当前状态。
|
||||
# 已被回复的 interrupted run 会被更晚创建的 resume run 取代,因此不会再被当作待处理中断返回。
|
||||
result = await db.execute(
|
||||
select(AgentRun)
|
||||
.where(
|
||||
AgentRun.thread_id == thread_id,
|
||||
AgentRun.uid == str(current_uid),
|
||||
AgentRun.run_type.in_(["chat", "resume"]),
|
||||
AgentRun.status.in_(["pending", "running", "cancel_requested"]),
|
||||
)
|
||||
.order_by(AgentRun.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
run = active_result.scalar_one_or_none()
|
||||
if not run:
|
||||
interrupted_result = await db.execute(
|
||||
select(AgentRun)
|
||||
.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}
|
||||
run = result.scalar_one_or_none()
|
||||
if run and run.status in ("pending", "running", "cancel_requested", "interrupted"):
|
||||
return {"run": run.to_dict()}
|
||||
return {"run": 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 }">
|
||||
<!-- 人工审批弹窗 - 放在输入框上方 -->
|
||||
<HumanApprovalModal
|
||||
:visible="approvalState.showModal"
|
||||
:questions="approvalState.questions"
|
||||
:visible="currentApprovalModalVisible"
|
||||
:questions="currentApprovalQuestions"
|
||||
@submit="handleQuestionSubmit"
|
||||
@cancel="handleQuestionCancel"
|
||||
/>
|
||||
@ -954,18 +954,38 @@ const currentThreadConfigNotice = computed(() => {
|
||||
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 = () =>
|
||||
approvalState.showModal ||
|
||||
currentApprovalModalVisible.value ||
|
||||
Boolean(
|
||||
approvalState.threadId &&
|
||||
chatState.currentThreadId === approvalState.threadId &&
|
||||
currentChatId.value === approvalState.threadId &&
|
||||
isProcessing.value
|
||||
)
|
||||
|
||||
// 计算是否显示Refs组件的条件
|
||||
const shouldShowRefs = computed(() => {
|
||||
const convs = conversations.value
|
||||
const lastConv = convs.length ? convs[convs.length - 1] : null
|
||||
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(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', handlePageVisibilityChange)
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
const chatMainContainer = document.querySelector('.chat-main')
|
||||
if (chatMainContainer) {
|
||||
@ -1532,6 +1556,9 @@ onDeactivated(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', handlePageVisibilityChange)
|
||||
}
|
||||
scrollController.cleanup()
|
||||
stopChatMainResizeObserver()
|
||||
stopStreamingStateRefresh()
|
||||
@ -1719,11 +1746,21 @@ const handleAttachmentRemove = async (attachment) => {
|
||||
}
|
||||
|
||||
// ==================== 审批功能管理 ====================
|
||||
const { approvalState, processApprovalInStream } = useApproval({
|
||||
const {
|
||||
approvalState,
|
||||
processApprovalInStream,
|
||||
restoreInterruptFromThreadState,
|
||||
hideApprovalState
|
||||
} = useApproval({
|
||||
getThreadState,
|
||||
fetchThreadMessages
|
||||
})
|
||||
|
||||
const restorePendingInterruptForThread = (threadId) => {
|
||||
if (!threadId) return false
|
||||
return restoreInterruptFromThreadState(threadId)
|
||||
}
|
||||
|
||||
const { handleStreamChunk } = useAgentStreamHandler({
|
||||
getThreadState,
|
||||
processApprovalInStream,
|
||||
@ -1739,9 +1776,35 @@ const { startRunStream, resumeActiveRunForThread, stopRunStreamSubscription } =
|
||||
fetchAgentState,
|
||||
resetOnGoingConv,
|
||||
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 ====================
|
||||
// 获取第一个非置顶的对话
|
||||
const getFirstNonPinnedChat = (chatList) => {
|
||||
@ -1809,6 +1872,7 @@ const selectChat = async (chatId) => {
|
||||
await handleAgentStateRefresh(chatId)
|
||||
syncThreadConfigSnapshot(chatId, { overwrite: false })
|
||||
await resumeActiveRunForThread(chatId)
|
||||
restorePendingInterruptForThread(chatId)
|
||||
}
|
||||
|
||||
const selectThreadFromRoute = async (threadId) => {
|
||||
@ -1885,6 +1949,10 @@ const handleSendMessage = async ({ image } = {}) => {
|
||||
|
||||
const threadState = getThreadState(threadId)
|
||||
if (!threadState) return
|
||||
threadState.pendingInterrupt = null
|
||||
if (approvalState.threadId === threadId) {
|
||||
hideApprovalState()
|
||||
}
|
||||
|
||||
const pendingAttachments = [...currentPendingThreadAttachments.value]
|
||||
const pendingAttachmentFileIds = pendingAttachments
|
||||
@ -1966,6 +2034,10 @@ const handleSendOrStop = async (payload) => {
|
||||
if (isProcessing.value && threadState?.activeRunId) {
|
||||
try {
|
||||
await agentApi.cancelAgentRun(threadState.activeRunId)
|
||||
threadState.pendingInterrupt = null
|
||||
if (approvalState.threadId === threadId) {
|
||||
hideApprovalState()
|
||||
}
|
||||
message.info('已发送取消请求')
|
||||
} catch (error) {
|
||||
handleChatError(error, 'stop')
|
||||
@ -1979,6 +2051,7 @@ const handleSendOrStop = async (payload) => {
|
||||
// ==================== 人工审批处理 ====================
|
||||
const handleApprovalWithStream = async (answer) => {
|
||||
const threadId = approvalState.threadId
|
||||
const parentRunId = approvalState.parentRunId
|
||||
if (!threadId) {
|
||||
message.error('无效的提问请求')
|
||||
approvalState.showModal = false
|
||||
@ -1992,14 +2065,17 @@ const handleApprovalWithStream = async (answer) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!approvalState.parentRunId) {
|
||||
if (!parentRunId) {
|
||||
message.error('无法找到需要恢复的运行任务')
|
||||
approvalState.showModal = false
|
||||
return
|
||||
}
|
||||
|
||||
const pendingInterrupt = threadState.pendingInterrupt
|
||||
|
||||
try {
|
||||
approvalState.showModal = false
|
||||
hideApprovalState()
|
||||
threadState.pendingInterrupt = null
|
||||
threadState.isStreaming = true
|
||||
resetOnGoingConv(threadId)
|
||||
const resumeRequestId = createClientRequestId()
|
||||
@ -2009,7 +2085,7 @@ const handleApprovalWithStream = async (answer) => {
|
||||
thread_id: threadId,
|
||||
meta: { request_id: resumeRequestId },
|
||||
resume: answer,
|
||||
parent_run_id: approvalState.parentRunId,
|
||||
parent_run_id: parentRunId,
|
||||
resume_request_id: resumeRequestId
|
||||
})
|
||||
const runId = runResp?.run_id
|
||||
@ -2018,6 +2094,10 @@ const handleApprovalWithStream = async (answer) => {
|
||||
}
|
||||
await startRunStream(threadId, runId, '0-0')
|
||||
} catch (error) {
|
||||
if (pendingInterrupt) {
|
||||
threadState.pendingInterrupt = pendingInterrupt
|
||||
restorePendingInterruptForThread(threadId)
|
||||
}
|
||||
threadState.isStreaming = false
|
||||
threadState.replyLoadingVisible = false
|
||||
handleChatError(error, 'resume')
|
||||
@ -2179,6 +2259,12 @@ const showMsgRefs = (msg) => {
|
||||
return false
|
||||
}
|
||||
|
||||
// 回复生成中(含 resume 续写的空窗期)不在最后一条消息上过早显示操作栏/来源。
|
||||
// isReplyLoading 兜底:切换/重连时 isStreaming 可能已置 false,但回复仍在生成。
|
||||
if (msg.isLast && (isProcessing.value || isReplyLoading.value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 只有真正完成的消息才显示 refs
|
||||
if (msg.isLast && msg.status === 'finished') {
|
||||
return ['copy', 'sources']
|
||||
@ -2333,6 +2419,12 @@ watch(
|
||||
|
||||
watch(currentChatId, (threadId, oldThreadId) => {
|
||||
if (threadId === oldThreadId) return
|
||||
if (!threadId || approvalState.threadId !== threadId) {
|
||||
hideApprovalState()
|
||||
}
|
||||
if (threadId) {
|
||||
restorePendingInterruptForThread(threadId)
|
||||
}
|
||||
emit('thread-change', threadId || '')
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -1,11 +1,45 @@
|
||||
<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>
|
||||
<div class="sep-header">
|
||||
<span class="note">提问</span>
|
||||
<span class="separator">|</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>
|
||||
</template>
|
||||
</BaseToolCall>
|
||||
@ -14,51 +48,55 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import BaseToolCall from '../BaseToolCall.vue'
|
||||
import { normalizeQuestions } from '@/utils/questionUtils'
|
||||
|
||||
const props = defineProps({
|
||||
toolCall: {
|
||||
type: Object,
|
||||
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 args = props.toolCall.args || props.toolCall.function?.arguments
|
||||
if (!args) return {}
|
||||
if (typeof args === 'object') return args
|
||||
try {
|
||||
return JSON.parse(args)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
const args = props.toolCall.args ?? props.toolCall.function?.arguments
|
||||
return parseJsonValue(args, {})
|
||||
})
|
||||
|
||||
// 解析结果
|
||||
const parsedResult = computed(() => {
|
||||
const content = props.toolCall.tool_call_result?.content
|
||||
if (!content) return null
|
||||
if (typeof content === 'object') return content
|
||||
try {
|
||||
return JSON.parse(content)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const content = props.toolCall.tool_call_result?.content ?? props.toolCall.result
|
||||
return parseJsonValue(content, null)
|
||||
})
|
||||
|
||||
const questions = computed(() => {
|
||||
const rawQuestions = parsedArgs.value.questions
|
||||
if (!Array.isArray(rawQuestions)) return []
|
||||
const argsQuestions = parsedArgs.value?.questions
|
||||
if (Array.isArray(argsQuestions) && argsQuestions.length) {
|
||||
return normalizeQuestions(argsQuestions)
|
||||
}
|
||||
|
||||
return rawQuestions
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') return null
|
||||
const question = String(item.question || '').trim()
|
||||
if (!question) return null
|
||||
const questionId = String(item.question_id || item.questionId || '').trim()
|
||||
return { questionId, question }
|
||||
})
|
||||
.filter(Boolean)
|
||||
const resultQuestions = parsedResult.value?.questions
|
||||
if (Array.isArray(resultQuestions) && resultQuestions.length) {
|
||||
return normalizeQuestions(resultQuestions)
|
||||
}
|
||||
|
||||
return []
|
||||
})
|
||||
|
||||
const shortQuestionSummary = computed(() => {
|
||||
@ -72,54 +110,85 @@ const shortQuestionSummary = computed(() => {
|
||||
return `${shortFirstQuestion} 等 ${questions.value.length} 题`
|
||||
})
|
||||
|
||||
// 用户答案
|
||||
const userAnswer = computed(() => {
|
||||
const result = parsedResult.value
|
||||
if (!result) return 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)) {
|
||||
return answer.join(', ')
|
||||
return answer
|
||||
.map((item) => getOptionLabel(item, questionItem))
|
||||
.filter(Boolean)
|
||||
.join('、')
|
||||
}
|
||||
|
||||
if (answer && typeof answer === 'object') {
|
||||
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 String(answer)
|
||||
|
||||
return getOptionLabel(answer, questionItem)
|
||||
}
|
||||
|
||||
// 显示答案
|
||||
const displayAnswer = computed(() => {
|
||||
const getQuestionAnswer = (questionItem) => {
|
||||
const answer = userAnswer.value
|
||||
if (!answer) return ''
|
||||
if (answer === null || answer === undefined || answer === '') return undefined
|
||||
|
||||
if (Array.isArray(answer)) {
|
||||
return answer.join(', ')
|
||||
if (questions.value.length === 1 && (typeof answer !== 'object' || Array.isArray(answer))) {
|
||||
return answer
|
||||
}
|
||||
|
||||
if (answer && typeof answer === 'object') {
|
||||
if (answer.type === 'other') {
|
||||
return `Other: ${String(answer.text || '').trim()}`
|
||||
if (answer && typeof answer === 'object' && !Array.isArray(answer)) {
|
||||
if (Object.prototype.hasOwnProperty.call(answer, questionItem.questionId)) {
|
||||
return answer[questionItem.questionId]
|
||||
}
|
||||
|
||||
const entries = Object.entries(answer)
|
||||
if (!entries.length) return ''
|
||||
|
||||
const summary = entries
|
||||
.map(([questionId, value]) => {
|
||||
const title = String(questionId || '').trim()
|
||||
return `${title}: ${formatSingleAnswer(value)}`
|
||||
})
|
||||
.join(' | ')
|
||||
|
||||
return summary.length > 120 ? summary.slice(0, 120) + '...' : summary
|
||||
if (questions.value.length === 1 && answer.type === 'other') {
|
||||
return answer
|
||||
}
|
||||
}
|
||||
|
||||
return String(answer)
|
||||
return undefined
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -127,11 +196,100 @@ const displayAnswer = computed(() => {
|
||||
.sep-header {
|
||||
.tag-answered {
|
||||
margin-left: 8px;
|
||||
color: var(--green-600);
|
||||
background: var(--green-50);
|
||||
color: var(--color-success-700);
|
||||
background: var(--color-success-50);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
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>
|
||||
|
||||
@ -7,7 +7,8 @@ import {
|
||||
resolveRunResumeAfterSeq
|
||||
} 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_CLIENT_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
|
||||
@ -102,7 +103,9 @@ export function useAgentRunStream({
|
||||
fetchAgentState,
|
||||
resetOnGoingConv,
|
||||
onScrollToBottom,
|
||||
streamSmoother
|
||||
streamSmoother,
|
||||
onInterruptDetected = null,
|
||||
onTerminalDetected = null
|
||||
}) {
|
||||
const saveActiveRunSnapshot = (threadId, runId, lastSeq = '0-0') => {
|
||||
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 = (
|
||||
threadId,
|
||||
runId,
|
||||
touchedThreadIds,
|
||||
{ delay = 200, scroll = false } = {}
|
||||
{ delay = 200, scroll = false, status = '' } = {}
|
||||
) => {
|
||||
const ts = getThreadState(threadId)
|
||||
if (!ts || ts.activeRunId !== runId) return
|
||||
const isInterrupted =
|
||||
status === RUN_INTERRUPTED_STATUS && hasPendingInterruptInThreads(touchedThreadIds, runId)
|
||||
touchedThreadIds.forEach((id) => streamSmoother?.flushThread(id))
|
||||
ts.isStreaming = false
|
||||
ts.activeRunId = null
|
||||
if (isInterrupted) {
|
||||
ts.activeRunId = runId
|
||||
saveActiveRunSnapshot(threadId, runId, ts.runLastSeq)
|
||||
} else {
|
||||
ts.activeRunId = null
|
||||
clearActiveRunSnapshot(threadId)
|
||||
touchedThreadIds.forEach((id) => clearPendingInterruptForRun(id, runId))
|
||||
}
|
||||
ts.lastRetryableJobTry = null
|
||||
ts.replyLoadingVisible = false
|
||||
ts.pendingRequestId = null
|
||||
clearActiveRunSnapshot(threadId)
|
||||
fetchThreadMessages({ agentId: unref(currentAgentId), threadId, delay }).finally(() => {
|
||||
resetOnGoingConv(threadId)
|
||||
fetchAgentState(unref(currentAgentId), threadId)
|
||||
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 ts = getThreadState(threadId)
|
||||
if (!ts || ts.activeRunId !== runId) return
|
||||
@ -263,8 +329,10 @@ export function useAgentRunStream({
|
||||
|
||||
if (event === 'end') {
|
||||
sawTerminalEvent = true
|
||||
if (RUN_TERMINAL_STATUSES.has(terminalStatus)) {
|
||||
finalizeRunStream(threadId, runId, touchedThreadIds)
|
||||
if (terminalStatus === RUN_INTERRUPTED_STATUS) {
|
||||
finalizeRunStream(threadId, runId, touchedThreadIds, { status: terminalStatus })
|
||||
} else if (RUN_TERMINAL_STATUSES.has(terminalStatus)) {
|
||||
finalizeRunStream(threadId, runId, touchedThreadIds, { status: terminalStatus })
|
||||
} else {
|
||||
touchedThreadIds.forEach((id) => streamSmoother?.flushThread(id))
|
||||
ts.isStreaming = false
|
||||
@ -281,8 +349,14 @@ export function useAgentRunStream({
|
||||
try {
|
||||
const runRes = await agentApi.getAgentRun(runId)
|
||||
const run = runRes?.run
|
||||
if (run && RUN_TERMINAL_STATUSES.has(run.status)) {
|
||||
finalizeRunStream(threadId, runId, touchedThreadIds)
|
||||
if (run?.status === RUN_INTERRUPTED_STATUS) {
|
||||
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 {
|
||||
scheduleRunReconnect(threadId, runId)
|
||||
}
|
||||
@ -319,7 +393,37 @@ export function useAgentRunStream({
|
||||
const resumeActiveRunForThread = async (threadId) => {
|
||||
if (!threadId) return
|
||||
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)
|
||||
if (snapshot?.run_id) {
|
||||
@ -329,7 +433,14 @@ export function useAgentRunStream({
|
||||
try {
|
||||
const runRes = await agentApi.getAgentRun(snapshot.run_id)
|
||||
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({
|
||||
snapshot,
|
||||
threadState: ts
|
||||
@ -350,6 +461,15 @@ export function useAgentRunStream({
|
||||
try {
|
||||
const active = await agentApi.getThreadActiveRun(threadId)
|
||||
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)) {
|
||||
resetOnGoingConv(threadId)
|
||||
await startRunStream(threadId, run.id, '0-0')
|
||||
@ -364,7 +484,9 @@ export function useAgentRunStream({
|
||||
ts.isStreaming = false
|
||||
ts.replyLoadingVisible = false
|
||||
ts.pendingRequestId = null
|
||||
ts.pendingInterrupt = null
|
||||
clearActiveRunSnapshot(threadId)
|
||||
notifyTerminalDetected(threadId, null, new Set([threadId]))
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { message } from 'ant-design-vue'
|
||||
import { handleChatError } from '@/utils/errorHandler'
|
||||
import { unref } from 'vue'
|
||||
import { extractPendingInterrupt } from '@/composables/useApproval'
|
||||
|
||||
const serializeToolArgs = (args) => {
|
||||
if (typeof args === 'string') return args
|
||||
@ -158,6 +159,7 @@ export function useAgentStreamHandler({
|
||||
threadState.isStreaming = false
|
||||
threadState.replyLoadingVisible = false
|
||||
threadState.pendingRequestId = null
|
||||
threadState.pendingInterrupt = null
|
||||
}
|
||||
return true
|
||||
|
||||
@ -208,6 +210,7 @@ export function useAgentStreamHandler({
|
||||
threadState.isStreaming = false
|
||||
threadState.replyLoadingVisible = false
|
||||
threadState.pendingRequestId = null
|
||||
threadState.pendingInterrupt = null
|
||||
console.log(`${debugPrefix}[finished]`, {
|
||||
threadId,
|
||||
currentAgentId: unref(currentAgentId),
|
||||
@ -239,6 +242,10 @@ export function useAgentStreamHandler({
|
||||
threadState.isStreaming = false
|
||||
threadState.replyLoadingVisible = false
|
||||
threadState.pendingRequestId = null
|
||||
const pendingInterrupt = extractPendingInterrupt(chunk, threadId)
|
||||
if (pendingInterrupt) {
|
||||
threadState.pendingInterrupt = pendingInterrupt
|
||||
}
|
||||
}
|
||||
// 如果有 message 字段,显示提示(例如:敏感内容检测)
|
||||
if (chunkMessage) {
|
||||
|
||||
@ -29,6 +29,7 @@ export function useAgentThreadState({
|
||||
lastRetryableJobTry: null,
|
||||
replyLoadingVisible: false,
|
||||
pendingRequestId: null,
|
||||
pendingInterrupt: null,
|
||||
onGoingConv: createOnGoingConvState(),
|
||||
agentState: null
|
||||
}
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { reactive } from 'vue'
|
||||
import { normalizeQuestions } from '@/utils/questionUtils'
|
||||
|
||||
const APPROVAL_REQUIRED_STATUSES = new Set([
|
||||
'ask_user_question_required',
|
||||
'human_approval_required'
|
||||
])
|
||||
|
||||
const extractQuestionPayload = (chunk) => {
|
||||
const interruptInfo = chunk?.interrupt_info || {}
|
||||
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 }) {
|
||||
const approvalState = reactive({
|
||||
showModal: false,
|
||||
@ -22,34 +40,15 @@ export function useApproval({ getThreadState, fetchThreadMessages }) {
|
||||
parentRunId: null
|
||||
})
|
||||
|
||||
const processApprovalInStream = (chunk, threadId, currentAgentId) => {
|
||||
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
|
||||
|
||||
const applyInterruptToApprovalState = (pendingInterrupt, fallbackThreadId) => {
|
||||
approvalState.showModal = true
|
||||
approvalState.questions = payload.questions
|
||||
approvalState.status = chunk.status || ''
|
||||
approvalState.threadId = chunk.thread_id || threadId
|
||||
approvalState.parentRunId = chunk.run_id || null
|
||||
|
||||
fetchThreadMessages({ agentId: currentAgentId, threadId })
|
||||
|
||||
return true
|
||||
approvalState.questions = pendingInterrupt.questions
|
||||
approvalState.status = pendingInterrupt.status || ''
|
||||
approvalState.threadId = pendingInterrupt.threadId || fallbackThreadId
|
||||
approvalState.parentRunId = pendingInterrupt.parentRunId || null
|
||||
}
|
||||
|
||||
const resetApprovalState = () => {
|
||||
const clearApprovalState = () => {
|
||||
approvalState.showModal = false
|
||||
approvalState.questions = []
|
||||
approvalState.status = ''
|
||||
@ -57,9 +56,56 @@ export function useApproval({ getThreadState, fetchThreadMessages }) {
|
||||
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 {
|
||||
approvalState,
|
||||
processApprovalInStream,
|
||||
restoreInterruptFromThreadState,
|
||||
hideApprovalState,
|
||||
resetApprovalState
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user