mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-24 02:05:11 +08:00
feat(playground): content blocks frontend renderer for v2 workflows
This commit is contained in:
@ -75,6 +75,19 @@ class BaseContent(BaseModel):
|
||||
),
|
||||
)
|
||||
|
||||
def __init__(self, **data: Any) -> None:
|
||||
super().__init__(**data)
|
||||
# Mark only the discriminator as "set" so partial-update callers using
|
||||
# ``model_dump(exclude_unset=True)`` (notably ``aupdate_messages``)
|
||||
# keep the ``type`` tag. Without it, ``TextContent(text="x")`` would
|
||||
# dump to ``{"text": "x"}`` and the next read-back through
|
||||
# ``MessageRead``'s discriminated union would fail with
|
||||
# ``union_tag_not_found``. Other defaulted fields stay unset so true
|
||||
# exclude_unset semantics survive: a patch like ``ContentBlock(title=
|
||||
# "...")`` no longer overwrites an existing block's ``duration`` /
|
||||
# ``header`` / ``contents`` with their defaults on merge.
|
||||
self.model_fields_set.add("type")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return self.model_dump()
|
||||
|
||||
@ -260,13 +273,10 @@ class ContentBlock(BaseContent):
|
||||
allow_markdown: bool = Field(default=True)
|
||||
media_url: list[str] | None = None
|
||||
|
||||
def __init__(self, **data) -> None:
|
||||
super().__init__(**data)
|
||||
# Mark every field as "set" so legacy callers iterating
|
||||
# ``model_fields_set`` (and ``model_dump(exclude_unset=True)``) see
|
||||
# the full ContentBlock shape, including the ``type="group"``
|
||||
# discriminator that downstream validators depend on.
|
||||
self.model_fields_set.update(type(self).model_fields)
|
||||
# ``__init__`` is inherited from ``BaseContent``: it marks only the
|
||||
# ``type`` discriminator as set, so ``model_dump(exclude_unset=True)``
|
||||
# preserves ``type="group"`` while leaving ``allow_markdown`` and the
|
||||
# other defaulted fields out of partial-update merges.
|
||||
|
||||
@field_validator("contents", mode="before")
|
||||
@classmethod
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
"use client";
|
||||
import { motion } from "framer-motion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { BorderTrail } from "@/components/core/border-trail";
|
||||
import { useToolDurations } from "@/components/core/playgroundComponent/chat-view/chat-messages/hooks/use-tool-durations";
|
||||
import {
|
||||
formatTime,
|
||||
formatToolTitle,
|
||||
} from "@/components/core/playgroundComponent/chat-view/chat-messages/utils/format";
|
||||
import type { ContentBlock } from "@/types/chat";
|
||||
import {
|
||||
type ContentBlockItem,
|
||||
type ContentType,
|
||||
isGroupedBlock,
|
||||
} from "@/types/chat";
|
||||
import { cn } from "@/utils/utils";
|
||||
import ForwardedIconComponent from "../../common/genericIconComponent";
|
||||
import {
|
||||
@ -19,9 +23,13 @@ import {
|
||||
} from "../../ui/accordion";
|
||||
import ContentDisplay from "./ContentDisplay";
|
||||
import DurationDisplay from "./DurationDisplay";
|
||||
import { groupConsecutiveCitations } from "./groupCitations";
|
||||
import { SourcesStrip } from "./SourcesStrip";
|
||||
import { ToolCallCard } from "./ToolCallCard";
|
||||
import { getToolStatus, TOOL_STATUS_CLASS } from "./toolStatus";
|
||||
|
||||
interface ContentBlockDisplayProps {
|
||||
contentBlocks: ContentBlock[];
|
||||
contentBlocks: ContentBlockItem[];
|
||||
isLoading?: boolean;
|
||||
state?: string;
|
||||
chatId: string;
|
||||
@ -39,13 +47,62 @@ export function ContentBlockDisplay({
|
||||
}: ContentBlockDisplayProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Use shared hook for tool duration tracking
|
||||
// Separate flat content items from grouped blocks. Memoize so child
|
||||
// components (notably Radix Accordion, which collects refs per render)
|
||||
// see stable references across re-renders. Without this, every parent
|
||||
// render produces fresh filter() arrays, every AccordionItem looks
|
||||
// 'new' to the collection, refs are re-registered, and Radix loops
|
||||
// until React hits the max-update-depth guard.
|
||||
const groupedBlocks = useMemo(
|
||||
() => contentBlocks.filter(isGroupedBlock),
|
||||
[contentBlocks],
|
||||
);
|
||||
const flatItems = useMemo(
|
||||
() =>
|
||||
contentBlocks.filter(
|
||||
(item): item is ContentType => !isGroupedBlock(item),
|
||||
),
|
||||
[contentBlocks],
|
||||
);
|
||||
|
||||
// Use shared hook for tool duration tracking (only grouped blocks)
|
||||
const { toolElapsedTimes, toolItems } = useToolDurations(
|
||||
contentBlocks,
|
||||
groupedBlocks.length > 0 ? groupedBlocks : undefined,
|
||||
isLoading ?? false,
|
||||
);
|
||||
|
||||
if (!toolItems.length) {
|
||||
// Stabilize the Radix Accordion defaultValue so the wrapped accordion
|
||||
// doesn't see a new array reference on every parent re-render.
|
||||
const runningToolKeys = useMemo(
|
||||
() =>
|
||||
toolItems
|
||||
.filter(({ content }) => getToolStatus(content) === "running")
|
||||
.map(({ toolKey }) => toolKey),
|
||||
[toolItems],
|
||||
);
|
||||
|
||||
// Controlled open state for the tools accordion. Radix only reads
|
||||
// ``defaultValue`` once at mount, so a tool that mounts or flips to
|
||||
// running *after* the first render never auto-expands. Seed with the
|
||||
// keys running at mount, then add any key that *newly* enters the
|
||||
// running state. We only auto-open on the running transition (diffed
|
||||
// against the previous set), never re-open, so a user collapse via
|
||||
// ``onValueChange`` stays collapsed even while the tool is still running.
|
||||
const [openToolKeys, setOpenToolKeys] = useState<string[]>(runningToolKeys);
|
||||
const prevRunningKeysRef = useRef<string[]>(runningToolKeys);
|
||||
useEffect(() => {
|
||||
const newlyRunning = runningToolKeys.filter(
|
||||
(key) => !prevRunningKeysRef.current.includes(key),
|
||||
);
|
||||
if (newlyRunning.length > 0) {
|
||||
setOpenToolKeys((prev) =>
|
||||
Array.from(new Set([...prev, ...newlyRunning])),
|
||||
);
|
||||
}
|
||||
prevRunningKeysRef.current = runningToolKeys;
|
||||
}, [runningToolKeys]);
|
||||
|
||||
if (!toolItems.length && !flatItems.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -65,126 +122,184 @@ export function ContentBlockDisplay({
|
||||
|
||||
return (
|
||||
<div className="relative py-3">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
duration: 0.15,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className={cn("relative rounded-lg bg-transparent", "overflow-hidden")}
|
||||
>
|
||||
{isLoading && (
|
||||
<BorderTrail
|
||||
size={100}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: 10,
|
||||
ease: "linear",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!hideHeader && (
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-2 align-baseline">
|
||||
{headerIcon && (
|
||||
<span data-testid="header-icon">
|
||||
<ForwardedIconComponent
|
||||
name={headerIcon}
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
state !== "partial" && "text-accent-emerald-foreground",
|
||||
)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<p className="m-0 flex items-center gap-2 text-sm font-semibold text-primary">
|
||||
{headerTitle}
|
||||
</p>
|
||||
{/* Render flat content items. Three routes:
|
||||
- tool_use items get wrapped in their own collapsible card
|
||||
(ToolCallCard) so they always carry the bordered-header
|
||||
chrome — the agent emits them flat, not inside a group
|
||||
- consecutive citations get coalesced into one Sources strip
|
||||
- everything else passes through ContentDisplay directly */}
|
||||
{flatItems.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{groupConsecutiveCitations(flatItems).map((run) => {
|
||||
if (run.kind === "sources") {
|
||||
return (
|
||||
<div key={`sources-${run.index}`} className="px-4 py-2">
|
||||
<SourcesStrip citations={run.citations} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (run.item.type === "tool_use") {
|
||||
return (
|
||||
<ToolCallCard
|
||||
key={`tool-${run.index}`}
|
||||
content={run.item}
|
||||
chatId={`${chatId}-tool-${run.index}`}
|
||||
playgroundPage={playgroundPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ContentDisplay
|
||||
key={`flat-${run.index}`}
|
||||
content={run.item}
|
||||
chatId={`${chatId}-flat-${run.index}`}
|
||||
playgroundPage={playgroundPage}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render grouped blocks with accordion UI */}
|
||||
{toolItems.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
duration: 0.15,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className={cn(
|
||||
"relative rounded-lg bg-transparent",
|
||||
"overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{isLoading && (
|
||||
<BorderTrail
|
||||
size={100}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: 10,
|
||||
ease: "linear",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!hideHeader && (
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-2 align-baseline">
|
||||
{headerIcon && (
|
||||
<span data-testid="header-icon">
|
||||
<ForwardedIconComponent
|
||||
name={headerIcon}
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
state !== "partial" && "text-accent-emerald-foreground",
|
||||
)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<p className="m-0 flex items-center gap-2 text-sm font-semibold text-primary">
|
||||
{headerTitle}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!playgroundPage && (
|
||||
<DurationDisplay duration={totalDuration} chatId={chatId} />
|
||||
)}
|
||||
<motion.div
|
||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!playgroundPage && (
|
||||
<DurationDisplay duration={totalDuration} chatId={chatId} />
|
||||
)}
|
||||
<motion.div
|
||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
className="cursor-pointer"
|
||||
)}
|
||||
|
||||
{(hideHeader || isExpanded) && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Accordion
|
||||
// Auto-expand any tool that's still running so the user
|
||||
// sees the in-flight call without clicking. assistant-ui's
|
||||
// terminal variant, ai-chatbot, Claude's web UI, and
|
||||
// ChatGPT all default to expanded-while-running and
|
||||
// user-collapsible after settling. Controlled (not
|
||||
// ``defaultValue``) so tools that start running after mount
|
||||
// also auto-expand.
|
||||
type="multiple"
|
||||
value={openToolKeys}
|
||||
onValueChange={setOpenToolKeys}
|
||||
className="w-full bg-transparent flex flex-col gap-2"
|
||||
>
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{toolItems.map(
|
||||
({ content, toolKey, blockIndex, contentIndex }, flatIdx) => {
|
||||
const rawTitle =
|
||||
content.header?.title ||
|
||||
content.name ||
|
||||
`Tool ${flatIdx + 1}`;
|
||||
const toolTitle =
|
||||
typeof rawTitle === "string"
|
||||
? formatToolTitle(rawTitle)
|
||||
: rawTitle;
|
||||
const toolDuration =
|
||||
toolElapsedTimes[toolKey] ?? content.duration ?? 0;
|
||||
const status = getToolStatus(content);
|
||||
const dotClass = TOOL_STATUS_CLASS[status];
|
||||
// Drop the "Called tool" / "Node" prefix and the
|
||||
// bg-muted code-pill chrome — consensus across
|
||||
// assistant-ui, ai-chatbot, CopilotKit, Claude, and
|
||||
// ChatGPT is just the tool name in medium weight as
|
||||
// the header.
|
||||
|
||||
{(hideHeader || isExpanded) && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Accordion
|
||||
type="multiple"
|
||||
className="w-full bg-transparent flex flex-col gap-2"
|
||||
>
|
||||
{toolItems.map(
|
||||
({ content, toolKey, blockIndex, contentIndex }, flatIdx) => {
|
||||
const rawTitle =
|
||||
content.header?.title ||
|
||||
content.name ||
|
||||
`Tool ${flatIdx + 1}`;
|
||||
const toolTitle =
|
||||
typeof rawTitle === "string"
|
||||
? formatToolTitle(rawTitle)
|
||||
: rawTitle;
|
||||
const isAgentStep =
|
||||
content.header?.icon === "GitBranch" ||
|
||||
(typeof content.name === "string" &&
|
||||
content.name.startsWith("Node "));
|
||||
const toolLabel = isAgentStep ? "Node" : "Called tool";
|
||||
const toolDuration =
|
||||
toolElapsedTimes[toolKey] ?? content.duration ?? 0;
|
||||
|
||||
return (
|
||||
<AccordionItem
|
||||
key={toolKey}
|
||||
value={toolKey}
|
||||
className="overflow-hidden rounded-xl border border-border/70 bg-primary-foreground shadow-sm"
|
||||
>
|
||||
<AccordionTrigger className="rounded-lg px-4 py-3 hover:bg-background/30 hover:no-underline">
|
||||
<div className="flex items-center justify-between w-full pr-2">
|
||||
<div className="flex items-center gap-1 text-sm font-normal min-w-0 flex-1 overflow-hidden">
|
||||
<div className="text-muted-foreground whitespace-nowrap flex-shrink-0">
|
||||
{toolLabel}{" "}
|
||||
</div>
|
||||
<div className="max-w-fit flex-1 truncate rounded-md border border-border/60 bg-transparent px-2 py-1 text-xs muted-foreground">
|
||||
<p className="truncate font-normal font-mono">
|
||||
return (
|
||||
<AccordionItem
|
||||
key={toolKey}
|
||||
value={toolKey}
|
||||
className="border border-border rounded-lg overflow-hidden bg-background"
|
||||
>
|
||||
<AccordionTrigger className="hover:bg-muted hover:no-underline px-3 py-2.5">
|
||||
<div className="flex items-center justify-between w-full pr-2 gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1 overflow-hidden">
|
||||
<span
|
||||
data-testid={`tool-status-${status}`}
|
||||
aria-label={`Tool ${status}`}
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full flex-shrink-0",
|
||||
dotClass,
|
||||
)}
|
||||
/>
|
||||
<p className="truncate text-sm font-medium font-mono">
|
||||
{toolTitle}
|
||||
</p>
|
||||
</div>
|
||||
{toolDuration > 0 && (
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatTime(toolDuration, true)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-accent-emerald-foreground">
|
||||
{formatTime(toolDuration, true)}
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pt-0">
|
||||
<div className="text-sm text-muted-foreground px-4 pb-4 max-h-96 overflow-auto">
|
||||
<ContentDisplay
|
||||
playgroundPage={playgroundPage}
|
||||
content={content}
|
||||
chatId={`${chatId}-${blockIndex}-${contentIndex}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pt-0">
|
||||
<div className="max-h-96 overflow-auto px-4 pb-4 text-sm text-muted-foreground">
|
||||
<ContentDisplay
|
||||
playgroundPage={playgroundPage}
|
||||
content={content}
|
||||
chatId={`${chatId}-${blockIndex}-${contentIndex}`}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,27 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Fragment, type ReactNode, useState } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import rehypeMathjax from "rehype-mathjax/browser";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { ContentType, JSONValue } from "@/types/chat";
|
||||
import { formatSeconds } from "@/components/core/playgroundComponent/chat-view/chat-messages/utils/format";
|
||||
import type { ContentBlockItem, JSONValue } from "@/types/chat";
|
||||
import { extractLanguage, isCodeBlock } from "@/utils/codeBlockUtils";
|
||||
import ForwardedIconComponent from "../../common/genericIconComponent";
|
||||
import SimplifiedCodeTabComponent from "../codeTabsComponent";
|
||||
import DurationDisplay from "./DurationDisplay";
|
||||
import { SourcesStrip } from "./SourcesStrip";
|
||||
import { looksPreformatted, unwrapToolMessage } from "./toolOutput";
|
||||
|
||||
export default function ContentDisplay({
|
||||
content,
|
||||
chatId,
|
||||
playgroundPage,
|
||||
}: {
|
||||
content: ContentType;
|
||||
// Accept any ContentBlockItem so nested ContentBlock groups land in the
|
||||
// `case "group"` branch below instead of falling through to no rendering.
|
||||
content: ContentBlockItem;
|
||||
chatId: string;
|
||||
playgroundPage?: boolean;
|
||||
}) {
|
||||
const renderDuration = content.duration !== undefined && !playgroundPage && (
|
||||
<div className="absolute right-2 top-4">
|
||||
<DurationDisplay duration={content.duration} chatId={chatId} />
|
||||
</div>
|
||||
);
|
||||
// Reasoning blocks surface their own duration inline via ReasoningDisplay's
|
||||
// "Thought for Xs" label, so skip the absolute top-right DurationDisplay
|
||||
// there to avoid rendering the same duration twice.
|
||||
const renderDuration = content.duration !== undefined &&
|
||||
content.type !== "reasoning" &&
|
||||
!playgroundPage && (
|
||||
<div className="absolute right-2 top-4">
|
||||
<DurationDisplay duration={content.duration} chatId={chatId} />
|
||||
</div>
|
||||
);
|
||||
|
||||
// Then render the specific content based on type
|
||||
let contentData: ReactNode | null = null;
|
||||
@ -127,11 +138,23 @@ export default function ContentDisplay({
|
||||
break;
|
||||
|
||||
case "tool_use": {
|
||||
const formatToolOutput = (output: JSONValue) => {
|
||||
if (output === null || output === undefined) return "";
|
||||
// Tool output rendering routes by the *unwrapped* payload shape:
|
||||
// - markdown-y string -> Markdown renderer (prose, no chrome)
|
||||
// - pre-formatted text -> code tab with language=text
|
||||
// (monospace, contained horizontal scroll,
|
||||
// built-in copy button)
|
||||
// - object / array -> code tab with language=json (same)
|
||||
// The LangChain ToolMessage envelope is unwrapped first so the
|
||||
// readable `content` field becomes the body and the plumbing
|
||||
// metadata (already shown by the accordion trigger) stays hidden.
|
||||
const formatToolOutput = (raw: JSONValue) => {
|
||||
const output = unwrapToolMessage(raw);
|
||||
if (output === null || output === undefined) return null;
|
||||
|
||||
// If it's a string, render as markdown
|
||||
if (typeof output === "string") {
|
||||
if (looksPreformatted(output)) {
|
||||
return <SimplifiedCodeTabComponent language="text" code={output} />;
|
||||
}
|
||||
return (
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
@ -170,7 +193,6 @@ export default function ContentDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
// For objects/arrays, format as JSON
|
||||
try {
|
||||
return (
|
||||
<SimplifiedCodeTabComponent
|
||||
@ -183,40 +205,37 @@ export default function ContentDisplay({
|
||||
}
|
||||
};
|
||||
|
||||
// Backend serializes ToolContent.tool_input under its alias `input`
|
||||
// when by_alias=True (AG-UI emission, certain dump paths). Prefer
|
||||
// the field name, fall back to the alias so already-stored messages
|
||||
// and live AG-UI events both render their arguments.
|
||||
const toolInput = content.tool_input ?? content.input ?? {};
|
||||
const hasInput = Object.keys(toolInput).length > 0;
|
||||
// Treat an empty (or whitespace-only) string as "no output" so it
|
||||
// doesn't render an empty bordered box. Legitimate falsy outputs like
|
||||
// 0 or false still render through formatToolOutput.
|
||||
const hasOutput =
|
||||
content.output !== undefined &&
|
||||
content.output !== null &&
|
||||
!(typeof content.output === "string" && content.output.trim() === "");
|
||||
const hasError = content.error != null;
|
||||
// Eyebrow labels (INPUT/OUTPUT/ERROR) used to bracket each section,
|
||||
// but the surrounding accordion card is already the "tool call"
|
||||
// context — extra labels just add chrome. Match the assistant-ui /
|
||||
// Claude pattern: args and result stack directly inside the card,
|
||||
// separated by a hairline rule. Empty sections render nothing.
|
||||
const showSeparator = hasInput && (hasOutput || hasError);
|
||||
contentData = (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeMathjax]}
|
||||
className="markdown prose max-w-full text-sm font-normal dark:prose-invert"
|
||||
>
|
||||
**Input:**
|
||||
</Markdown>
|
||||
<SimplifiedCodeTabComponent
|
||||
language="json"
|
||||
code={JSON.stringify(content.tool_input, null, 2)}
|
||||
/>
|
||||
{content.output !== undefined && (
|
||||
<>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeMathjax]}
|
||||
className="markdown prose max-w-full text-sm font-normal dark:prose-invert"
|
||||
>
|
||||
**Output:**
|
||||
</Markdown>
|
||||
<div className="mt-1">{formatToolOutput(content.output)}</div>
|
||||
</>
|
||||
<div className="flex flex-col gap-3">
|
||||
{hasInput && <ToolInputDisplay input={toolInput} />}
|
||||
{showSeparator && <div className="h-px bg-border" />}
|
||||
{hasOutput && (
|
||||
<div className="max-h-96 overflow-auto">
|
||||
{formatToolOutput(content.output as JSONValue)}
|
||||
</div>
|
||||
)}
|
||||
{content.error != null && (
|
||||
<div className="text-destructive">
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeMathjax]}
|
||||
className="markdown prose max-w-full text-sm font-normal dark:prose-invert"
|
||||
>
|
||||
**Error:**
|
||||
</Markdown>
|
||||
{hasError && (
|
||||
<div className="rounded-md bg-destructive/10 text-destructive">
|
||||
<SimplifiedCodeTabComponent
|
||||
language="json"
|
||||
code={JSON.stringify(content.error, null, 2)}
|
||||
@ -242,6 +261,162 @@ export default function ContentDisplay({
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
|
||||
case "image":
|
||||
contentData = (
|
||||
<div className="flex flex-col gap-2">
|
||||
{content.urls?.map((url, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={url}
|
||||
alt={content.caption || `Image ${index + 1}`}
|
||||
className="max-w-full rounded"
|
||||
/>
|
||||
))}
|
||||
{/* base64 is a fallback for when no usable URL is provided.
|
||||
`some(Boolean)` so urls=[""] or urls=[null] don't suppress the
|
||||
fallback while rendering a broken <img src=""> above. */}
|
||||
{!content.urls?.some(Boolean) && content.base64 && (
|
||||
<img
|
||||
src={`data:${content.mime_type || "image/png"};base64,${content.base64}`}
|
||||
alt={content.caption || "Image"}
|
||||
className="max-w-full rounded"
|
||||
/>
|
||||
)}
|
||||
{content.caption && (
|
||||
<p className="text-xs text-muted-foreground">{content.caption}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
|
||||
case "audio":
|
||||
contentData = (
|
||||
<div className="flex flex-col gap-2">
|
||||
{content.urls?.map((url, index) => (
|
||||
<audio key={index} controls className="w-full">
|
||||
<source src={url} type={content.mime_type} />
|
||||
</audio>
|
||||
))}
|
||||
{!content.urls?.some(Boolean) && content.base64 && (
|
||||
<audio controls className="w-full">
|
||||
<source
|
||||
src={`data:${content.mime_type || "audio/mpeg"};base64,${content.base64}`}
|
||||
type={content.mime_type || "audio/mpeg"}
|
||||
/>
|
||||
</audio>
|
||||
)}
|
||||
{content.transcript && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{content.transcript}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
|
||||
case "video":
|
||||
contentData = (
|
||||
<div className="flex flex-col gap-2">
|
||||
{content.urls?.map((url, index) => (
|
||||
<video key={index} controls className="max-w-full rounded">
|
||||
<source src={url} type={content.mime_type} />
|
||||
</video>
|
||||
))}
|
||||
{!content.urls?.some(Boolean) && content.base64 && (
|
||||
<video controls className="max-w-full rounded">
|
||||
<source
|
||||
src={`data:${content.mime_type || "video/mp4"};base64,${content.base64}`}
|
||||
type={content.mime_type || "video/mp4"}
|
||||
/>
|
||||
</video>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
|
||||
case "file":
|
||||
contentData = (
|
||||
<div className="flex items-center gap-2">
|
||||
<ForwardedIconComponent
|
||||
name="File"
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
{content.urls?.map((url, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={url}
|
||||
download={content.filename}
|
||||
className="text-sm underline text-primary hover:text-primary/80"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{content.filename || `Download file ${index + 1}`}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
|
||||
case "reasoning":
|
||||
contentData = (
|
||||
<ReasoningDisplay text={content.text} duration={content.duration} />
|
||||
);
|
||||
break;
|
||||
|
||||
case "usage": {
|
||||
// Backend serializes Optional[int] as JSON null, so `!= null` catches
|
||||
// both undefined and null. Using `!== undefined` here would render the
|
||||
// literal string "null in / null out" when the producer reports no
|
||||
// counts.
|
||||
const hasInput = content.input_tokens != null;
|
||||
const hasOutput = content.output_tokens != null;
|
||||
const hasTokens = hasInput || hasOutput;
|
||||
contentData = (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{content.model && (
|
||||
<span className="font-medium">{content.model}</span>
|
||||
)}
|
||||
{hasTokens && (
|
||||
<span>
|
||||
Tokens: {hasInput ? `${content.input_tokens} in` : ""}
|
||||
{hasInput && hasOutput ? " / " : ""}
|
||||
{hasOutput ? `${content.output_tokens} out` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "citation":
|
||||
// Single citation renders as a one-card Sources strip; consecutive
|
||||
// flat citations are coalesced into a multi-card strip by
|
||||
// ContentBlockDisplay before they reach this branch.
|
||||
contentData = <SourcesStrip citations={[content]} />;
|
||||
break;
|
||||
|
||||
case "group":
|
||||
// A nested ContentBlock inside another container. Render the title as
|
||||
// a small section header and recurse on each child. The outer
|
||||
// ContentBlockDisplay handles top-level groups; this branch only
|
||||
// fires when a producer nests a group inside a parent's contents.
|
||||
contentData = (
|
||||
<div className="flex flex-col gap-2">
|
||||
{content.title && (
|
||||
<p className="text-sm font-medium text-primary">{content.title}</p>
|
||||
)}
|
||||
{content.contents?.map((child, index) => (
|
||||
<ContentDisplay
|
||||
key={index}
|
||||
content={child}
|
||||
chatId={`${chatId}-${index}`}
|
||||
playgroundPage={playgroundPage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
@ -251,3 +426,87 @@ export default function ContentDisplay({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a tool's input. Flat objects (string/number/bool/null values
|
||||
* only) become labelled rows; anything nested falls back to a JSON block
|
||||
* so the structure stays readable. Empty input is handled by the caller
|
||||
* (the surrounding card simply skips this component). */
|
||||
function ToolInputDisplay({ input }: { input: Record<string, JSONValue> }) {
|
||||
const entries = Object.entries(input);
|
||||
// A value counts as "flat" if it's a primitive or a list of primitives.
|
||||
// Nested objects (or arrays of objects) fall through to the JSON block
|
||||
// where indentation makes them readable.
|
||||
const isPrimitive = (v: JSONValue) => v === null || typeof v !== "object";
|
||||
const isFlat = entries.every(
|
||||
([, v]) => isPrimitive(v) || (Array.isArray(v) && v.every(isPrimitive)),
|
||||
);
|
||||
if (!isFlat) {
|
||||
return (
|
||||
<SimplifiedCodeTabComponent
|
||||
language="json"
|
||||
code={JSON.stringify(input, null, 2)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
{entries.map(([key, value]) => (
|
||||
<Fragment key={key}>
|
||||
<dt className="font-mono text-muted-foreground">{key}</dt>
|
||||
<dd className="font-mono break-all">{JSON.stringify(value)}</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reasoning section. Live shimmer while streaming, collapsible summary once
|
||||
* the producer attaches a `duration` (its signal that the step is done). */
|
||||
function ReasoningDisplay({
|
||||
text,
|
||||
duration,
|
||||
}: {
|
||||
text: string;
|
||||
duration?: number;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isStreaming = duration === undefined;
|
||||
|
||||
if (isStreaming) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ForwardedIconComponent
|
||||
name="Sparkles"
|
||||
className="h-3 w-3"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="animate-pulse">Thinking…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground w-fit"
|
||||
>
|
||||
<ForwardedIconComponent
|
||||
name="Sparkles"
|
||||
className="h-3 w-3"
|
||||
aria-hidden
|
||||
/>
|
||||
<span>Thought for {formatSeconds(duration)}</span>
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="ml-1 pl-3 text-xs text-muted-foreground whitespace-pre-wrap border-l-2 border-muted-foreground/20 italic">
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
100
src/frontend/src/components/core/chatComponents/SourcesStrip.tsx
Normal file
100
src/frontend/src/components/core/chatComponents/SourcesStrip.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import type { CitationContent } from "@/types/chat";
|
||||
|
||||
/** Returns the URL if its scheme is http(s), otherwise null. Guards against
|
||||
* `javascript:` / `data:` / `vbscript:` and other unsafe schemes that
|
||||
* React would otherwise pass through to a clickable anchor in production. */
|
||||
function safeUrl(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:"
|
||||
? raw
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort host extraction. Returns null for unparseable inputs so the
|
||||
* caller can decide whether to fall back to the raw URL or hide the chip. */
|
||||
function extractDomain(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
return parsed.hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function FaviconImg({ domain }: { domain: string }) {
|
||||
// Google's favicon service handles missing icons gracefully (returns a
|
||||
// globe), so we don't need to plumb our own onError state.
|
||||
const src = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=32`;
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded-sm flex-shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceCard({ citation }: { citation: CitationContent }) {
|
||||
const url = safeUrl(citation.url);
|
||||
const domain = extractDomain(citation.url) ?? "source";
|
||||
const heading = citation.title || domain;
|
||||
const inner = (
|
||||
<div className="flex flex-col gap-1 p-3 w-56 flex-shrink-0 rounded-lg border border-border bg-background hover:bg-muted/50 transition-colors h-full">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<FaviconImg domain={domain} />
|
||||
<span className="truncate">{domain}</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium line-clamp-2">{heading}</p>
|
||||
{citation.cited_text && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 italic">
|
||||
{citation.cited_text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
if (!url) {
|
||||
// URL was missing or unsafe — render the card content as a div so
|
||||
// there's no clickable surface routing to a javascript: handler.
|
||||
return inner;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="no-underline text-foreground"
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** Horizontally-scrolling row of source cards. Used both for single
|
||||
* citations and for coalesced groups of consecutive citations. */
|
||||
export function SourcesStrip({ citations }: { citations: CitationContent[] }) {
|
||||
if (citations.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{citations.length > 1 ? "Sources" : "Source"}
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 overflow-x-auto pb-1">
|
||||
{citations.map((citation, idx) => (
|
||||
<SourceCard
|
||||
key={`${citation.id ?? citation.url ?? "src"}-${idx}`}
|
||||
citation={citation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/frontend/src/components/core/chatComponents/ToolCallCard.tsx
Normal file
102
src/frontend/src/components/core/chatComponents/ToolCallCard.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
formatTime,
|
||||
formatToolTitle,
|
||||
} from "@/components/core/playgroundComponent/chat-view/chat-messages/utils/format";
|
||||
import type { ToolContent } from "@/types/chat";
|
||||
import { cn } from "@/utils/utils";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "../../ui/accordion";
|
||||
import ContentDisplay from "./ContentDisplay";
|
||||
import { getToolStatus, TOOL_STATUS_CLASS } from "./toolStatus";
|
||||
|
||||
/** Bordered collapsible card for a single tool call. Matches the
|
||||
* assistant-ui / ai-chatbot / CopilotKit / Claude / ChatGPT consensus:
|
||||
* header is a clickable row with a status dot, the tool name in
|
||||
* medium-weight mono, and a duration on the right. Body holds the args
|
||||
* and result via ContentDisplay's tool_use branch.
|
||||
*
|
||||
* Auto-expands while the tool is running; collapses to header-only once
|
||||
* the producer attaches a duration (the resolved state). User can toggle
|
||||
* manually either way after that. */
|
||||
export function ToolCallCard({
|
||||
content,
|
||||
chatId,
|
||||
playgroundPage,
|
||||
}: {
|
||||
content: ToolContent;
|
||||
chatId: string;
|
||||
playgroundPage?: boolean;
|
||||
}) {
|
||||
const status = getToolStatus(content);
|
||||
const dotClass = TOOL_STATUS_CLASS[status];
|
||||
|
||||
const rawTitle = content.header?.title || content.name || "Tool";
|
||||
const toolTitle =
|
||||
typeof rawTitle === "string" ? formatToolTitle(rawTitle) : rawTitle;
|
||||
const toolDuration = content.duration ?? 0;
|
||||
const itemKey = chatId;
|
||||
|
||||
// Controlled open state. Radix reads ``defaultValue`` only once at mount,
|
||||
// so a card that mounts collapsed and later flips to running (history
|
||||
// replay, scrollback) never auto-expands. Drive ``value`` from state and
|
||||
// open on the running transition; the deps guard means a user collapse
|
||||
// while the tool is still running isn't re-opened on the next render.
|
||||
const [value, setValue] = useState(status === "running" ? itemKey : "");
|
||||
useEffect(() => {
|
||||
if (status === "running") {
|
||||
setValue(itemKey);
|
||||
}
|
||||
}, [status, itemKey]);
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
className="w-full"
|
||||
>
|
||||
<AccordionItem
|
||||
value={itemKey}
|
||||
className="border border-border rounded-lg overflow-hidden bg-background"
|
||||
>
|
||||
<AccordionTrigger className="hover:bg-muted hover:no-underline px-3 py-2.5">
|
||||
<div className="flex items-center justify-between w-full pr-2 gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1 overflow-hidden">
|
||||
<span
|
||||
data-testid={`tool-status-${status}`}
|
||||
aria-label={`Tool ${status}`}
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full flex-shrink-0",
|
||||
dotClass,
|
||||
)}
|
||||
/>
|
||||
<p className="truncate text-sm font-medium font-mono">
|
||||
{toolTitle}
|
||||
</p>
|
||||
</div>
|
||||
{toolDuration > 0 && (
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatTime(toolDuration, true)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pt-0">
|
||||
<div className="text-sm text-muted-foreground px-4 pb-4">
|
||||
<ContentDisplay
|
||||
content={content}
|
||||
chatId={chatId}
|
||||
playgroundPage={playgroundPage}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,399 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ContentBlockItem } from "@/types/chat";
|
||||
import ContentDisplay from "../ContentDisplay";
|
||||
|
||||
// react-markdown ships ESM that jest doesn't transpile by default; the
|
||||
// MarkdownComponent only matters for text/error/json/code cases, none of
|
||||
// which these tests touch.
|
||||
jest.mock("react-markdown", () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
jest.mock("rehype-mathjax/browser", () => () => {});
|
||||
jest.mock("remark-gfm", () => () => {});
|
||||
// Mocks use module identifiers as the importer (ContentDisplay) sees them,
|
||||
// which is why these paths are relative to ContentDisplay.tsx (one dir up
|
||||
// from this test file), not to the test file itself.
|
||||
jest.mock("@/components/common/genericIconComponent", () => ({
|
||||
__esModule: true,
|
||||
default: ({ name }: { name: string }) => (
|
||||
<span data-testid={`icon-${name}`} />
|
||||
),
|
||||
ForwardedIconComponent: ({ name }: { name: string }) => (
|
||||
<span data-testid={`icon-${name}`} />
|
||||
),
|
||||
}));
|
||||
jest.mock("@/components/core/codeTabsComponent", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="code-tabs" />,
|
||||
}));
|
||||
jest.mock("../DurationDisplay", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="duration" />,
|
||||
}));
|
||||
|
||||
describe("ContentDisplay", () => {
|
||||
describe("usage", () => {
|
||||
it("hides the 'Tokens:' label when both counts are null", () => {
|
||||
// Backend Optional[int] serializes as null when absent. Regression
|
||||
// guard: previously rendered the literal string 'null in / null out'
|
||||
// because the check used `!== undefined`.
|
||||
const usage = {
|
||||
type: "usage",
|
||||
model: "gpt-4o",
|
||||
input_tokens: null,
|
||||
output_tokens: null,
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={usage} chatId="t1" />);
|
||||
expect(screen.queryByText(/Tokens:/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/null/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders both counts when they are zero", () => {
|
||||
// 0 is a legitimate count; `!= null` keeps it, `!== undefined` would
|
||||
// also keep it. This is a forward-compat guard for future refactors.
|
||||
const usage = {
|
||||
type: "usage",
|
||||
model: "claude",
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={usage} chatId="t2" />);
|
||||
expect(screen.getByText(/0 in/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/0 out/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only the input count when output is null", () => {
|
||||
const usage = {
|
||||
type: "usage",
|
||||
input_tokens: 42,
|
||||
output_tokens: null,
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={usage} chatId="t3" />);
|
||||
const span = screen.getByText(/Tokens:/);
|
||||
expect(span).toHaveTextContent("Tokens: 42 in");
|
||||
expect(span).not.toHaveTextContent("null");
|
||||
});
|
||||
});
|
||||
|
||||
describe("image", () => {
|
||||
it("renders the base64 fallback when urls contains only empty strings", () => {
|
||||
// Regression guard: previously suppressed base64 whenever urls had any
|
||||
// length, so urls=[''] killed the working fallback.
|
||||
const image = {
|
||||
type: "image",
|
||||
urls: [""],
|
||||
base64: "AAAA",
|
||||
mime_type: "image/png",
|
||||
caption: "fallback",
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={image} chatId="t4" />);
|
||||
const imgs = screen.getAllByRole("img");
|
||||
const base64Img = imgs.find((img) =>
|
||||
(img as HTMLImageElement).src.startsWith("data:image/png;base64,"),
|
||||
);
|
||||
expect(base64Img).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not duplicate the image when a valid URL and base64 are both set", () => {
|
||||
const image = {
|
||||
type: "image",
|
||||
urls: ["https://example.com/a.png"],
|
||||
base64: "AAAA",
|
||||
mime_type: "image/png",
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={image} chatId="t5" />);
|
||||
const imgs = screen.getAllByRole("img");
|
||||
const base64Img = imgs.find((img) =>
|
||||
(img as HTMLImageElement).src.startsWith("data:"),
|
||||
);
|
||||
expect(base64Img).toBeUndefined();
|
||||
expect(imgs).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reasoning", () => {
|
||||
it("shows a 'Thinking…' shimmer while the duration is absent", () => {
|
||||
// No duration on the content means the producer is still emitting
|
||||
// reasoning chunks. The renderer should show a live indicator
|
||||
// rather than the resolved summary.
|
||||
const reasoning = {
|
||||
type: "reasoning",
|
||||
text: "Considering options...",
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={reasoning} chatId="t-r1" />);
|
||||
const label = screen.getByText(/Thinking/i);
|
||||
expect(label).toBeInTheDocument();
|
||||
// Live label gets animate-pulse so the user sees it as in-flight.
|
||||
expect(label.className).toMatch(/animate-pulse/);
|
||||
});
|
||||
|
||||
it("collapses by default with a 'Thought for …' summary when duration is set", () => {
|
||||
const reasoning = {
|
||||
type: "reasoning",
|
||||
text: "I checked the docs and decided to call the weather tool.",
|
||||
duration: 3200,
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={reasoning} chatId="t-r2" />);
|
||||
expect(screen.getByText(/Thought for/)).toBeInTheDocument();
|
||||
// Body stays collapsed until the user clicks the summary trigger.
|
||||
expect(screen.queryByText(/I checked the docs/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderDuration", () => {
|
||||
it("renders the top-right duration for a non-reasoning block", () => {
|
||||
const tool = {
|
||||
type: "tool_use",
|
||||
name: "search",
|
||||
tool_input: {},
|
||||
duration: 1200,
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={tool} chatId="t-d1" />);
|
||||
expect(screen.getByTestId("duration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("skips the top-right duration for reasoning to avoid double render", () => {
|
||||
// ReasoningDisplay already surfaces the duration inline as
|
||||
// "Thought for Xs"; the absolute top-right DurationDisplay must be
|
||||
// skipped for reasoning so it doesn't render twice.
|
||||
const reasoning = {
|
||||
type: "reasoning",
|
||||
text: "Decided to call the weather tool.",
|
||||
duration: 3200,
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={reasoning} chatId="t-d2" />);
|
||||
expect(screen.getByText(/Thought for/)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("duration")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool_use", () => {
|
||||
it("reads input from the 'input' alias when the backend dumped by_alias=True", () => {
|
||||
// The Python ToolContent.tool_input field has alias="input"; AG-UI
|
||||
// and other serialization paths emit by_alias=True, so the stored
|
||||
// shape uses `input` not `tool_input`. Renderer must fall back.
|
||||
const tool = {
|
||||
type: "tool_use",
|
||||
name: "fetch_content",
|
||||
input: { urls: ["https://langflow.org"] },
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={tool} chatId="t-t-alias" />);
|
||||
expect(screen.getByText("urls")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/no arguments/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders flat input as key/value rows, not JSON", () => {
|
||||
// {query: "weather", units: "metric"} is a flat object — easier to
|
||||
// scan as two rows than as JSON with braces and quotes.
|
||||
const tool = {
|
||||
type: "tool_use",
|
||||
name: "weather",
|
||||
tool_input: { query: "weather", units: "metric" },
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={tool} chatId="t-t2" />);
|
||||
expect(screen.getByText("query")).toBeInTheDocument();
|
||||
expect(screen.getByText('"weather"')).toBeInTheDocument();
|
||||
expect(screen.getByText("units")).toBeInTheDocument();
|
||||
expect(screen.getByText('"metric"')).toBeInTheDocument();
|
||||
// No JSON code block for the flat case.
|
||||
expect(screen.queryByTestId("code-tabs")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to JSON block when input has nested values", () => {
|
||||
// Nested object would be ugly in row form — code block keeps it
|
||||
// readable and copyable.
|
||||
const tool = {
|
||||
type: "tool_use",
|
||||
name: "search",
|
||||
tool_input: { filters: { min: 1, max: 10 } },
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={tool} chatId="t-t3" />);
|
||||
expect(screen.getByTestId("code-tabs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("unwraps a LangChain ToolMessage-shaped output to render only its content", () => {
|
||||
// Backend tools return {content, name, id, tool_call_id, status} —
|
||||
// the LangChain ToolMessage shape. The metadata is duplicated by
|
||||
// the accordion trigger; only `content` is interesting to show.
|
||||
const tool = {
|
||||
type: "tool_use",
|
||||
name: "fetch_content",
|
||||
tool_input: {},
|
||||
output: {
|
||||
content: "the readable body",
|
||||
name: "fetch_content",
|
||||
id: "abc-123",
|
||||
tool_call_id: "toolu_xyz",
|
||||
status: "success",
|
||||
},
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={tool} chatId="t-t-unwrap" />);
|
||||
expect(screen.getByText(/the readable body/)).toBeInTheDocument();
|
||||
// Plumbing metadata should not be rendered.
|
||||
expect(screen.queryByText(/toolu_xyz/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/abc-123/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("routes pre-formatted string output through the code tab (not markdown)", () => {
|
||||
// Pandas df.to_string() style output has wide column padding that
|
||||
// markdown rendering would mangle. Detection should kick it into
|
||||
// the monospace code-tab path instead.
|
||||
const tool = {
|
||||
type: "tool_use",
|
||||
name: "df_describe",
|
||||
tool_input: {},
|
||||
output:
|
||||
" text url\n" +
|
||||
"0 Langflow | Low-code AI builder for agentic... https://langflow.org",
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={tool} chatId="t-t-pre" />);
|
||||
expect(screen.getByTestId("code-tabs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the OUTPUT section entirely when output is null", () => {
|
||||
// output: null still rendered a visible 'OUTPUT' eyebrow with no
|
||||
// body. Treat null the same as undefined so the empty section
|
||||
// doesn't render at all.
|
||||
const tool = {
|
||||
type: "tool_use",
|
||||
name: "search",
|
||||
tool_input: {},
|
||||
output: null,
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={tool} chatId="t-t-null" />);
|
||||
expect(screen.queryByText("OUTPUT")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the output section when output is an empty string", () => {
|
||||
// An empty (or whitespace-only) string output used to render an empty
|
||||
// bordered box. Treat it as no output so nothing renders. The output
|
||||
// box is the only `.max-h-96 overflow-auto` element here.
|
||||
const tool = {
|
||||
type: "tool_use",
|
||||
name: "search",
|
||||
tool_input: {},
|
||||
output: " ",
|
||||
} as unknown as ContentBlockItem;
|
||||
const { container } = render(
|
||||
<ContentDisplay content={tool} chatId="t-t-empty-out" />,
|
||||
);
|
||||
expect(container.querySelector(".max-h-96")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the JSON block when output has extra fields beyond ToolMessage", () => {
|
||||
// Don't unwrap if the producer added fields the user might need —
|
||||
// could be tool-specific data the renderer shouldn't drop silently.
|
||||
const tool = {
|
||||
type: "tool_use",
|
||||
name: "fetch_content",
|
||||
tool_input: {},
|
||||
output: {
|
||||
content: "the body",
|
||||
custom_field: 42,
|
||||
},
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={tool} chatId="t-t-no-unwrap" />);
|
||||
// Falls through to the JSON code block (mocked as code-tabs).
|
||||
expect(screen.getByTestId("code-tabs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the error body in a destructive-toned panel", () => {
|
||||
// No eyebrow label — the surrounding accordion already says
|
||||
// "tool call" and the destructive bg + color carries the meaning.
|
||||
const tool = {
|
||||
type: "tool_use",
|
||||
name: "search",
|
||||
tool_input: {},
|
||||
error: "boom",
|
||||
} as unknown as ContentBlockItem;
|
||||
const { container } = render(
|
||||
<ContentDisplay content={tool} chatId="t-t-err" />,
|
||||
);
|
||||
// The destructive panel wraps the code tab.
|
||||
expect(container.querySelector(".bg-destructive\\/10")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders nothing for empty input (no 'no arguments' placeholder)", () => {
|
||||
// The surrounding accordion provides enough context; an explicit
|
||||
// empty-state line just adds chrome. The OUTPUT below carries the
|
||||
// useful information.
|
||||
const tool = {
|
||||
type: "tool_use",
|
||||
name: "search",
|
||||
tool_input: {},
|
||||
output: "hi",
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={tool} chatId="t-t-empty-input" />);
|
||||
expect(screen.queryByText(/no arguments/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("citation", () => {
|
||||
it("renders the domain extracted from the URL alongside the title", () => {
|
||||
const citation = {
|
||||
type: "citation",
|
||||
url: "https://docs.python.org/3/library/typing.html",
|
||||
title: "typing — Support for type hints",
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={citation} chatId="t-c1" />);
|
||||
expect(screen.getByText(/docs\.python\.org/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("typing — Support for type hints"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render an anchor for non-http URLs (sanitization)", () => {
|
||||
// javascript: and other non-http(s) schemes must never become a
|
||||
// clickable link; we degrade to a text-only card instead.
|
||||
const citation = {
|
||||
type: "citation",
|
||||
url: "javascript:alert(1)",
|
||||
title: "bad",
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={citation} chatId="t-c2" />);
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("bad")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the cited_text snippet as plain text, not HTML", () => {
|
||||
// The cited_text comes from upstream model output and may contain
|
||||
// angle brackets. React text nodes escape them, so the literal
|
||||
// characters render rather than parsing as markup.
|
||||
const citation = {
|
||||
type: "citation",
|
||||
url: "https://example.com",
|
||||
title: "src",
|
||||
cited_text: "<script>alert(1)</script>",
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={citation} chatId="t-c3" />);
|
||||
expect(screen.getByText("<script>alert(1)</script>")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("group", () => {
|
||||
it("renders the title and recurses through nested contents", () => {
|
||||
// Nested ContentBlock inside another container: previously fell
|
||||
// through ContentDisplay's switch and rendered nothing. Two visible
|
||||
// children prove recursion: usage and citation both render text
|
||||
// directly without a reveal-on-click behavior.
|
||||
const nested = {
|
||||
type: "group",
|
||||
title: "Inner step",
|
||||
contents: [
|
||||
{ type: "usage", input_tokens: 1, output_tokens: 2 },
|
||||
{
|
||||
type: "citation",
|
||||
url: "https://example.com",
|
||||
title: "Cited source",
|
||||
},
|
||||
],
|
||||
} as unknown as ContentBlockItem;
|
||||
render(<ContentDisplay content={nested} chatId="t6" />);
|
||||
expect(screen.getByText("Inner step")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Tokens: 1 in \/ 2 out/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Cited source")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,49 @@
|
||||
import type { ContentType } from "@/types/chat";
|
||||
import { groupConsecutiveCitations } from "../groupCitations";
|
||||
|
||||
const text = (s: string) =>
|
||||
({ type: "text", text: s }) as unknown as ContentType;
|
||||
const cite = (url: string) =>
|
||||
({ type: "citation", url }) as unknown as ContentType;
|
||||
|
||||
describe("groupConsecutiveCitations", () => {
|
||||
it("emits each non-citation item as its own single run", () => {
|
||||
const runs = groupConsecutiveCitations([text("a"), text("b")]);
|
||||
expect(runs).toHaveLength(2);
|
||||
expect(runs.every((r) => r.kind === "single")).toBe(true);
|
||||
});
|
||||
|
||||
it("coalesces a run of consecutive citations into one sources run", () => {
|
||||
const runs = groupConsecutiveCitations([
|
||||
cite("https://a"),
|
||||
cite("https://b"),
|
||||
cite("https://c"),
|
||||
]);
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0].kind).toBe("sources");
|
||||
if (runs[0].kind === "sources") {
|
||||
expect(runs[0].citations).toHaveLength(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps non-adjacent citation groups separate", () => {
|
||||
// text in the middle splits the run into two Sources groups.
|
||||
const runs = groupConsecutiveCitations([
|
||||
cite("https://a"),
|
||||
text("answer"),
|
||||
cite("https://b"),
|
||||
]);
|
||||
expect(runs).toHaveLength(3);
|
||||
expect(runs[0].kind).toBe("sources");
|
||||
expect(runs[1].kind).toBe("single");
|
||||
expect(runs[2].kind).toBe("sources");
|
||||
});
|
||||
|
||||
it("still wraps a single citation in a sources run for consistent UI", () => {
|
||||
// Single-citation case must still flow through the strip so the
|
||||
// visual treatment is uniform.
|
||||
const runs = groupConsecutiveCitations([cite("https://a")]);
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0].kind).toBe("sources");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,86 @@
|
||||
import type { JSONValue } from "@/types/chat";
|
||||
import { looksPreformatted, unwrapToolMessage } from "../toolOutput";
|
||||
|
||||
describe("unwrapToolMessage", () => {
|
||||
it("unwraps a canonical LangChain ToolMessage shape down to its .content", () => {
|
||||
const out = unwrapToolMessage({
|
||||
content: "the body",
|
||||
name: "fetch",
|
||||
id: "abc",
|
||||
tool_call_id: "toolu_x",
|
||||
status: "success",
|
||||
});
|
||||
expect(out).toBe("the body");
|
||||
});
|
||||
|
||||
it("leaves the envelope intact when extra fields are present", () => {
|
||||
// The producer might be carrying tool-specific data; silently
|
||||
// dropping it would lose information.
|
||||
const envelope = { content: "the body", custom_field: 42 } as JSONValue;
|
||||
expect(unwrapToolMessage(envelope)).toEqual(envelope);
|
||||
});
|
||||
|
||||
it("leaves arrays untouched", () => {
|
||||
const arr = [1, 2, 3] as JSONValue;
|
||||
expect(unwrapToolMessage(arr)).toBe(arr);
|
||||
});
|
||||
|
||||
it("leaves objects without a content key untouched", () => {
|
||||
const obj = { result: 42, count: 7 } as JSONValue;
|
||||
expect(unwrapToolMessage(obj)).toBe(obj);
|
||||
});
|
||||
|
||||
it("leaves primitives untouched", () => {
|
||||
expect(unwrapToolMessage("hello")).toBe("hello");
|
||||
expect(unwrapToolMessage(42)).toBe(42);
|
||||
expect(unwrapToolMessage(null)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksPreformatted", () => {
|
||||
it("flags pandas-style column-padded output", () => {
|
||||
// The actual pandas df.to_string() output that broke the playground:
|
||||
// dozens of consecutive spaces between column values.
|
||||
const s =
|
||||
" text url\n" +
|
||||
"0 Langflow | Low-code AI builder for agentic and... https://langflow.org";
|
||||
expect(looksPreformatted(s)).toBe(true);
|
||||
});
|
||||
|
||||
it("flags strings containing tabs", () => {
|
||||
expect(looksPreformatted("col1\tcol2\tcol3")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags strings with very long single lines", () => {
|
||||
// A 130-char single line is almost certainly a log entry or wide
|
||||
// payload that markdown would mangle.
|
||||
expect(looksPreformatted("x".repeat(130))).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves ordinary prose alone", () => {
|
||||
expect(
|
||||
looksPreformatted(
|
||||
"Langflow.org is up — it's described as a Low-code AI builder.",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves short markdown alone", () => {
|
||||
expect(looksPreformatted("**Result:** the answer is 42")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores leading-indent CommonMark code blocks", () => {
|
||||
// 4-space-indented lines are a CommonMark indented code block. The old
|
||||
// `/ {4,}/` heuristic tripped on the leading run and forced the whole
|
||||
// output into a monospace block; markdown should still render here.
|
||||
const s =
|
||||
"Here is the code:\n\n const x = 1;\n const y = 2;\n\nDone.";
|
||||
expect(looksPreformatted(s)).toBe(false);
|
||||
});
|
||||
|
||||
it("still flags interior column padding (table alignment)", () => {
|
||||
// 4+ spaces AFTER content on a line is genuine column alignment that
|
||||
// markdown would mangle.
|
||||
expect(looksPreformatted("name value\nfoo 42")).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,47 @@
|
||||
import type { ContentType } from "@/types/chat";
|
||||
import { getToolStatus } from "../toolStatus";
|
||||
|
||||
describe("getToolStatus", () => {
|
||||
it("returns 'running' when a tool has no duration yet", () => {
|
||||
const tool = {
|
||||
type: "tool_use",
|
||||
name: "search",
|
||||
tool_input: {},
|
||||
} as unknown as ContentType;
|
||||
expect(getToolStatus(tool)).toBe("running");
|
||||
});
|
||||
|
||||
it("returns 'done' once a tool reports a duration", () => {
|
||||
const tool = {
|
||||
type: "tool_use",
|
||||
name: "search",
|
||||
tool_input: {},
|
||||
duration: 1200,
|
||||
} as unknown as ContentType;
|
||||
expect(getToolStatus(tool)).toBe("done");
|
||||
});
|
||||
|
||||
it("returns 'error' when the tool reports an error, even with a duration", () => {
|
||||
// Error wins: an errored tool shouldn't look successful just because
|
||||
// the producer also stamped a duration on the failed call.
|
||||
const tool = {
|
||||
type: "tool_use",
|
||||
name: "search",
|
||||
tool_input: {},
|
||||
duration: 800,
|
||||
error: "boom",
|
||||
} as unknown as ContentType;
|
||||
expect(getToolStatus(tool)).toBe("error");
|
||||
});
|
||||
|
||||
it("returns 'done' for non-tool_use content with a duration", () => {
|
||||
// Other content types (reasoning, etc.) carrying a duration count
|
||||
// as resolved even though they have no notion of 'error'.
|
||||
const reasoning = {
|
||||
type: "reasoning",
|
||||
text: "thought",
|
||||
duration: 500,
|
||||
} as unknown as ContentType;
|
||||
expect(getToolStatus(reasoning)).toBe("done");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,35 @@
|
||||
import type { CitationContent, ContentType } from "@/types/chat";
|
||||
|
||||
/** A run of flat content items: either a single non-citation item rendered
|
||||
* via ContentDisplay, or a coalesced run of citations rendered as one
|
||||
* SourcesStrip. The synthetic 'sources' kind exists so the renderer can
|
||||
* group consecutive citations into one Sources row without inventing a
|
||||
* fake content type or mutating the original list. */
|
||||
export type FlatRun =
|
||||
| { kind: "single"; item: ContentType; index: number }
|
||||
| { kind: "sources"; citations: CitationContent[]; index: number };
|
||||
|
||||
/** Walk the flat items list and merge consecutive citation entries into
|
||||
* one 'sources' run. Non-citation items pass through as 'single' runs.
|
||||
* `index` is the position of the first contributing item, used as a
|
||||
* stable React key. */
|
||||
export function groupConsecutiveCitations(items: ContentType[]): FlatRun[] {
|
||||
const runs: FlatRun[] = [];
|
||||
let i = 0;
|
||||
while (i < items.length) {
|
||||
const item = items[i];
|
||||
if (item.type !== "citation") {
|
||||
runs.push({ kind: "single", item, index: i });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const start = i;
|
||||
const citations: CitationContent[] = [];
|
||||
while (i < items.length && items[i].type === "citation") {
|
||||
citations.push(items[i] as CitationContent);
|
||||
i += 1;
|
||||
}
|
||||
runs.push({ kind: "sources", citations, index: start });
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
import type { JSONValue } from "@/types/chat";
|
||||
|
||||
/** LangChain ToolMessage envelope shape — the readable payload lives on
|
||||
* `.content`; the rest is plumbing already exposed by the surrounding
|
||||
* accordion trigger (tool name, id, status). Used to decide whether to
|
||||
* unwrap an output object down to its `.content` field. */
|
||||
const TOOL_MESSAGE_KEYS = new Set([
|
||||
"content",
|
||||
"name",
|
||||
"id",
|
||||
"tool_call_id",
|
||||
"status",
|
||||
]);
|
||||
|
||||
/** Strip the LangChain ToolMessage envelope from a tool output, returning
|
||||
* the inner `content` value when the keys are a subset of the canonical
|
||||
* set. Any extra key (custom tool-specific data) prevents unwrap so the
|
||||
* user doesn't silently lose information. */
|
||||
export function unwrapToolMessage(output: JSONValue): JSONValue {
|
||||
if (
|
||||
output !== null &&
|
||||
typeof output === "object" &&
|
||||
!Array.isArray(output) &&
|
||||
"content" in output &&
|
||||
Object.keys(output).every((k) => TOOL_MESSAGE_KEYS.has(k))
|
||||
) {
|
||||
return (output as Record<string, JSONValue>).content;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Detect strings that look like pre-formatted output (pandas
|
||||
* df.to_string(), ASCII tables, fixed-width logs) rather than prose or
|
||||
* markdown. Markdown rendering mangles column alignment, so these need to
|
||||
* render in a monospace <pre> block with horizontal scroll instead.
|
||||
*
|
||||
* Heuristics, any one fires:
|
||||
* - 4+ spaces after non-space content on a line (interior column padding)
|
||||
* - a line longer than 120 chars (long log line or wide table row)
|
||||
* - tab characters (very rare in markdown, common in tool output)
|
||||
*/
|
||||
export function looksPreformatted(s: string): boolean {
|
||||
if (s.includes("\t")) return true;
|
||||
// Interior column padding (4+ spaces after non-space content on a line)
|
||||
// signals a fixed-width table or aligned output. A *leading* run of 4+
|
||||
// spaces is just a CommonMark indented code block, which should still go
|
||||
// through the markdown renderer, so require a preceding non-space char.
|
||||
if (/\S {4,}/.test(s)) return true;
|
||||
const longestLine = s.split("\n").reduce((m, l) => Math.max(m, l.length), 0);
|
||||
return longestLine > 120;
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
import type { ContentType } from "@/types/chat";
|
||||
|
||||
export type ToolStatus = "running" | "done" | "error";
|
||||
|
||||
/** Derives a tool's visible status from the data the producer already sets.
|
||||
* An `error` payload wins (even with a duration, an errored tool shouldn't
|
||||
* look successful); a `duration` means the step resolved; otherwise the
|
||||
* call is still in flight. */
|
||||
export function getToolStatus(content: ContentType): ToolStatus {
|
||||
if (content.type === "tool_use" && content.error != null) {
|
||||
return "error";
|
||||
}
|
||||
if (content.duration !== undefined) {
|
||||
return "done";
|
||||
}
|
||||
return "running";
|
||||
}
|
||||
|
||||
/** Tailwind classes for the small status dot on the accordion trigger. */
|
||||
export const TOOL_STATUS_CLASS: Record<ToolStatus, string> = {
|
||||
running: "bg-primary animate-pulse",
|
||||
done: "bg-accent-emerald-foreground",
|
||||
error: "bg-destructive",
|
||||
};
|
||||
@ -128,6 +128,51 @@ export const BotMessage = memo(
|
||||
? persistedDuration
|
||||
: liveDisplayTime;
|
||||
|
||||
// A message with token usage should still surface the MessageMetadata
|
||||
// pill even when no duration was recorded (e.g. v2 runs that didn't
|
||||
// emit ``build_duration``, historical messages restored from DB).
|
||||
// Without this the user would never see "X tokens" for those.
|
||||
const totalTokens = chat.properties?.usage?.total_tokens;
|
||||
const hasUsage = typeof totalTokens === "number" && totalTokens > 0;
|
||||
const showMetadata = displayTime > 0 || hasUsage;
|
||||
|
||||
// The renderer is data-driven, but content_blocks shows up in two
|
||||
// shapes:
|
||||
// - Legacy: an "Agent Steps" group wraps the tool calls (and the
|
||||
// legacy agent also appends a flat TextContent at the top that
|
||||
// duplicates Message.text). Render the group via the accordion
|
||||
// and let CustomMarkdownField paint Message.text below — the
|
||||
// historical "tools on top, text after" layout.
|
||||
// - Interleaved (post agent-events rewiring): no group, just flat
|
||||
// tool_use / citation / text items in producer order. Trust the
|
||||
// content_blocks order and suppress the bubble body so text
|
||||
// doesn't double-paint.
|
||||
// The signal is: a flat non-text block (tool_use, citation, …) with
|
||||
// no group present means the producer is making an ordering claim.
|
||||
const contentBlocks = chat.content_blocks ?? [];
|
||||
const hasGroup = contentBlocks.some((block) => block.type === "group");
|
||||
const hasFlatNonText = contentBlocks.some(
|
||||
(block) => block.type !== "group" && block.type !== "text",
|
||||
);
|
||||
const hasTextBlock = contentBlocks.some((block) => block.type === "text");
|
||||
const useContentBlockOrdering = !hasGroup && hasFlatNonText;
|
||||
// Suppress the bubble body only when the content blocks actually carry
|
||||
// the answer text. If a producer emits only flat tool_use / citation
|
||||
// items (no TextContent) and stuffs the answer into Message.text,
|
||||
// keep the bubble body so the assistant text isn't hidden.
|
||||
const showBubbleBody =
|
||||
!useContentBlockOrdering || editMessage || !hasTextBlock;
|
||||
// In legacy / pure-text mode, strip a top-level TextContent only when it
|
||||
// duplicates Message.text — those items would render above the grouped
|
||||
// accordion. A divergent text block (text !== Message.text) is kept so
|
||||
// it isn't silently dropped.
|
||||
const displayedContentBlocks = useContentBlockOrdering
|
||||
? contentBlocks
|
||||
: contentBlocks.filter(
|
||||
(block) =>
|
||||
block.type !== "text" || block.text !== chat.message?.toString(),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full word-break-break-word mt-2">
|
||||
@ -169,13 +214,15 @@ export const BotMessage = memo(
|
||||
<span>
|
||||
{t("chat.runningStatus")} {formatSeconds(displayTime)}
|
||||
</span>
|
||||
) : !thinkingActive && displayTime > 0 ? (
|
||||
) : !thinkingActive && showMetadata ? (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
{t("chat.finishedIn")}
|
||||
</span>
|
||||
{displayTime > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
{t("chat.finishedIn")}
|
||||
</span>
|
||||
)}
|
||||
<MessageMetadata
|
||||
duration={displayTime}
|
||||
duration={displayTime > 0 ? displayTime : undefined}
|
||||
usage={chat.properties?.usage ?? undefined}
|
||||
timestamp={chat.timestamp}
|
||||
/>
|
||||
@ -184,11 +231,11 @@ export const BotMessage = memo(
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{((chat.content_blocks && chat.content_blocks.length > 0) ||
|
||||
{(displayedContentBlocks.length > 0 ||
|
||||
(isBuilding && lastMessage)) && (
|
||||
<ContentBlockDisplay
|
||||
playgroundPage={playgroundPage}
|
||||
contentBlocks={chat.content_blocks || []}
|
||||
contentBlocks={displayedContentBlocks}
|
||||
isLoading={getContentBlockLoadingState(
|
||||
chat,
|
||||
isBuilding,
|
||||
@ -200,51 +247,54 @@ export const BotMessage = memo(
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="form-modal-chat-text-position flex-grow mt-2">
|
||||
<div className="form-modal-chat-text">
|
||||
<div className="flex w-full flex-col">
|
||||
<div
|
||||
className="flex w-full flex-col dark:text-white"
|
||||
data-testid="div-chat-message"
|
||||
>
|
||||
{showBubbleBody && (
|
||||
<div className="form-modal-chat-text-position flex-grow mt-2">
|
||||
<div className="form-modal-chat-text">
|
||||
<div className="flex w-full flex-col">
|
||||
<div
|
||||
data-testid={`chat-message-${chat.sender_name}-${chatMessage}`}
|
||||
className="flex w-full flex-col"
|
||||
className="flex w-full flex-col dark:text-white"
|
||||
data-testid="div-chat-message"
|
||||
>
|
||||
{(chatMessage === "" || (isEmpty && !isStreaming)) &&
|
||||
isBuilding &&
|
||||
lastMessage ? (
|
||||
<IconComponent
|
||||
name="MoreHorizontal"
|
||||
className="h-8 w-8 animate-pulse"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
{editMessage ? (
|
||||
<EditMessageField
|
||||
key={`edit-message-${chat.id}`}
|
||||
message={decodedMessage}
|
||||
onEdit={handleEditMessage}
|
||||
onCancel={() => setEditMessage(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<CustomMarkdownField
|
||||
isAudioMessage={isAudioMessage}
|
||||
chat={chat}
|
||||
isEmpty={isEmpty && !isStreaming}
|
||||
chatMessage={decodedMessage}
|
||||
editedFlag={editedFlag}
|
||||
<div
|
||||
data-testid={`chat-message-${chat.sender_name}-${chatMessage}`}
|
||||
className="flex w-full flex-col"
|
||||
>
|
||||
{(chatMessage === "" ||
|
||||
(isEmpty && !isStreaming)) &&
|
||||
isBuilding &&
|
||||
lastMessage ? (
|
||||
<IconComponent
|
||||
name="MoreHorizontal"
|
||||
className="h-8 w-8 animate-pulse"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
{editMessage ? (
|
||||
<EditMessageField
|
||||
key={`edit-message-${chat.id}`}
|
||||
message={decodedMessage}
|
||||
onEdit={handleEditMessage}
|
||||
onCancel={() => setEditMessage(false)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
<CustomMarkdownField
|
||||
isAudioMessage={isAudioMessage}
|
||||
chat={chat}
|
||||
isEmpty={isEmpty && !isStreaming}
|
||||
chatMessage={decodedMessage}
|
||||
editedFlag={editedFlag}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import useFlowStore from "@/stores/flowStore";
|
||||
import { isGroupedBlock } from "@/types/chat";
|
||||
import type { chatMessagePropsType } from "@/types/components";
|
||||
import { BotMessage } from "./bot-message";
|
||||
import { ErrorView } from "./error-message";
|
||||
@ -27,7 +28,7 @@ export default function ChatMessage({
|
||||
|
||||
// Error messages
|
||||
if (chat.category === "error") {
|
||||
const blocks = chat.content_blocks ?? [];
|
||||
const blocks = (chat.content_blocks ?? []).filter(isGroupedBlock);
|
||||
return (
|
||||
<ErrorView
|
||||
blocks={blocks}
|
||||
|
||||
@ -273,7 +273,7 @@ describe("updateMessage — streaming token preservation (UI-014)", () => {
|
||||
expect(updated).toHaveLength(1);
|
||||
expect(updated![0].content_blocks).toBeDefined();
|
||||
expect(updated![0].content_blocks).toHaveLength(1);
|
||||
expect(updated![0].content_blocks![0].contents[0]).toMatchObject({
|
||||
expect(updated![0].content_blocks![0].contents![0]).toMatchObject({
|
||||
type: "tool_use",
|
||||
name: "fetch_pokemon",
|
||||
});
|
||||
|
||||
@ -109,7 +109,7 @@ export const SanitizedMarkdown = ({
|
||||
);
|
||||
},
|
||||
// biome-ignore lint/suspicious/noExplicitAny: pre-existing react-markdown override; proper type cleanup out of scope for this PR
|
||||
code: ({ node, inline, className, children, ...props }: any) => {
|
||||
code: ({ node, className, children, ...props }: any) => {
|
||||
let content = children as string;
|
||||
if (
|
||||
Array.isArray(children) &&
|
||||
@ -130,9 +130,18 @@ export const SanitizedMarkdown = ({
|
||||
}
|
||||
}
|
||||
|
||||
// react-markdown v8+ removed the `inline` prop. Detect a
|
||||
// fenced code block by the presence of either a
|
||||
// `language-X` className (hinted block) or multi-line
|
||||
// content (unhinted block). Anything else is inline
|
||||
// backtick code — rendering it as a `<div>`/`<pre>`
|
||||
// CodeTabsComponent inside a markdown `<p>` triggers the
|
||||
// 'div cannot be a descendant of p' hydration error.
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
const isBlock =
|
||||
match !== null || (content?.includes("\n") ?? false);
|
||||
|
||||
return !inline ? (
|
||||
return isBlock ? (
|
||||
<CodeTabsComponent
|
||||
language={(match && match[1]) || ""}
|
||||
code={String(content).replace(/\n$/, "")}
|
||||
|
||||
@ -282,4 +282,208 @@ describe("applyStateDelta", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("build_duration stamping on output-node success", () => {
|
||||
// The AG-UI translator drops the backend's per-vertex duration, so the
|
||||
// bridge has to derive ``build_duration`` client-side and stamp it onto
|
||||
// the last bot message. These pin the gates (output-type only, success
|
||||
// only, buildStartTime required) so a regression would surface here
|
||||
// instead of in a playwright run.
|
||||
|
||||
const OUTPUT_NODE_ID = "ChatOutput-XYZ";
|
||||
const NON_OUTPUT_NODE_ID = "LanguageModelComponent-ABC";
|
||||
|
||||
const seedFlow = (nodes: Array<{ id: string; type: string }>) => {
|
||||
useFlowStore.setState({
|
||||
nodes: nodes.map((n) => ({
|
||||
id: n.id,
|
||||
// Only ``data.type`` matters here; the rest of the node shape is
|
||||
// surplus for the helper but required by AllNodeType typing.
|
||||
data: { type: n.type },
|
||||
})) as unknown as ReturnType<typeof useFlowStore.getState>["nodes"],
|
||||
});
|
||||
};
|
||||
|
||||
const seedBotMessage = (msg: {
|
||||
id: string;
|
||||
properties?: Record<string, unknown>;
|
||||
}) => {
|
||||
// Stamping reads ``useMessagesStore`` when React Query returns nothing
|
||||
// (the shareable-playground / IOModal path), so seed it there.
|
||||
const { useMessagesStore } = require("@/stores/messagesStore");
|
||||
useMessagesStore.setState({
|
||||
messages: [
|
||||
{
|
||||
id: msg.id,
|
||||
sender: "Machine",
|
||||
sender_name: "AI",
|
||||
session_id: "s1",
|
||||
flow_id: "f1",
|
||||
text: "Hi",
|
||||
files: [],
|
||||
timestamp: "2026-05-28T00:00:00Z",
|
||||
properties: msg.properties ?? {},
|
||||
content_blocks: [],
|
||||
category: "message",
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
const { useMessagesStore } = require("@/stores/messagesStore");
|
||||
const { queryClient } = require("@/contexts");
|
||||
useMessagesStore.setState({ messages: [] });
|
||||
useFlowStore.setState({ nodes: [], buildStartTime: null });
|
||||
// Clear the React Query messages cache so the scoping test below
|
||||
// doesn't leak seeded sessions into the Zustand-fallback tests.
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("stamps build_duration on the last bot message when an output node finishes successfully", () => {
|
||||
seedFlow([{ id: OUTPUT_NODE_ID, type: "ChatOutput" }]);
|
||||
seedBotMessage({ id: "m1" });
|
||||
useFlowStore.setState({ buildStartTime: Date.now() - 1500 });
|
||||
|
||||
applyStateDelta(
|
||||
[
|
||||
{
|
||||
op: "add",
|
||||
path: `/nodes/${OUTPUT_NODE_ID}`,
|
||||
value: { status: "success", output: { results: {} } },
|
||||
},
|
||||
],
|
||||
"run-1",
|
||||
new Set<string>(),
|
||||
);
|
||||
|
||||
const { useMessagesStore } = require("@/stores/messagesStore");
|
||||
const stamped = useMessagesStore.getState().messages[0];
|
||||
expect(stamped.properties.build_duration).toBeGreaterThanOrEqual(1500);
|
||||
// Resets so the next segment is measured fresh.
|
||||
expect(useFlowStore.getState().buildStartTime).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not stamp when the finishing node is not an output type", () => {
|
||||
seedFlow([{ id: NON_OUTPUT_NODE_ID, type: "LanguageModelComponent" }]);
|
||||
seedBotMessage({ id: "m1" });
|
||||
useFlowStore.setState({ buildStartTime: Date.now() - 500 });
|
||||
|
||||
applyStateDelta(
|
||||
[
|
||||
{
|
||||
op: "add",
|
||||
path: `/nodes/${NON_OUTPUT_NODE_ID}`,
|
||||
value: { status: "success", output: { results: {} } },
|
||||
},
|
||||
],
|
||||
"run-1",
|
||||
new Set<string>(),
|
||||
);
|
||||
|
||||
const { useMessagesStore } = require("@/stores/messagesStore");
|
||||
const msg = useMessagesStore.getState().messages[0];
|
||||
expect(msg.properties.build_duration).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not stamp when buildStartTime is missing", () => {
|
||||
seedFlow([{ id: OUTPUT_NODE_ID, type: "ChatOutput" }]);
|
||||
seedBotMessage({ id: "m1" });
|
||||
useFlowStore.setState({ buildStartTime: null });
|
||||
|
||||
applyStateDelta(
|
||||
[
|
||||
{
|
||||
op: "add",
|
||||
path: `/nodes/${OUTPUT_NODE_ID}`,
|
||||
value: { status: "success", output: { results: {} } },
|
||||
},
|
||||
],
|
||||
"run-1",
|
||||
new Set<string>(),
|
||||
);
|
||||
|
||||
const { useMessagesStore } = require("@/stores/messagesStore");
|
||||
const msg = useMessagesStore.getState().messages[0];
|
||||
expect(msg.properties.build_duration).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not overwrite a build_duration already on the message (nested-segment guard)", () => {
|
||||
seedFlow([{ id: OUTPUT_NODE_ID, type: "ChatOutput" }]);
|
||||
seedBotMessage({ id: "m1", properties: { build_duration: 999 } });
|
||||
useFlowStore.setState({ buildStartTime: Date.now() - 5000 });
|
||||
|
||||
applyStateDelta(
|
||||
[
|
||||
{
|
||||
op: "add",
|
||||
path: `/nodes/${OUTPUT_NODE_ID}`,
|
||||
value: { status: "success", output: { results: {} } },
|
||||
},
|
||||
],
|
||||
"run-1",
|
||||
new Set<string>(),
|
||||
);
|
||||
|
||||
const { useMessagesStore } = require("@/stores/messagesStore");
|
||||
const msg = useMessagesStore.getState().messages[0];
|
||||
expect(msg.properties.build_duration).toBe(999);
|
||||
});
|
||||
|
||||
it("scopes the stamp to the running session, not another session's cache", () => {
|
||||
// D1 regression: the bot-message lookup must be scoped to the running
|
||||
// flow/session. The unscoped form walked every messages cache and could
|
||||
// stamp build_duration onto a bot message from a different session the
|
||||
// user had open in the same tab.
|
||||
const { queryClient } = require("@/contexts");
|
||||
const MESSAGES_QUERY_KEY = "useGetMessagesQuery";
|
||||
const botMsg = (id: string) => ({
|
||||
id,
|
||||
sender: "Machine",
|
||||
sender_name: "AI",
|
||||
text: "hi",
|
||||
files: [],
|
||||
timestamp: "2026-05-28T00:00:00Z",
|
||||
properties: {} as Record<string, unknown>,
|
||||
content_blocks: [],
|
||||
category: "message",
|
||||
});
|
||||
const otherKey = [
|
||||
MESSAGES_QUERY_KEY,
|
||||
{ id: "f1", session_id: "other-session" },
|
||||
];
|
||||
const runningKey = [
|
||||
MESSAGES_QUERY_KEY,
|
||||
{ id: "f1", session_id: "running-session" },
|
||||
];
|
||||
queryClient.setQueryData(otherKey, [botMsg("other-msg")]);
|
||||
queryClient.setQueryData(runningKey, [botMsg("running-msg")]);
|
||||
|
||||
seedFlow([{ id: OUTPUT_NODE_ID, type: "ChatOutput" }]);
|
||||
useFlowStore.setState({ buildStartTime: Date.now() - 1500 });
|
||||
|
||||
applyStateDelta(
|
||||
[
|
||||
{
|
||||
op: "add",
|
||||
path: `/nodes/${OUTPUT_NODE_ID}`,
|
||||
value: { status: "success", output: { results: {} } },
|
||||
},
|
||||
],
|
||||
"run-1",
|
||||
new Set<string>(),
|
||||
"f1",
|
||||
"running-session",
|
||||
);
|
||||
|
||||
const running = queryClient.getQueryData(runningKey) as Array<{
|
||||
properties: { build_duration?: number };
|
||||
}>;
|
||||
const other = queryClient.getQueryData(otherKey) as Array<{
|
||||
properties: { build_duration?: number };
|
||||
}>;
|
||||
expect(running[0].properties.build_duration).toBeGreaterThanOrEqual(1500);
|
||||
expect(other[0].properties.build_duration).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -10,15 +10,22 @@
|
||||
|
||||
import { type BaseEvent, EventType } from "@ag-ui/client";
|
||||
import { handleMessageEvent } from "@/components/core/playgroundComponent/chat-view/utils/message-event-handler";
|
||||
import {
|
||||
findLastBotMessage,
|
||||
updateMessageProperties,
|
||||
} from "@/components/core/playgroundComponent/chat-view/utils/message-utils";
|
||||
import { BuildStatus } from "@/constants/enums";
|
||||
import { persistMessageProperties } from "@/controllers/API/helpers/persist-message-properties";
|
||||
import useAlertStore from "@/stores/alertStore";
|
||||
import useFlowStore from "@/stores/flowStore";
|
||||
import { useMessagesStore } from "@/stores/messagesStore";
|
||||
import type {
|
||||
ChatInputType,
|
||||
ChatOutputType,
|
||||
VertexBuildTypeAPI,
|
||||
VertexDataTypeAPI,
|
||||
} from "@/types/api";
|
||||
import { isOutputType } from "@/utils/reactflowUtils";
|
||||
import {
|
||||
buildWorkflowRunRequest,
|
||||
createWorkflowAgent,
|
||||
@ -112,6 +119,8 @@ export function applyStateDelta(
|
||||
ops: JsonPatchOp[],
|
||||
runId: string,
|
||||
nodeIds: Set<string>,
|
||||
flowId?: string,
|
||||
sessionId?: string,
|
||||
): void {
|
||||
const flowStore = useFlowStore.getState();
|
||||
for (const op of ops) {
|
||||
@ -155,10 +164,99 @@ export function applyStateDelta(
|
||||
artifacts: null,
|
||||
};
|
||||
flowStore.addDataToFlowPool(entry, nodeId);
|
||||
|
||||
// When a ChatOutput (or other output type) finishes, stamp the
|
||||
// elapsed segment duration onto the bot message it just produced.
|
||||
// Mirrors the v1 ``end_vertex`` callback in ``buildUtils.ts`` so
|
||||
// the playground header can render the "Finished in" pill and
|
||||
// MessageMetadata can show its duration. The AG-UI translator
|
||||
// does not propagate ``build_duration`` from the backend, so this
|
||||
// is the only path that sets it for v2 runs.
|
||||
if (value.status === "success") {
|
||||
stampSegmentDurationForOutputNode(nodeId, flowId, sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If ``nodeId`` is an output-type node, compute the time since the
|
||||
* flow's ``buildStartTime`` and persist it as ``build_duration`` on the
|
||||
* last bot message in the matching React Query cache (and the Zustand
|
||||
* fallback used by the shareable playground). ``flowId``/``sessionId`` scope
|
||||
* the lookup to the running session so the duration can't land on a bot
|
||||
* message from a different session's cache. Resets ``buildStartTime`` so the
|
||||
* next segment is measured fresh.
|
||||
*
|
||||
* Mirrors the per-vertex segment logic in ``buildUtils.ts`` so the v2
|
||||
* AG-UI bridge produces the same on-message metadata the v1 build
|
||||
* callbacks do.
|
||||
*/
|
||||
function stampSegmentDurationForOutputNode(
|
||||
nodeId: string,
|
||||
flowId?: string,
|
||||
sessionId?: string,
|
||||
): void {
|
||||
const flowState = useFlowStore.getState();
|
||||
const node = flowState.nodes.find((n) => n.id === nodeId);
|
||||
const nodeType = node?.data?.type as string | undefined;
|
||||
if (!nodeType || !isOutputType(nodeType) || !flowState.buildStartTime) {
|
||||
return;
|
||||
}
|
||||
const segmentDurationMs = Date.now() - flowState.buildStartTime;
|
||||
|
||||
const found = findLastBotMessage(flowId, sessionId);
|
||||
if (found && !found.message.properties?.build_duration) {
|
||||
updateMessageProperties(found.message.id!, found.queryKey, {
|
||||
build_duration: segmentDurationMs,
|
||||
});
|
||||
|
||||
const storeMsg = useMessagesStore
|
||||
.getState()
|
||||
.messages.find((m) => m.id === found.message.id);
|
||||
if (storeMsg) {
|
||||
useMessagesStore.getState().updateMessage({
|
||||
...storeMsg,
|
||||
properties: {
|
||||
...storeMsg.properties,
|
||||
build_duration: segmentDurationMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
persistMessageProperties(found.message.id!, {
|
||||
...found.message,
|
||||
properties: {
|
||||
...found.message.properties,
|
||||
build_duration: segmentDurationMs,
|
||||
},
|
||||
});
|
||||
} else if (!found) {
|
||||
// Shareable-playground fallback: React Query cache is empty, so look
|
||||
// for the last bot message in the Zustand store and stamp there.
|
||||
const storeMessages = useMessagesStore.getState().messages;
|
||||
for (let i = storeMessages.length - 1; i >= 0; i--) {
|
||||
const msg = storeMessages[i];
|
||||
if (msg.sender === "Machine" && !msg.properties?.build_duration) {
|
||||
const updatedProperties = {
|
||||
...msg.properties,
|
||||
build_duration: segmentDurationMs,
|
||||
};
|
||||
useMessagesStore.getState().updateMessage({
|
||||
...msg,
|
||||
properties: updatedProperties,
|
||||
});
|
||||
if (msg.id) {
|
||||
persistMessageProperties(msg.id, { properties: updatedProperties });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flowState.setBuildStartTime(Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a workflow through the AG-UI service and update flowStore as events
|
||||
* arrive. Resolves when the run ends (RUN_FINISHED or RUN_ERROR), the
|
||||
@ -203,6 +301,11 @@ export async function runFlowAGUI(
|
||||
const flowStore = useFlowStore.getState();
|
||||
const setErrorData = useAlertStore.getState().setErrorData;
|
||||
const touchedNodeIds = new Set<string>();
|
||||
// ``setIsBuilding(true)`` (called upstream by ``buildFlow``) clears
|
||||
// ``buildStartTime``. Initialise it here so the segment duration the
|
||||
// per-vertex success handler stamps onto bot messages is measured
|
||||
// from the actual moment the run started, not from a stale tick.
|
||||
flowStore.setBuildStartTime(Date.now());
|
||||
// Server derives run_id from session_id/flow_id and announces it via
|
||||
// RUN_STARTED. Until that arrives we have no id to stamp on flow-pool
|
||||
// entries, so we fall back to an empty string (matches the legacy
|
||||
@ -237,7 +340,8 @@ export async function runFlowAGUI(
|
||||
setRunId: (r) => {
|
||||
runId = r;
|
||||
},
|
||||
applyDelta: (ops) => applyStateDelta(ops, runId, touchedNodeIds),
|
||||
applyDelta: (ops) =>
|
||||
applyStateDelta(ops, runId, touchedNodeIds, opts.flowId, opts.threadId),
|
||||
handleCustomEvent: (eventType, data) => handleMessageEvent(eventType, data),
|
||||
onFinished: () => {
|
||||
terminalEventSeen = true;
|
||||
|
||||
@ -9,6 +9,7 @@ import { CustomProfileIcon } from "@/customization/components/custom-profile-ico
|
||||
import { ENABLE_DATASTAX_LANGFLOW } from "@/customization/feature-flags";
|
||||
import useFlowStore from "@/stores/flowStore";
|
||||
import useFlowsManagerStore from "@/stores/flowsManagerStore";
|
||||
import { isGroupedBlock } from "@/types/chat";
|
||||
import Robot from "../../../../../assets/robot.png";
|
||||
import IconComponent, {
|
||||
ForwardedIconComponent,
|
||||
@ -130,6 +131,43 @@ export default function ChatMessage({
|
||||
const isEmpty = decodedMessage?.trim() === "";
|
||||
const { mutate: updateMessageMutation } = useUpdateMessage();
|
||||
|
||||
// The renderer is data-driven, but content_blocks shows up in two
|
||||
// shapes:
|
||||
// - Legacy: an "Agent Steps" group wraps the tool calls (and the
|
||||
// legacy agent also appends a flat TextContent at the top that
|
||||
// duplicates Message.text). Render the group via the accordion
|
||||
// and let CustomMarkdownField paint Message.text below — the
|
||||
// historical "tools on top, text after" layout.
|
||||
// - Interleaved (post agent-events rewiring): no group, just flat
|
||||
// tool_use / citation / text items in producer order. Trust the
|
||||
// content_blocks order and suppress the bubble body so text
|
||||
// doesn't double-paint.
|
||||
// The signal is: a flat non-text block (tool_use, citation, …) with
|
||||
// no group present means the producer is making an ordering claim.
|
||||
const contentBlocks = chat.content_blocks ?? [];
|
||||
const hasGroup = contentBlocks.some((block) => block.type === "group");
|
||||
const hasFlatNonText = contentBlocks.some(
|
||||
(block) => block.type !== "group" && block.type !== "text",
|
||||
);
|
||||
const hasTextBlock = contentBlocks.some((block) => block.type === "text");
|
||||
const useContentBlockOrdering = !hasGroup && hasFlatNonText;
|
||||
// Suppress the bubble body only when the content blocks actually carry
|
||||
// the answer text. If a producer emits only flat tool_use / citation
|
||||
// items (no TextContent) and stuffs the answer into Message.text, keep
|
||||
// the bubble body so the assistant text isn't hidden.
|
||||
const showBubbleBody =
|
||||
!useContentBlockOrdering || editMessage || !hasTextBlock;
|
||||
// In legacy / pure-text mode, strip a top-level TextContent only when it
|
||||
// duplicates Message.text — those items would render above the grouped
|
||||
// accordion. A divergent text block (text !== Message.text) is kept so it
|
||||
// isn't silently dropped.
|
||||
const displayedContentBlocks = useContentBlockOrdering
|
||||
? contentBlocks
|
||||
: contentBlocks.filter(
|
||||
(block) =>
|
||||
block.type !== "text" || block.text !== chat.message?.toString(),
|
||||
);
|
||||
|
||||
const handleEditMessage = (message: string) => {
|
||||
updateMessageMutation(
|
||||
{
|
||||
@ -191,7 +229,10 @@ export default function ChatMessage({
|
||||
) : null;
|
||||
|
||||
if (chat.category === "error") {
|
||||
const blocks = chat.content_blocks ?? [];
|
||||
// ErrorView walks block.contents on each entry, so filter to the grouped
|
||||
// ContentBlock shape (it has `contents`). Flat ContentType items can now
|
||||
// also appear in content_blocks and would otherwise crash ErrorView.
|
||||
const blocks = (chat.content_blocks ?? []).filter(isGroupedBlock);
|
||||
|
||||
return (
|
||||
<ErrorView
|
||||
@ -305,10 +346,10 @@ export default function ChatMessage({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{chat.content_blocks && chat.content_blocks.length > 0 && (
|
||||
{displayedContentBlocks.length > 0 && (
|
||||
<ContentBlockDisplay
|
||||
playgroundPage={playgroundPage}
|
||||
contentBlocks={chat.content_blocks}
|
||||
contentBlocks={displayedContentBlocks}
|
||||
isLoading={
|
||||
chat.properties?.state === "partial" &&
|
||||
isBuilding &&
|
||||
@ -318,7 +359,7 @@ export default function ChatMessage({
|
||||
chatId={chat.id}
|
||||
/>
|
||||
)}
|
||||
{!chat.isSend ? (
|
||||
{!chat.isSend && !showBubbleBody ? null : !chat.isSend ? (
|
||||
<div className="form-modal-chat-text-position flex-grow">
|
||||
<div className="form-modal-chat-text">
|
||||
{hidden && chat.thought && chat.thought !== "" && (
|
||||
|
||||
78
src/frontend/src/types/chat/__tests__/isGroupedBlock.test.ts
Normal file
78
src/frontend/src/types/chat/__tests__/isGroupedBlock.test.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { type ContentBlockItem, isGroupedBlock } from "..";
|
||||
|
||||
// Backend's BaseContent serializes `contents: []` by default on every leaf,
|
||||
// so the structural fallback in `isGroupedBlock` MUST NOT fire when the
|
||||
// discriminator says the item is a flat ContentType — otherwise a flat
|
||||
// CodeContent or CitationContent with a `title` field gets misrouted into
|
||||
// the grouped accordion and silently disappears from the UI.
|
||||
|
||||
describe("isGroupedBlock", () => {
|
||||
it("returns true when type === 'group' (discriminator wins)", () => {
|
||||
const block: ContentBlockItem = {
|
||||
type: "group",
|
||||
title: "Agent Steps",
|
||||
contents: [],
|
||||
};
|
||||
expect(isGroupedBlock(block)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for a flat CodeContent that happens to have a title", () => {
|
||||
// Backend serializes optional `title` and inherited `contents: []` on
|
||||
// every CodeContent, so the structural fallback would misclassify this
|
||||
// as a group if it didn't gate on the discriminator first.
|
||||
const code = {
|
||||
type: "code",
|
||||
title: "snippet.py",
|
||||
code: "print('hi')",
|
||||
language: "python",
|
||||
contents: [],
|
||||
} as unknown as ContentBlockItem;
|
||||
expect(isGroupedBlock(code)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a flat CitationContent with title + empty contents", () => {
|
||||
const citation = {
|
||||
type: "citation",
|
||||
title: "Source",
|
||||
url: "https://example.com",
|
||||
contents: [],
|
||||
} as unknown as ContentBlockItem;
|
||||
expect(isGroupedBlock(citation)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a flat ToolContent (no title, has contents)", () => {
|
||||
const tool = {
|
||||
type: "tool_use",
|
||||
name: "fetch",
|
||||
contents: [],
|
||||
} as unknown as ContentBlockItem;
|
||||
expect(isGroupedBlock(tool)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for a legacy ContentBlock dict that lacks a type field", () => {
|
||||
// Pre-discriminator persisted shape: only title + contents, no `type`.
|
||||
// This is the case the structural fallback exists for.
|
||||
const legacy = {
|
||||
title: "Agent Steps",
|
||||
contents: [{ type: "tool_use", name: "x" }],
|
||||
allow_markdown: true,
|
||||
} as unknown as ContentBlockItem;
|
||||
expect(isGroupedBlock(legacy)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for a legacy-shaped dict that lacks a contents array", () => {
|
||||
// Without contents we can't tell it's a group; treat as flat.
|
||||
const malformed = {
|
||||
title: "Stray title",
|
||||
} as unknown as ContentBlockItem;
|
||||
expect(isGroupedBlock(malformed)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a TextContent", () => {
|
||||
const text = {
|
||||
type: "text",
|
||||
text: "hello",
|
||||
} as unknown as ContentBlockItem;
|
||||
expect(isGroupedBlock(text)).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -25,7 +25,7 @@ export type ChatMessageType = {
|
||||
icon?: string;
|
||||
category?: string;
|
||||
properties?: PropertiesType;
|
||||
content_blocks?: ContentBlock[];
|
||||
content_blocks?: ContentBlockItem[];
|
||||
};
|
||||
|
||||
export type SourceType = {
|
||||
@ -85,11 +85,20 @@ export type FlowPoolObjectType = {
|
||||
// Base content type
|
||||
export interface BaseContent {
|
||||
type: string;
|
||||
// Optional stable identity carried across re-emissions of the same logical
|
||||
// block. Set by producers that have a natural id (e.g. LangChain
|
||||
// tool_call_id). Consumers fall back to position-derived dedup when absent.
|
||||
id?: string;
|
||||
duration?: number;
|
||||
header?: {
|
||||
title?: string;
|
||||
icon?: string;
|
||||
};
|
||||
// Nested content. Leaf types leave this empty; container-shaped types
|
||||
// (ContentBlock, multimodal ToolContent, multi-step ReasoningContent)
|
||||
// populate it. Typed as ContentBlockItem so nested ContentBlock groups
|
||||
// are allowed, matching the backend's discriminated union.
|
||||
contents?: ContentBlockItem[];
|
||||
}
|
||||
|
||||
// Individual content types
|
||||
@ -128,11 +137,69 @@ export interface CodeContent extends BaseContent {
|
||||
export interface ToolContent extends BaseContent {
|
||||
type: "tool_use";
|
||||
name?: string;
|
||||
tool_input: Record<string, JSONValue>;
|
||||
// The backend serializes this field as `tool_input` by default but
|
||||
// emits it under the `input` alias when AG-UI / any other path runs
|
||||
// model_dump with by_alias=True. Accept both shapes; renderers should
|
||||
// prefer `tool_input` and fall back to `input`.
|
||||
tool_input?: Record<string, JSONValue>;
|
||||
input?: Record<string, JSONValue>;
|
||||
output?: JSONValue;
|
||||
error?: JSONValue | string;
|
||||
}
|
||||
|
||||
export interface ImageContent extends BaseContent {
|
||||
type: "image";
|
||||
urls?: string[];
|
||||
base64?: string;
|
||||
mime_type?: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
export interface AudioContent extends BaseContent {
|
||||
type: "audio";
|
||||
urls?: string[];
|
||||
base64?: string;
|
||||
mime_type?: string;
|
||||
duration?: number;
|
||||
transcript?: string;
|
||||
}
|
||||
|
||||
export interface VideoContent extends BaseContent {
|
||||
type: "video";
|
||||
urls?: string[];
|
||||
base64?: string;
|
||||
mime_type?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface FileContent extends BaseContent {
|
||||
type: "file";
|
||||
urls?: string[];
|
||||
mime_type?: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export interface ReasoningContent extends BaseContent {
|
||||
type: "reasoning";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface UsageContent extends BaseContent {
|
||||
type: "usage";
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface CitationContent extends BaseContent {
|
||||
type: "citation";
|
||||
url?: string;
|
||||
title?: string;
|
||||
cited_text?: string;
|
||||
start_index?: number;
|
||||
end_index?: number;
|
||||
}
|
||||
|
||||
// Union type for all content types
|
||||
export type ContentType =
|
||||
| ErrorContent
|
||||
@ -140,15 +207,50 @@ export type ContentType =
|
||||
| MediaContent
|
||||
| JSONContent
|
||||
| CodeContent
|
||||
| ToolContent;
|
||||
| ToolContent
|
||||
| ImageContent
|
||||
| AudioContent
|
||||
| VideoContent
|
||||
| FileContent
|
||||
| ReasoningContent
|
||||
| UsageContent
|
||||
| CitationContent;
|
||||
|
||||
// Updated ContentBlock interface
|
||||
export interface ContentBlock {
|
||||
// A titled group of nested contents. Matches the backend's ContentBlock,
|
||||
// which is a member of the ContentType discriminated union with tag "group".
|
||||
// Extends BaseContent so it inherits id/header/duration alongside the
|
||||
// group-specific fields.
|
||||
export interface ContentBlock extends BaseContent {
|
||||
type: "group";
|
||||
title: string;
|
||||
contents: ContentType[];
|
||||
allow_markdown: boolean;
|
||||
contents: ContentBlockItem[];
|
||||
// Optional to match the backend default (True) and to tolerate hand-built
|
||||
// or legacy payloads that don't include the field.
|
||||
allow_markdown?: boolean;
|
||||
media_url?: string[];
|
||||
component: string;
|
||||
// Legacy field kept for backwards compatibility with older callers.
|
||||
component?: string;
|
||||
}
|
||||
|
||||
// A content block item can be either a grouped ContentBlock or a flat ContentType
|
||||
export type ContentBlockItem = ContentType | ContentBlock;
|
||||
|
||||
// Type guard for grouped ContentBlock items. Prefers the discriminator
|
||||
// `type === "group"` (new payloads). Falls back to a structural check ONLY
|
||||
// when `type` is absent, so legacy ContentBlock dicts persisted before the
|
||||
// discriminator existed (no `type`, only title + contents) are still
|
||||
// classified as groups. The fallback must not fire on flat ContentType
|
||||
// items that happen to carry a `title` (CodeContent, CitationContent) and
|
||||
// an empty inherited `contents: []` -- backend BaseContent serializes
|
||||
// contents on every item, so the type check is what keeps them out.
|
||||
export function isGroupedBlock(item: ContentBlockItem): item is ContentBlock {
|
||||
if (item.type === "group") return true;
|
||||
if (item.type) return false;
|
||||
return (
|
||||
"title" in item &&
|
||||
"contents" in item &&
|
||||
Array.isArray((item as { contents?: unknown }).contents)
|
||||
);
|
||||
}
|
||||
|
||||
export interface PlaygroundEvent {
|
||||
@ -158,7 +260,7 @@ export interface PlaygroundEvent {
|
||||
allow_markdown?: boolean;
|
||||
icon?: string | null;
|
||||
sender_name: string;
|
||||
content_blocks?: ContentBlock[] | null;
|
||||
content_blocks?: ContentBlockItem[] | null;
|
||||
files?: string[];
|
||||
text?: string;
|
||||
timestamp?: string;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { ContentBlock, UsageType } from "../chat";
|
||||
import type { ContentBlockItem, UsageType } from "../chat";
|
||||
|
||||
type Message = {
|
||||
flow_id: string;
|
||||
@ -31,7 +31,7 @@ type Message = {
|
||||
usage?: UsageType | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
content_blocks?: ContentBlock[];
|
||||
content_blocks?: ContentBlockItem[];
|
||||
};
|
||||
|
||||
export type { Message };
|
||||
|
||||
@ -75,6 +75,19 @@ class BaseContent(BaseModel):
|
||||
),
|
||||
)
|
||||
|
||||
def __init__(self, **data: Any) -> None:
|
||||
super().__init__(**data)
|
||||
# Mark only the discriminator as "set" so partial-update callers using
|
||||
# ``model_dump(exclude_unset=True)`` (notably ``aupdate_messages``)
|
||||
# keep the ``type`` tag. Without it, ``TextContent(text="x")`` would
|
||||
# dump to ``{"text": "x"}`` and the next read-back through
|
||||
# ``MessageRead``'s discriminated union would fail with
|
||||
# ``union_tag_not_found``. Other defaulted fields stay unset so true
|
||||
# exclude_unset semantics survive: a patch like ``ContentBlock(title=
|
||||
# "...")`` no longer overwrites an existing block's ``duration`` /
|
||||
# ``header`` / ``contents`` with their defaults on merge.
|
||||
self.model_fields_set.add("type")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return self.model_dump()
|
||||
|
||||
@ -260,17 +273,10 @@ class ContentBlock(BaseContent):
|
||||
allow_markdown: bool = Field(default=True)
|
||||
media_url: list[str] | None = None
|
||||
|
||||
def __init__(self, **data) -> None:
|
||||
super().__init__(**data)
|
||||
# Mark only the discriminator as "set" so partial-update callers
|
||||
# using ``model_dump(exclude_unset=True)`` (notably
|
||||
# ``aupdate_messages``) still carry the ``type="group"`` field
|
||||
# downstream. Other defaulted fields stay unset so true
|
||||
# exclude_unset semantics survive: a patch like
|
||||
# ``ContentBlock(title="...")`` no longer overwrites an existing
|
||||
# block's ``duration`` / ``header`` / ``contents`` with their
|
||||
# defaults on merge.
|
||||
self.model_fields_set.add("type")
|
||||
# ``__init__`` is inherited from ``BaseContent``: it marks only the
|
||||
# ``type`` discriminator as set, so ``model_dump(exclude_unset=True)``
|
||||
# preserves ``type="group"`` while leaving ``allow_markdown`` and the
|
||||
# other defaulted fields out of partial-update merges.
|
||||
|
||||
@field_validator("contents", mode="before")
|
||||
@classmethod
|
||||
|
||||
@ -743,7 +743,15 @@ class MessageResponse(DefaultModel):
|
||||
|
||||
properties: Properties | None = None
|
||||
category: str | None = None
|
||||
content_blocks: list[ContentType | ContentBlock] | None = None
|
||||
# ContentBlock joined the ContentType discriminated union as tag "group",
|
||||
# so list[ContentType] alone covers both flat ContentType (text, image,
|
||||
# tool_use, ...) and the wrapping ContentBlock shape. The previous
|
||||
# `list[ContentType | ContentBlock]` union confused Pydantic's
|
||||
# discriminator: any dict with a `contents` field (which every backend
|
||||
# BaseContent now serializes by default, even as []) was routed to
|
||||
# ContentBlock and rejected with `type: literal_error` for non-group
|
||||
# types like text.
|
||||
content_blocks: list[ContentType] | None = None
|
||||
session_metadata: dict | None = None
|
||||
|
||||
@field_validator("content_blocks", mode="before")
|
||||
@ -754,10 +762,11 @@ class MessageResponse(DefaultModel):
|
||||
if isinstance(v, list):
|
||||
return [cls.validate_content_blocks(block) for block in v]
|
||||
if isinstance(v, dict):
|
||||
# Flat ContentType (has "type" but no "contents") vs grouped ContentBlock
|
||||
if "type" in v and "contents" not in v:
|
||||
return _CONTENT_TYPE_ADAPTER.validate_python(v)
|
||||
return ContentBlock.model_validate(v)
|
||||
# The discriminator on `type` handles flat ContentType vs grouped
|
||||
# ContentBlock uniformly. The old "type and not contents"
|
||||
# heuristic broke once BaseContent started serializing
|
||||
# `contents: []` on every leaf.
|
||||
return _CONTENT_TYPE_ADAPTER.validate_python(v)
|
||||
return v
|
||||
|
||||
@field_validator("properties", mode="before")
|
||||
|
||||
@ -18,7 +18,7 @@ from lfx.schema.content_types import (
|
||||
ToolContent,
|
||||
)
|
||||
from lfx.schema.data import Data
|
||||
from lfx.schema.message import ErrorMessage, Message
|
||||
from lfx.schema.message import ErrorMessage, Message, MessageResponse
|
||||
from lfx.schema.properties import Source
|
||||
from lfx.utils.constants import (
|
||||
MESSAGE_SENDER_AI,
|
||||
@ -544,3 +544,149 @@ class TestContentBlockExcludeUnset:
|
||||
# ``header``, ``media_url`` must stay out of the dump so they
|
||||
# don't clobber existing values on merge.
|
||||
assert dump.keys() <= {"type", "title", "allow_markdown"}
|
||||
|
||||
|
||||
class TestMessageResponseContentBlocksValidation:
|
||||
"""Regression: MessageResponse must accept every ContentType in content_blocks.
|
||||
|
||||
The agent now appends flat items (TextContent, ToolContent, etc.) to
|
||||
``content_blocks`` instead of nesting them inside a ContentBlock
|
||||
group. The serialized form lands on the API as
|
||||
``{"type": "text", ...}`` etc. If MessageResponse only validates
|
||||
ContentBlock, the API errors out with
|
||||
``content_blocks.type: Input should be 'group'`` and the entire
|
||||
chat response fails to render.
|
||||
"""
|
||||
|
||||
_BASE_KWARGS = {
|
||||
"sender": "Machine",
|
||||
"sender_name": "AI",
|
||||
"session_id": "s",
|
||||
"text": "Doing well, thanks!",
|
||||
"edit": False,
|
||||
}
|
||||
|
||||
def test_flat_text_content_validates(self):
|
||||
# The exact shape the agent's setter appends after `message.text =`.
|
||||
payload = {
|
||||
**self._BASE_KWARGS,
|
||||
"content_blocks": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": None,
|
||||
"duration": None,
|
||||
"header": {},
|
||||
"contents": [],
|
||||
"text": "Doing well, thanks!",
|
||||
},
|
||||
],
|
||||
}
|
||||
resp = MessageResponse.model_validate(payload)
|
||||
assert resp.content_blocks is not None
|
||||
assert len(resp.content_blocks) == 1
|
||||
assert resp.content_blocks[0].type == "text"
|
||||
assert resp.content_blocks[0].text == "Doing well, thanks!"
|
||||
|
||||
def test_flat_tool_content_validates(self):
|
||||
payload = {
|
||||
**self._BASE_KWARGS,
|
||||
"content_blocks": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"name": "search",
|
||||
"tool_input": {"q": "x"},
|
||||
"contents": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
resp = MessageResponse.model_validate(payload)
|
||||
assert resp.content_blocks[0].type == "tool_use"
|
||||
assert resp.content_blocks[0].name == "search"
|
||||
|
||||
def test_mixed_flat_and_grouped_validates(self):
|
||||
# Chronological-events shape: a tool call, then a grouped block
|
||||
# (legacy), then the setter-appended text. All three must round-trip.
|
||||
payload = {
|
||||
**self._BASE_KWARGS,
|
||||
"content_blocks": [
|
||||
{"type": "tool_use", "name": "calc", "contents": []},
|
||||
{
|
||||
"type": "group",
|
||||
"title": "Agent Steps",
|
||||
"contents": [],
|
||||
"allow_markdown": True,
|
||||
},
|
||||
{"type": "text", "text": "done", "contents": []},
|
||||
],
|
||||
}
|
||||
resp = MessageResponse.model_validate(payload)
|
||||
types = [b.type for b in resp.content_blocks]
|
||||
assert types == ["tool_use", "group", "text"]
|
||||
|
||||
def test_validates_agent_setter_emitted_content_blocks(self):
|
||||
# End-to-end: the agent's setter path. process_agent_events calls
|
||||
# `message.text = "..."` post-construction and the setter appends
|
||||
# a TextContent to content_blocks. The serialized payload from
|
||||
# that Message must validate as a MessageResponse without the
|
||||
# discriminated-union rejecting the flat TextContent shape.
|
||||
message = Message(
|
||||
text="",
|
||||
sender="Machine",
|
||||
sender_name="AI",
|
||||
session_id="s",
|
||||
)
|
||||
# Trigger the setter; this is what handle_on_chain_end does.
|
||||
message.text = "Doing well, thanks!"
|
||||
assert any(isinstance(b, TextContent) for b in message.content_blocks)
|
||||
|
||||
# Round-trip the model_dump payload, which is what the API sends
|
||||
# over the wire.
|
||||
payload = {**self._BASE_KWARGS, "content_blocks": [b.model_dump() for b in message.content_blocks]}
|
||||
resp = MessageResponse.model_validate(payload)
|
||||
assert resp.content_blocks is not None
|
||||
assert resp.content_blocks[0].type == "text"
|
||||
assert resp.content_blocks[0].text == "Doing well, thanks!"
|
||||
|
||||
|
||||
class TestContentTypeExcludeUnsetPreservesDiscriminator:
|
||||
"""Regression: ContentType.model_dump(exclude_unset=True) must keep type.
|
||||
|
||||
aupdate_messages applies a partial-update via:
|
||||
msg.sqlmodel_update(message.model_dump(exclude_unset=True, exclude_none=True))
|
||||
|
||||
If TextContent dumps to ``{"text": "..."}`` (stripping the defaulted
|
||||
``type`` field), the stored row's content_blocks entry has no
|
||||
discriminator. The next read-back through MessageRead's discriminated
|
||||
union fails with ``union_tag_not_found``, aupdate_messages raises
|
||||
ValueError, astore_message silently falls through to aadd_messages,
|
||||
and a duplicate row gets inserted with a new id — which the chat
|
||||
view renders as a duplicate bubble.
|
||||
|
||||
The fix is in BaseContent.__init__: mark every field as set so
|
||||
exclude_unset keeps the discriminator.
|
||||
"""
|
||||
|
||||
def test_text_content_keeps_type(self):
|
||||
dump = TextContent(text="hi").model_dump(exclude_unset=True, exclude_none=True)
|
||||
assert dump.get("type") == "text", f"missing discriminator: {dump}"
|
||||
assert dump.get("text") == "hi"
|
||||
|
||||
def test_tool_content_keeps_type(self):
|
||||
dump = ToolContent(name="search").model_dump(exclude_unset=True, exclude_none=True)
|
||||
assert dump.get("type") == "tool_use", f"missing discriminator: {dump}"
|
||||
|
||||
def test_error_content_keeps_type(self):
|
||||
dump = ErrorContent(reason="boom").model_dump(exclude_unset=True, exclude_none=True)
|
||||
assert dump.get("type") == "error", f"missing discriminator: {dump}"
|
||||
|
||||
def test_content_block_group_keeps_type(self):
|
||||
dump = ContentBlock(title="Agent Steps", contents=[]).model_dump(
|
||||
exclude_unset=True,
|
||||
exclude_none=True,
|
||||
)
|
||||
assert dump.get("type") == "group", f"missing discriminator: {dump}"
|
||||
assert dump.get("title") == "Agent Steps"
|
||||
|
||||
def test_image_content_keeps_type(self):
|
||||
dump = ImageContent(urls=["x"]).model_dump(exclude_unset=True, exclude_none=True)
|
||||
assert dump.get("type") == "image", f"missing discriminator: {dump}"
|
||||
|
||||
Reference in New Issue
Block a user