mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(agent,ui): recovery affordance card for non-transient LLM errors
This commit is contained in:
parent
48510c9751
commit
f18626304e
@ -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);
|
||||
}
|
||||
|
||||
// ===== 提取方法 =====
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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?',
|
||||
|
||||
@ -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: '执行?',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user