diff --git a/frontend/src/utils/finalAnswer.ts b/frontend/src/utils/finalAnswer.ts new file mode 100644 index 00000000..1dbb8031 --- /dev/null +++ b/frontend/src/utils/finalAnswer.ts @@ -0,0 +1,71 @@ +// finalAnswer.ts +// +// Helpers for normalising LLM-emitted final answers before rendering. +// +// Background: not every model reliably calls our `final_answer` tool. Many +// models — especially smaller ones or those SFT'd on different conventions — +// instead embed the answer inside …, …, +// or prefix it with "Final Answer:" / "最终答案:". When the agent loop accepts +// such a natural-stop response as the final answer, those wrappers leak into +// the rendered output. This module provides a single helper to strip them +// before the markdown renderer sees the text. +// +// The function is intentionally conservative: it only strips a wrapper when +// it covers the *entire* trimmed content. We don't want to corrupt user- +// authored markdown that happens to contain the word "Final Answer:" or an +// XML-style tag in the middle of a sentence. + +const ANSWER_TAG_RE = + /^\s*<(answer|final_answer|final-answer)\b[^>]*>([\s\S]*?)<\/\1>\s*$/i; + +const FENCED_ANSWER_RE = + /^\s*```(?:final_answer|answer)\s*\n?([\s\S]*?)\n?```\s*$/i; + +const ANSWER_PREFIX_RE = + /^\s*(?:final\s*answer|最终答案|答案|答)\s*[::]\s*/i; + +/** + * Remove common "final answer" wrappers that some models emit instead of + * calling the structured `final_answer` tool. Returns the original string + * (trimmed only when stripping happens) when no wrapper is detected. + * + * Recognised wrappers (must cover the entire trimmed content): + * - `…` / `…` (case-insensitive) + * - ```` ```final_answer\n…\n``` ```` fenced code block + * - Leading `Final Answer:` / `最终答案:` / `答:` prefix + */ +export function unwrapFinalAnswerWrappers(content: string): string { + if (!content || typeof content !== 'string') { + return content ?? ''; + } + + let result = content; + let changed = false; + + // Strip outer XML-style answer tags. Loop in case the model nested them + // (e.g. …), but cap iterations + // to avoid pathological inputs. + for (let i = 0; i < 3; i++) { + const tagMatch = result.match(ANSWER_TAG_RE); + if (!tagMatch) break; + result = tagMatch[2]; + changed = true; + } + + // Strip fenced "```final_answer" code block wrappers. + const fencedMatch = result.match(FENCED_ANSWER_RE); + if (fencedMatch) { + result = fencedMatch[1]; + changed = true; + } + + // Strip a leading "Final Answer:" / "最终答案:" prefix when it is the very + // first non-whitespace token. Only applied once. + const prefixMatch = result.match(ANSWER_PREFIX_RE); + if (prefixMatch) { + result = result.slice(prefixMatch[0].length); + changed = true; + } + + return changed ? result.trim() : result; +} diff --git a/frontend/src/views/chat/components/AgentStreamDisplay.vue b/frontend/src/views/chat/components/AgentStreamDisplay.vue index b0effd40..2eca90e9 100644 --- a/frontend/src/views/chat/components/AgentStreamDisplay.vue +++ b/frontend/src/views/chat/components/AgentStreamDisplay.vue @@ -207,7 +207,7 @@ v-if="event.content && event.content.trim()" class="answer-content markdown-content" > -
+
@@ -385,6 +385,7 @@ import { useAuthStore } from '@/stores/auth'; import { useI18n } from 'vue-i18n'; import i18n from '@/i18n'; import { hydrateProtectedFileImages } from '@/utils/security'; +import { unwrapFinalAnswerWrappers } from '@/utils/finalAnswer'; import { buildManualMarkdown, copyTextToClipboard, @@ -1059,12 +1060,34 @@ const buildFullEventList = (stream: any[]) => { return result; }; +// When the agent loop ended via natural-stop (no final_answer tool call) and +// we promote the trailing thinking event into a virtual answer card below +// the tree, we must hide that same thinking event from the tree to avoid +// rendering its content twice. +const promotedThinkingEventId = computed(() => { + const final = finalContent.value; + if (!final || final.type !== 'thinking') return null; + // Only promote when there's no real answer event content already. + const stream = eventStream.value; + if (!stream || !Array.isArray(stream)) return null; + const hasRealAnswer = stream.some( + (e: any) => e.type === 'answer' && e.content && e.content.trim() + ); + if (hasRealAnswer) return null; + return final.event_id || null; +}); + // Intermediate events (tree children: everything except answer) const intermediateEvents = computed(() => { const stream = eventStream.value; if (!stream || !Array.isArray(stream)) return []; const result = buildFullEventList(stream); - return result.filter((e: any) => e.type !== 'answer' && e.type !== 'agent_complete'); + const promotedId = promotedThinkingEventId.value; + return result.filter((e: any) => { + if (e.type === 'answer' || e.type === 'agent_complete') return false; + if (promotedId && e.type === 'thinking' && e.event_id === promotedId) return false; + return true; + }); }); // Events to display (non-tree: before answer starts show all, after answer starts show only answer) @@ -1105,14 +1128,23 @@ const displayEvents = computed(() => { } if (final.type === 'thinking') { - const thinkingFiltered = result.filter((e: any) => + // The agent loop ended via natural-stop (the model wrote its answer as + // free text instead of calling final_answer). Synthesize a virtual + // `answer` event from the trailing thinking content so it renders with + // the answer card UI (expanded markdown + copy/add toolbar) rather than + // the collapsed "思考" card. The original thinking event is still in + // the intermediate-steps tree when applicable. + const thinking = result.find((e: any) => e.type === 'thinking' && e.event_id === final.event_id ); - if (final.showAnswerToolbar) { - const answerDoneEvents = result.filter((e: any) => e.type === 'answer' && e.done === true); - return [...thinkingFiltered, ...answerDoneEvents]; - } - return thinkingFiltered; + if (!thinking || !thinking.content) return result; + return [{ + type: 'answer', + event_id: thinking.event_id, + content: thinking.content, + done: true, + _promoted_from_thinking: true, + }]; } return result; @@ -1761,6 +1793,15 @@ const renderMarkdownContent = (content: any): string => { return DOMPurify.sanitize(protectedHTML, DOMPurifyConfig); }; +// Renders an answer event's content. Strips final-answer wrappers +// (e.g. …, "Final Answer:") that some models emit instead +// of calling the structured final_answer tool, then delegates to the +// standard markdown renderer. +const renderAnswerContent = (content: any): string => { + const contentStr = typeof content === 'string' ? content : String(content || ''); + return renderMarkdownContent(unwrapFinalAnswerWrappers(contentStr)); +}; + // Legacy Markdown rendering function (kept for summaries) const renderMarkdown = (content: any): string => { const contentStr = typeof content === 'string' ? content : String(content || ''); @@ -2168,24 +2209,26 @@ const formatJSON = (obj: any): string => { } }; -// Helper function to get actual content (from answer or last thinking) +// Helper function to get actual content (from answer or last thinking). +// Strips final-answer wrappers (e.g. …, "Final Answer:") +// so callers like copy and add-to-knowledge get clean text. const getActualContent = (answerEvent: any): string => { // First try to get content from answer event const answerContent = (answerEvent?.content || '').trim(); if (answerContent) { - return answerContent; + return unwrapFinalAnswerWrappers(answerContent).trim(); } - + // If answer is empty, try to get from last thinking const stream = eventStream.value; if (stream && Array.isArray(stream)) { const thinkingEvents = stream.filter((e: any) => e.type === 'thinking' && e.content && e.content.trim()); if (thinkingEvents.length > 0) { const lastThinking = thinkingEvents[thinkingEvents.length - 1]; - return (lastThinking.content || '').trim(); + return unwrapFinalAnswerWrappers((lastThinking.content || '').trim()).trim(); } } - + return ''; };