mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 22:15:10 +00:00
fix: minor edits, cleanup, light changes
This commit is contained in:
1 parent
e2f85bf19b
commit
94766c93cb
22 files changed
+500
-188
No files matched your search
@@ -176,7 +176,7 @@ export default function SharedAgent() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-[95%] max-w-[1500px] flex-col items-center pb-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
|
||||
<div className="flex w-[95%] max-w-290 flex-col items-center pb-2 md:w-10/12 lg:w-9/12 xl:w-8/12 2xl:w-7/12">
|
||||
<div className="w-full px-2">
|
||||
<MessageInput
|
||||
onSubmit={(text) => handleQuestionSubmission(text)}
|
||||
|
||||
@@ -33,6 +33,7 @@ const render = (props: {
|
||||
toolCalls?: ToolCallsType[];
|
||||
segments?: AnswerSegment[];
|
||||
isStreaming?: boolean;
|
||||
suppressStatusLine?: boolean;
|
||||
}): string =>
|
||||
renderToStaticMarkup(
|
||||
<I18nextProvider i18n={testI18n}>
|
||||
@@ -141,3 +142,81 @@ describe('AnswerFlow', () => {
|
||||
expect(html).not.toContain('fade-in-bubble');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AnswerFlow activity indicator', () => {
|
||||
const thoughtThenCall: AnswerSegment[] = [
|
||||
{ kind: 'thought', text: 'reasoning here' },
|
||||
{ kind: 'tool', call_id: 'c1' },
|
||||
];
|
||||
|
||||
it('shows the status line once every step has settled', () => {
|
||||
const html = render({
|
||||
thought: 'reasoning here',
|
||||
toolCalls: [search()],
|
||||
segments: thoughtThenCall,
|
||||
isStreaming: true,
|
||||
});
|
||||
expect(html).toContain('Thinking…');
|
||||
});
|
||||
|
||||
it('leaves the status line out while a call is still running', () => {
|
||||
const html = render({
|
||||
thought: 'reasoning here',
|
||||
toolCalls: [search({ status: 'pending' })],
|
||||
segments: thoughtThenCall,
|
||||
isStreaming: true,
|
||||
});
|
||||
expect(html).not.toContain('Thinking…');
|
||||
expect(html).toContain('Searching the web');
|
||||
});
|
||||
|
||||
it('leaves the status line out while reasoning is the live step', () => {
|
||||
const html = render({
|
||||
thought: 'reasoning here',
|
||||
segments: [{ kind: 'thought', text: 'reasoning here' }],
|
||||
isStreaming: true,
|
||||
});
|
||||
expect(html).not.toContain('Thinking…');
|
||||
expect(html).toContain('shimmer-text');
|
||||
});
|
||||
|
||||
it('says it is generating once answer text has started', () => {
|
||||
const html = render({
|
||||
message: 'the answer',
|
||||
thought: 'reasoning here',
|
||||
toolCalls: [search()],
|
||||
segments: thoughtThenCall,
|
||||
isStreaming: true,
|
||||
});
|
||||
expect(html).toContain('Generating…');
|
||||
});
|
||||
|
||||
it('stays quiet when the answer is no longer streaming', () => {
|
||||
const html = render({
|
||||
thought: 'reasoning here',
|
||||
toolCalls: [search()],
|
||||
segments: thoughtThenCall,
|
||||
});
|
||||
expect(html).not.toContain('Thinking…');
|
||||
});
|
||||
|
||||
it('defers to a run that already shows its own progress', () => {
|
||||
const html = render({
|
||||
toolCalls: [search()],
|
||||
segments: [{ kind: 'tool', call_id: 'c1' }],
|
||||
isStreaming: true,
|
||||
suppressStatusLine: true,
|
||||
});
|
||||
expect(html).not.toContain('Thinking…');
|
||||
});
|
||||
|
||||
it('treats a call handed to the client as still running', () => {
|
||||
const html = render({
|
||||
toolCalls: [search({ status: 'requires_client_execution' })],
|
||||
segments: [{ kind: 'tool', call_id: 'c1' }],
|
||||
isStreaming: true,
|
||||
});
|
||||
expect(html).not.toContain('Thinking…');
|
||||
expect(html).toContain('Searching the web');
|
||||
});
|
||||
});
|
||||
@@ -4,15 +4,17 @@ 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 {
|
||||
getToolChipLabel,
|
||||
isToolCallRunning,
|
||||
} from '../utils/streamingStatusUtils';
|
||||
import { AnswerSegment, getAnswerSegments } from './answerSegments';
|
||||
import MarkdownAnswer from './MarkdownAnswer';
|
||||
import StreamingStatusLine from './StreamingStatusLine';
|
||||
import { ToolCallsType } from './types';
|
||||
import { isWikiWriteCall } from './wikiToolCall';
|
||||
|
||||
@@ -24,8 +26,13 @@ type AnswerFlowProps = {
|
||||
segments?: AnswerSegment[];
|
||||
isStreaming?: boolean;
|
||||
agentId?: string;
|
||||
/** Set when the bubble already carries its own progress UI (a research run). */
|
||||
suppressStatusLine?: boolean;
|
||||
renderApproval: (toolCall: ToolCallsType) => React.ReactNode;
|
||||
renderWikiWrite: (toolCall: ToolCallsType) => React.ReactNode;
|
||||
renderWikiWrite: (
|
||||
toolCall: ToolCallsType,
|
||||
isLive: boolean,
|
||||
) => React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -39,14 +46,26 @@ export default function AnswerFlow({
|
||||
segments,
|
||||
isStreaming,
|
||||
agentId,
|
||||
suppressStatusLine,
|
||||
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;
|
||||
|
||||
// One derivation of "something here is already announcing activity": every
|
||||
// chip shimmers off this, and the status line below fills only the gaps it
|
||||
// leaves. Deriving it a second time from the flat fields let the two disagree,
|
||||
// which showed up as no indicator at all between a settled step and the answer.
|
||||
const liveSteps = steps.map((step, index) => {
|
||||
if (!isStreaming) return false;
|
||||
if (step.kind === 'thought') return index === lastIndex && !message;
|
||||
const call = callById.get(step.call_id);
|
||||
return Boolean(call && isToolCallRunning(call));
|
||||
});
|
||||
const hasLiveStep = liveSteps.some(Boolean);
|
||||
|
||||
return (
|
||||
<>
|
||||
{steps.map((step, index) => {
|
||||
@@ -55,7 +74,7 @@ export default function AnswerFlow({
|
||||
<InlineThoughtChip
|
||||
key={`thought-${index}`}
|
||||
thought={step.text}
|
||||
isActive={isStreaming && index === lastIndex && !message}
|
||||
isActive={liveSteps[index]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -73,13 +92,13 @@ export default function AnswerFlow({
|
||||
if (isWikiWriteCall(call))
|
||||
return (
|
||||
<Fragment key={`wiki-${call.call_id}`}>
|
||||
{renderWikiWrite(call)}
|
||||
{renderWikiWrite(call, liveSteps[index])}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
if (call.tool_name === 'scheduler')
|
||||
return (
|
||||
<div key={`scheduler-${call.call_id}`} className="my-2 w-full">
|
||||
<div key={`scheduler-${call.call_id}`} className="my-2 mr-5 ml-6">
|
||||
<SchedulerToolCallCard
|
||||
result={call.result}
|
||||
actionName={call.action_name}
|
||||
@@ -90,27 +109,28 @@ export default function AnswerFlow({
|
||||
);
|
||||
|
||||
return (
|
||||
<InlineToolCallChip key={`tool-${call.call_id}`} toolCall={call} />
|
||||
<InlineToolCallChip
|
||||
key={`tool-${call.call_id}`}
|
||||
toolCall={call}
|
||||
isLive={liveSteps[index]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{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">
|
||||
{/* ``ml-6`` is the answer's text column: step labels sit at the same
|
||||
offset, with their icons in the gutter to its left. */}
|
||||
<div className="fade-in-bubble my-2 mr-5 ml-6 flex max-w-full flex-col">
|
||||
<MarkdownAnswer content={message} isStreaming={isStreaming} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isStreaming && !hasLiveStep && !suppressStatusLine && (
|
||||
<StreamingStatusLine
|
||||
hasAnswerText={Boolean(message)}
|
||||
className="my-2 ml-6"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -145,7 +165,11 @@ function InlineThoughtChip({
|
||||
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"
|
||||
// ml-4 plus the button's own px-2 puts the icon on the answer's ml-6
|
||||
// text column. has-[>svg]:px-2 restates that padding under the same
|
||||
// variant the button's own has-[>svg]:px-3 uses; a plain px-2 does not
|
||||
// override it, and the chevron makes it match.
|
||||
className="hover:bg-muted/60 ml-4 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 has-[>svg]:px-2"
|
||||
>
|
||||
<img src={Cloud} alt="" aria-hidden className="h-4 w-4 shrink-0" />
|
||||
<span
|
||||
@@ -165,7 +189,7 @@ function InlineThoughtChip({
|
||||
{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"
|
||||
className="text-muted-foreground mt-1 ml-6 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}
|
||||
@@ -173,12 +197,12 @@ function InlineThoughtChip({
|
||||
</div>
|
||||
)}
|
||||
{!showLiveWindow && !isOpen && (
|
||||
<p className="text-muted-foreground mt-0.5 ml-3 truncate text-sm">
|
||||
<p className="text-muted-foreground mt-0.5 ml-6 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">
|
||||
<p className="fade-in text-muted-foreground mt-0.5 ml-6 text-sm leading-normal wrap-break-word whitespace-pre-wrap">
|
||||
{thought}
|
||||
</p>
|
||||
)}
|
||||
@@ -186,10 +210,18 @@ function InlineThoughtChip({
|
||||
);
|
||||
}
|
||||
|
||||
function InlineToolCallChip({ toolCall }: { toolCall: ToolCallsType }) {
|
||||
function InlineToolCallChip({
|
||||
toolCall,
|
||||
isLive,
|
||||
}: {
|
||||
toolCall: ToolCallsType;
|
||||
isLive?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isPending = toolCall.status === 'pending';
|
||||
// Liveness is what animates; running is what the call's own status means, so
|
||||
// a call left pending by a dropped stream still reads in the present tense.
|
||||
const isRunning = isToolCallRunning(toolCall);
|
||||
const label = getToolChipLabel(toolCall, t);
|
||||
|
||||
return (
|
||||
@@ -199,13 +231,17 @@ function InlineToolCallChip({ toolCall }: { toolCall: ToolCallsType }) {
|
||||
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"
|
||||
// ml-4 plus the button's own px-2 puts the icon on the answer's ml-6
|
||||
// text column. has-[>svg]:px-2 restates that padding under the same
|
||||
// variant the button's own has-[>svg]:px-3 uses; a plain px-2 does not
|
||||
// override it, and the chevron makes it match.
|
||||
className="hover:bg-muted/60 ml-4 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 has-[>svg]:px-2"
|
||||
>
|
||||
{/* 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' : ''
|
||||
isLive ? 'animate-pulse' : ''
|
||||
}`}
|
||||
>
|
||||
<ToolIcon
|
||||
@@ -216,7 +252,7 @@ function InlineToolCallChip({ toolCall }: { toolCall: ToolCallsType }) {
|
||||
</span>
|
||||
<span
|
||||
className={`min-w-0 truncate text-left ${
|
||||
isPending ? 'shimmer-text' : 'text-muted-foreground'
|
||||
isLive ? 'shimmer-text' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
@@ -234,7 +270,7 @@ function InlineToolCallChip({ toolCall }: { toolCall: ToolCallsType }) {
|
||||
/>
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<div className="fade-in mt-2 flex flex-col gap-2">
|
||||
<div className="fade-in mt-2 mr-5 ml-6 flex flex-col gap-2">
|
||||
<ToolCallPanel
|
||||
title={t('conversation.inlineSteps.arguments')}
|
||||
copyText={JSON.stringify(toolCall.arguments ?? {}, null, 2)}
|
||||
@@ -251,8 +287,10 @@ function InlineToolCallChip({ toolCall }: { toolCall: ToolCallsType }) {
|
||||
: JSON.stringify(toolCall.result ?? {}, null, 2)
|
||||
}
|
||||
>
|
||||
{isPending && (
|
||||
<p className="shimmer-text text-xs">
|
||||
{isRunning && (
|
||||
<p
|
||||
className={`text-xs ${isLive ? 'shimmer-text' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{t('conversation.inlineSteps.running')}
|
||||
</p>
|
||||
)}
|
||||
@@ -266,7 +304,7 @@ function InlineToolCallChip({ toolCall }: { toolCall: ToolCallsType }) {
|
||||
{t('conversation.inlineSteps.denied')}
|
||||
</p>
|
||||
)}
|
||||
{!isPending &&
|
||||
{!isRunning &&
|
||||
toolCall.status !== 'error' &&
|
||||
toolCall.status !== 'denied' && (
|
||||
<p className="max-h-80 overflow-y-auto font-mono text-xs whitespace-pre-wrap">
|
||||
|
||||
@@ -377,11 +377,13 @@ export default function Conversation() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* One notch narrower than the message column above it, which keeps its
|
||||
own width. */}
|
||||
<div
|
||||
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-11/12 lg:w-10/12 xl:w-9/12 2xl:w-8/12'
|
||||
? 'max-w-290'
|
||||
: 'max-w-290 md:w-10/12 lg:w-9/12 xl:w-8/12 2xl:w-7/12'
|
||||
}`}
|
||||
>
|
||||
<div className="flex w-full items-center rounded-full px-2">
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
import { Pencil } from 'lucide-react';
|
||||
import { forwardRef, Fragment, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import WorkflowRunArtifacts from '../agents/workflow/WorkflowRunArtifacts';
|
||||
import ChevronDown from '../assets/chevron-down.svg';
|
||||
import DocsGPT3 from '../assets/cute_docsgpt3.svg';
|
||||
import Dislike from '../assets/dislike.svg?react';
|
||||
import Document from '../assets/document.svg';
|
||||
import DocumentationDark from '../assets/documentation-dark.svg';
|
||||
@@ -14,7 +14,6 @@ import Edit from '../assets/edit.svg';
|
||||
import Like from '../assets/like.svg?react';
|
||||
import Link from '../assets/link.svg';
|
||||
import Sources from '../assets/sources.svg';
|
||||
import UserIcon from '../assets/user.svg';
|
||||
import CopyButton from '../components/CopyButton';
|
||||
|
||||
import { Avatar } from '../components/ui/avatar';
|
||||
@@ -22,18 +21,18 @@ import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { Sheet, SheetContent } from '../components/ui/sheet';
|
||||
import SpeakButton from '../components/TextToSpeechButton';
|
||||
import { useDarkTheme, useOutsideAlerter } from '../hooks';
|
||||
import { useOutsideAlerter } from '../hooks';
|
||||
import {
|
||||
selectChunks,
|
||||
selectSelectedDocs,
|
||||
selectToken,
|
||||
} from '../preferences/preferenceSlice';
|
||||
import { isToolCallRunning } from '../utils/streamingStatusUtils';
|
||||
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 { wikiWriteActionKey, wikiWritePath } from './wikiToolCall';
|
||||
|
||||
@@ -99,7 +98,6 @@ const ConversationBubble = forwardRef<
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkTheme] = useDarkTheme();
|
||||
// const bubbleRef = useRef<HTMLDivElement | null>(null);
|
||||
const chunks = useSelector(selectChunks);
|
||||
const selectedDocs = useSelector(selectSelectedDocs);
|
||||
@@ -113,11 +111,6 @@ 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.
|
||||
@@ -173,7 +166,7 @@ const ConversationBubble = forwardRef<
|
||||
<div className={`group ${className}`}>
|
||||
<div className="flex flex-col items-end">
|
||||
{filesAttached && filesAttached.length > 0 && (
|
||||
<div className="mr-12 mb-4 flex flex-wrap justify-end gap-2">
|
||||
<div className="mr-5 mb-4 flex flex-wrap justify-end gap-2">
|
||||
{filesAttached.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
@@ -198,12 +191,11 @@ const ConversationBubble = forwardRef<
|
||||
ref={ref}
|
||||
className={`flex flex-row-reverse justify-items-start`}
|
||||
>
|
||||
<Avatar className="mt-2 shrink-0 text-2xl">
|
||||
<img className="mr-1 rounded-full" width={30} src={UserIcon} />
|
||||
</Avatar>
|
||||
{!isEditClicked && (
|
||||
<>
|
||||
<div className="relative mr-2 flex w-full min-w-0 flex-col">
|
||||
{/* ``mr-3`` plus the pill's own ``mr-2`` puts the question's
|
||||
right edge on the answer's ``mr-5`` gutter. */}
|
||||
<div className="relative mr-3 flex w-full min-w-0 flex-col">
|
||||
<div className="mr-2 ml-2 flex max-w-full min-w-0 items-start gap-2 rounded-3xl bg-linear-to-b from-violet-500 to-violet-600 px-5 py-4 text-sm leading-normal wrap-anywhere whitespace-pre-wrap text-white sm:text-base">
|
||||
<div
|
||||
ref={messageRef}
|
||||
@@ -295,60 +287,6 @@ const ConversationBubble = forwardRef<
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const preprocessLaTeX = (content: string) => {
|
||||
// Replace block-level LaTeX delimiters \[ \] with $$ $$
|
||||
const blockProcessedContent = content.replace(
|
||||
/\\\[(.*?)\\\]/gs,
|
||||
(_, equation) => `$$${equation}$$`,
|
||||
);
|
||||
|
||||
// Replace inline LaTeX delimiters \( \) with $ $
|
||||
const inlineProcessedContent = blockProcessedContent.replace(
|
||||
/\\\((.*?)\\\)/gs,
|
||||
(_, equation) => `$${equation}$`,
|
||||
);
|
||||
|
||||
return inlineProcessedContent;
|
||||
};
|
||||
const processMarkdownContent = (content: string) => {
|
||||
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: Array<{
|
||||
type: 'text' | 'mermaid';
|
||||
content: string;
|
||||
}> = [];
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
bubble = (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -360,8 +298,10 @@ const ConversationBubble = forwardRef<
|
||||
sources?.some((source) => source.link === 'None')
|
||||
? null
|
||||
: sources && (
|
||||
<div className="mb-4 flex flex-col flex-wrap items-start self-start lg:flex-nowrap">
|
||||
<div className="my-2 flex flex-row items-center justify-center gap-3">
|
||||
// Stretched, not shrink-to-fit: the grid below sizes off this box,
|
||||
// so a fit-content parent would leave its width to the cards.
|
||||
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-stretch lg:flex-nowrap">
|
||||
<div className="my-2 ml-6 flex flex-row items-center justify-center gap-3">
|
||||
<Avatar
|
||||
src={Sources}
|
||||
alt={t('conversation.sources.title')}
|
||||
@@ -372,7 +312,9 @@ const ConversationBubble = forwardRef<
|
||||
{t('conversation.sources.title')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="fade-in mr-5 ml-3 w-full">
|
||||
{/* Width comes from the stretched parent minus these margins;
|
||||
w-full here would be the column width plus them. */}
|
||||
<div className="fade-in mr-5 ml-6">
|
||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
{sources?.slice(0, 3)?.map((source, index) => (
|
||||
<div
|
||||
@@ -454,7 +396,7 @@ const ConversationBubble = forwardRef<
|
||||
)}
|
||||
{research && <ResearchProgress research={research} />}
|
||||
{!message && onOpenArtifact && completedArtifacts.length > 0 && (
|
||||
<div className="my-2 ml-2 flex flex-wrap justify-start gap-2">
|
||||
<div className="my-2 ml-6 flex flex-wrap justify-start gap-2">
|
||||
{completedArtifacts.map((artifact, artifactIndex) => (
|
||||
<Button
|
||||
key={artifact.id ?? `${artifact.callId}-${artifactIndex}`}
|
||||
@@ -494,7 +436,7 @@ const ConversationBubble = forwardRef<
|
||||
</div>
|
||||
)}
|
||||
{workflowRunId && (
|
||||
<div className="my-2 mr-5 ml-2">
|
||||
<div className="my-2 mr-5 ml-6">
|
||||
<WorkflowRunArtifacts
|
||||
workflowRunId={workflowRunId}
|
||||
inProgress={isStreaming}
|
||||
@@ -504,17 +446,6 @@ const ConversationBubble = forwardRef<
|
||||
{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>
|
||||
@@ -528,32 +459,26 @@ const ConversationBubble = forwardRef<
|
||||
segments={segments}
|
||||
isStreaming={isStreaming}
|
||||
agentId={agentId}
|
||||
// A research run already narrates itself above; the status line
|
||||
// would be a second live indicator away from the point of action.
|
||||
suppressStatusLine={Boolean(research)}
|
||||
renderApproval={(toolCall: ToolCallsType) => (
|
||||
<div className="fade-in mt-4 w-full">
|
||||
<div className="fade-in mt-4 mr-5 ml-6">
|
||||
<ToolCallApprovalBar
|
||||
toolCall={toolCall}
|
||||
onToolAction={onToolAction}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
renderWikiWrite={(toolCall: ToolCallsType) => (
|
||||
<div className="fade-in mt-4 w-full">
|
||||
<WikiWriteToolCallCard toolCall={toolCall} />
|
||||
</div>
|
||||
renderWikiWrite={(toolCall: ToolCallsType, isLive: boolean) => (
|
||||
<WikiWriteToolCallCard toolCall={toolCall} isLive={isLive} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{/* 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">
|
||||
// ml-4 plus each button's own p-2 puts the first glyph on the answer's
|
||||
// ml-6 text column.
|
||||
<div className="my-2 ml-4 flex flex-wrap justify-start gap-2">
|
||||
{type === 'ERROR' ? (
|
||||
<div className="relative block items-center justify-center">
|
||||
<div>{retryBtn}</div>
|
||||
@@ -937,39 +862,48 @@ function ToolCallApprovalBar({
|
||||
);
|
||||
}
|
||||
|
||||
/** The wiki-write step, in the same inline chip language as the other steps. */
|
||||
export function WikiWriteToolCallCard({
|
||||
toolCall,
|
||||
isLive,
|
||||
}: {
|
||||
toolCall: ToolCallsType;
|
||||
isLive?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const path = wikiWritePath(toolCall);
|
||||
const actionKey = wikiWriteActionKey(toolCall.action_name);
|
||||
const isError = toolCall.status === 'error';
|
||||
const namespace = isToolCallRunning(toolCall)
|
||||
? 'wikiWrite.active'
|
||||
: 'wikiWrite';
|
||||
const label = t(`conversation.${namespace}.${actionKey}`, {
|
||||
defaultValue: t(`conversation.${namespace}.edited`),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2.5 rounded-2xl border px-4 py-2.5 text-sm ${
|
||||
isError
|
||||
? 'border-destructive/40 bg-destructive/5'
|
||||
: 'border-primary/30 bg-primary/5 dark:bg-primary/10'
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true" className="text-base leading-none">
|
||||
✏️
|
||||
</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{t(`conversation.wikiWrite.${actionKey}`, {
|
||||
defaultValue: t('conversation.wikiWrite.edited'),
|
||||
})}
|
||||
<div className="my-2 mr-5 ml-6 flex min-w-0 items-center gap-2 py-1.5 text-sm">
|
||||
<Pencil
|
||||
aria-hidden
|
||||
className={`text-muted-foreground h-4 w-4 shrink-0 ${
|
||||
isLive ? 'animate-pulse' : ''
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`shrink-0 ${isLive ? 'shimmer-text' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{path && (
|
||||
<code className="dark:bg-card min-w-0 truncate rounded-md bg-black/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
<code
|
||||
className="text-muted-foreground bg-muted dark:bg-answer-bubble min-w-0 truncate rounded-md px-1.5 py-0.5 font-mono text-xs"
|
||||
title={path}
|
||||
>
|
||||
{path}
|
||||
</code>
|
||||
)}
|
||||
{isError && (
|
||||
<span className="text-destructive text-xs">
|
||||
<span className="text-destructive shrink-0 text-xs">
|
||||
{t('conversation.wikiWrite.failed')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import DocsGPT3 from '../assets/cute_docsgpt3.svg';
|
||||
import Retry from '../assets/retry.svg?react';
|
||||
import { Button } from '../components/ui/button';
|
||||
import {
|
||||
@@ -131,6 +130,12 @@ export default function ConversationMessages({
|
||||
? 'w-full max-w-325 px-2'
|
||||
: 'w-full max-w-325 px-2 md:w-11/12 lg:w-10/12 xl:w-9/12 2xl:w-8/12';
|
||||
|
||||
// The empty state sits directly on top of the composer with nothing between
|
||||
// them, so it takes the composer's width rather than the wider message column.
|
||||
const emptyStateColumnClass = isSplitView
|
||||
? 'w-full max-w-290 px-2'
|
||||
: 'w-full max-w-290 px-2 md:w-10/12 lg:w-9/12 xl:w-8/12 2xl:w-7/12';
|
||||
|
||||
const renderResponseView = (query: Query, index: number) => {
|
||||
const isLastMessage = index === queries.length - 1;
|
||||
const bubbleMargin = DEFAULT_BUBBLE_MARGIN;
|
||||
@@ -228,19 +233,7 @@ export default function ConversationMessages({
|
||||
className={`fade-in-bubble group dark:text-foreground flex flex-col flex-wrap self-start ${bubbleMargin}`}
|
||||
>
|
||||
<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">
|
||||
<div className="flex h-8.5 w-8.5 items-center justify-center overflow-hidden rounded-full">
|
||||
<img
|
||||
src={DocsGPT3}
|
||||
alt={t('conversation.answer')}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-base font-semibold">
|
||||
{t('conversation.answer')}
|
||||
</p>
|
||||
</div>
|
||||
<StreamingStatusLine className="my-2 ml-3" />
|
||||
<StreamingStatusLine className="my-2 ml-6" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -252,7 +245,7 @@ export default function ConversationMessages({
|
||||
if (queries.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full w-full justify-center overflow-y-auto will-change-scroll sm:pt-6 lg:pt-12">
|
||||
<div className={columnClass}>
|
||||
<div className={emptyStateColumnClass}>
|
||||
{headerContent}
|
||||
{showHeroOnEmpty ? <Hero handleQuestion={handleQuestion} /> : null}
|
||||
</div>
|
||||
|
||||
@@ -140,7 +140,7 @@ export const SharedConversation = () => {
|
||||
twitterDescription="Shared conversations with DocsGPT"
|
||||
/>
|
||||
<div className="bg-background flex h-full flex-col items-center justify-between gap-2 overflow-y-hidden">
|
||||
<div className="border-b-border w-full max-w-[1200px] border-b p-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
|
||||
<div className="border-b-border w-full max-w-325 border-b p-2 md:w-11/12 lg:w-10/12 xl:w-9/12 2xl:w-8/12">
|
||||
<h1 className="text-foreground dark:text-foreground text-4xl font-semibold">
|
||||
{title}
|
||||
</h1>
|
||||
@@ -160,7 +160,7 @@ export const SharedConversation = () => {
|
||||
queries={queries}
|
||||
status={status}
|
||||
/>
|
||||
<div className="flex w-full max-w-[1200px] flex-col items-center gap-4 pb-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
|
||||
<div className="flex w-full max-w-290 flex-col items-center gap-4 pb-2 md:w-10/12 lg:w-9/12 xl:w-8/12 2xl:w-7/12">
|
||||
{apiKey ? (
|
||||
<div className="w-full px-2">
|
||||
<MessageInput
|
||||
|
||||
@@ -23,6 +23,11 @@ export default function StreamingStatusLine({
|
||||
aria-live="polite"
|
||||
className={cn('flex h-6 min-w-0 items-center gap-2', className)}
|
||||
>
|
||||
{/* Stands in for a step chip's icon so the live text keeps the same
|
||||
offset whether a chip or this line is carrying it. */}
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-pulse rounded-full" />
|
||||
</span>
|
||||
<span className="shimmer-text max-w-[70vw] truncate text-sm lg:max-w-md">
|
||||
{hasAnswerText
|
||||
? t('conversation.streamingStatus.generating')
|
||||
|
||||
@@ -18,10 +18,10 @@ beforeAll(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
const render = (toolCall: ToolCallsType): string =>
|
||||
const render = (toolCall: ToolCallsType, isLive?: boolean): string =>
|
||||
renderToStaticMarkup(
|
||||
<I18nextProvider i18n={testI18n}>
|
||||
<WikiWriteToolCallCard toolCall={toolCall} />
|
||||
<WikiWriteToolCallCard toolCall={toolCall} isLive={isLive} />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
@@ -34,11 +34,49 @@ describe('WikiWriteToolCallCard', () => {
|
||||
arguments: { path: '/policy.md', old_str: 'a', new_str: 'b' },
|
||||
status: 'completed',
|
||||
});
|
||||
expect(html).toContain('✏️');
|
||||
expect(html).toContain('Edited wiki page');
|
||||
expect(html).toContain('/policy.md');
|
||||
});
|
||||
|
||||
it('reads in the present tense while the write is still running', () => {
|
||||
const html = render(
|
||||
{
|
||||
tool_name: 'wiki',
|
||||
action_name: 'wiki_create',
|
||||
call_id: 'c1',
|
||||
arguments: { path: '/policy.md' },
|
||||
status: 'pending',
|
||||
},
|
||||
true,
|
||||
);
|
||||
expect(html).toContain('Creating wiki page…');
|
||||
expect(html).toContain('shimmer-text');
|
||||
});
|
||||
|
||||
it('drops the shimmer once the write has settled', () => {
|
||||
const html = render({
|
||||
tool_name: 'wiki',
|
||||
action_name: 'wiki_create',
|
||||
call_id: 'c1',
|
||||
arguments: { path: '/policy.md' },
|
||||
status: 'completed',
|
||||
});
|
||||
expect(html).not.toContain('shimmer-text');
|
||||
});
|
||||
|
||||
it('marks a failed write', () => {
|
||||
const html = render({
|
||||
tool_name: 'wiki',
|
||||
action_name: 'wiki_delete',
|
||||
call_id: 'c1',
|
||||
arguments: { path: '/policy.md' },
|
||||
status: 'error',
|
||||
error: 'boom',
|
||||
});
|
||||
expect(html).toContain('Deleted wiki page');
|
||||
expect(html).toContain('failed');
|
||||
});
|
||||
|
||||
it('uses the new path for a rename', () => {
|
||||
const html = render({
|
||||
tool_name: 'wiki',
|
||||
|
||||
@@ -7,6 +7,7 @@ import en from '../locale/en.json';
|
||||
import AnswerFlow from './AnswerFlow';
|
||||
import reducer, {
|
||||
addQuery,
|
||||
applyMessageTail,
|
||||
setStatus,
|
||||
updateStreamingQuery,
|
||||
updateThought,
|
||||
@@ -92,4 +93,56 @@ describe('stream reducers feeding the answer flow', () => {
|
||||
html.indexOf('DocsGPT rocks.'),
|
||||
);
|
||||
});
|
||||
|
||||
// Reopening the streaming conversation polls /tail alongside the live SSE, so
|
||||
// the flat snapshot lands between two deltas.
|
||||
it('keeps every step visible when a tail snapshot interleaves with the stream', () => {
|
||||
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.' } }),
|
||||
);
|
||||
state = reducer(
|
||||
state,
|
||||
updateToolCall({ ...at, tool_call: searchCall('completed') }),
|
||||
);
|
||||
state = reducer(
|
||||
state,
|
||||
applyMessageTail({
|
||||
index: 0,
|
||||
tail: {
|
||||
status: 'pending',
|
||||
thought: 'I should search.',
|
||||
tool_calls: [searchCall('completed')],
|
||||
},
|
||||
}),
|
||||
);
|
||||
// A second call arrives after the tail; it must not become the whole order.
|
||||
state = reducer(
|
||||
state,
|
||||
updateToolCall({
|
||||
...at,
|
||||
tool_call: { ...searchCall('completed'), call_id: 'c2' },
|
||||
}),
|
||||
);
|
||||
|
||||
const query = state.queries[0];
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nextProvider i18n={testI18n}>
|
||||
<AnswerFlow
|
||||
thought={query.thought}
|
||||
toolCalls={query.tool_calls}
|
||||
segments={query.segments}
|
||||
isStreaming
|
||||
renderApproval={() => null}
|
||||
renderWikiWrite={() => null}
|
||||
/>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('I should search.');
|
||||
expect(html.split('Searched the web').length - 1).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -87,8 +87,17 @@ describe('synthesizeSegments / getAnswerSegments', () => {
|
||||
});
|
||||
|
||||
it('prefers live steps when present', () => {
|
||||
const live: AnswerSegment[] = [{ kind: 'thought', text: 'live' }];
|
||||
expect(getAnswerSegments({ thought: 'flat', segments: live })).toBe(live);
|
||||
const live: AnswerSegment[] = [
|
||||
{ kind: 'thought', text: 'live' },
|
||||
{ kind: 'tool', call_id: 'c1' },
|
||||
];
|
||||
expect(
|
||||
getAnswerSegments({
|
||||
thought: 'live',
|
||||
tool_calls: [call({ call_id: 'c1' })],
|
||||
segments: live,
|
||||
}),
|
||||
).toBe(live);
|
||||
});
|
||||
|
||||
it('falls back when steps were cleared by a tail snapshot', () => {
|
||||
@@ -96,4 +105,59 @@ describe('synthesizeSegments / getAnswerSegments', () => {
|
||||
{ kind: 'thought', text: 'flat' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back when the order misses a call the answer holds', () => {
|
||||
expect(
|
||||
getAnswerSegments({
|
||||
thought: 'reasoning',
|
||||
tool_calls: [call({ call_id: 'c1' }), call({ call_id: 'c2' })],
|
||||
segments: [{ kind: 'tool', call_id: 'c2' }],
|
||||
}),
|
||||
).toEqual([
|
||||
{ kind: 'thought', text: 'reasoning' },
|
||||
{ kind: 'tool', call_id: 'c1' },
|
||||
{ kind: 'tool', call_id: 'c2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back when the order misses reasoning the answer holds', () => {
|
||||
expect(
|
||||
getAnswerSegments({
|
||||
thought: 'full reasoning',
|
||||
tool_calls: [call({ call_id: 'c1' })],
|
||||
segments: [
|
||||
{ kind: 'tool', call_id: 'c1' },
|
||||
{ kind: 'thought', text: 'reasoning' },
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{ kind: 'thought', text: 'full reasoning' },
|
||||
{ kind: 'tool', call_id: 'c1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the live order when reasoning arrived in several pieces', () => {
|
||||
const live: AnswerSegment[] = [
|
||||
{ kind: 'thought', text: 'plan' },
|
||||
{ kind: 'tool', call_id: 'c1' },
|
||||
{ kind: 'thought', text: 'verify' },
|
||||
];
|
||||
expect(
|
||||
getAnswerSegments({
|
||||
thought: 'planverify',
|
||||
tool_calls: [call({ call_id: 'c1' })],
|
||||
segments: live,
|
||||
}),
|
||||
).toBe(live);
|
||||
});
|
||||
|
||||
it('does not fall back for a call the backend left without an id', () => {
|
||||
const live: AnswerSegment[] = [{ kind: 'tool', call_id: 'c1' }];
|
||||
expect(
|
||||
getAnswerSegments({
|
||||
tool_calls: [call({ call_id: 'c1' }), call({ call_id: '' })],
|
||||
segments: live,
|
||||
}),
|
||||
).toBe(live);
|
||||
});
|
||||
});
|
||||
@@ -45,10 +45,38 @@ export function synthesizeSegments(query: {
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the recorded order still accounts for every part of the answer. A
|
||||
* call with no id can never be recorded, so it never counts against the order.
|
||||
*/
|
||||
function coversAnswer(
|
||||
query: { thought?: string; tool_calls?: ToolCallsType[] },
|
||||
segments: AnswerSegment[],
|
||||
): boolean {
|
||||
const ordered = new Set(
|
||||
segments.flatMap((segment) =>
|
||||
segment.kind === 'tool' ? [segment.call_id] : [],
|
||||
),
|
||||
);
|
||||
const everyCallOrdered = (query.tool_calls ?? []).every(
|
||||
(call) => !call.call_id || ordered.has(call.call_id),
|
||||
);
|
||||
const orderedThought = segments
|
||||
.flatMap((segment) => (segment.kind === 'thought' ? [segment.text] : []))
|
||||
.join('');
|
||||
return everyCallOrdered && orderedThought === (query.thought ?? '');
|
||||
}
|
||||
|
||||
export function getAnswerSegments(query: {
|
||||
thought?: string;
|
||||
tool_calls?: ToolCallsType[];
|
||||
segments?: AnswerSegment[];
|
||||
}): AnswerSegment[] {
|
||||
return query.segments?.length ? query.segments : synthesizeSegments(query);
|
||||
// The order only arranges what ``thought``/``tool_calls`` already hold, so one
|
||||
// that no longer accounts for them (a tail snapshot landed mid-stream, a
|
||||
// reconnect replayed part of it) loses to synthesis rather than hiding the
|
||||
// steps it never recorded.
|
||||
if (query.segments?.length && coversAnswer(query, query.segments))
|
||||
return query.segments;
|
||||
return synthesizeSegments(query);
|
||||
}
|
||||
@@ -1013,9 +1013,11 @@ 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;
|
||||
// A tail is a flat snapshot with no ordering. The live order is left
|
||||
// alone: dropping it here left the stream reducers rebuilding a partial
|
||||
// one from the next delta, which hid every step that came before. It is
|
||||
// rendering that decides, per answer, whether the recorded order still
|
||||
// accounts for the snapshot or synthesis has to take over.
|
||||
const status = tail?.status as MessageStatus | undefined;
|
||||
query.messageStatus = status;
|
||||
query.lastHeartbeatAt = tail?.last_heartbeat_at ?? query.lastHeartbeatAt;
|
||||
|
||||
@@ -752,6 +752,12 @@ Avoid over-scrolling in mobile browsers
|
||||
color: inherit;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Same specificity as the dark gradient above, which would otherwise
|
||||
outrank the reset and paint the sweep as a solid bar behind the text. */
|
||||
.dark .shimmer-text {
|
||||
background-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
|
||||
@@ -1100,7 +1100,15 @@
|
||||
"insert": "Wiki-Seite bearbeitet",
|
||||
"delete": "Wiki-Seite gelöscht",
|
||||
"rename": "Wiki-Seite umbenannt",
|
||||
"failed": "fehlgeschlagen"
|
||||
"failed": "fehlgeschlagen",
|
||||
"active": {
|
||||
"edited": "Bearbeite Wiki…",
|
||||
"create": "Erstelle Wiki-Seite…",
|
||||
"str_replace": "Bearbeite Wiki-Seite…",
|
||||
"insert": "Bearbeite Wiki-Seite…",
|
||||
"delete": "Lösche Wiki-Seite…",
|
||||
"rename": "Benenne Wiki-Seite um…"
|
||||
}
|
||||
},
|
||||
"streamingStatus": {
|
||||
"thinking": "Denke nach…",
|
||||
|
||||
@@ -1101,7 +1101,15 @@
|
||||
"insert": "Edited wiki page",
|
||||
"delete": "Deleted wiki page",
|
||||
"rename": "Renamed wiki page",
|
||||
"failed": "failed"
|
||||
"failed": "failed",
|
||||
"active": {
|
||||
"edited": "Editing wiki…",
|
||||
"create": "Creating wiki page…",
|
||||
"str_replace": "Editing wiki page…",
|
||||
"insert": "Editing wiki page…",
|
||||
"delete": "Deleting wiki page…",
|
||||
"rename": "Renaming wiki page…"
|
||||
}
|
||||
},
|
||||
"streamingStatus": {
|
||||
"thinking": "Thinking…",
|
||||
|
||||
@@ -1100,7 +1100,15 @@
|
||||
"insert": "Página de wiki editada",
|
||||
"delete": "Página de wiki eliminada",
|
||||
"rename": "Página de wiki renombrada",
|
||||
"failed": "falló"
|
||||
"failed": "falló",
|
||||
"active": {
|
||||
"edited": "Editando wiki…",
|
||||
"create": "Creando página de wiki…",
|
||||
"str_replace": "Editando página de wiki…",
|
||||
"insert": "Editando página de wiki…",
|
||||
"delete": "Eliminando página de wiki…",
|
||||
"rename": "Renombrando página de wiki…"
|
||||
}
|
||||
},
|
||||
"streamingStatus": {
|
||||
"thinking": "Pensando…",
|
||||
|
||||
@@ -1100,7 +1100,15 @@
|
||||
"insert": "Wikiページを編集",
|
||||
"delete": "Wikiページを削除",
|
||||
"rename": "Wikiページの名前を変更",
|
||||
"failed": "失敗"
|
||||
"failed": "失敗",
|
||||
"active": {
|
||||
"edited": "Wikiを編集中…",
|
||||
"create": "Wikiページを作成中…",
|
||||
"str_replace": "Wikiページを編集中…",
|
||||
"insert": "Wikiページを編集中…",
|
||||
"delete": "Wikiページを削除中…",
|
||||
"rename": "Wikiページの名前を変更中…"
|
||||
}
|
||||
},
|
||||
"streamingStatus": {
|
||||
"thinking": "考え中…",
|
||||
|
||||
@@ -1100,7 +1100,15 @@
|
||||
"insert": "Отредактирована страница вики",
|
||||
"delete": "Удалена страница вики",
|
||||
"rename": "Переименована страница вики",
|
||||
"failed": "не удалось"
|
||||
"failed": "не удалось",
|
||||
"active": {
|
||||
"edited": "Редактирую вики…",
|
||||
"create": "Создаю страницу вики…",
|
||||
"str_replace": "Редактирую страницу вики…",
|
||||
"insert": "Редактирую страницу вики…",
|
||||
"delete": "Удаляю страницу вики…",
|
||||
"rename": "Переименовываю страницу вики…"
|
||||
}
|
||||
},
|
||||
"streamingStatus": {
|
||||
"thinking": "Думаю…",
|
||||
|
||||
@@ -1100,7 +1100,15 @@
|
||||
"insert": "已編輯 Wiki 頁面",
|
||||
"delete": "已刪除 Wiki 頁面",
|
||||
"rename": "已重新命名 Wiki 頁面",
|
||||
"failed": "失敗"
|
||||
"failed": "失敗",
|
||||
"active": {
|
||||
"edited": "正在編輯 Wiki…",
|
||||
"create": "正在建立 Wiki 頁面…",
|
||||
"str_replace": "正在編輯 Wiki 頁面…",
|
||||
"insert": "正在編輯 Wiki 頁面…",
|
||||
"delete": "正在刪除 Wiki 頁面…",
|
||||
"rename": "正在重新命名 Wiki 頁面…"
|
||||
}
|
||||
},
|
||||
"streamingStatus": {
|
||||
"thinking": "思考中…",
|
||||
|
||||
@@ -1100,7 +1100,15 @@
|
||||
"insert": "已编辑 Wiki 页面",
|
||||
"delete": "已删除 Wiki 页面",
|
||||
"rename": "已重命名 Wiki 页面",
|
||||
"failed": "失败"
|
||||
"failed": "失败",
|
||||
"active": {
|
||||
"edited": "正在编辑 Wiki…",
|
||||
"create": "正在创建 Wiki 页面…",
|
||||
"str_replace": "正在编辑 Wiki 页面…",
|
||||
"insert": "正在编辑 Wiki 页面…",
|
||||
"delete": "正在删除 Wiki 页面…",
|
||||
"rename": "正在重命名 Wiki 页面…"
|
||||
}
|
||||
},
|
||||
"streamingStatus": {
|
||||
"thinking": "思考中…",
|
||||
|
||||
@@ -91,12 +91,26 @@ function activityLabel(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the call has been handed off and not come back yet. A call the client
|
||||
* still has to execute counts: it has no result either.
|
||||
*/
|
||||
export function isToolCallRunning(toolCall: {
|
||||
status?: ToolCallsType['status'];
|
||||
}): boolean {
|
||||
return (
|
||||
toolCall.status === 'pending' ||
|
||||
toolCall.status === 'requires_client_execution'
|
||||
);
|
||||
}
|
||||
|
||||
export function getToolChipLabel(
|
||||
toolCall: ToolCallsType,
|
||||
t: TFunction,
|
||||
): string {
|
||||
const activity = describeToolCall(toolCall);
|
||||
const namespace =
|
||||
toolCall.status === 'pending' ? 'streamingStatus' : 'toolChip';
|
||||
const namespace = isToolCallRunning(toolCall)
|
||||
? 'streamingStatus'
|
||||
: 'toolChip';
|
||||
return activityLabel(activity, namespace, t);
|
||||
}
|
||||
Reference in new issue
Block a user