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') }}
+
+
+
+
+ {{ $t('chat.feedback.title') }}
+
+
{{ $t('chat.feedback.description') }}
+
+
+
+
+
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: '执行?',