diff --git a/frontend/src/agents/agentPreviewSlice.ts b/frontend/src/agents/agentPreviewSlice.ts index 55152d52..27387db3 100644 --- a/frontend/src/agents/agentPreviewSlice.ts +++ b/frontend/src/agents/agentPreviewSlice.ts @@ -1,5 +1,9 @@ import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { + appendThoughtText, + recordToolCall, +} from '../conversation/answerSegments'; import { handleFetchAnswer, handleFetchAnswerSteaming, @@ -206,6 +210,7 @@ export const agentPreviewSlice = createSlice({ delete state.queries[index].thought; delete state.queries[index].sources; delete state.queries[index].tool_calls; + delete state.queries[index].segments; delete state.queries[index].error; delete state.queries[index].structured; delete state.queries[index].schema; @@ -245,6 +250,8 @@ export const agentPreviewSlice = createSlice({ if (query.thought != undefined) { state.queries[index].thought = (state.queries[index].thought || '') + query.thought; + if (!state.queries[index].segments) state.queries[index].segments = []; + appendThoughtText(state.queries[index].segments, query.thought); } }, updateStreamingSource( @@ -279,6 +286,9 @@ export const agentPreviewSlice = createSlice({ ...tool_call, }; } else state.queries[index].tool_calls.push(tool_call); + + if (!state.queries[index].segments) state.queries[index].segments = []; + recordToolCall(state.queries[index].segments, tool_call.call_id); }, updateQuery( state, diff --git a/frontend/src/conversation/AnswerFlow.test.tsx b/frontend/src/conversation/AnswerFlow.test.tsx new file mode 100644 index 00000000..70114c1b --- /dev/null +++ b/frontend/src/conversation/AnswerFlow.test.tsx @@ -0,0 +1,143 @@ +import i18n from 'i18next'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { I18nextProvider, initReactI18next } from 'react-i18next'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import en from '../locale/en.json'; +import { AnswerSegment } from './answerSegments'; +import AnswerFlow from './AnswerFlow'; +import { ToolCallsType } from './types'; + +const testI18n = i18n.createInstance(); + +beforeAll(async () => { + await testI18n.use(initReactI18next).init({ + lng: 'en', + fallbackLng: 'en', + resources: { en: { translation: en } }, + }); +}); + +const search = (overrides: Partial = {}): ToolCallsType => ({ + tool_name: 'brave', + action_name: 'brave_web_search', + call_id: 'c1', + arguments: { query: 'docsgpt setup' }, + status: 'completed', + ...overrides, +}); + +const render = (props: { + message?: string; + thought?: string; + toolCalls?: ToolCallsType[]; + segments?: AnswerSegment[]; + isStreaming?: boolean; +}): string => + renderToStaticMarkup( + +
APPROVAL_BAR
} + renderWikiWrite={() =>
WIKI_CARD
} + /> +
, + ); + +describe('AnswerFlow', () => { + it('renders the same markup whether the steps arrived live or were fetched', () => { + const live = render({ + message: 'part one part two', + thought: 'reasoning here', + toolCalls: [search()], + // Live ordering interleaved the tool call with the answer text. + segments: [ + { kind: 'thought', text: 'reasoning here' }, + { kind: 'tool', call_id: 'c1' }, + ], + }); + const fetched = render({ + message: 'part one part two', + thought: 'reasoning here', + toolCalls: [search()], + }); + expect(live).toBe(fetched); + }); + + it('keeps the answer in a single bubble with the steps above it', () => { + const html = render({ + message: 'part one part two', + toolCalls: [search()], + segments: [{ kind: 'tool', call_id: 'c1' }], + }); + expect(html.split('fade-in-bubble').length - 1).toBe(1); + expect(html.indexOf('Searched the web')).toBeLessThan( + html.indexOf('part one part two'), + ); + }); + + it('labels a running call in present tense and a finished one in past tense', () => { + expect( + render({ + toolCalls: [search({ status: 'pending' })], + segments: [{ kind: 'tool', call_id: 'c1' }], + isStreaming: true, + }), + ).toContain('Searching the web'); + expect( + render({ + toolCalls: [search()], + segments: [{ kind: 'tool', call_id: 'c1' }], + }), + ).toContain('Searched the web'); + }); + + it('keeps tool arguments and results hidden until expanded', () => { + const html = render({ + toolCalls: [search({ result: { secret: 'RESULT_BODY' } })], + segments: [{ kind: 'tool', call_id: 'c1' }], + }); + expect(html).not.toContain('RESULT_BODY'); + }); + + it('routes a call awaiting approval to the approval bar, not a chip', () => { + const html = render({ + toolCalls: [search({ status: 'awaiting_approval' })], + segments: [{ kind: 'tool', call_id: 'c1' }], + }); + expect(html).toContain('APPROVAL_BAR'); + expect(html).not.toContain('Searched the web'); + }); + + it('puts reasoning before tool calls for a reloaded conversation', () => { + const html = render({ + message: 'the answer', + thought: 'my reasoning', + toolCalls: [search()], + }); + expect(html.indexOf('my reasoning')).toBeLessThan( + html.indexOf('Searched the web'), + ); + expect(html.indexOf('Searched the web')).toBeLessThan( + html.indexOf('the answer'), + ); + }); + + it('ignores a step whose call is missing from tool_calls', () => { + const html = render({ + toolCalls: [], + segments: [{ kind: 'tool', call_id: 'gone' }], + }); + expect(html).not.toContain('Searched'); + }); + + it('renders steps with no answer text yet', () => { + const html = render({ + toolCalls: [search({ status: 'pending' })], + segments: [{ kind: 'tool', call_id: 'c1' }], + isStreaming: true, + }); + expect(html).toContain('Searching the web'); + expect(html).not.toContain('fade-in-bubble'); + }); +}); diff --git a/frontend/src/conversation/AnswerFlow.tsx b/frontend/src/conversation/AnswerFlow.tsx new file mode 100644 index 00000000..321f5991 --- /dev/null +++ b/frontend/src/conversation/AnswerFlow.tsx @@ -0,0 +1,303 @@ +import { Fragment, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import SchedulerToolCallCard from '../agents/schedules/SchedulerToolCallCard'; +import ChevronDown from '../assets/chevron-down.svg?react'; +import Cloud from '../assets/cloud.svg'; +import DocsGPT3 from '../assets/cute_docsgpt3.svg'; +import CopyButton from '../components/CopyButton'; +import ToolIcon from '../components/ToolIcon'; +import { Avatar } from '../components/ui/avatar'; +import { Button } from '../components/ui/button'; +import { usePacedText } from '../hooks'; +import { getToolChipLabel } from '../utils/streamingStatusUtils'; +import { AnswerSegment, getAnswerSegments } from './answerSegments'; +import MarkdownAnswer from './MarkdownAnswer'; +import { ToolCallsType } from './types'; +import { isWikiWriteCall } from './wikiToolCall'; + +type AnswerFlowProps = { + message?: string; + thought?: string; + toolCalls?: ToolCallsType[]; + // Absent on reload, where the order is synthesized from the flat fields. + segments?: AnswerSegment[]; + isStreaming?: boolean; + agentId?: string; + renderApproval: (toolCall: ToolCallsType) => React.ReactNode; + renderWikiWrite: (toolCall: ToolCallsType) => React.ReactNode; +}; + +/** + * Never splits the answer with a step, which is what keeps a streaming answer and + * the same answer fetched back rendering identically. + */ +export default function AnswerFlow({ + message, + thought, + toolCalls, + segments, + isStreaming, + agentId, + renderApproval, + renderWikiWrite, +}: AnswerFlowProps) { + const { t } = useTranslation(); + const steps = getAnswerSegments({ thought, tool_calls: toolCalls, segments }); + const callById = new Map((toolCalls ?? []).map((c) => [c.call_id, c])); + const lastIndex = steps.length - 1; + + return ( + <> + {steps.map((step, index) => { + if (step.kind === 'thought') { + return ( + + ); + } + + const call = callById.get(step.call_id); + if (!call) return null; + + if (call.status === 'awaiting_approval') + return ( + + {renderApproval(call)} + + ); + + if (isWikiWriteCall(call)) + return ( + + {renderWikiWrite(call)} + + ); + + if (call.tool_name === 'scheduler') + return ( +
+ +
+ ); + + return ( + + ); + })} + {message && ( +
+
+ +

+ {t('conversation.answer')} +

+
+
+ +
+
+ )} + + ); +} + +function InlineThoughtChip({ + thought, + isActive, +}: { + thought: string; + isActive?: boolean; +}) { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + const liveRef = useRef(null); + + // Expanding means the user wants it all now, so pace the live window only. + const pacedThought = usePacedText(thought, Boolean(isActive) && !isOpen); + const showLiveWindow = Boolean(isActive) && !isOpen; + + // The window keeps a fixed height so deltas never resize the page and + // retrigger the conversation scroll pin; scrolling it instead (with CSS + // scroll-smooth) glides the text up rather than snapping a line at a time. + useEffect(() => { + const el = liveRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [pacedThought, isOpen]); + + return ( +
+ + {showLiveWindow && ( +
+
+ {pacedThought} +
+
+ )} + {!showLiveWindow && !isOpen && ( +

+ {thought} +

+ )} + {isOpen && ( +

+ {thought} +

+ )} +
+ ); +} + +function InlineToolCallChip({ toolCall }: { toolCall: ToolCallsType }) { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + const isPending = toolCall.status === 'pending'; + const label = getToolChipLabel(toolCall, t); + + return ( +
+ + {isOpen && ( +
+ +

+ {JSON.stringify(toolCall.arguments ?? {}, null, 2)} +

+
+ + {isPending && ( +

+ {t('conversation.inlineSteps.running')} +

+ )} + {toolCall.status === 'error' && ( +

+ {toolCall.error} +

+ )} + {toolCall.status === 'denied' && ( +

+ {t('conversation.inlineSteps.denied')} +

+ )} + {!isPending && + toolCall.status !== 'error' && + toolCall.status !== 'denied' && ( +

+ {JSON.stringify(toolCall.result ?? {}, null, 2)} +

+ )} +
+
+ )} +
+ ); +} + +function ToolCallPanel({ + title, + copyText, + children, +}: { + title: string; + copyText: string; + children: React.ReactNode; +}) { + return ( +
+
+ + {title} + + +
+
{children}
+
+ ); +} diff --git a/frontend/src/conversation/Conversation.tsx b/frontend/src/conversation/Conversation.tsx index 7a88cace..a699251b 100644 --- a/frontend/src/conversation/Conversation.tsx +++ b/frontend/src/conversation/Conversation.tsx @@ -372,7 +372,7 @@ export default function Conversation() { className={`from-background pointer-events-none absolute bottom-0 left-1/2 h-6 w-full -translate-x-1/2 rounded-t-2xl bg-linear-to-t to-transparent bg-clip-content px-2 ${ isSplitArtifactOpen ? 'max-w-325' - : 'max-w-325 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12' + : 'max-w-325 md:w-11/12 lg:w-10/12 xl:w-9/12 2xl:w-8/12' }`} /> @@ -381,7 +381,7 @@ export default function Conversation() { className={`bg-opacity-0 z-3 flex h-auto w-full flex-col items-end self-center rounded-2xl py-1 ${ isSplitArtifactOpen ? 'max-w-325' - : 'max-w-325 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12' + : 'max-w-325 md:w-11/12 lg:w-10/12 xl:w-9/12 2xl:w-8/12' }`} >
diff --git a/frontend/src/conversation/ConversationBubble.tsx b/frontend/src/conversation/ConversationBubble.tsx index df942b20..4027d005 100644 --- a/frontend/src/conversation/ConversationBubble.tsx +++ b/frontend/src/conversation/ConversationBubble.tsx @@ -2,21 +2,10 @@ import 'katex/dist/katex.min.css'; import { forwardRef, Fragment, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import ReactMarkdown from 'react-markdown'; import { useSelector } from 'react-redux'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { - oneLight, - vscDarkPlus, -} from 'react-syntax-highlighter/dist/cjs/styles/prism'; -import rehypeKatex from 'rehype-katex'; -import remarkGfm from 'remark-gfm'; -import remarkMath from 'remark-math'; -import SchedulerToolCallCard from '../agents/schedules/SchedulerToolCallCard'; import WorkflowRunArtifacts from '../agents/workflow/WorkflowRunArtifacts'; import ChevronDown from '../assets/chevron-down.svg'; -import Cloud from '../assets/cloud.svg'; import DocsGPT3 from '../assets/cute_docsgpt3.svg'; import Dislike from '../assets/dislike.svg?react'; import Document from '../assets/document.svg'; @@ -27,17 +16,10 @@ import Link from '../assets/link.svg'; import Sources from '../assets/sources.svg'; import UserIcon from '../assets/user.svg'; import CopyButton from '../components/CopyButton'; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from '../components/ui/accordion'; + import { Avatar } from '../components/ui/avatar'; import { Button } from '../components/ui/button'; import { Input } from '../components/ui/input'; -import MermaidRenderer from '../components/MermaidRenderer'; -import Spinner from '../components/Spinner'; import { Sheet, SheetContent } from '../components/ui/sheet'; import SpeakButton from '../components/TextToSpeechButton'; import { useDarkTheme, useOutsideAlerter } from '../hooks'; @@ -46,15 +28,14 @@ import { selectSelectedDocs, selectToken, } from '../preferences/preferenceSlice'; -import classes from './ConversationBubble.module.css'; +import AnswerFlow from './AnswerFlow'; +import { AnswerSegment } from './answerSegments'; import { FEEDBACK, MESSAGE_TYPE, ResearchState } from './conversationModels'; +import MarkdownAnswer from './MarkdownAnswer'; import ResearchProgress from './ResearchProgress'; +import StreamingStatusLine from './StreamingStatusLine'; import { ToolCallsType } from './types'; -import { - isWikiWriteCall, - wikiWriteActionKey, - wikiWritePath, -} from './wikiToolCall'; +import { wikiWriteActionKey, wikiWritePath } from './wikiToolCall'; const DisableSourceFE = import.meta.env.VITE_DISABLE_SOURCE_FE || false; @@ -69,6 +50,8 @@ const ConversationBubble = forwardRef< thought?: string; sources?: { title: string; text: string; link: string }[]; toolCalls?: ToolCallsType[]; + /** Arrival order of the answer's parts; drives inline rendering. */ + segments?: AnswerSegment[]; /** Set when this answer came from a workflow agent run; renders the * run's produced artifacts as click-through chips/previews. */ workflowRunId?: string; @@ -101,6 +84,7 @@ const ConversationBubble = forwardRef< thought, sources, toolCalls, + segments, workflowRunId, research, retryBtn, @@ -129,6 +113,11 @@ const ConversationBubble = forwardRef< const editableQueryRef = useRef(null); const [isQuestionCollapsed, setIsQuestionCollapsed] = useState(true); + // These already shimmer in place above; the status line would say it twice. + const hasLiveInlineStep = + (toolCalls ?? []).some((call) => call.status === 'pending') || + Boolean(thought && !message); + const formatToolName = (toolName: string | undefined): string => { if (!toolName) return ''; // Display-name overrides for tools whose label differs from the formatted key. @@ -383,7 +372,7 @@ const ConversationBubble = forwardRef< {t('conversation.sources.title')}

-
+
{sources?.slice(0, 3)?.map((source, index) => (
)} {research && } - {toolCalls && toolCalls.length > 0 && ( - - )} {!message && onOpenArtifact && completedArtifacts.length > 0 && (
{completedArtifacts.map((artifact, artifactIndex) => ( @@ -519,212 +501,56 @@ const ConversationBubble = forwardRef< />
)} - {thought && ( - + {type === 'ERROR' ? ( + message && ( +
+
+ +

+ {t('conversation.answer')} +

+
+
+ +
+
+ ) + ) : ( + ( +
+ +
+ )} + renderWikiWrite={(toolCall: ToolCallsType) => ( +
+ +
+ )} + /> )} - {message && ( -
-
- -

- {t('conversation.answer')} -

-
-
- {(() => { - const contentSegments = processMarkdownContent(message); - return ( - <> - {contentSegments.map((segment, index) => ( - - {segment.type === 'text' ? ( - { - const el = document.getElementById( - `source-${sourceIdx}`, - ); - if (el) { - el.scrollIntoView({ - behavior: 'smooth', - block: 'center', - }); - el.classList.add( - 'ring-2', - 'ring-purple-500', - ); - setTimeout( - () => - el.classList.remove( - 'ring-2', - 'ring-purple-500', - ), - 2000, - ); - } - }} - className="mx-0.5 h-5 min-w-5 rounded-full bg-purple-100 px-1.5 text-xs font-semibold text-purple-700 hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-300 dark:hover:bg-purple-900/60" - title={`Jump to source ${num}`} - > - {num} - - ); - } - return ( - - {children} - - ); - }, - code(props) { - const { - children, - className, - node, - ref, - ...rest - } = props; - const match = /language-(\w+)/.exec( - className || '', - ); - const language = match ? match[1] : ''; - - return match ? ( -
-
- - {language} - - -
- - {String(children).replace(/\n$/, '')} - -
- ) : ( - - {children} - - ); - }, - ul({ children }) { - return ( -
    - {children} -
- ); - }, - ol({ children }) { - return ( -
    - {children} -
- ); - }, - table({ children }) { - return ( -
- - {children} -
-
- ); - }, - thead({ children }) { - return ( - - {children} - - ); - }, - tr({ children }) { - return ( - - {children} - - ); - }, - th({ children }) { - return ( - {children} - ); - }, - td({ children }) { - return ( - {children} - ); - }, - }} - > - {segment.content} -
- ) : ( -
- -
- )} -
- ))} - - ); - })()} -
-
+ {/* Only when nothing inline is already showing the activity: a live + reasoning or tool segment carries its own shimmer, so this would be + a second indicator away from the point of action. */} + {isStreaming && !hasLiveInlineStep && ( + )} {message && (
@@ -1150,315 +976,3 @@ export function WikiWriteToolCallCard({
); } - -export function ToolCalls({ - toolCalls, - onToolAction, - agentId, -}: { - toolCalls: ToolCallsType[]; - onToolAction?: ( - callId: string, - decision: 'approved' | 'denied', - comment?: string, - ) => void; - agentId?: string; -}) { - const [isToolCallsOpen, setIsToolCallsOpen] = useState(false); - - const awaitingCalls = toolCalls.filter( - (tc) => tc.status === 'awaiting_approval', - ); - const wikiWriteCalls = toolCalls.filter( - (tc) => tc.status !== 'awaiting_approval' && isWikiWriteCall(tc), - ); - const resolvedCalls = toolCalls.filter( - (tc) => tc.status !== 'awaiting_approval' && !isWikiWriteCall(tc), - ); - - return ( -
- {/* Approval bars — always visible, compact inline */} - {awaitingCalls.length > 0 && ( -
- {awaitingCalls.map((tc) => ( - - ))} -
- )} - - {/* Wiki write cards — always visible (D24: visibility is the control) */} - {wikiWriteCalls.length > 0 && ( -
- {wikiWriteCalls.map((toolCall, index) => ( - - ))} -
- )} - - {/* Regular tool calls accordion */} - {resolvedCalls.length > 0 && ( - <> -
- - -
- {isToolCallsOpen && ( -
-
- {resolvedCalls.map((toolCall, index) => { - if (toolCall.tool_name === 'scheduler') { - return ( - - ); - } - return ( - - - - {`${toolCall.tool_name} - ${toolCall.action_name.substring(0, toolCall.action_name.lastIndexOf('_'))}`} - - -
-
-

- Arguments{' '} - -

-

- - {JSON.stringify(toolCall.arguments, null, 2)} - -

-
-
-

- Response{' '} - -

- {toolCall.status === 'pending' && ( - - - - )} - {toolCall.status === 'completed' && ( -

- - {JSON.stringify(toolCall.result, null, 2)} - -

- )} - {toolCall.status === 'error' && ( -

- - {toolCall.error} - -

- )} - {toolCall.status === 'denied' && ( -

- - Denied by user - -

- )} -
-
-
-
-
- ); - })} -
-
- )} - - )} -
- ); -} - -function Thought({ - thought, - preprocessLaTeX, -}: { - thought: string; - preprocessLaTeX: (content: string) => string; -}) { - const { t } = useTranslation(); - const [isDarkTheme] = useDarkTheme(); - const [isThoughtOpen, setIsThoughtOpen] = useState(false); - - return ( -
-
- - -
- {isThoughtOpen && ( -
-
- -
- - {language} - - -
- - {String(children).replace(/\n$/, '')} - -
- ) : ( - - {children} - - ); - }, - ul({ children }) { - return ( -
    - {children} -
- ); - }, - ol({ children }) { - return ( -
    - {children} -
- ); - }, - table({ children }) { - return ( -
- - {children} -
-
- ); - }, - thead({ children }) { - return ( - - {children} - - ); - }, - tr({ children }) { - return ( - - {children} - - ); - }, - th({ children }) { - return {children}; - }, - td({ children }) { - return {children}; - }, - }} - > - {preprocessLaTeX(thought ?? '')} - -
-
- )} -
- ); -} diff --git a/frontend/src/conversation/ConversationMessages.tsx b/frontend/src/conversation/ConversationMessages.tsx index 6fe2b6e6..34d60d31 100644 --- a/frontend/src/conversation/ConversationMessages.tsx +++ b/frontend/src/conversation/ConversationMessages.tsx @@ -22,6 +22,7 @@ import { import Hero from '../Hero'; import ConversationBubble from './ConversationBubble'; import { FEEDBACK, Query, Status } from './conversationModels'; +import StreamingStatusLine from './StreamingStatusLine'; type ConversationMessagesProps = { handleQuestion: (params: { @@ -55,6 +56,8 @@ const STICK_THRESHOLD_PX = 48; const DEFAULT_BUBBLE_MARGIN = 'mb-7'; const FIRST_QUESTION_BUBBLE_MARGIN_TOP = 'mt-5'; +// Below DEFAULT_BUBBLE_MARGIN so a question groups with its own answer. +const QUESTION_BUBBLE_MARGIN_BOTTOM = 'mb-3'; export default function ConversationMessages({ handleQuestion, @@ -126,7 +129,7 @@ export default function ConversationMessages({ const columnClass = isSplitView ? 'w-full max-w-325 px-2' - : 'w-full max-w-325 px-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12'; + : 'w-full max-w-325 px-2 md:w-11/12 lg:w-10/12 xl:w-9/12 2xl:w-8/12'; const renderResponseView = (query: Query, index: number) => { const isLastMessage = index === queries.length - 1; @@ -201,6 +204,7 @@ export default function ConversationMessages({ thought={query.thought} sources={query.sources} toolCalls={query.tool_calls} + segments={query.segments} workflowRunId={query.workflow_run_id} research={query.research} onOpenArtifact={onOpenArtifact} @@ -236,13 +240,7 @@ export default function ConversationMessages({ {t('conversation.answer')}

-
-
- - - -
-
+
); @@ -279,9 +277,9 @@ export default function ConversationMessages({ `$$${equation}$$`, + ); + return blockProcessedContent.replace( + /\\\((.*?)\\\)/gs, + (_, equation) => `$${equation}$`, + ); +} + +type ContentSegment = { type: 'text' | 'mermaid'; content: string }; + +export function processMarkdownContent(content: string): ContentSegment[] { + let processedContent = preprocessLaTeX(content); + + // Convert citation references [N] into markdown links [N](#cite-N) + // so ReactMarkdown renders them as tags we can style. + // Avoid matching inside code blocks or existing links. + processedContent = processedContent.replace( + /(? `[${num}](#cite-${num})`, + ); + + const contentSegments: ContentSegment[] = []; + let lastIndex = 0; + const regex = /```mermaid\n([\s\S]*?)```/g; + let match; + + while ((match = regex.exec(processedContent)) !== null) { + const textBefore = processedContent.substring(lastIndex, match.index); + if (textBefore) contentSegments.push({ type: 'text', content: textBefore }); + contentSegments.push({ type: 'mermaid', content: match[1].trim() }); + lastIndex = match.index + match[0].length; + } + + const textAfter = processedContent.substring(lastIndex); + if (textAfter) contentSegments.push({ type: 'text', content: textAfter }); + + return contentSegments; +} + +export default function MarkdownAnswer({ + content, + isStreaming, +}: { + content: string; + isStreaming?: boolean; +}) { + const [isDarkTheme] = useDarkTheme(); + // Re-runs on every streamed token otherwise. + const contentSegments = useMemo( + () => processMarkdownContent(content), + [content], + ); + + return ( + <> + {contentSegments.map((segment, index) => ( + + {segment.type === 'text' ? ( + { + const el = document.getElementById( + `source-${sourceIdx}`, + ); + if (el) { + el.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + el.classList.add('ring-2', 'ring-purple-500'); + setTimeout( + () => + el.classList.remove( + 'ring-2', + 'ring-purple-500', + ), + 2000, + ); + } + }} + className="mx-0.5 h-5 min-w-5 rounded-full bg-purple-100 px-1.5 text-xs font-semibold text-purple-700 hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-300 dark:hover:bg-purple-900/60" + title={`Jump to source ${num}`} + > + {num} + + ); + } + return ( + + {children} + + ); + }, + code(props) { + const { children, className, node, ref, ...rest } = props; + const match = /language-(\w+)/.exec(className || ''); + const language = match ? match[1] : ''; + + return match ? ( +
+
+ + {language} + + +
+ + {String(children).replace(/\n$/, '')} + +
+ ) : ( + + {children} + + ); + }, + ul({ children }) { + return ( +
    + {children} +
+ ); + }, + ol({ children }) { + return ( +
    + {children} +
+ ); + }, + table({ children }) { + return ( +
+ + {children} +
+
+ ); + }, + thead({ children }) { + return ( + + {children} + + ); + }, + tr({ children }) { + return ( + + {children} + + ); + }, + th({ children }) { + return {children}; + }, + td({ children }) { + return {children}; + }, + }} + > + {segment.content} + + ) : ( +
+ +
+ )} +
+ ))} + + ); +} diff --git a/frontend/src/conversation/StreamingStatusLine.tsx b/frontend/src/conversation/StreamingStatusLine.tsx new file mode 100644 index 00000000..fea2ce4a --- /dev/null +++ b/frontend/src/conversation/StreamingStatusLine.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from 'react-i18next'; + +import { cn } from '../lib/utils'; + +type StreamingStatusLineProps = { + hasAnswerText?: boolean; + className?: string; +}; + +/** + * Only renders while no step chip is live above it, since chips announce their + * own activity. That leaves the two states nothing else covers. + */ +export default function StreamingStatusLine({ + hasAnswerText = false, + className, +}: StreamingStatusLineProps) { + const { t } = useTranslation(); + + return ( +
+ + {hasAnswerText + ? t('conversation.streamingStatus.generating') + : t('conversation.streamingStatus.thinking')} + +
+ ); +} diff --git a/frontend/src/conversation/WikiWriteToolCallCard.test.tsx b/frontend/src/conversation/WikiWriteToolCallCard.test.tsx index 576f550b..4441710b 100644 --- a/frontend/src/conversation/WikiWriteToolCallCard.test.tsx +++ b/frontend/src/conversation/WikiWriteToolCallCard.test.tsx @@ -4,7 +4,8 @@ import { I18nextProvider, initReactI18next } from 'react-i18next'; import { beforeAll, describe, expect, it } from 'vitest'; import en from '../locale/en.json'; -import { ToolCalls, WikiWriteToolCallCard } from './ConversationBubble'; +import AnswerFlow from './AnswerFlow'; +import { WikiWriteToolCallCard } from './ConversationBubble'; import { ToolCallsType } from './types'; const testI18n = i18n.createInstance(); @@ -63,11 +64,17 @@ describe('WikiWriteToolCallCard', () => { }); }); -describe('ToolCalls placement', () => { +describe('tool call placement in the answer flow', () => { const renderToolCalls = (toolCalls: ToolCallsType[]): string => renderToStaticMarkup( - + null} + renderWikiWrite={(toolCall) => ( + + )} + /> , ); @@ -87,14 +94,14 @@ describe('ToolCalls placement', () => { status: 'completed', }; - it('renders wiki write cards outside the collapsed accordion (default closed)', () => { + it('shows wiki write cards inline and keeps other call arguments collapsed', () => { const html = renderToolCalls([wikiWrite, otherCall]); expect(html).toContain('Edited wiki page'); expect(html).toContain('/policy.md'); expect(html).not.toContain('secret-arg'); }); - it('does not double-render wiki write cards inside the accordion', () => { + it('renders each wiki write card exactly once', () => { const html = renderToolCalls([wikiWrite]); const occurrences = html.split('Edited wiki page').length - 1; expect(occurrences).toBe(1); diff --git a/frontend/src/conversation/answerFlowIntegration.test.tsx b/frontend/src/conversation/answerFlowIntegration.test.tsx new file mode 100644 index 00000000..afc32b64 --- /dev/null +++ b/frontend/src/conversation/answerFlowIntegration.test.tsx @@ -0,0 +1,95 @@ +import i18n from 'i18next'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { I18nextProvider, initReactI18next } from 'react-i18next'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import en from '../locale/en.json'; +import AnswerFlow from './AnswerFlow'; +import reducer, { + addQuery, + setStatus, + updateStreamingQuery, + updateThought, + updateToolCall, +} from './conversationSlice'; +import { ToolCallsType } from './types'; + +const testI18n = i18n.createInstance(); + +beforeAll(async () => { + await testI18n.use(initReactI18next).init({ + lng: 'en', + fallbackLng: 'en', + resources: { en: { translation: en } }, + }); +}); + +const searchCall = (status: ToolCallsType['status']): ToolCallsType => ({ + tool_name: 'brave', + action_name: 'brave_web_search', + call_id: 'c1', + arguments: { query: 'docsgpt' }, + status, + ...(status === 'completed' ? { result: { hits: 1 } } : {}), +}); + +// Covers the seam the unit tests leave open: that the stream reducers produce +// exactly the shape AnswerFlow renders from. +describe('stream reducers feeding the answer flow', () => { + it('records steps in arrival order and renders them above one answer bubble', () => { + const at = { index: 0, conversationId: null }; + let state = reducer(undefined, addQuery({ prompt: 'hi' })); + state = reducer(state, setStatus('loading')); + state = reducer( + state, + updateThought({ ...at, query: { thought: 'I should search.' } }), + ); + // The same call arrives twice, pending then completed. + state = reducer( + state, + updateToolCall({ ...at, tool_call: searchCall('pending') }), + ); + state = reducer( + state, + updateToolCall({ ...at, tool_call: searchCall('completed') }), + ); + state = reducer( + state, + updateStreamingQuery({ ...at, query: { response: 'Docs' } }), + ); + state = reducer( + state, + updateStreamingQuery({ ...at, query: { response: 'GPT rocks.' } }), + ); + + const query = state.queries[0]; + expect(query.segments).toEqual([ + { kind: 'thought', text: 'I should search.' }, + { kind: 'tool', call_id: 'c1' }, + ]); + expect(query.response).toBe('DocsGPT rocks.'); + expect(query.tool_calls).toHaveLength(1); + expect(query.tool_calls?.[0].status).toBe('completed'); + + const html = renderToStaticMarkup( + + null} + renderWikiWrite={() => null} + /> + , + ); + + expect(html.split('fade-in-bubble').length - 1).toBe(1); + expect(html.indexOf('I should search.')).toBeLessThan( + html.indexOf('Searched the web'), + ); + expect(html.indexOf('Searched the web')).toBeLessThan( + html.indexOf('DocsGPT rocks.'), + ); + }); +}); diff --git a/frontend/src/conversation/answerSegments.test.ts b/frontend/src/conversation/answerSegments.test.ts new file mode 100644 index 00000000..1eef9725 --- /dev/null +++ b/frontend/src/conversation/answerSegments.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { + AnswerSegment, + appendThoughtText, + getAnswerSegments, + recordToolCall, + synthesizeSegments, +} from './answerSegments'; +import { ToolCallsType } from './types'; + +const call = (overrides: Partial = {}): ToolCallsType => ({ + tool_name: 'brave', + action_name: 'brave_web_search', + call_id: 'c1', + arguments: {}, + status: 'completed', + ...overrides, +}); + +describe('appendThoughtText', () => { + it('coalesces consecutive reasoning deltas', () => { + const segments: AnswerSegment[] = []; + appendThoughtText(segments, 'I should '); + appendThoughtText(segments, 'search'); + expect(segments).toEqual([{ kind: 'thought', text: 'I should search' }]); + }); + + it('starts a new reasoning step after a tool call', () => { + const segments: AnswerSegment[] = []; + appendThoughtText(segments, 'plan'); + recordToolCall(segments, 'c1'); + appendThoughtText(segments, 'now verify'); + expect(segments).toEqual([ + { kind: 'thought', text: 'plan' }, + { kind: 'tool', call_id: 'c1' }, + { kind: 'thought', text: 'now verify' }, + ]); + }); +}); + +describe('recordToolCall', () => { + it('records a call once even though pending and completed both fire', () => { + const segments: AnswerSegment[] = []; + recordToolCall(segments, 'c1'); + recordToolCall(segments, 'c1'); + expect(segments).toEqual([{ kind: 'tool', call_id: 'c1' }]); + }); + + it('keeps arrival order across several calls', () => { + const segments: AnswerSegment[] = []; + recordToolCall(segments, 'c1'); + recordToolCall(segments, 'c2'); + recordToolCall(segments, 'c1'); + expect(segments).toEqual([ + { kind: 'tool', call_id: 'c1' }, + { kind: 'tool', call_id: 'c2' }, + ]); + }); + + it('ignores an empty call id', () => { + const segments: AnswerSegment[] = []; + recordToolCall(segments, ''); + expect(segments).toEqual([]); + }); +}); + +describe('synthesizeSegments / getAnswerSegments', () => { + it('orders a reloaded answer as reasoning then tool calls', () => { + expect( + synthesizeSegments({ + thought: 'reasoning', + tool_calls: [call({ call_id: 'c1' }), call({ call_id: 'c2' })], + }), + ).toEqual([ + { kind: 'thought', text: 'reasoning' }, + { kind: 'tool', call_id: 'c1' }, + { kind: 'tool', call_id: 'c2' }, + ]); + }); + + it('omits fields that are absent', () => { + expect(synthesizeSegments({ thought: 'only reasoning' })).toEqual([ + { kind: 'thought', text: 'only reasoning' }, + ]); + expect(synthesizeSegments({})).toEqual([]); + }); + + it('prefers live steps when present', () => { + const live: AnswerSegment[] = [{ kind: 'thought', text: 'live' }]; + expect(getAnswerSegments({ thought: 'flat', segments: live })).toBe(live); + }); + + it('falls back when steps were cleared by a tail snapshot', () => { + expect(getAnswerSegments({ thought: 'flat', segments: [] })).toEqual([ + { kind: 'thought', text: 'flat' }, + ]); + }); +}); diff --git a/frontend/src/conversation/answerSegments.ts b/frontend/src/conversation/answerSegments.ts new file mode 100644 index 00000000..f380f306 --- /dev/null +++ b/frontend/src/conversation/answerSegments.ts @@ -0,0 +1,54 @@ +import { ToolCallsType } from './types'; + +/** + * Ordering layer only: ``response``/``thought``/``tool_calls`` keep accumulating + * as before and stay the source of truth for copy, feedback, history and resume. + */ +export type AnswerSegment = + | { kind: 'thought'; text: string } + // Id only. The call is upserted in place in ``query.tool_calls``, so a chip + // always reads its current status rather than a frozen copy. + | { kind: 'tool'; call_id: string }; + +export function appendThoughtText( + segments: AnswerSegment[], + delta: string, +): void { + const last = segments[segments.length - 1]; + if (last?.kind === 'thought') last.text += delta; + else segments.push({ kind: 'thought', text: delta }); +} + +export function recordToolCall( + segments: AnswerSegment[], + callId: string, +): void { + if (!callId) return; + // Each call arrives twice (pending, then completed); only the first fixes + // its position. + const seen = segments.some( + (segment) => segment.kind === 'tool' && segment.call_id === callId, + ); + if (!seen) segments.push({ kind: 'tool', call_id: callId }); +} + +/** Fallback order for answers with no recorded arrival order (reloads, shares). */ +export function synthesizeSegments(query: { + thought?: string; + tool_calls?: ToolCallsType[]; +}): AnswerSegment[] { + const segments: AnswerSegment[] = []; + if (query.thought) segments.push({ kind: 'thought', text: query.thought }); + query.tool_calls?.forEach((call) => { + if (call.call_id) segments.push({ kind: 'tool', call_id: call.call_id }); + }); + return segments; +} + +export function getAnswerSegments(query: { + thought?: string; + tool_calls?: ToolCallsType[]; + segments?: AnswerSegment[]; +}): AnswerSegment[] { + return query.segments?.length ? query.segments : synthesizeSegments(query); +} diff --git a/frontend/src/conversation/conversationModels.ts b/frontend/src/conversation/conversationModels.ts index e7e5fe84..e8ba8713 100644 --- a/frontend/src/conversation/conversationModels.ts +++ b/frontend/src/conversation/conversationModels.ts @@ -1,3 +1,4 @@ +import { AnswerSegment } from './answerSegments'; import { ToolCallsType } from './types'; export type MESSAGE_TYPE = 'QUESTION' | 'ANSWER' | 'ERROR'; @@ -62,6 +63,10 @@ export interface Query { thought?: string; sources?: { title: string; text: string; link: string }[]; tool_calls?: ToolCallsType[]; + // Arrival-ordered layout of the fields above, so reasoning and tool calls + // render where they happened. Live-stream only; absent on reload, where + // ``getAnswerSegments`` synthesizes an order instead. + segments?: AnswerSegment[]; // Set when this answer came from a workflow agent run; lets the chat render // the run's produced artifacts via WorkflowRunArtifacts. workflow_run_id?: string; diff --git a/frontend/src/conversation/conversationSlice.ts b/frontend/src/conversation/conversationSlice.ts index fe68fabd..8d24c5f5 100644 --- a/frontend/src/conversation/conversationSlice.ts +++ b/frontend/src/conversation/conversationSlice.ts @@ -18,6 +18,7 @@ import { selectCompletedAttachments, } from '../upload/uploadSlice'; import { newIdempotencyKey } from '../utils/idempotency'; +import { appendThoughtText, recordToolCall } from './answerSegments'; import { handleFetchAnswer, handleFetchAnswerSteaming, @@ -785,6 +786,7 @@ export const conversationSlice = createSlice({ delete state.queries[index].thought; delete state.queries[index].sources; delete state.queries[index].tool_calls; + delete state.queries[index].segments; delete state.queries[index].error; delete state.queries[index].structured; delete state.queries[index].schema; @@ -852,6 +854,8 @@ export const conversationSlice = createSlice({ if (query.thought != undefined) { state.queries[index].thought = (state.queries[index].thought || '') + query.thought; + if (!state.queries[index].segments) state.queries[index].segments = []; + appendThoughtText(state.queries[index].segments, query.thought); } }, updateStreamingSource( @@ -884,6 +888,9 @@ export const conversationSlice = createSlice({ ...tool_call, }; } else state.queries[index].tool_calls.push(tool_call); + + if (!state.queries[index].segments) state.queries[index].segments = []; + recordToolCall(state.queries[index].segments, tool_call.call_id); }, // Records the workflow run id so the answer bubble can render the run's // produced artifacts (WorkflowRunArtifacts fetches by this id). @@ -1006,6 +1013,9 @@ export const conversationSlice = createSlice({ const { index, tail } = action.payload; const query = state.queries[index]; if (!query) return; + // A tail is a flat snapshot with no ordering, so any live segments it + // overwrites would be stale; drop them and let rendering synthesize. + delete query.segments; const status = tail?.status as MessageStatus | undefined; query.messageStatus = status; query.lastHeartbeatAt = tail?.last_heartbeat_at ?? query.lastHeartbeatAt; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 037d666a..dac0771f 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -65,6 +65,36 @@ export function useMediaQuery() { return { isMobile, isTablet, isDesktop }; } +/** + * Reveals ``text`` gradually while ``enabled``, catching up faster the further + * the display falls behind (lag stays bounded at roughly half a second). + * When ``enabled`` is false the full text is returned immediately. + */ +export function usePacedText(text: string, enabled: boolean): string { + const textRef = useRef(text); + textRef.current = text; + const [visibleLength, setVisibleLength] = useState(() => + enabled ? 0 : text.length, + ); + + useEffect(() => { + if (!enabled) { + setVisibleLength(textRef.current.length); + return; + } + const id = window.setInterval(() => { + setVisibleLength((len) => { + const backlog = textRef.current.length - len; + if (backlog <= 0) return len; + return len + Math.max(2, Math.ceil(backlog * 0.08)); + }); + }, 50); + return () => window.clearInterval(id); + }, [enabled]); + + return enabled ? text.slice(0, Math.min(visibleLength, text.length)) : text; +} + export function useDarkTheme() { const getSystemThemePreference = () => { return ( diff --git a/frontend/src/index.css b/frontend/src/index.css index 05c9c28b..5081d1a3 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -713,46 +713,44 @@ Avoid over-scrolling in mobile browsers animation: fadeInUp 0.5s forwards; } - .thinking-dots { - display: inline-flex; - gap: 4px; - align-items: center; + .shimmer-text { + background: linear-gradient( + 90deg, + #6b7280 0%, + #6b7280 40%, + #d1d5db 50%, + #6b7280 60%, + #6b7280 100% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: shimmer-sweep 2s linear infinite; } - .thinking-dots span { - width: 6px; - height: 6px; - border-radius: 50%; - background-color: #7d54d1; - animation: thinkingBounce 1.4s infinite ease-in-out both; + .dark .shimmer-text { + background-image: linear-gradient( + 90deg, + #9ca3af 0%, + #9ca3af 40%, + #f9fafb 50%, + #9ca3af 60%, + #9ca3af 100% + ); } - .dark .thinking-dots span { - background-color: #976af3; - } - - .thinking-dots span:nth-child(1) { - animation-delay: -0.32s; - } - - .thinking-dots span:nth-child(2) { - animation-delay: -0.16s; - } - - .thinking-dots span:nth-child(3) { - animation-delay: 0s; - } - - @keyframes thinkingBounce { - 0%, - 80%, - 100% { - transform: scale(0.6); - opacity: 0.4; + @keyframes shimmer-sweep { + to { + background-position: -200% 0; } - 40% { - transform: scale(1); - opacity: 1; + } + + @media (prefers-reduced-motion: reduce) { + .shimmer-text { + animation: none; + color: inherit; + background: none; } } diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 4b876f43..da87aea6 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -1101,6 +1101,37 @@ "delete": "Wiki-Seite gelöscht", "rename": "Wiki-Seite umbenannt", "failed": "fehlgeschlagen" + }, + "streamingStatus": { + "thinking": "Denke nach…", + "generating": "Generiere…", + "searchingWeb": "Suche im Web nach „{{query}}“…", + "searchingWebGeneric": "Suche im Web…", + "searchingImages": "Suche Bilder zu „{{query}}“…", + "readingPage": "Lese {{target}}…", + "searchingKnowledge": "Durchsuche Wissensdatenbank…", + "accessingMemory": "Greife auf das Gedächtnis zu…", + "runningCode": "Führe Code aus…", + "creatingArtifact": "Erstelle ein Artefakt…", + "usingTool": "Verwende {{tool}}…" + }, + "toolChip": { + "searchingWeb": "Im Web gesucht · „{{query}}“", + "searchedWebGeneric": "Im Web gesucht", + "searchingImages": "Bilder gesucht · „{{query}}“", + "readingPage": "{{target}} gelesen", + "searchingKnowledge": "Wissensdatenbank durchsucht", + "accessingMemory": "Auf Gedächtnis zugegriffen", + "runningCode": "Code ausgeführt", + "creatingArtifact": "Artefakt erstellt", + "usingTool": "{{tool}} verwendet" + }, + "inlineSteps": { + "arguments": "Argumente", + "response": "Antwort", + "running": "Läuft…", + "denied": "Vom Benutzer abgelehnt", + "failed": "fehlgeschlagen" } }, "agents": { diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 0c825232..e0f68681 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -1102,6 +1102,37 @@ "delete": "Deleted wiki page", "rename": "Renamed wiki page", "failed": "failed" + }, + "streamingStatus": { + "thinking": "Thinking…", + "generating": "Generating…", + "searchingWeb": "Searching the web for “{{query}}”…", + "searchingWebGeneric": "Searching the web…", + "searchingImages": "Searching images for “{{query}}”…", + "readingPage": "Reading {{target}}…", + "searchingKnowledge": "Searching knowledge base…", + "accessingMemory": "Accessing memory…", + "runningCode": "Running code…", + "creatingArtifact": "Creating an artifact…", + "usingTool": "Using {{tool}}…" + }, + "toolChip": { + "searchingWeb": "Searched the web · “{{query}}”", + "searchedWebGeneric": "Searched the web", + "searchingImages": "Searched images · “{{query}}”", + "readingPage": "Read {{target}}", + "searchingKnowledge": "Searched knowledge base", + "accessingMemory": "Accessed memory", + "runningCode": "Ran code", + "creatingArtifact": "Created an artifact", + "usingTool": "Used {{tool}}" + }, + "inlineSteps": { + "arguments": "Arguments", + "response": "Response", + "running": "Running…", + "denied": "Denied by user", + "failed": "failed" } }, "agents": { diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 5e74875d..c9e3b33d 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -1101,6 +1101,37 @@ "delete": "Página de wiki eliminada", "rename": "Página de wiki renombrada", "failed": "falló" + }, + "streamingStatus": { + "thinking": "Pensando…", + "generating": "Generando…", + "searchingWeb": "Buscando en la web “{{query}}”…", + "searchingWebGeneric": "Buscando en la web…", + "searchingImages": "Buscando imágenes de “{{query}}”…", + "readingPage": "Leyendo {{target}}…", + "searchingKnowledge": "Buscando en la base de conocimientos…", + "accessingMemory": "Accediendo a la memoria…", + "runningCode": "Ejecutando código…", + "creatingArtifact": "Creando un artefacto…", + "usingTool": "Usando {{tool}}…" + }, + "toolChip": { + "searchingWeb": "Buscado en la web · “{{query}}”", + "searchedWebGeneric": "Buscado en la web", + "searchingImages": "Buscadas imágenes · “{{query}}”", + "readingPage": "Leído {{target}}", + "searchingKnowledge": "Buscado en la base de conocimientos", + "accessingMemory": "Memoria consultada", + "runningCode": "Código ejecutado", + "creatingArtifact": "Artefacto creado", + "usingTool": "Usado {{tool}}" + }, + "inlineSteps": { + "arguments": "Argumentos", + "response": "Respuesta", + "running": "Ejecutando…", + "denied": "Denegado por el usuario", + "failed": "falló" } }, "agents": { diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 6a8b2410..cba64262 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -1101,6 +1101,37 @@ "delete": "Wikiページを削除", "rename": "Wikiページの名前を変更", "failed": "失敗" + }, + "streamingStatus": { + "thinking": "考え中…", + "generating": "生成中…", + "searchingWeb": "「{{query}}」をウェブ検索中…", + "searchingWebGeneric": "ウェブ検索中…", + "searchingImages": "「{{query}}」の画像を検索中…", + "readingPage": "{{target}} を読み込み中…", + "searchingKnowledge": "ナレッジベースを検索中…", + "accessingMemory": "メモリにアクセス中…", + "runningCode": "コードを実行中…", + "creatingArtifact": "アーティファクトを作成中…", + "usingTool": "{{tool}} を使用中…" + }, + "toolChip": { + "searchingWeb": "ウェブ検索済み · 「{{query}}」", + "searchedWebGeneric": "ウェブ検索済み", + "searchingImages": "画像検索済み · 「{{query}}」", + "readingPage": "{{target}} を読み込み済み", + "searchingKnowledge": "ナレッジベース検索済み", + "accessingMemory": "メモリにアクセス済み", + "runningCode": "コード実行済み", + "creatingArtifact": "アーティファクト作成済み", + "usingTool": "{{tool}} を使用済み" + }, + "inlineSteps": { + "arguments": "引数", + "response": "レスポンス", + "running": "実行中…", + "denied": "ユーザーによって拒否されました", + "failed": "失敗" } }, "agents": { diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index 34fada96..e63d312e 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -1101,6 +1101,37 @@ "delete": "Удалена страница вики", "rename": "Переименована страница вики", "failed": "не удалось" + }, + "streamingStatus": { + "thinking": "Думаю…", + "generating": "Генерирую…", + "searchingWeb": "Ищу в интернете «{{query}}»…", + "searchingWebGeneric": "Ищу в интернете…", + "searchingImages": "Ищу изображения «{{query}}»…", + "readingPage": "Читаю {{target}}…", + "searchingKnowledge": "Ищу в базе знаний…", + "accessingMemory": "Обращаюсь к памяти…", + "runningCode": "Выполняю код…", + "creatingArtifact": "Создаю артефакт…", + "usingTool": "Использую {{tool}}…" + }, + "toolChip": { + "searchingWeb": "Поиск в интернете · «{{query}}»", + "searchedWebGeneric": "Выполнен поиск в интернете", + "searchingImages": "Поиск изображений · «{{query}}»", + "readingPage": "Прочитано {{target}}", + "searchingKnowledge": "Выполнен поиск в базе знаний", + "accessingMemory": "Обращение к памяти выполнено", + "runningCode": "Код выполнен", + "creatingArtifact": "Артефакт создан", + "usingTool": "Использован {{tool}}" + }, + "inlineSteps": { + "arguments": "Аргументы", + "response": "Ответ", + "running": "Выполняется…", + "denied": "Отклонено пользователем", + "failed": "не удалось" } }, "agents": { diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index e16a2e28..85d829bf 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -1101,6 +1101,37 @@ "delete": "已刪除 Wiki 頁面", "rename": "已重新命名 Wiki 頁面", "failed": "失敗" + }, + "streamingStatus": { + "thinking": "思考中…", + "generating": "生成中…", + "searchingWeb": "正在網路搜尋「{{query}}」…", + "searchingWebGeneric": "正在搜尋網路…", + "searchingImages": "正在搜尋「{{query}}」的圖片…", + "readingPage": "正在閱讀 {{target}}…", + "searchingKnowledge": "正在搜尋知識庫…", + "accessingMemory": "正在存取記憶…", + "runningCode": "正在執行程式碼…", + "creatingArtifact": "正在建立 Artifact…", + "usingTool": "正在使用 {{tool}}…" + }, + "toolChip": { + "searchingWeb": "已搜尋網路 · 「{{query}}」", + "searchedWebGeneric": "已搜尋網路", + "searchingImages": "已搜尋圖片 · 「{{query}}」", + "readingPage": "已閱讀 {{target}}", + "searchingKnowledge": "已搜尋知識庫", + "accessingMemory": "已存取記憶", + "runningCode": "已執行程式碼", + "creatingArtifact": "已建立 Artifact", + "usingTool": "已使用 {{tool}}" + }, + "inlineSteps": { + "arguments": "參數", + "response": "回應", + "running": "執行中…", + "denied": "已被使用者拒絕", + "failed": "失敗" } }, "agents": { diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 88dd9222..b9be75d5 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -1101,6 +1101,37 @@ "delete": "已删除 Wiki 页面", "rename": "已重命名 Wiki 页面", "failed": "失败" + }, + "streamingStatus": { + "thinking": "思考中…", + "generating": "生成中…", + "searchingWeb": "正在网上搜索“{{query}}”…", + "searchingWebGeneric": "正在搜索网络…", + "searchingImages": "正在搜索“{{query}}”的图片…", + "readingPage": "正在阅读 {{target}}…", + "searchingKnowledge": "正在搜索知识库…", + "accessingMemory": "正在访问记忆…", + "runningCode": "正在运行代码…", + "creatingArtifact": "正在创建工件…", + "usingTool": "正在使用 {{tool}}…" + }, + "toolChip": { + "searchingWeb": "已搜索网络 · “{{query}}”", + "searchedWebGeneric": "已搜索网络", + "searchingImages": "已搜索图片 · “{{query}}”", + "readingPage": "已阅读 {{target}}", + "searchingKnowledge": "已搜索知识库", + "accessingMemory": "已访问记忆", + "runningCode": "已运行代码", + "creatingArtifact": "已创建工件", + "usingTool": "已使用 {{tool}}" + }, + "inlineSteps": { + "arguments": "参数", + "response": "响应", + "running": "正在运行…", + "denied": "已被用户拒绝", + "failed": "失败" } }, "agents": { diff --git a/frontend/src/utils/streamingStatusUtils.test.ts b/frontend/src/utils/streamingStatusUtils.test.ts new file mode 100644 index 00000000..1b30823c --- /dev/null +++ b/frontend/src/utils/streamingStatusUtils.test.ts @@ -0,0 +1,128 @@ +import type { TFunction } from 'i18next'; +import { describe, expect, it } from 'vitest'; + +import type { ToolCallsType } from '../conversation/types'; +import { getToolChipLabel } from './streamingStatusUtils'; + +// Stub that renders "key" or "key|value,value" so assertions can check both +// the selected key and the interpolated values. +const t = ((key: string, opts?: Record) => { + const values = opts + ? Object.entries(opts) + .filter(([k]) => k !== 'interpolation') + .map(([, v]) => String(v)) + : []; + return values.length ? `${key}|${values.join(',')}` : key; +}) as unknown as TFunction; + +const call = (overrides: Partial): ToolCallsType => ({ + tool_name: 'brave', + action_name: 'brave_web_search', + call_id: 'c1', + arguments: {}, + status: 'completed', + ...overrides, +}); + +describe('getToolChipLabel', () => { + it('uses present tense while running and past tense once settled', () => { + const args = { query: 'docsgpt' }; + expect( + getToolChipLabel(call({ arguments: args, status: 'pending' }), t), + ).toBe('conversation.streamingStatus.searchingWeb|docsgpt'); + expect(getToolChipLabel(call({ arguments: args }), t)).toBe( + 'conversation.toolChip.searchingWeb|docsgpt', + ); + }); + + it('falls back to the generic search label without a query, per namespace', () => { + expect(getToolChipLabel(call({ status: 'pending' }), t)).toBe( + 'conversation.streamingStatus.searchingWebGeneric', + ); + expect(getToolChipLabel(call({}), t)).toBe( + 'conversation.toolChip.searchedWebGeneric', + ); + }); + + it('treats a status-less call as settled, matching the non-shimmer row', () => { + expect( + getToolChipLabel( + call({ arguments: { query: 'docsgpt' }, status: undefined }), + t, + ), + ).toBe('conversation.toolChip.searchingWeb|docsgpt'); + expect(getToolChipLabel(call({ status: undefined }), t)).toBe( + 'conversation.toolChip.searchedWebGeneric', + ); + }); + + it('labels read_webpage with the hostname', () => { + expect( + getToolChipLabel( + call({ + tool_name: 'read_webpage', + action_name: 'read_webpage', + arguments: { url: 'https://docs.docsgpt.cloud/quickstart' }, + }), + t, + ), + ).toBe('conversation.toolChip.readingPage|docs.docsgpt.cloud'); + }); + + it('keeps the raw value when the url does not parse', () => { + expect( + getToolChipLabel( + call({ action_name: 'read_webpage', arguments: { url: 'not a url' } }), + t, + ), + ).toBe('conversation.toolChip.readingPage|not a url'); + }); + + it('maps image search, knowledge, memory, code and artifact tools', () => { + expect( + getToolChipLabel( + call({ + action_name: 'ddg_image_search', + arguments: { query: 'cats' }, + }), + t, + ), + ).toBe('conversation.toolChip.searchingImages|cats'); + expect( + getToolChipLabel( + call({ tool_name: 'internal_search', action_name: 'search' }), + t, + ), + ).toBe('conversation.toolChip.searchingKnowledge'); + expect( + getToolChipLabel( + call({ tool_name: 'memory', action_name: 'memory_view' }), + t, + ), + ).toBe('conversation.toolChip.accessingMemory'); + expect( + getToolChipLabel( + call({ tool_name: 'code_executor', action_name: 'run_code' }), + t, + ), + ).toBe('conversation.toolChip.runningCode'); + expect( + getToolChipLabel( + call({ + tool_name: 'artifact_generator', + action_name: 'create_artifact', + }), + t, + ), + ).toBe('conversation.toolChip.creatingArtifact'); + }); + + it('falls back to a formatted tool name for unknown tools', () => { + expect( + getToolChipLabel( + call({ tool_name: 'mcp_tool', action_name: 'some_dynamic_action' }), + t, + ), + ).toBe('conversation.toolChip.usingTool|Mcp Tool'); + }); +}); diff --git a/frontend/src/utils/streamingStatusUtils.ts b/frontend/src/utils/streamingStatusUtils.ts new file mode 100644 index 00000000..aad9e4bb --- /dev/null +++ b/frontend/src/utils/streamingStatusUtils.ts @@ -0,0 +1,102 @@ +import type { TFunction } from 'i18next'; + +import type { ToolCallsType } from '../conversation/types'; + +const WEB_SEARCH_ACTIONS = new Set([ + 'brave_web_search', + 'ddg_web_search', + 'ddg_news_search', +]); +const IMAGE_SEARCH_ACTIONS = new Set([ + 'brave_image_search', + 'ddg_image_search', +]); +const ARTIFACT_ACTIONS = new Set([ + 'create_artifact', + 'edit_artifact', + 'rewrite_artifact', +]); + +// Display-name overrides for tools whose label differs from the formatted key. +const TOOL_LABEL_OVERRIDES: Record = { + artifact_generator: 'Artifact', +}; + +function formatToolLabel(toolName: string | undefined): string { + if (!toolName) return ''; + if (TOOL_LABEL_OVERRIDES[toolName]) return TOOL_LABEL_OVERRIDES[toolName]; + return toolName + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); +} + +// i18n.ts leaves escapeValue at its default (true); React escapes on render, +// so disable it here to keep user queries like O'Brien readable. +const NO_ESCAPE = { interpolation: { escapeValue: false } } as const; + +/** + * An activity as an i18n key suffix plus values, so the present- and past-tense + * phrasings stay two lookups of one mapping instead of two mappings. + */ +export type ToolActivity = { key: string; values?: Record }; + +export function describeToolCall(toolCall: ToolCallsType): ToolActivity { + const { tool_name, action_name, arguments: args } = toolCall; + const query = typeof args?.query === 'string' ? args.query : undefined; + + if (WEB_SEARCH_ACTIONS.has(action_name)) + return query ? { key: 'searchingWeb', values: { query } } : { key: 'web' }; + if (IMAGE_SEARCH_ACTIONS.has(action_name)) + return query + ? { key: 'searchingImages', values: { query } } + : { key: 'web' }; + if (action_name === 'read_webpage') { + const url = typeof args?.url === 'string' ? args.url : undefined; + if (url) { + let target = url; + try { + target = new URL(url).hostname; + } catch { + // not a parseable url; show it as given + } + return { key: 'readingPage', values: { target } }; + } + } + if (action_name === 'run_code') return { key: 'runningCode' }; + if (ARTIFACT_ACTIONS.has(action_name)) return { key: 'creatingArtifact' }; + if (tool_name === 'internal_search') return { key: 'searchingKnowledge' }; + if (tool_name === 'memory' || tool_name === 'notes') + return { key: 'accessingMemory' }; + + return { key: 'usingTool', values: { tool: formatToolLabel(tool_name) } }; +} + +// The generic-search fallback key differs between the two namespaces. +const GENERIC_SEARCH_KEY: Record = { + streamingStatus: 'searchingWebGeneric', + toolChip: 'searchedWebGeneric', +}; + +function activityLabel( + activity: ToolActivity, + namespace: 'streamingStatus' | 'toolChip', + t: TFunction, +): string { + const key = + activity.key === 'web' ? GENERIC_SEARCH_KEY[namespace] : activity.key; + return t( + `conversation.${namespace}.${key}`, + activity.values ? { ...activity.values, ...NO_ESCAPE } : undefined, + ); +} + +export function getToolChipLabel( + toolCall: ToolCallsType, + t: TFunction, +): string { + const activity = describeToolCall(toolCall); + const namespace = + toolCall.status === 'pending' ? 'streamingStatus' : 'toolChip'; + return activityLabel(activity, namespace, t); +}