mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 14:14:50 +00:00
Merge pull request #2636 from ManishMadan2882/main
(feat)ui/chat: inline streaming steps for reasoning and tool calls
This commit is contained in:
25 files changed
+1571
-602
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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> = {}): 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(
|
||||
<I18nextProvider i18n={testI18n}>
|
||||
<AnswerFlow
|
||||
{...props}
|
||||
renderApproval={() => <div>APPROVAL_BAR</div>}
|
||||
renderWikiWrite={() => <div>WIKI_CARD</div>}
|
||||
/>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<InlineThoughtChip
|
||||
key={`thought-${index}`}
|
||||
thought={step.text}
|
||||
isActive={isStreaming && index === lastIndex && !message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const call = callById.get(step.call_id);
|
||||
if (!call) return null;
|
||||
|
||||
if (call.status === 'awaiting_approval')
|
||||
return (
|
||||
<Fragment key={`approval-${call.call_id}`}>
|
||||
{renderApproval(call)}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
if (isWikiWriteCall(call))
|
||||
return (
|
||||
<Fragment key={`wiki-${call.call_id}`}>
|
||||
{renderWikiWrite(call)}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
if (call.tool_name === 'scheduler')
|
||||
return (
|
||||
<div key={`scheduler-${call.call_id}`} className="my-2 w-full">
|
||||
<SchedulerToolCallCard
|
||||
result={call.result}
|
||||
actionName={call.action_name}
|
||||
status={call.status}
|
||||
agentId={agentId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<InlineToolCallChip key={`tool-${call.call_id}`} toolCall={call} />
|
||||
);
|
||||
})}
|
||||
{message && (
|
||||
<div className="flex max-w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
|
||||
<div className="my-2 flex flex-row items-center justify-center gap-3">
|
||||
<Avatar
|
||||
src={DocsGPT3}
|
||||
alt={t('conversation.answer')}
|
||||
className="h-8.5 w-8.5 text-2xl"
|
||||
imgClassName="h-full w-full object-cover"
|
||||
/>
|
||||
<p className="text-base font-semibold">
|
||||
{t('conversation.answer')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="fade-in-bubble mr-5 flex max-w-full flex-col rounded-3xl py-4.5">
|
||||
<MarkdownAnswer content={message} isStreaming={isStreaming} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineThoughtChip({
|
||||
thought,
|
||||
isActive,
|
||||
}: {
|
||||
thought: string;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const liveRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="my-2 flex w-full flex-col">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-expanded={isOpen}
|
||||
className="hover:bg-muted/60 flex h-auto w-fit max-w-full items-center justify-start gap-2 rounded-lg bg-transparent px-2 py-1.5 text-sm font-normal"
|
||||
>
|
||||
<img src={Cloud} alt="" aria-hidden className="h-4 w-4 shrink-0" />
|
||||
<span
|
||||
className={`min-w-0 truncate text-left ${
|
||||
isActive ? 'shimmer-text' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{t('conversation.reasoning')}
|
||||
</span>
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={`text-muted-foreground h-4 w-4 shrink-0 transform transition-transform duration-200 ${
|
||||
isOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
{showLiveWindow && (
|
||||
<div
|
||||
ref={liveRef}
|
||||
className="text-muted-foreground mt-1 ml-3 h-24 overflow-hidden scroll-smooth mask-[linear-gradient(to_bottom,transparent,black_40%)] text-sm leading-normal motion-reduce:scroll-auto"
|
||||
>
|
||||
<div className="flex min-h-full flex-col justify-end wrap-break-word whitespace-pre-wrap">
|
||||
{pacedThought}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!showLiveWindow && !isOpen && (
|
||||
<p className="text-muted-foreground mt-0.5 ml-3 truncate text-sm">
|
||||
{thought}
|
||||
</p>
|
||||
)}
|
||||
{isOpen && (
|
||||
<p className="fade-in text-muted-foreground mt-0.5 ml-3 text-sm leading-normal wrap-break-word whitespace-pre-wrap">
|
||||
{thought}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineToolCallChip({ toolCall }: { toolCall: ToolCallsType }) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isPending = toolCall.status === 'pending';
|
||||
const label = getToolChipLabel(toolCall, t);
|
||||
|
||||
return (
|
||||
<div className="my-2 flex w-full flex-col">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-expanded={isOpen}
|
||||
className="hover:bg-muted/60 flex h-auto w-fit max-w-full items-center justify-start gap-2 rounded-lg bg-transparent px-2 py-1.5 text-sm font-normal"
|
||||
>
|
||||
{/* ToolIcon renders nothing for a tool with no bundled icon, so the
|
||||
dot below stands in via ``only:block`` to keep the row aligned. */}
|
||||
<span
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center ${
|
||||
isPending ? 'animate-pulse' : ''
|
||||
}`}
|
||||
>
|
||||
<ToolIcon
|
||||
name={toolCall.tool_name}
|
||||
className="text-muted-foreground h-4 w-4"
|
||||
/>
|
||||
<span className="bg-muted-foreground/50 hidden h-1.5 w-1.5 rounded-full only:block" />
|
||||
</span>
|
||||
<span
|
||||
className={`min-w-0 truncate text-left ${
|
||||
isPending ? 'shimmer-text' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{toolCall.status === 'error' && (
|
||||
<span className="text-destructive shrink-0 text-xs">
|
||||
{t('conversation.inlineSteps.failed')}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={`text-muted-foreground h-4 w-4 shrink-0 transform transition-transform duration-200 ${
|
||||
isOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<div className="fade-in mt-2 flex flex-col gap-2">
|
||||
<ToolCallPanel
|
||||
title={t('conversation.inlineSteps.arguments')}
|
||||
copyText={JSON.stringify(toolCall.arguments ?? {}, null, 2)}
|
||||
>
|
||||
<p className="max-h-80 overflow-y-auto font-mono text-xs whitespace-pre-wrap">
|
||||
{JSON.stringify(toolCall.arguments ?? {}, null, 2)}
|
||||
</p>
|
||||
</ToolCallPanel>
|
||||
<ToolCallPanel
|
||||
title={t('conversation.inlineSteps.response')}
|
||||
copyText={
|
||||
toolCall.status === 'error'
|
||||
? (toolCall.error ?? '')
|
||||
: JSON.stringify(toolCall.result ?? {}, null, 2)
|
||||
}
|
||||
>
|
||||
{isPending && (
|
||||
<p className="shimmer-text text-xs">
|
||||
{t('conversation.inlineSteps.running')}
|
||||
</p>
|
||||
)}
|
||||
{toolCall.status === 'error' && (
|
||||
<p className="text-destructive font-mono text-xs whitespace-pre-wrap">
|
||||
{toolCall.error}
|
||||
</p>
|
||||
)}
|
||||
{toolCall.status === 'denied' && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('conversation.inlineSteps.denied')}
|
||||
</p>
|
||||
)}
|
||||
{!isPending &&
|
||||
toolCall.status !== 'error' &&
|
||||
toolCall.status !== 'denied' && (
|
||||
<p className="max-h-80 overflow-y-auto font-mono text-xs whitespace-pre-wrap">
|
||||
{JSON.stringify(toolCall.result ?? {}, null, 2)}
|
||||
</p>
|
||||
)}
|
||||
</ToolCallPanel>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallPanel({
|
||||
title,
|
||||
copyText,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
copyText: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-muted/50 dark:bg-answer-bubble overflow-hidden rounded-xl">
|
||||
<div className="flex items-center justify-between px-3 py-1.5">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
{title}
|
||||
</span>
|
||||
<CopyButton textToCopy={copyText} />
|
||||
</div>
|
||||
<div className="px-3 pb-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
@@ -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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex w-full items-center rounded-full px-2">
|
||||
|
||||
@@ -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<HTMLDivElement>(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')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="fade-in mr-5 ml-3 max-w-[90vw] md:max-w-[70vw] lg:max-w-[50vw]">
|
||||
<div className="fade-in mr-5 ml-3 w-full">
|
||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
{sources?.slice(0, 3)?.map((source, index) => (
|
||||
<div
|
||||
@@ -464,13 +453,6 @@ const ConversationBubble = forwardRef<
|
||||
</div>
|
||||
)}
|
||||
{research && <ResearchProgress research={research} />}
|
||||
{toolCalls && toolCalls.length > 0 && (
|
||||
<ToolCalls
|
||||
toolCalls={toolCalls}
|
||||
onToolAction={onToolAction}
|
||||
agentId={agentId}
|
||||
/>
|
||||
)}
|
||||
{!message && onOpenArtifact && completedArtifacts.length > 0 && (
|
||||
<div className="my-2 ml-2 flex flex-wrap justify-start gap-2">
|
||||
{completedArtifacts.map((artifact, artifactIndex) => (
|
||||
@@ -519,212 +501,56 @@ const ConversationBubble = forwardRef<
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{thought && (
|
||||
<Thought thought={thought} preprocessLaTeX={preprocessLaTeX} />
|
||||
{type === 'ERROR' ? (
|
||||
message && (
|
||||
<div className="flex max-w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
|
||||
<div className="my-2 flex flex-row items-center justify-center gap-3">
|
||||
<Avatar
|
||||
src={DocsGPT3}
|
||||
alt={t('conversation.answer')}
|
||||
className="h-8.5 w-8.5 text-2xl"
|
||||
imgClassName="h-full w-full object-cover"
|
||||
/>
|
||||
<p className="text-base font-semibold">
|
||||
{t('conversation.answer')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="fade-in-bubble text-destructive/80 dark:border-destructive dark:bg-destructive/15 relative mr-5 flex max-w-full flex-row items-center rounded-full border border-transparent bg-[#FFE7E7] p-2 px-6 py-5 text-sm font-normal dark:text-white">
|
||||
<MarkdownAnswer content={message} isStreaming={isStreaming} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<AnswerFlow
|
||||
message={message}
|
||||
thought={thought}
|
||||
toolCalls={toolCalls}
|
||||
segments={segments}
|
||||
isStreaming={isStreaming}
|
||||
agentId={agentId}
|
||||
renderApproval={(toolCall: ToolCallsType) => (
|
||||
<div className="fade-in mt-4 w-full">
|
||||
<ToolCallApprovalBar
|
||||
toolCall={toolCall}
|
||||
onToolAction={onToolAction}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
renderWikiWrite={(toolCall: ToolCallsType) => (
|
||||
<div className="fade-in mt-4 w-full">
|
||||
<WikiWriteToolCallCard toolCall={toolCall} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{message && (
|
||||
<div className="flex max-w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
|
||||
<div className="my-2 flex flex-row items-center justify-center gap-3">
|
||||
<Avatar
|
||||
src={DocsGPT3}
|
||||
alt={t('conversation.answer')}
|
||||
className="h-8.5 w-8.5 text-2xl"
|
||||
imgClassName="h-full w-full object-cover"
|
||||
/>
|
||||
<p className="text-base font-semibold">
|
||||
{t('conversation.answer')}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`fade-in-bubble bg-answer-bubble mr-5 flex max-w-full rounded-2xl px-6 py-4.5 ${
|
||||
type === 'ERROR'
|
||||
? 'text-destructive/80 dark:border-destructive dark:bg-destructive/15 relative flex-row items-center rounded-full border border-transparent bg-[#FFE7E7] p-2 py-5 text-sm font-normal dark:text-white'
|
||||
: 'flex-col rounded-3xl'
|
||||
}`}
|
||||
>
|
||||
{(() => {
|
||||
const contentSegments = processMarkdownContent(message);
|
||||
return (
|
||||
<>
|
||||
{contentSegments.map((segment, index) => (
|
||||
<Fragment key={index}>
|
||||
{segment.type === 'text' ? (
|
||||
<ReactMarkdown
|
||||
className="fade-in flex flex-col gap-3 leading-normal wrap-break-word whitespace-pre-wrap"
|
||||
remarkPlugins={[
|
||||
remarkGfm,
|
||||
[remarkMath, { singleDollarTextMath: false }],
|
||||
]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
a({ href, children }) {
|
||||
if (href?.startsWith('#cite-')) {
|
||||
const num = href.replace('#cite-', '');
|
||||
const sourceIdx = parseInt(num, 10) - 1;
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
code(props) {
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
node,
|
||||
ref,
|
||||
...rest
|
||||
} = props;
|
||||
const match = /language-(\w+)/.exec(
|
||||
className || '',
|
||||
);
|
||||
const language = match ? match[1] : '';
|
||||
|
||||
return match ? (
|
||||
<div className="group border-border relative overflow-hidden rounded-xl border">
|
||||
<div className="bg-muted flex items-center justify-between px-2 py-1">
|
||||
<span className="text-foreground dark:text-foreground text-xs font-medium">
|
||||
{language}
|
||||
</span>
|
||||
<CopyButton
|
||||
textToCopy={String(children).replace(
|
||||
/\n$/,
|
||||
'',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
{...rest}
|
||||
PreTag="div"
|
||||
language={language}
|
||||
style={
|
||||
isDarkTheme ? vscDarkPlus : oneLight
|
||||
}
|
||||
className="mt-0!"
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
}}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
) : (
|
||||
<code className="dark:bg-accent dark:text-foreground rounded-md bg-gray-200 px-2 py-1 text-xs font-normal whitespace-pre-line">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
ul({ children }) {
|
||||
return (
|
||||
<ul
|
||||
className={`list-inside list-disc pl-4 whitespace-normal ${classes.list}`}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
ol({ children }) {
|
||||
return (
|
||||
<ol
|
||||
className={`list-inside list-decimal pl-4 whitespace-normal ${classes.list}`}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="border-border relative overflow-x-auto rounded-lg border">
|
||||
<table className="dark:text-foreground w-full text-left text-gray-700">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return (
|
||||
<thead className="bg-muted text-foreground text-xs uppercase">
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
},
|
||||
tr({ children }) {
|
||||
return (
|
||||
<tr className="border-border odd:bg-card even:bg-muted border-b">
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
th({ children }) {
|
||||
return (
|
||||
<th className="px-6 py-3">{children}</th>
|
||||
);
|
||||
},
|
||||
td({ children }) {
|
||||
return (
|
||||
<td className="px-6 py-3">{children}</td>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{segment.content}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<div
|
||||
className="my-4 w-full"
|
||||
style={{ minWidth: '100%' }}
|
||||
>
|
||||
<MermaidRenderer
|
||||
code={segment.content}
|
||||
isLoading={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
{/* 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 && (
|
||||
<StreamingStatusLine
|
||||
hasAnswerText={Boolean(message)}
|
||||
className="my-2 ml-3"
|
||||
/>
|
||||
)}
|
||||
{message && (
|
||||
<div className="my-2 ml-2 flex flex-wrap justify-start gap-2">
|
||||
@@ -1150,315 +976,3 @@ export function WikiWriteToolCallCard({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="relative mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
|
||||
{/* Approval bars — always visible, compact inline */}
|
||||
{awaitingCalls.length > 0 && (
|
||||
<div className="fade-in mt-4 ml-3 w-[90vw] md:w-[70vw] lg:w-full">
|
||||
{awaitingCalls.map((tc) => (
|
||||
<ToolCallApprovalBar
|
||||
key={`approval-${tc.call_id}`}
|
||||
toolCall={tc}
|
||||
onToolAction={onToolAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wiki write cards — always visible (D24: visibility is the control) */}
|
||||
{wikiWriteCalls.length > 0 && (
|
||||
<div className="fade-in mt-4 mr-5 ml-3 grid w-[90vw] grid-cols-1 gap-2 md:w-[70vw] lg:w-full">
|
||||
{wikiWriteCalls.map((toolCall, index) => (
|
||||
<WikiWriteToolCallCard
|
||||
key={`wiki-write-${toolCall.call_id ?? index}`}
|
||||
toolCall={toolCall}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Regular tool calls accordion */}
|
||||
{resolvedCalls.length > 0 && (
|
||||
<>
|
||||
<div className="my-2 flex flex-row items-center justify-center gap-3">
|
||||
<Avatar
|
||||
src={Sources}
|
||||
alt={'ToolCalls'}
|
||||
className="h-6.5 w-7.5 text-xl"
|
||||
imgClassName="h-full w-full object-fill"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto bg-transparent px-0 py-0 font-normal hover:bg-transparent dark:hover:bg-transparent"
|
||||
onClick={() => setIsToolCallsOpen(!isToolCallsOpen)}
|
||||
>
|
||||
<p className="text-base font-semibold">Tool Calls</p>
|
||||
<img
|
||||
src={ChevronDown}
|
||||
alt="ChevronDown"
|
||||
className={`h-4 w-4 transform transition-transform duration-200 dark:invert ${isToolCallsOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{isToolCallsOpen && (
|
||||
<div className="fade-in mr-5 ml-3 w-[90vw] md:w-[70vw] lg:w-full">
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{resolvedCalls.map((toolCall, index) => {
|
||||
if (toolCall.tool_name === 'scheduler') {
|
||||
return (
|
||||
<SchedulerToolCallCard
|
||||
key={`scheduler-${toolCall.call_id ?? index}`}
|
||||
result={toolCall.result}
|
||||
actionName={toolCall.action_name}
|
||||
status={toolCall.status}
|
||||
agentId={agentId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Accordion
|
||||
key={`tool-call-${index}`}
|
||||
type="single"
|
||||
collapsible
|
||||
className="bg-muted dark:bg-answer-bubble w-full rounded-4xl"
|
||||
>
|
||||
<AccordionItem value="tool-call">
|
||||
<AccordionTrigger className="px-6 py-2 text-sm font-semibold">
|
||||
{`${toolCall.tool_name} - ${toolCall.action_name.substring(0, toolCall.action_name.lastIndexOf('_'))}`}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="border-border flex flex-col rounded-2xl border">
|
||||
<p className="dark:bg-background flex flex-row items-center justify-between rounded-t-2xl bg-black/10 px-2 py-1 font-mono text-sm font-semibold wrap-break-word">
|
||||
<span>Arguments</span>{' '}
|
||||
<CopyButton
|
||||
textToCopy={JSON.stringify(
|
||||
toolCall.arguments,
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
/>
|
||||
</p>
|
||||
<p className="dark:bg-card max-h-80 overflow-y-auto rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
|
||||
<span className="dark:text-muted-foreground leading-5.75 text-black">
|
||||
{JSON.stringify(toolCall.arguments, null, 2)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-border flex flex-col rounded-2xl border">
|
||||
<p className="dark:bg-background flex flex-row items-center justify-between rounded-t-2xl bg-black/10 px-2 py-1 font-mono text-sm font-semibold wrap-break-word">
|
||||
<span>Response</span>{' '}
|
||||
<CopyButton
|
||||
textToCopy={
|
||||
toolCall.status === 'error'
|
||||
? toolCall.error || 'Unknown error'
|
||||
: JSON.stringify(toolCall.result, null, 2)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
{toolCall.status === 'pending' && (
|
||||
<span className="dark:bg-card flex w-full items-center justify-center rounded-b-2xl p-2">
|
||||
<Spinner size="small" />
|
||||
</span>
|
||||
)}
|
||||
{toolCall.status === 'completed' && (
|
||||
<p className="dark:bg-card max-h-80 overflow-y-auto rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
|
||||
<span className="dark:text-muted-foreground leading-5.75 text-black">
|
||||
{JSON.stringify(toolCall.result, null, 2)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{toolCall.status === 'error' && (
|
||||
<p className="dark:bg-card max-h-80 overflow-y-auto rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
|
||||
<span className="text-destructive leading-5.75">
|
||||
{toolCall.error}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{toolCall.status === 'denied' && (
|
||||
<p className="dark:bg-card rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
|
||||
<span className="text-muted-foreground leading-5.75">
|
||||
Denied by user
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Thought({
|
||||
thought,
|
||||
preprocessLaTeX,
|
||||
}: {
|
||||
thought: string;
|
||||
preprocessLaTeX: (content: string) => string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkTheme] = useDarkTheme();
|
||||
const [isThoughtOpen, setIsThoughtOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
|
||||
<div className="my-2 flex flex-row items-center justify-center gap-3">
|
||||
<Avatar
|
||||
src={Cloud}
|
||||
alt={'Thought'}
|
||||
className="h-6.5 w-7.5 text-xl"
|
||||
imgClassName="h-full w-full object-fill"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto bg-transparent px-0 py-0 font-normal hover:bg-transparent dark:hover:bg-transparent"
|
||||
onClick={() => setIsThoughtOpen(!isThoughtOpen)}
|
||||
>
|
||||
<p className="text-base font-semibold">
|
||||
{t('conversation.reasoning')}
|
||||
</p>
|
||||
<img
|
||||
src={ChevronDown}
|
||||
alt="ChevronDown"
|
||||
className={`h-4 w-4 transform transition-transform duration-200 dark:invert ${isThoughtOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{isThoughtOpen && (
|
||||
<div className="fade-in mr-5 ml-2 max-w-[90vw] md:max-w-[70vw] lg:max-w-[50vw]">
|
||||
<div className="bg-muted dark:bg-answer-bubble rounded-3xl px-7 py-4.5">
|
||||
<ReactMarkdown
|
||||
className="fade-in leading-normal wrap-break-word whitespace-pre-wrap"
|
||||
remarkPlugins={[
|
||||
remarkGfm,
|
||||
[remarkMath, { singleDollarTextMath: false }],
|
||||
]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
code(props) {
|
||||
const { children, className, node, ref, ...rest } = props;
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
|
||||
return match ? (
|
||||
<div className="group border-border relative overflow-hidden rounded-xl border">
|
||||
<div className="bg-muted flex items-center justify-between px-2 py-1">
|
||||
<span className="text-foreground dark:text-foreground text-xs font-medium">
|
||||
{language}
|
||||
</span>
|
||||
<CopyButton
|
||||
textToCopy={String(children).replace(/\n$/, '')}
|
||||
/>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
{...rest}
|
||||
PreTag="div"
|
||||
language={language}
|
||||
style={isDarkTheme ? vscDarkPlus : oneLight}
|
||||
className="mt-0!"
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
}}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
) : (
|
||||
<code className="dark:bg-accent dark:text-foreground rounded-md bg-gray-200 px-2 py-1 text-xs font-normal whitespace-pre-line">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
ul({ children }) {
|
||||
return (
|
||||
<ul className="list-inside list-disc pl-4 whitespace-normal">
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
ol({ children }) {
|
||||
return (
|
||||
<ol className="list-inside list-decimal pl-4 whitespace-normal">
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="border-border relative overflow-x-auto rounded-lg border">
|
||||
<table className="dark:text-foreground w-full text-left text-gray-700">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return (
|
||||
<thead className="bg-muted text-foreground text-xs uppercase">
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
},
|
||||
tr({ children }) {
|
||||
return (
|
||||
<tr className="border-border odd:bg-card even:bg-muted border-b">
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
th({ children }) {
|
||||
return <th className="px-6 py-3">{children}</th>;
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="px-6 py-3">{children}</td>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{preprocessLaTeX(thought ?? '')}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted mr-5 flex rounded-3xl px-6 py-5">
|
||||
<div className="thinking-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
<StreamingStatusLine className="my-2 ml-3" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -279,9 +277,9 @@ export default function ConversationMessages({
|
||||
<Fragment key={`${index}-query-fragment`}>
|
||||
<MessageScrollerItem messageId={`q-${index}`} scrollAnchor>
|
||||
<ConversationBubble
|
||||
className={
|
||||
className={`${QUESTION_BUBBLE_MARGIN_BOTTOM} ${
|
||||
index === 0 ? FIRST_QUESTION_BUBBLE_MARGIN_TOP : ''
|
||||
}
|
||||
}`}
|
||||
message={query.prompt}
|
||||
type="QUESTION"
|
||||
handleUpdatedQuestionSubmission={handleQuestionSubmission}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
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 CopyButton from '../components/CopyButton';
|
||||
import MermaidRenderer from '../components/MermaidRenderer';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
import classes from './ConversationBubble.module.css';
|
||||
|
||||
// Replaces block-level ``\[ \]`` and inline ``\( \)`` LaTeX delimiters with the
|
||||
// ``$$``/``$`` forms remark-math understands.
|
||||
export function preprocessLaTeX(content: string): string {
|
||||
const blockProcessedContent = content.replace(
|
||||
/\\\[(.*?)\\\]/gs,
|
||||
(_, equation) => `$$${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 <a> tags we can style.
|
||||
// Avoid matching inside code blocks or existing links.
|
||||
processedContent = processedContent.replace(
|
||||
/(?<!\[)\[(\d+)\](?!\()/g,
|
||||
(_, num) => `[${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) => (
|
||||
<Fragment key={index}>
|
||||
{segment.type === 'text' ? (
|
||||
<ReactMarkdown
|
||||
className="fade-in flex flex-col gap-3 leading-normal wrap-break-word whitespace-pre-wrap"
|
||||
remarkPlugins={[
|
||||
remarkGfm,
|
||||
[remarkMath, { singleDollarTextMath: false }],
|
||||
]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
a({ href, children }) {
|
||||
if (href?.startsWith('#cite-')) {
|
||||
const num = href.replace('#cite-', '');
|
||||
const sourceIdx = parseInt(num, 10) - 1;
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
code(props) {
|
||||
const { children, className, node, ref, ...rest } = props;
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
|
||||
return match ? (
|
||||
<div className="group border-border relative overflow-hidden rounded-xl border">
|
||||
<div className="bg-muted flex items-center justify-between px-2 py-1">
|
||||
<span className="text-foreground dark:text-foreground text-xs font-medium">
|
||||
{language}
|
||||
</span>
|
||||
<CopyButton
|
||||
textToCopy={String(children).replace(/\n$/, '')}
|
||||
/>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
{...rest}
|
||||
PreTag="div"
|
||||
language={language}
|
||||
style={isDarkTheme ? vscDarkPlus : oneLight}
|
||||
className="mt-0!"
|
||||
customStyle={{ margin: 0, borderRadius: 0 }}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
) : (
|
||||
<code className="dark:bg-accent dark:text-foreground rounded-md bg-gray-200 px-2 py-1 text-xs font-normal whitespace-pre-line">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
ul({ children }) {
|
||||
return (
|
||||
<ul
|
||||
className={`list-inside list-disc pl-4 whitespace-normal ${classes.list}`}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
ol({ children }) {
|
||||
return (
|
||||
<ol
|
||||
className={`list-inside list-decimal pl-4 whitespace-normal ${classes.list}`}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="border-border relative overflow-x-auto rounded-lg border">
|
||||
<table className="dark:text-foreground w-full text-left text-gray-700">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return (
|
||||
<thead className="bg-muted text-foreground text-xs uppercase">
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
},
|
||||
tr({ children }) {
|
||||
return (
|
||||
<tr className="border-border odd:bg-card even:bg-muted border-b">
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
th({ children }) {
|
||||
return <th className="px-6 py-3">{children}</th>;
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="px-6 py-3">{children}</td>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{segment.content}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<div className="my-4 w-full" style={{ minWidth: '100%' }}>
|
||||
<MermaidRenderer code={segment.content} isLoading={isStreaming} />
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn('flex h-6 min-w-0 items-center gap-2', className)}
|
||||
>
|
||||
<span className="shimmer-text max-w-[70vw] truncate text-sm lg:max-w-md">
|
||||
{hasAnswerText
|
||||
? t('conversation.streamingStatus.generating')
|
||||
: t('conversation.streamingStatus.thinking')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
<I18nextProvider i18n={testI18n}>
|
||||
<ToolCalls toolCalls={toolCalls} />
|
||||
<AnswerFlow
|
||||
toolCalls={toolCalls}
|
||||
renderApproval={() => null}
|
||||
renderWikiWrite={(toolCall) => (
|
||||
<WikiWriteToolCallCard toolCall={toolCall} />
|
||||
)}
|
||||
/>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
<I18nextProvider i18n={testI18n}>
|
||||
<AnswerFlow
|
||||
message={query.response}
|
||||
thought={query.thought}
|
||||
toolCalls={query.tool_calls}
|
||||
segments={query.segments}
|
||||
renderApproval={() => null}
|
||||
renderWikiWrite={() => null}
|
||||
/>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
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.'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+33
-35
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
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>): 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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> };
|
||||
|
||||
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<string, string> = {
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user