From f18626304ee668c4ce946435ac499bd3c776104e Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 10 May 2026 19:15:57 +0800 Subject: [PATCH] feat(agent,ui): recovery affordance card for non-transient LLM errors --- .../vip/mate/agent/GraphEventPublisher.java | 40 +++++ .../agent/graph/node/FinalAnswerNode.java | 20 ++- .../vip/mate/channel/web/ChatController.java | 26 +++ .../src/components/chat/MessageBubble.vue | 170 ++++++++++++++++++ mateclaw-ui/src/composables/chat/useChat.ts | 27 +++ mateclaw-ui/src/composables/chat/useStream.ts | 2 + mateclaw-ui/src/i18n/locales/en-US.ts | 13 ++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 13 ++ 8 files changed, 310 insertions(+), 1 deletion(-) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/GraphEventPublisher.java b/mateclaw-server/src/main/java/vip/mate/agent/GraphEventPublisher.java index e63d8865..1cc8b5f1 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/GraphEventPublisher.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/GraphEventPublisher.java @@ -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. + * + *

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 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); + } + // ===== 提取方法 ===== /** diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java index 3574a2ed..6792e0a6 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/FinalAnswerNode.java @@ -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 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); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index 6a122a44..19c79358 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -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 feedbackEvent = null; private Long planId = null; private List 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); } diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index cd2f835b..edd4c766 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -221,6 +221,37 @@

{{ $t('chat.evidenceDescription') }}

+ +
+ + + +
+
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(() => { 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 }. + * + *

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(() => { + 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(() => { 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; diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index e6a511c3..2d06cec4 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -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 diff --git a/mateclaw-ui/src/composables/chat/useStream.ts b/mateclaw-ui/src/composables/chat/useStream.ts index d95a0243..01f92d6c 100644 --- a/mateclaw-ui/src/composables/chat/useStream.ts +++ b/mateclaw-ui/src/composables/chat/useStream.ts @@ -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 diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 8cb9907f..4f31a350 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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?', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index f6770d90..9a340bae 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -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: '执行?',