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 @@
!
- {{ message }}
+
+ {{ message }},
+
+
@@ -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);