From 6248e2c7b8f9c1ba7567d5d1ec638d034156a696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Wed, 26 Aug 2026 22:55:20 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=AF=E6=8C=81=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E5=90=8E=E7=BB=A7=E7=BB=AD=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将本轮文档上下文纳入可持久化的 Agent 用户消息 =- 在异常提示中提供请重试动作并自动发送继续 --- .../agent/runtime/AgentRunService.java | 40 ++++--- .../AgentRunServiceDraftAndHitlTest.java | 23 ++++ .../agentChatRuntimeManager.test.ts | 51 +++++++++ .../ai/agent-chat/agentChatRuntimeManager.ts | 30 +++++- .../app/src/views/ai/agent-chat/index.vue | 100 ++++++++++++++---- .../views/ai/shared/agent-agui/projection.ts | 3 +- .../chat-timeline/ChatErrorNotice.vue | 56 +++++++++- .../components/chat-timeline/ChatTimeline.vue | 14 +++ .../chat-timeline/ChatTimelineItem.vue | 7 ++ .../chat-timeline/ChatTimelineTurn.vue | 14 +++ .../__tests__/ChatTimelineToolbar.test.ts | 22 ++++ 11 files changed, 311 insertions(+), 49 deletions(-) diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunService.java index f168a3c5..94954729 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunService.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/AgentRunService.java @@ -423,7 +423,7 @@ public class AgentRunService { chatContext.getExt().put(DOCUMENT_CONTEXT_TOKEN_ESTIMATE_EXT_KEY, documentContext.tokenEstimate()); String runtimePrompt = effectivePrompt(prompt, !boundDocuments.isEmpty(), !boundMedia.isEmpty()); - AgentMessage userMessage = buildAgentMessage(runtimePrompt, boundMedia); + AgentMessage userMessage = buildAgentMessage(runtimePrompt, boundMedia, documentContext); threadPoolTaskExecutor.execute(() -> startRuntime( agent, userMessage, documentContext, account, requestId, traceId, runtimeSessionId, assistantCode, chatContext, runOutput, persistChatlog, runtimeSessionStore, lockHandle)); @@ -646,24 +646,6 @@ public class AgentRunService { return agentDocumentService.bindDraft(documentUploads); } - /** - * 将本轮文档正文追加到临时运行定义的系统提示词中。 - * - *

正文只存在于本轮模型调用定义,不写入 chatlog 或 AgentScope 消息记忆。

- * - * @param bundle 临时运行时编译结果 - * @param documentContext 本轮文档上下文 - */ - private void appendDocumentContext(AgentRuntimeBundle bundle, AgentDocumentContext documentContext) { - if (bundle == null || bundle.getDefinition() == null - || documentContext == null || documentContext.text().isBlank()) { - return; - } - String current = bundle.getDefinition().getSystemPrompt(); - bundle.getDefinition().setSystemPrompt( - (current == null ? "" : current) + documentContext.text()); - } - /** * 为仅附件输入生成可持久化的最小用户意图。 * @@ -1206,7 +1188,6 @@ public class AgentRunService { } AgentRuntimeContext runtimeContext = buildAgentRuntimeContext(chatContext, traceId, runtimeSessionId); AgentRuntimeBundle bundle = agentRuntimeCompiler.compile(agent, runtimeContext, !persistChatlog); - appendDocumentContext(bundle, documentContext); AgentRuntime runtime = agentRuntimeFactory.create(); // 会话初始化请求 AgentInitRequest request = new AgentInitRequest(); @@ -2340,13 +2321,30 @@ public class AgentRunService { return message; } - private AgentMessage buildAgentMessage(String prompt, List media) { + /** + * 构建发送给 AgentScope 的用户消息。 + * + *

文档正文属于用户提供的不可信材料,作为用户内容块进入本轮模型调用和 AgentScope + * memory。聊天记录仍单独保存原始输入与附件引用,页面不会展示正文内容块。

+ * + * @param prompt 用户输入 + * @param media 图片附件 + * @param documentContext 本轮选中的文档上下文 + * @return 可持久化的运行时用户消息 + */ + private AgentMessage buildAgentMessage(String prompt, + List media, + AgentDocumentContext documentContext) { AgentMessage message = new AgentMessage(); message.setRole(AgentMessageRole.USER); List blocks = new ArrayList<>(); if (prompt != null && !prompt.isBlank()) { blocks.add(new AgentTextBlock(prompt)); } + if (documentContext != null && documentContext.text() != null + && !documentContext.text().isBlank()) { + blocks.add(new AgentTextBlock(documentContext.text())); + } if (media != null) { for (AgentBoundMedia item : media) { AgentMediaBlock image = new AgentMediaBlock("image"); diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServiceDraftAndHitlTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServiceDraftAndHitlTest.java index 9d2605a8..c396f42d 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServiceDraftAndHitlTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/AgentRunServiceDraftAndHitlTest.java @@ -10,6 +10,7 @@ import com.easyagents.agent.runtime.event.AgentRuntimeEventType; import com.easyagents.agent.runtime.message.AgentKnowledgeReference; import com.easyagents.agent.runtime.message.AgentMessage; import com.easyagents.agent.runtime.message.AgentMessageRole; +import com.easyagents.agent.runtime.message.AgentTextBlock; import com.easyagents.agent.runtime.persistence.session.AgentSessionStore; import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore; import org.junit.Assert; @@ -69,6 +70,28 @@ import java.util.concurrent.atomic.AtomicBoolean; */ public class AgentRunServiceDraftAndHitlTest { + /** + * 验证文档上下文随用户消息进入可持久化 memory,同时保持独立内容块边界。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + public void buildAgentMessageShouldIncludeDocumentContext() throws Exception { + AgentRunService service = new AgentRunService(); + AgentDocumentContext documentContext = new AgentDocumentContext( + "\n<<>>\n正文\n<<>>", 8, List.of()); + + AgentMessage message = invoke(service, "buildAgentMessage", + new Class[]{String.class, List.class, AgentDocumentContext.class}, + "请介绍文档", List.of(), documentContext); + + Assert.assertEquals(2, message.getContentBlocks().size()); + Assert.assertEquals("请介绍文档", + ((AgentTextBlock) message.getContentBlocks().get(0)).getText()); + Assert.assertEquals(documentContext.text(), + ((AgentTextBlock) message.getContentBlocks().get(1)).getText()); + } + /** * 创建用于 owner 恢复测试的运行描述。 * diff --git a/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.test.ts b/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.test.ts index 19e9390d..f2282273 100644 --- a/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.test.ts @@ -181,6 +181,57 @@ describe('agentChatRuntimeManager', () => { expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined(); }); + it('输入已确认后将模型错误转换为可重试的用户提示', async () => { + let resolveRun: (() => void) | undefined; + let runOptions: EasyFlowAguiRunOptions | undefined; + aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => { + runOptions = options; + return new Promise((resolve) => { + resolveRun = resolve; + }); + }); + useUserStore().setUserInfo({ + avatar: '', + id: 'retry-user', + loginName: 'retry-user', + nickname: '重试用户', + tenantId: 'tenant-1', + }); + + await agentChatRuntimeManager.start({ + agentId: 'agent-1', + prompt: '介绍文档', + sessionId: 'retry-session', + }); + runOptions?.onEvent({ + name: easyFlowAguiCustomEvent.inputAccepted, + type: EventType.CUSTOM, + value: {}, + }); + runOptions?.onEvent({ + code: 'MODEL_ERROR', + message: 'Retries exhausted: 2/2', + runId: 'run-test', + threadId: 'retry-session', + type: EventType.RUN_ERROR, + }); + resolveRun?.(); + await Promise.resolve(); + await Promise.resolve(); + + const snapshot = agentChatRuntimeManager.getSnapshot('retry-session'); + expect(snapshot).toEqual( + expect.objectContaining({ + error: '模型连接异常', + retryContextReady: true, + terminalOutcome: 'failed', + }), + ); + expect(snapshot?.items.find((item) => item.type === 'error')).toEqual( + expect.objectContaining({ message: '模型连接异常' }), + ); + }); + it('刷新恢复已确认输入时重新触发草稿清理回调', async () => { const account = { avatar: '', diff --git a/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.ts b/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.ts index f0283ab7..1ed872cc 100644 --- a/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.ts +++ b/easyflow-ui-admin/app/src/views/ai/agent-chat/agentChatRuntimeManager.ts @@ -45,6 +45,7 @@ interface RuntimeSessionState { prompt: string; projectionToolArgs: Record; projectionToolNames: Record; + retryContextReady: boolean; runId?: string; roundId: string; sending: boolean; @@ -65,6 +66,7 @@ interface StoredRuntimeSession { prompt: string; projectionToolArgs?: Record; projectionToolNames?: Record; + retryContextReady?: boolean; runId?: string; roundId: string; sending: boolean; @@ -86,10 +88,12 @@ interface StartOptions { images?: ChatImageAttachment[]; onInputAccepted?: () => Promise | void; prompt: string; + retryContextReady?: boolean; sessionId?: string; } -const STORAGE_VERSION = 5; +const STORAGE_VERSION = 6; +const MODEL_CONNECTION_ERROR_MESSAGE = '模型连接异常'; const STREAM_NOTIFY_INTERVAL_MS = 50; const STREAM_PERSIST_INTERVAL_MS = 300; const MAX_RUNTIME_SESSIONS_PER_IDENTITY = 10; @@ -177,6 +181,7 @@ function persistSession(state: RuntimeSessionState) { prompt: state.prompt, projectionToolArgs: state.projectionToolArgs, projectionToolNames: state.projectionToolNames, + retryContextReady: state.retryContextReady, runId: state.runId, roundId: state.roundId, sending: state.sending, @@ -298,7 +303,7 @@ function restoreSession(identity: string, sessionId: string) { } const parsed = JSON.parse(raw) as StoredRuntimeSession; if ( - ![3, 4, STORAGE_VERSION].includes(parsed.version) || + ![3, 4, 5, STORAGE_VERSION].includes(parsed.version) || parsed.sessionId !== sessionId ) { return undefined; @@ -324,6 +329,7 @@ function restoreSession(identity: string, sessionId: string) { typeof parsed.projectionToolNames === 'object' ? parsed.projectionToolNames : {}, + retryContextReady: Boolean(parsed.retryContextReady), runId: parsed.runId, roundId: parsed.roundId, sending: Boolean(parsed.sending && parsed.runId), @@ -386,6 +392,7 @@ function acceptInput( ) { replaceAcceptedAttachments(state.items, state.roundId, payload); state.inputAccepted = true; + state.retryContextReady = true; persistSession(state); notifyInputAccepted(state); } @@ -563,7 +570,9 @@ function finishRuntimeSuccess(state: RuntimeSessionState) { } function finishRuntimeFailure(state: RuntimeSessionState, error: unknown) { - state.error = errorMessage(error); + state.error = state.retryContextReady + ? MODEL_CONNECTION_ERROR_MESSAGE + : errorMessage(error); state.sending = false; state.completed = true; const last = state.items[state.items.length - 1]; @@ -664,7 +673,9 @@ export const agentChatRuntimeManager = { event.type === EventType.RUN_ERROR && event.code !== 'RUN_CANCELLED' ) { - current.error = event.message || '发送失败,请稍后再试'; + current.error = current.retryContextReady + ? MODEL_CONNECTION_ERROR_MESSAGE + : event.message || '发送失败,请稍后再试'; } observeTerminalEvent(current, event.type); applyAguiEventToTimeline( @@ -674,6 +685,9 @@ export const agentChatRuntimeManager = { onInputAccepted(payload) { acceptInput(current, payload); }, + runErrorMessage: current.retryContextReady + ? MODEL_CONNECTION_ERROR_MESSAGE + : undefined, roundId: current.roundId, startedAt: current.startedAt, }, @@ -757,6 +771,7 @@ export const agentChatRuntimeManager = { prompt: options.prompt, projectionToolArgs: {}, projectionToolNames: {}, + retryContextReady: Boolean(options.retryContextReady), runId, roundId, sending: true, @@ -794,7 +809,9 @@ export const agentChatRuntimeManager = { event.type === EventType.RUN_ERROR && event.code !== 'RUN_CANCELLED' ) { - current.error = event.message || '发送失败,请稍后再试'; + current.error = current.retryContextReady + ? MODEL_CONNECTION_ERROR_MESSAGE + : event.message || '发送失败,请稍后再试'; } observeTerminalEvent(current, event.type); applyAguiEventToTimeline( @@ -804,6 +821,9 @@ export const agentChatRuntimeManager = { onInputAccepted(payload) { acceptInput(current, payload); }, + runErrorMessage: current.retryContextReady + ? MODEL_CONNECTION_ERROR_MESSAGE + : undefined, roundId, startedAt, }, diff --git a/easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue b/easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue index 4375c0fe..88b6c35b 100644 --- a/easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue +++ b/easyflow-ui-admin/app/src/views/ai/agent-chat/index.vue @@ -2,6 +2,7 @@ import type { ChatDocumentAttachment, ChatImageAttachment, + ChatTimelineErrorItem, ChatTimelineItem, ChatTimelineMessageItem, ChatTimelineToolApprovalPayload, @@ -108,6 +109,7 @@ const loadingKnowledges = ref(false); const savingExtraKnowledges = ref(false); const sending = ref(false); const runtimeRunning = ref(false); +const retryableErrorRoundId = ref(''); const approvalLoadingKey = ref(''); const knowledgeOptions = ref<{ label: string; value: string }[]>([]); const knowledgeMap = ref(new Map()); @@ -463,12 +465,19 @@ function syncRuntimeSnapshot(sessionId = currentSessionId.value) { : undefined; if (!snapshot) { sending.value = false; + retryableErrorRoundId.value = ''; return false; } currentSessionId.value = snapshot.sessionId; selectedAgentId.value = String(snapshot.agentId); timelineItems.value = snapshot.items; sending.value = snapshot.sending; + retryableErrorRoundId.value = + snapshot.terminalOutcome === 'failed' && + snapshot.retryContextReady && + !snapshot.sending + ? snapshot.roundId + : ''; maybeRefreshCompletedRuntimeSession(snapshot); if (snapshot.prompt && !currentSession.value) { upsertSessionRecord( @@ -492,15 +501,20 @@ async function loadConversation(sessionId: string) { if (!sessionId) { timelineItems.value = []; sending.value = false; + retryableErrorRoundId.value = ''; return; } const runtimeSnapshot = agentChatRuntimeManager.getSnapshot(sessionId); - if (runtimeSnapshot?.sending) { + if ( + runtimeSnapshot?.sending || + runtimeSnapshot?.terminalOutcome === 'failed' + ) { syncRuntimeSnapshot(sessionId); await syncSessionRoute(sessionId); return; } loadingConversation.value = true; + retryableErrorRoundId.value = ''; try { const detailRes = await getAgentSession(sessionId); const res = await getAgentConversation(sessionId); @@ -660,12 +674,25 @@ function buildCapabilities() { ]; } -async function sendContent(rawContent: string) { +interface SendContentOptions { + includeComposer?: boolean; + retryContextReady?: boolean; +} + +async function sendContent( + rawContent: string, + options: SendContentOptions = {}, +) { const content = rawContent.trim(); + const includeComposer = options.includeComposer !== false; + const readyImageCount = includeComposer + ? composer.images.readyItems.value.length + : 0; + const readyDocumentCount = includeComposer + ? composer.documents.readyItems.value.length + : 0; if ( - (!content && - composer.images.readyItems.value.length === 0 && - composer.documents.readyItems.value.length === 0) || + (!content && readyImageCount === 0 && readyDocumentCount === 0) || !selectedAgentId.value || sending.value ) { @@ -675,44 +702,54 @@ async function sendContent(rawContent: string) { ElMessage.warning('当前回复完成后再发送新消息'); return; } - if (composer.images.uploading.value) { + if (includeComposer && composer.images.uploading.value) { ElMessage.warning('图片上传完成后再发送'); return; } - if (composer.documents.processing.value) { + if (includeComposer && composer.documents.processing.value) { ElMessage.warning('文档读取完成后再发送'); return; } - const failedImage = composer.images.items.value.find( - (item) => item.status === 'error', - ); + const failedImage = includeComposer + ? composer.images.items.value.find((item) => item.status === 'error') + : undefined; if (failedImage) { ElMessage.error(failedImage.error || '请处理上传失败的图片'); return; } - const failedDocument = composer.documents.items.value.find( - (item) => item.status === 'error', - ); + const failedDocument = includeComposer + ? composer.documents.items.value.find((item) => item.status === 'error') + : undefined; if (failedDocument) { ElMessage.error(failedDocument.error || '请处理读取失败的文档'); return; } - await composer.flush(); + if (includeComposer) { + await composer.flush(); + } sending.value = true; + retryableErrorRoundId.value = ''; try { const sessionId = await agentChatRuntimeManager.start({ agentId: selectedAgentId.value, agentName: selectedAgent.value?.name, baseItems: timelineItems.value, capabilities: buildCapabilities(), - documentUploadIds: composer.documents.uploadIds.value, - documents: composer.documents.readyItems.value.map((item) => ({ - ...item, - })), - imageUploadIds: composer.images.uploadIds.value, - images: composer.images.readyItems.value.map((item) => ({ ...item })), - onInputAccepted: markComposerInputAccepted, + documentUploadIds: includeComposer + ? composer.documents.uploadIds.value + : undefined, + documents: includeComposer + ? composer.documents.readyItems.value.map((item) => ({ ...item })) + : undefined, + imageUploadIds: includeComposer + ? composer.images.uploadIds.value + : undefined, + images: includeComposer + ? composer.images.readyItems.value.map((item) => ({ ...item })) + : undefined, + onInputAccepted: includeComposer ? markComposerInputAccepted : undefined, prompt: content, + retryContextReady: Boolean(options.retryContextReady), sessionId: composer.sessionId.value, }); await bindCreatedSession(sessionId, content); @@ -725,6 +762,24 @@ async function sendContent(rawContent: string) { } } +function modelErrorAction(item: ChatTimelineErrorItem) { + return item.roundId === retryableErrorRoundId.value ? '请重试' : undefined; +} + +async function handleModelErrorRetry(item: ChatTimelineErrorItem) { + if ( + item.roundId !== retryableErrorRoundId.value || + sending.value || + runtimeRunning.value + ) { + return; + } + await sendContent('继续', { + includeComposer: false, + retryContextReady: true, + }); +} + async function handleSend() { await sendContent(promptText.value); } @@ -1266,12 +1321,15 @@ onBeforeUnmount(() => { :artifact-loader="loadCurrentAgentArtifact" :items="timelineItems" :document-loader="loadAgentChatDocument" + :error-action="modelErrorAction" + :error-action-disabled="sending || runtimeRunning" :image-loader="loadAgentChatImage" empty-text="选择智能体后开始对话" :approval-loading="Boolean(approvalLoadingKey)" :copy-action="handleCopyMessage" :copyable="canCopyMessage" @approve="handleApprove" + @error-action="handleModelErrorRetry" @reject="handleReject" @select-next-variant="() => undefined" @select-previous-variant="() => undefined" diff --git a/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/projection.ts b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/projection.ts index 77d84ae3..6f6ff645 100644 --- a/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/projection.ts +++ b/easyflow-ui-admin/app/src/views/ai/shared/agent-agui/projection.ts @@ -18,6 +18,7 @@ import { easyFlowAguiCustomEvent } from './custom-events'; export interface AguiTimelineProjectionOptions { finishedAt?: number; onInputAccepted?: (payload: Record) => Promise | void; + runErrorMessage?: string; roundId?: string; startedAt?: number; } @@ -361,7 +362,7 @@ export function applyAguiEventToTimeline( } ChatTimelineBuilder.appendError( items, - event.message || '请求失败', + options.runErrorMessage || event.message || '请求失败', metadata(options, state), ); ChatTimelineBuilder.finalize(items, { diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatErrorNotice.vue b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatErrorNotice.vue index 9ab042bf..420d1b38 100644 --- a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatErrorNotice.vue +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatErrorNotice.vue @@ -1,13 +1,30 @@ @@ -39,4 +56,41 @@ defineProps<{ border: 1px solid currentColor; border-radius: 50%; } + +.chat-error-notice__content { + min-width: 0; +} + +.chat-error-notice__action { + padding: 0; + margin-left: var(--space-1); + font: inherit; + font-weight: 600; + line-height: inherit; + color: currentcolor; + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + background: transparent; + border: 0; + border-radius: var(--el-border-radius-small); +} + +.chat-error-notice__action:hover:not(:disabled) { + opacity: 0.8; +} + +.chat-error-notice__action:active:not(:disabled) { + opacity: 0.65; +} + +.chat-error-notice__action:focus-visible { + outline: 2px solid var(--el-color-primary-light-3); + outline-offset: 2px; +} + +.chat-error-notice__action:disabled { + cursor: not-allowed; + opacity: 0.5; +} diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimeline.vue b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimeline.vue index 3d299ef9..913d70ed 100644 --- a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimeline.vue +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimeline.vue @@ -3,6 +3,7 @@ import type { ChatArtifactLoader, ChatDocumentLoader, ChatImageLoader, + ChatTimelineErrorItem, ChatTimelineItem as ChatTimelineItemType, ChatTimelineMessageItem, ChatTimelineToolApprovalPayload, @@ -23,6 +24,8 @@ const props = defineProps<{ documentLoader?: ChatDocumentLoader; emptyText?: string; emptyTitle?: string; + errorAction?: (item: ChatTimelineErrorItem) => string | undefined; + errorActionDisabled?: boolean; imageLoader?: ChatImageLoader; items: ChatTimelineItemType[]; regenerable?: (item: ChatTimelineMessageItem) => boolean; @@ -33,6 +36,7 @@ const props = defineProps<{ const emit = defineEmits<{ approve: [payload: ChatTimelineToolApprovalPayload]; copyMessage: [item: ChatTimelineMessageItem]; + errorAction: [item: ChatTimelineErrorItem]; regenerateMessage: [item: ChatTimelineMessageItem]; reject: [payload: ChatTimelineToolApprovalPayload]; selectNextVariant: [item: ChatTimelineMessageItem]; @@ -210,6 +214,10 @@ function canRegenerateMessage(item: ChatTimelineItemType) { return item.type === 'message' && (props.regenerable?.(item) ?? false); } +function errorActionLabel(item: ChatTimelineItemType) { + return item.type === 'error' ? props.errorAction?.(item) : undefined; +} + function isAssistantActionAnchor(item: ChatTimelineItemType) { return ( item.type === 'message' && @@ -275,6 +283,8 @@ watch( :copy-action="copyAction" :copyable="copyable" :document-loader="documentLoader" + :error-action="errorAction" + :error-action-disabled="errorActionDisabled" :image-loader="imageLoader" :items="entry.items" :regenerable="regenerable" @@ -283,6 +293,7 @@ watch( :variant-loading="variantLoading" @approve="emit('approve', $event)" @copy-message="emit('copyMessage', $event)" + @error-action="emit('errorAction', $event)" @layout-changed="handleLayoutChanged" @layout-toggle="handleLayoutToggle" @regenerate-message="emit('regenerateMessage', $event)" @@ -306,6 +317,8 @@ watch( :assistant-avatar="assistantAvatar" :item="entry.item" :document-loader="documentLoader" + :error-action-disabled="errorActionDisabled" + :error-action-label="errorActionLabel(entry.item)" :image-loader="imageLoader" :approval-loading="approvalLoading" :copy-action="copyAction" @@ -315,6 +328,7 @@ watch( :variant-loading="isVariantLoading(entry.item)" @approve="emit('approve', $event)" @copy-message="emit('copyMessage', $event)" + @error-action="emit('errorAction', $event)" @regenerate-message="emit('regenerateMessage', $event)" @reject="emit('reject', $event)" @select-next-variant="emit('selectNextVariant', $event)" diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineItem.vue b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineItem.vue index 33292bf4..02040e8c 100644 --- a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineItem.vue +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineItem.vue @@ -3,6 +3,7 @@ import type { ChatArtifactLoader, ChatDocumentLoader, ChatImageLoader, + ChatTimelineErrorItem, ChatTimelineItem, ChatTimelineMessageItem, ChatTimelineMessagePart, @@ -31,6 +32,8 @@ const props = defineProps<{ copyable?: boolean; copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise; documentLoader?: ChatDocumentLoader; + errorActionDisabled?: boolean; + errorActionLabel?: string; imageLoader?: ChatImageLoader; item: ChatTimelineItem; regenerable?: boolean; @@ -41,6 +44,7 @@ const props = defineProps<{ const emit = defineEmits<{ approve: [payload: ChatTimelineToolApprovalPayload]; copyMessage: [item: ChatTimelineMessageItem]; + errorAction: [item: ChatTimelineErrorItem]; regenerateMessage: [item: ChatTimelineMessageItem]; reject: [payload: ChatTimelineToolApprovalPayload]; selectNextVariant: [item: ChatTimelineMessageItem]; @@ -280,7 +284,10 @@ function handleCopyAction() { diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineTurn.vue b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineTurn.vue index cd9cd4ad..ca1a6cbd 100644 --- a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineTurn.vue +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineTurn.vue @@ -3,6 +3,7 @@ import type { ChatArtifactLoader, ChatDocumentLoader, ChatImageLoader, + ChatTimelineErrorItem, ChatTimelineItem, ChatTimelineMessageItem, ChatTimelineToolApprovalPayload, @@ -21,6 +22,8 @@ const props = defineProps<{ copyable?: (item: ChatTimelineMessageItem) => boolean; copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise; documentLoader?: ChatDocumentLoader; + errorAction?: (item: ChatTimelineErrorItem) => string | undefined; + errorActionDisabled?: boolean; imageLoader?: ChatImageLoader; items: ChatTimelineItem[]; regenerable?: (item: ChatTimelineMessageItem) => boolean; @@ -32,6 +35,7 @@ const props = defineProps<{ const emit = defineEmits<{ approve: [payload: ChatTimelineToolApprovalPayload]; copyMessage: [item: ChatTimelineMessageItem]; + errorAction: [item: ChatTimelineErrorItem]; layoutChanged: []; layoutToggle: [roundId: string]; regenerateMessage: [item: ChatTimelineMessageItem]; @@ -234,6 +238,10 @@ function canRegenerateMessage(item: ChatTimelineItem) { return item.type === 'message' && (props.regenerable?.(item) ?? false); } +function errorActionLabel(item: ChatTimelineItem) { + return item.type === 'error' ? props.errorAction?.(item) : undefined; +} + function isVariantLoading(item: ChatTimelineItem) { return item.type === 'message' && (props.variantLoading?.(item) ?? false); } @@ -313,6 +321,8 @@ function handleNestedLayoutToggle() { " :item="item" :document-loader="documentLoader" + :error-action-disabled="errorActionDisabled" + :error-action-label="errorActionLabel(item)" :image-loader="imageLoader" :approval-loading="approvalLoading" :copy-action="copyAction" @@ -322,6 +332,7 @@ function handleNestedLayoutToggle() { :variant-loading="isVariantLoading(item)" @approve="emit('approve', $event)" @copy-message="emit('copyMessage', $event)" + @error-action="emit('errorAction', $event)" @regenerate-message="emit('regenerateMessage', $event)" @reject="emit('reject', $event)" @select-next-variant="emit('selectNextVariant', $event)" @@ -338,6 +349,8 @@ function handleNestedLayoutToggle() { :artifact-loader="artifactLoader" :item="finalMessage" :document-loader="documentLoader" + :error-action-disabled="errorActionDisabled" + :error-action-label="errorActionLabel(finalMessage)" :image-loader="imageLoader" :copy-action="copyAction" :copyable="canCopyMessage(finalMessage)" @@ -345,6 +358,7 @@ function handleNestedLayoutToggle() { :regenerate-disabled="regenerateDisabled" :variant-loading="isVariantLoading(finalMessage)" @copy-message="emit('copyMessage', $event)" + @error-action="emit('errorAction', $event)" @regenerate-message="emit('regenerateMessage', $event)" @select-next-variant="emit('selectNextVariant', $event)" @select-previous-variant="emit('selectPreviousVariant', $event)" diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineToolbar.test.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineToolbar.test.ts index ee4b8662..fb824ae9 100644 --- a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineToolbar.test.ts +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/__tests__/ChatTimelineToolbar.test.ts @@ -154,6 +154,28 @@ describe('chat timeline toolbar', () => { ); }); + it('renders and emits an accessible error action', async () => { + const errorItem: Extract = { + id: 'error-1', + message: '模型连接异常', + roundId: 'round-1', + type: 'error', + }; + const wrapper = mount(ChatTimeline, { + props: { + errorAction: () => '请重试', + items: [errorItem], + }, + }); + + const retryButton = wrapper.get('.chat-error-notice__action'); + expect(wrapper.text()).toContain('模型连接异常,请重试'); + expect(retryButton.text()).toBe('请重试'); + await retryButton.trigger('click'); + + expect(wrapper.emitted('errorAction')?.[0]?.[0]).toEqual(errorItem); + }); + it('shows a check icon after the copy action succeeds', async () => { vi.useFakeTimers(); const copyAction = vi.fn().mockResolvedValue(true);