fix: 支持模型异常后继续文档对话

- 将本轮文档上下文纳入可持久化的 Agent 用户消息

=- 在异常提示中提供请重试动作并自动发送继续
This commit is contained in:
2026-08-26 22:55:20 +08:00
parent 4de8cc5bd0
commit 6248e2c7b8
11 changed files with 311 additions and 49 deletions

View File

@@ -1,13 +1,30 @@
<script setup lang="ts">
defineProps<{
actionDisabled?: boolean;
actionLabel?: string;
message: string;
}>();
const emit = defineEmits<{
action: [];
}>();
</script>
<template>
<div class="chat-error-notice" role="alert">
<span class="chat-error-notice__icon" aria-hidden="true">!</span>
<span>{{ message }}</span>
<span class="chat-error-notice__content">
<span>{{ message }}<template v-if="actionLabel"></template></span>
<button
v-if="actionLabel"
type="button"
class="chat-error-notice__action"
:disabled="actionDisabled"
@click="emit('action')"
>
{{ actionLabel }}
</button>
</span>
</div>
</template>
@@ -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;
}
</style>

View File

@@ -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)"

View File

@@ -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<boolean>;
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() {
</div>
<ChatErrorNotice
v-else-if="item.type === 'error'"
:action-disabled="errorActionDisabled"
:action-label="errorActionLabel"
:message="item.message"
@action="emit('errorAction', item)"
/>
</div>
</template>

View File

@@ -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<boolean>;
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)"

View File

@@ -154,6 +154,28 @@ describe('chat timeline toolbar', () => {
);
});
it('renders and emits an accessible error action', async () => {
const errorItem: Extract<ChatTimelineItem, { type: 'error' }> = {
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);