feat(agent,ui): recovery affordance card for non-transient LLM errors

This commit is contained in:
matevip 2026-05-10 19:15:57 +08:00
parent 48510c9751
commit f18626304e
8 changed files with 310 additions and 1 deletions

View File

@ -45,6 +45,22 @@ public final class GraphEventPublisher {
*/
public static final String EVENT_FINISH_REASON = "finish_reason";
/**
* User-facing recovery affordances offered after a turn ends in a
* non-transient error. Carries the error type + message + a
* data-driven list of actions ({@code retry}, {@code regenerate},
* {@code report}) so the frontend can render the right buttons
* without hard-coding which categories deserve which actions.
*
* <p>Sibling to {@link #EVENT_FINISH_REASON} (which only carries the
* machine-readable reason). The two are kept separate so legacy
* consumers of {@code finish_reason} don't have to learn a new
* payload shape and so a future graph branch (e.g. evidence-
* insufficient "rerun with the listed files attached") can emit
* feedback affordances without abusing the finish_reason channel.
*/
public static final String EVENT_FEEDBACK = "feedback_event";
/**
* Multimodal sidecar routing decision for the current turn. Emitted once
* per turn before the graph starts streaming; the channel-side accumulator
@ -226,6 +242,30 @@ public final class GraphEventPublisher {
), ts);
}
/**
* Emit a recovery-affordance event for the frontend. {@code errorType}
* mirrors the {@code NodeStreamingChatHelper.ErrorType} value (e.g.
* {@code AUTH_ERROR}, {@code BILLING}, {@code MODEL_NOT_FOUND}, or
* the generic {@code UNKNOWN}); {@code errorMessage} is the
* user-friendly text already displayed in the bubble; {@code actions}
* is the ordered list of buttons to render. Default offering is the
* standard {@code retry / regenerate / report} triad call sites
* can narrow this if a category has limitations (e.g. AUTH_ERROR
* shouldn't offer "retry" until the key is fixed).
*/
public static GraphEvent feedback(String errorType, String errorMessage,
java.util.List<String> actions) {
long ts = System.currentTimeMillis();
return new GraphEvent(EVENT_FEEDBACK, Map.of(
"errorType", errorType != null ? errorType : "",
"errorMessage", errorMessage != null ? errorMessage : "",
"actions", actions != null && !actions.isEmpty()
? actions
: java.util.List.of("retry", "regenerate", "report"),
"timestamp", ts
), ts);
}
// ===== 提取方法 =====
/**

View File

@ -170,6 +170,24 @@ public class FinalAnswerNode implements NodeAction {
validation.unsupportedReferences());
}
// Build the event list. Always carries the finish_reason event so
// downstream consumers (memory gate, channel accumulator, message
// metadata persistence) see a machine-readable status. When the
// turn ended in a non-transient error, also attach a
// feedback_event so the frontend can render retry/regenerate/
// report affordances next to the red "[错误] ..." bubble without
// this, fatal errors leave the user staring at error text with no
// way to recover short of retyping the whole prompt.
List<GraphEventPublisher.GraphEvent> events =
new java.util.ArrayList<>(2);
events.add(GraphEventPublisher.finishReason(finishReason.getValue()));
if (finishReason == FinishReason.ERROR_FALLBACK) {
events.add(GraphEventPublisher.feedback(
"ERROR_FALLBACK",
finalAnswer,
List.of("retry", "regenerate", "report")));
}
// 不重置 CONTENT_STREAMED/THINKING_STREAMED保留上游节点的标志
var builder = MateClawStateAccessor.output()
.finalAnswer(finalAnswer)
@ -183,7 +201,7 @@ public class FinalAnswerNode implements NodeAction {
// signal. APPEND-strategy on PENDING_EVENTS means this
// composes safely with any earlier events upstream nodes
// attached.
.events(List.of(GraphEventPublisher.finishReason(finishReason.getValue())));
.events(events);
if (!finalThinking.isEmpty()) {
builder.finalThinking(finalThinking);

View File

@ -1646,6 +1646,14 @@ public class ChatController {
* having to guess from text. Empty string until the event arrives.
*/
private String finishReason = "";
/**
* Recovery affordance payload from {@link
* vip.mate.agent.GraphEventPublisher#feedback}. Persisted into
* {@code metadata.feedbackEvent} so a page reload still surfaces
* the retry/regenerate/report card on the failed assistant
* bubble. Null when the turn ended cleanly.
*/
private Map<String, Object> feedbackEvent = null;
private Long planId = null;
private List<String> planSteps = List.of();
private Integer currentPlanStep = null;
@ -1691,6 +1699,15 @@ public class ChatController {
finishReason = String.valueOf(reason);
}
}
if (vip.mate.agent.GraphEventPublisher.EVENT_FEEDBACK
.equals(delta.eventType())) {
// Snapshot the affordance payload so it persists into
// message metadata. The same event is also rebroadcast
// live (via the broadcastEvent fall-through below) so
// an already-mounted UI sees it instantly without
// waiting for the message-save round trip.
feedbackEvent = delta.eventData();
}
if (vip.mate.agent.GraphEventPublisher.EVENT_ROUTING_DECISION.equals(delta.eventType())) {
// Captured at turn start; persisted under metadata.routing so the
// chat UI can render which sidecar (if any) was invoked. Internal
@ -1982,6 +1999,15 @@ public class ChatController {
// brittle text matching on the assistant content.
metadata.put("finishReason", finishReason);
}
if (feedbackEvent != null && !feedbackEvent.isEmpty()) {
// Persist the recovery-affordance payload so the
// retry/regenerate/report card survives page reload.
// Stored as-is (errorType, errorMessage, actions,
// timestamp) frontend MessageBubble reads
// metadata.feedbackEvent and renders one button per
// entry in `actions`.
metadata.put("feedbackEvent", feedbackEvent);
}
if (routingDecision != null && !routingDecision.isEmpty()) {
metadata.put("routing", routingDecision);
}

View File

@ -221,6 +221,37 @@
<p class="evidence-card__description">{{ $t('chat.evidenceDescription') }}</p>
</div>
<!--
feedback_event card: recovery affordances for turns that ended
in a non-transient error. Backend's NodeStreamingChatHelper
handles transient TLS / IO retries silently; this card only
appears for the residue (auth, billing, model-not-found, raw
parse failures, etc.) that no amount of retry can fix without
user input. Buttons are data-driven from the event's `actions`
array so the backend can narrow the offering per error type
without a frontend release.
-->
<div v-if="feedbackInfo" class="feedback-card">
<div class="feedback-card__header">
<el-icon class="feedback-card__icon"><WarningFilled /></el-icon>
<span class="feedback-card__title">{{ $t('chat.feedback.title') }}</span>
</div>
<p class="feedback-card__description">{{ $t('chat.feedback.description') }}</p>
<div class="feedback-card__actions">
<button
v-for="action in feedbackInfo.actions"
:key="action"
class="feedback-card__btn"
:class="`feedback-card__btn--${action}`"
type="button"
@click="handleFeedbackAction(action)"
>
<el-icon v-if="action === 'retry' || action === 'regenerate'"><RefreshRight /></el-icon>
{{ feedbackActionLabel(action) }}
</button>
</div>
</div>
<!-- 附件列表 -->
<div v-if="attachments?.length" class="message-attachments">
<div
@ -359,6 +390,7 @@
<script setup lang="ts">
import { computed, ref, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import {
ArrowDown,
CloseBold,
@ -927,6 +959,66 @@ const isEvidenceInsufficient = computed<boolean>(() => {
return parsedMetadata.value?.finishReason === 'evidence_insufficient'
})
/**
* Recovery-affordance payload from the graph's feedback_event. Populated
* for assistant turns that ended in a non-transient error (after the
* helper's TLS / IO retry loop has already given up). Shape mirrors
* GraphEventPublisher.feedback: { errorType, errorMessage, actions }.
*
* <p>Surfaces a card with buttons for each action: "retry" and
* "regenerate" both replay the last user message; "report" copies the
* error details for a bug report. The card sits right under the red
* "[错误] …" content so users see the recovery options inline rather
* than having to retype the whole prompt.
*/
interface FeedbackInfo {
errorType: string
errorMessage: string
actions: string[]
timestamp?: number
}
const feedbackInfo = computed<FeedbackInfo | undefined>(() => {
if (props.message.role !== 'assistant') return undefined
const raw = parsedMetadata.value?.feedbackEvent as FeedbackInfo | undefined
if (!raw || !Array.isArray(raw.actions) || raw.actions.length === 0) return undefined
return raw
})
function handleFeedbackAction(action: string) {
if (action === 'retry' || action === 'regenerate') {
emit('regenerate')
return
}
if (action === 'report') {
// Copy error details for a bug report. Lower-friction than a modal
// and works offline; users paste the result into wherever they file
// issues. Uses the clipboard helper with execCommand fallback for
// non-HTTPS contexts (e.g. internal IPs without TLS).
const lines = [
`Error type: ${feedbackInfo.value?.errorType || 'UNKNOWN'}`,
`Message: ${feedbackInfo.value?.errorMessage || ''}`,
`Conversation: ${(props.message as any).conversationId || ''}`,
`Message id: ${(props.message as any).id || ''}`,
`Timestamp: ${new Date(feedbackInfo.value?.timestamp || Date.now()).toISOString()}`,
].join('\n')
copyToClipboard(lines).then(() => {
ElMessage.success(t('chat.feedback.reportCopied'))
}).catch(() => {
console.error('[feedback_event] copy failed:\n' + lines)
ElMessage.error(t('chat.feedback.reportFailed'))
})
}
}
function feedbackActionLabel(action: string): string {
// Action labels go through i18n so the same data-driven button list
// renders correctly in zh-CN / en-US. Falls back to the raw action
// key if a future backend introduces a label we haven't translated.
const key = `chat.feedback.${action}`
const localized = t(key)
return localized === key ? action : localized
}
const browserActionsMeta = computed<BrowserAction[]>(() => {
return parsedMetadata.value?.browserActions || []
})
@ -1741,6 +1833,84 @@ watch(isGenerating, (generating) => {
opacity: 0.85;
}
/* ==================== feedback_event recovery card (ERROR_FALLBACK) ==================== */
.feedback-card {
margin-top: 8px;
padding: 12px 16px;
border-radius: 8px;
background: color-mix(in srgb, var(--mc-danger, #dc2626) 8%, var(--mc-bg-elevated));
border: 1px solid color-mix(in srgb, var(--mc-danger, #dc2626) 30%, transparent);
font-size: 13px;
max-width: 480px;
line-height: 1.5;
}
.feedback-card__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.feedback-card__icon {
flex-shrink: 0;
color: var(--mc-danger, #dc2626);
}
.feedback-card__title {
font-weight: 600;
color: var(--mc-danger, #dc2626);
font-size: 14px;
}
.feedback-card__description {
margin: 4px 0 8px;
color: var(--mc-text-primary);
font-size: 13px;
opacity: 0.85;
}
.feedback-card__actions {
display: flex;
justify-content: flex-end;
gap: 6px;
flex-wrap: wrap;
}
.feedback-card__btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--mc-danger, #dc2626) 35%, transparent);
background: color-mix(in srgb, var(--mc-danger, #dc2626) 10%, var(--mc-bg-elevated));
color: var(--mc-danger, #dc2626);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.feedback-card__btn:hover {
background: color-mix(in srgb, var(--mc-danger, #dc2626) 18%, var(--mc-bg-elevated));
border-color: color-mix(in srgb, var(--mc-danger, #dc2626) 55%, transparent);
}
/* Report button is secondary action muted neutral palette so the
primary "retry" stays visually emphasized. */
.feedback-card__btn--report {
border-color: var(--mc-border);
background: var(--mc-bg-elevated);
color: var(--mc-text-secondary);
}
.feedback-card__btn--report:hover {
background: var(--mc-bg-sunken);
border-color: var(--mc-border-strong, var(--mc-border));
color: var(--mc-text-primary);
}
/* ==================== 附件 ==================== */
.message-attachments {
display: flex;

View File

@ -729,6 +729,33 @@ export function useChat(options: UseChatOptions): UseChatReturn {
}
})
// Live recovery affordance event. Emitted by the graph when a turn
// ends in a non-transient error (ERROR_FALLBACK). Carries
// { errorType, errorMessage, actions } — actions is the ordered list
// of buttons the failed-bubble card should render. Persisting onto
// metadata.feedbackEvent matches the post-reload code path in
// MessageBubble (which reads the same metadata key) so the card
// appears immediately during the live stream AND survives a refresh.
stream.on('feedback_event', (data) => {
if (isStaleEvent(data)) return
if (!currentAssistantId.value) return
const msg = getMessage(currentAssistantId.value)
if (!msg) return
const metadata = parseMetadata((msg as any).metadata)
updateMessage(currentAssistantId.value, {
...msg,
metadata: {
...metadata,
feedbackEvent: {
errorType: data.errorType,
errorMessage: data.errorMessage,
actions: data.actions,
timestamp: data.timestamp || Date.now(),
},
},
} as any)
})
stream.on('phase', (data) => {
if (isStaleEvent(data)) return
const phase = data.phase as StreamPhase

View File

@ -58,6 +58,8 @@ export type SSEEventType =
| 'content_truncated'
| 'tool_result_chunk'
| 'delegation_batch'
// Recovery affordance for non-transient errors (ERROR_FALLBACK turns)
| 'feedback_event'
export interface SSEEvent {
type: SSEEventType

View File

@ -328,6 +328,19 @@ export default {
// EVIDENCE_INSUFFICIENT info card (finishReason=evidence_insufficient)
evidenceTitle: 'Run completed — some source references could not be verified',
evidenceDescription: 'The full answer is preserved above. The classes/files listed in the trailing "[evidence insufficient] …" line were not actually opened during this run; the model may have inferred them from naming. Ask a follow-up to have each one read before relying on those references.',
// feedback_event card — emitted when a turn ends in a non-transient error.
// Buttons are data-driven from the event's `actions` array; labels live
// under chat.feedback.{action} so a future backend can ship a new action
// (e.g. "switch_model") with a single new translation key.
feedback: {
title: 'This turn failed',
description: 'The model call did not finish normally; the error reason is shown above. Click Retry to replay the same prompt, or Report to copy the error details for a bug report.',
retry: 'Retry',
regenerate: 'Regenerate',
report: 'Report',
reportCopied: 'Error details copied to clipboard',
reportFailed: 'Copy failed — check browser permissions',
},
// Approval bar
approvalAllow: 'Allow',
approvalExecute: 'to execute?',

View File

@ -328,6 +328,19 @@ export default {
// EVIDENCE_INSUFFICIENT 提示卡finishReason=evidence_insufficient
evidenceTitle: '任务已完成,但部分源码引用未被验证',
evidenceDescription: '回答全文已保留并展示。底部「[证据不足] …」列出的类/文件并未在本次工具结果里被实际读取,模型可能基于命名推断。如需确认这些引用,请追问让模型逐一读取后再下结论。',
// feedback_event card — emitted when a turn ends in a non-transient error.
// Buttons are data-driven from the event's `actions` array; labels live
// under chat.feedback.{action} so a future backend can ship a new action
// (e.g. "switch_model") with a single new translation key.
feedback: {
title: '本次回答失败',
description: '模型调用未能正常完成,已显示错误原因。可点击「重试」让模型用同一条提问再试一次,或「上报问题」复制错误详情用于反馈。',
retry: '重试',
regenerate: '重新生成',
report: '上报问题',
reportCopied: '错误详情已复制到剪贴板',
reportFailed: '复制失败,请检查浏览器权限',
},
// 审批栏
approvalAllow: '允许',
approvalExecute: '执行?',