流式结构化输出
New流式结构化输出组件族:对不完整 JSON 容错的分色渲染——可承受未闭合字符串的增量 tokenizer、仅对新到片段做淡入、流式光标,以及函数调用外壳。
Tool call
"use client";
import { RotateCcw } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { StreamingFunctionCall, StreamingJson } from "@/components/motion/streaming-json";
/** Tool-call arguments — kept in sync with `RESULT_JSON`'s `query` so the two demos read as one coherent call. */
const FUNCTION_CALL_ARGS = `{"query": "streaming ui patterns 2026", "max_results": 5, "recency": "1_month"}`;
/** ~12-line tool-result sample: strings, numbers, booleans, a nested object and an array of objects. */
const RESULT_JSON = `{
"tool": "web_search",
"query": "streaming ui patterns 2026",
"cached": false,
"took_ms": 812,
"filters": {
"recency": "1_month",
"safe_search": true
},
"results": [
{ "title": "Designing streaming tool calls", "score": 0.93 },
{ "title": "Partial JSON rendering patterns", "score": 0.87 }
]
}`;
const TOTAL_LENGTH = Math.max(FUNCTION_CALL_ARGS.length, RESULT_JSON.length);
// Deterministic per-tick advance sizes (cycled, never random) — gives the
// reveal a bit of texture while staying reproducible from run to run.
const STEP_SIZES = [4, 6, 3, 7, 5];
const TICK_MS = 40;
/** Live demo: a streaming tool call followed by its streaming JSON result, auto-playing on mount and replayable. */
export function StreamingJsonPreview() {
const reduce = useReducedMotion() ?? false;
const [progress, setProgress] = useState(0);
const stepRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stop = useCallback(() => {
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
const play = useCallback(() => {
stop();
stepRef.current = 0;
setProgress(0);
timerRef.current = setInterval(() => {
setProgress((current) => {
const step = STEP_SIZES[stepRef.current % STEP_SIZES.length];
stepRef.current += 1;
const next = current + step;
if (next >= TOTAL_LENGTH) {
stop();
return TOTAL_LENGTH;
}
return next;
});
}, TICK_MS);
}, [stop]);
useEffect(() => {
if (reduce) {
stop();
setProgress(TOTAL_LENGTH);
return;
}
play();
return stop;
}, [reduce, play, stop]);
const fnText = FUNCTION_CALL_ARGS.slice(0, Math.min(progress, FUNCTION_CALL_ARGS.length));
const jsonText = RESULT_JSON.slice(0, Math.min(progress, RESULT_JSON.length));
const fnStreaming = !reduce && progress < FUNCTION_CALL_ARGS.length;
const jsonStreaming = !reduce && progress < RESULT_JSON.length;
return (
<div className="flex w-full items-center justify-center">
<div className="w-full max-w-xl rounded-2xl border border-border bg-background p-5">
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-medium text-foreground">Tool call</p>
<button
type="button"
onClick={play}
className="inline-flex h-7 items-center gap-1.5 rounded-full border border-border px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
>
<RotateCcw className="h-3.5 w-3.5" />
Replay
</button>
</div>
<StreamingFunctionCall
name="web_search"
text={fnText}
streaming={fnStreaming}
className="mb-3"
/>
<StreamingJson text={jsonText} streaming={jsonStreaming} />
</div>
</div>
);
}
"use client";
// ui-lab-ten.vercel.app/components/blocks/streaming-json
import { motion, useReducedMotion } from "motion/react";
import { useRef } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
/**
* Token kinds produced by the tokenizer. `literal` covers `true` / `false` /
* `null` — and any bare run of letters seen while a value is still
* streaming in (e.g. a partial `tru`) — since they all share one color.
*/
type JsonTokenType = "key" | "string" | "number" | "literal" | "punct";
interface JsonToken {
type: JsonTokenType;
text: string;
/** Character offsets into the source text — used to split a token across the stable/fresh boundary while streaming. */
start: number;
end: number;
}
const WHITESPACE = /\s/;
const DIGIT = /[0-9]/;
const WORD_CHAR = /[a-zA-Z_]/;
/**
* Hand-rolled tokenizer for (possibly incomplete) JSON text. Deliberately
* not `JSON.parse`-based — partial JSON throws, and re-parsing the whole
* document on every chunk would also throw away exactly the incremental
* info this component needs. Scans once, left to right, never backtracks
* past a token boundary, so it stays cheap to re-run on every streamed
* chunk.
*
* Key vs. string is the one genuinely ambiguous call. Correctly telling
* "this string is an object member's key" apart from "this string is a
* value" needs real structural context — a bracket-depth stack plus
* "are we before the first colon of this member" tracking. Instead this
* uses a simpler, good-enough rule: a string token is a "key" if the first
* non-whitespace character after its closing quote is `:`. That rule is
* wrong only for a string *value* immediately followed by a colon, which
* essentially never happens in real tool-call / tool-result JSON — a
* deliberate simplicity-over-completeness trade-off.
*/
function tokenizeJson(input: string): JsonToken[] {
const tokens: JsonToken[] = [];
const n = input.length;
let i = 0;
const pushPunct = (text: string, start: number, end: number) => {
const last = tokens.at(-1);
if (last?.type === "punct" && last.end === start) {
last.text += text;
last.end = end;
} else {
tokens.push({ type: "punct", text, start, end });
}
};
while (i < n) {
const ch = input[i];
if (ch === '"') {
const start = i;
let j = i + 1;
let closed = false;
while (j < n) {
const c = input[j];
if (c === "\\") {
// Skip the escaped character too, so an escaped quote (`\"`)
// never looks like the closing quote. If this runs past the end
// of a still-streaming string, the loop below simply stops.
j += 2;
continue;
}
if (c === '"') {
j += 1;
closed = true;
break;
}
j += 1;
}
const end = Math.min(j, n);
let k = end;
while (k < n && WHITESPACE.test(input[k])) k += 1;
const isKey = closed && input[k] === ":";
tokens.push({
type: isKey ? "key" : "string",
text: input.slice(start, end),
start,
end,
});
i = end;
continue;
}
if (ch === "-" || DIGIT.test(ch)) {
const start = i;
let j = i;
if (input[j] === "-") j += 1;
while (j < n && DIGIT.test(input[j])) j += 1;
if (input[j] === ".") {
j += 1;
while (j < n && DIGIT.test(input[j])) j += 1;
}
if (input[j] === "e" || input[j] === "E") {
j += 1;
if (input[j] === "+" || input[j] === "-") j += 1;
while (j < n && DIGIT.test(input[j])) j += 1;
}
// A lone trailing "-" (the number hasn't streamed in yet) still gets
// colored as a number rather than falling through uncounted.
if (j === start) j = start + 1;
tokens.push({ type: "number", text: input.slice(start, j), start, end: j });
i = j;
continue;
}
if (WORD_CHAR.test(ch)) {
const start = i;
let j = i;
while (j < n && WORD_CHAR.test(input[j])) j += 1;
tokens.push({ type: "literal", text: input.slice(start, j), start, end: j });
i = j;
continue;
}
// Structural punctuation and whitespace both fall here; merging
// consecutive characters keeps the token count down and doesn't cost
// anything visually since both render in the same muted color.
pushPunct(ch, i, i + 1);
i += 1;
}
return tokens;
}
const TOKEN_CLASS: Record<JsonTokenType, string> = {
key: "text-[#e25507] dark:text-[#ff8549]",
string: "text-emerald-600 dark:text-[#40C977]",
number: "text-[#339CFF]",
literal: "text-violet-500 dark:text-violet-400",
punct: "text-muted-foreground",
};
function renderTokens(tokens: JsonToken[], keyPrefix: string) {
// Keyed by the token's start offset (unique and stable within a given
// text — a split token's two halves get distinct starts too) rather than
// array index, since index would misattribute state across renders as
// tokens are inserted ahead of it while streaming.
return tokens.map((token) => (
<span key={`${keyPrefix}-${token.start}`} className={TOKEN_CLASS[token.type]}>
{token.text}
</span>
));
}
/**
* Splits a token list at a character `offset`, cutting the one token that
* straddles the boundary in two so both halves keep the original type
* (and thus color). Used to separate the stable, already-rendered prefix
* from the newly-arrived tail.
*/
function splitTokens(tokens: JsonToken[], offset: number): [JsonToken[], JsonToken[]] {
const before: JsonToken[] = [];
const after: JsonToken[] = [];
for (const token of tokens) {
if (token.end <= offset) {
before.push(token);
} else if (token.start >= offset) {
after.push(token);
} else {
const cut = offset - token.start;
before.push({ ...token, text: token.text.slice(0, cut), end: offset });
after.push({ ...token, text: token.text.slice(cut), start: offset });
}
}
return [before, after];
}
/**
* Small breathing block cursor for in-progress streams. Reimplemented
* locally rather than imported from `agent-thread` — this block stays
* self-contained with no cross-component dependency, same reasoning as
* `ThreadThinking`'s independent header in that module.
*/
function StreamCaret({ className }: { className?: string }) {
const reduce = useReducedMotion() ?? false;
const base = "inline-block h-3.5 w-[7px] translate-y-[2px] rounded-[2px] bg-foreground/70";
if (reduce) {
return <span aria-hidden className={cn(base, "opacity-50", className)} />;
}
return (
<motion.span
aria-hidden
className={cn(base, className)}
animate={{ opacity: [1, 0.15, 1] }}
transition={{ duration: 1, repeat: Infinity, ease: "easeInOut" }}
/>
);
}
export interface StreamingJsonProps {
/** The (possibly incomplete) JSON text to render, colorized as it grows. */
text: string;
/** Appends a breathing block cursor at the tail while true. */
streaming?: boolean;
/** Drops the rounded background/padding shell for inline embedding, e.g. inside `StreamingFunctionCall`. */
bare?: boolean;
className?: string;
}
/**
* Fault-tolerant syntax-colored renderer for streaming JSON — tool inputs
* and structured outputs that arrive as a growing, possibly-unclosed
* string. Colors keys, strings, numbers and booleans/null as they appear,
* without ever calling `JSON.parse` (which throws on partial JSON).
*
* Only the newly-appended tail re-animates in: a ref remembers the text
* from the previous render, the stable prefix renders as plain colored
* spans, and just the new suffix is wrapped in a `motion.span` that fades
* in — so a long, already-settled block never reflows or re-flashes as
* more text streams in behind it. Under `useReducedMotion()` the fade is
* skipped entirely and the full text renders immediately.
*/
export function StreamingJson({ text, streaming = false, bare = false, className }: StreamingJsonProps) {
const reduce = useReducedMotion() ?? false;
const prevTextRef = useRef("");
const tokens = tokenizeJson(text);
// Track the previous render's text so only the newly-appended tail
// animates. If `text` isn't a continuation of the previous value (e.g. a
// caller resets it), treat the whole thing as fresh rather than diffing
// unrelated content.
const prevText = prevTextRef.current;
const prefixLen = text.startsWith(prevText) ? prevText.length : 0;
prevTextRef.current = text;
const [stable, fresh] = reduce ? [tokens, []] : splitTokens(tokens, prefixLen);
const content = (
<>
{renderTokens(stable, "s")}
{fresh.length > 0 ? (
<motion.span
key={text.length}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.15, ease: EASE_OUT }}
>
{renderTokens(fresh, "f")}
</motion.span>
) : null}
{streaming ? <StreamCaret className="ml-0.5" /> : null}
</>
);
if (bare) {
return <code className={cn("whitespace-pre-wrap break-words align-baseline", className)}>{content}</code>;
}
return (
<pre
className={cn(
"overflow-x-auto rounded-xl bg-black/[0.04] p-3 font-mono text-[13px] leading-[20px] whitespace-pre dark:bg-white/5",
className,
)}
>
<code>{content}</code>
</pre>
);
}
export interface StreamingFunctionCallProps {
/** Function / tool name shown before the argument list, e.g. `"web_search"`. */
name: string;
/** The (possibly incomplete) JSON argument text, rendered via `StreamingJson`'s bare variant. */
text: string;
streaming?: boolean;
className?: string;
}
/**
* Streaming tool/function-call row — `name(` then its arguments fading in
* through `StreamingJson`, matching how tool-call deltas actually arrive:
* the name first, then the argument JSON key by key. The caller drives
* completeness through `streaming` alone; this component never tries to
* detect a balanced closing brace itself, so the trailing `)` only renders
* once `streaming` is false — showing it mid-stream would misleadingly
* suggest the call had already finished.
*/
export function StreamingFunctionCall({
name,
text,
streaming = false,
className,
}: StreamingFunctionCallProps) {
return (
<div className={cn("font-mono text-[13px]", className)}>
<span className="font-medium text-foreground">{name}</span>
<span className="text-muted-foreground">(</span>
<StreamingJson text={text} streaming={streaming} bare />
{!streaming ? <span className="text-muted-foreground">)</span> : null}
</div>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// ui-lab-ten.vercel.app/components/blocks/streaming-json
import { motion, useReducedMotion } from "motion/react";
import { useRef } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
/**
* Token kinds produced by the tokenizer. `literal` covers `true` / `false` /
* `null` — and any bare run of letters seen while a value is still
* streaming in (e.g. a partial `tru`) — since they all share one color.
*/
type JsonTokenType = "key" | "string" | "number" | "literal" | "punct";
interface JsonToken {
type: JsonTokenType;
text: string;
/** Character offsets into the source text — used to split a token across the stable/fresh boundary while streaming. */
start: number;
end: number;
}
const WHITESPACE = /\s/;
const DIGIT = /[0-9]/;
const WORD_CHAR = /[a-zA-Z_]/;
/**
* Hand-rolled tokenizer for (possibly incomplete) JSON text. Deliberately
* not `JSON.parse`-based — partial JSON throws, and re-parsing the whole
* document on every chunk would also throw away exactly the incremental
* info this component needs. Scans once, left to right, never backtracks
* past a token boundary, so it stays cheap to re-run on every streamed
* chunk.
*
* Key vs. string is the one genuinely ambiguous call. Correctly telling
* "this string is an object member's key" apart from "this string is a
* value" needs real structural context — a bracket-depth stack plus
* "are we before the first colon of this member" tracking. Instead this
* uses a simpler, good-enough rule: a string token is a "key" if the first
* non-whitespace character after its closing quote is `:`. That rule is
* wrong only for a string *value* immediately followed by a colon, which
* essentially never happens in real tool-call / tool-result JSON — a
* deliberate simplicity-over-completeness trade-off.
*/
function tokenizeJson(input: string): JsonToken[] {
const tokens: JsonToken[] = [];
const n = input.length;
let i = 0;
const pushPunct = (text: string, start: number, end: number) => {
const last = tokens.at(-1);
if (last?.type === "punct" && last.end === start) {
last.text += text;
last.end = end;
} else {
tokens.push({ type: "punct", text, start, end });
}
};
while (i < n) {
const ch = input[i];
if (ch === '"') {
const start = i;
let j = i + 1;
let closed = false;
while (j < n) {
const c = input[j];
if (c === "\\") {
// Skip the escaped character too, so an escaped quote (`\"`)
// never looks like the closing quote. If this runs past the end
// of a still-streaming string, the loop below simply stops.
j += 2;
continue;
}
if (c === '"') {
j += 1;
closed = true;
break;
}
j += 1;
}
const end = Math.min(j, n);
let k = end;
while (k < n && WHITESPACE.test(input[k])) k += 1;
const isKey = closed && input[k] === ":";
tokens.push({
type: isKey ? "key" : "string",
text: input.slice(start, end),
start,
end,
});
i = end;
continue;
}
if (ch === "-" || DIGIT.test(ch)) {
const start = i;
let j = i;
if (input[j] === "-") j += 1;
while (j < n && DIGIT.test(input[j])) j += 1;
if (input[j] === ".") {
j += 1;
while (j < n && DIGIT.test(input[j])) j += 1;
}
if (input[j] === "e" || input[j] === "E") {
j += 1;
if (input[j] === "+" || input[j] === "-") j += 1;
while (j < n && DIGIT.test(input[j])) j += 1;
}
// A lone trailing "-" (the number hasn't streamed in yet) still gets
// colored as a number rather than falling through uncounted.
if (j === start) j = start + 1;
tokens.push({ type: "number", text: input.slice(start, j), start, end: j });
i = j;
continue;
}
if (WORD_CHAR.test(ch)) {
const start = i;
let j = i;
while (j < n && WORD_CHAR.test(input[j])) j += 1;
tokens.push({ type: "literal", text: input.slice(start, j), start, end: j });
i = j;
continue;
}
// Structural punctuation and whitespace both fall here; merging
// consecutive characters keeps the token count down and doesn't cost
// anything visually since both render in the same muted color.
pushPunct(ch, i, i + 1);
i += 1;
}
return tokens;
}
const TOKEN_CLASS: Record<JsonTokenType, string> = {
key: "text-[#e25507] dark:text-[#ff8549]",
string: "text-emerald-600 dark:text-[#40C977]",
number: "text-[#339CFF]",
literal: "text-violet-500 dark:text-violet-400",
punct: "text-muted-foreground",
};
function renderTokens(tokens: JsonToken[], keyPrefix: string) {
// Keyed by the token's start offset (unique and stable within a given
// text — a split token's two halves get distinct starts too) rather than
// array index, since index would misattribute state across renders as
// tokens are inserted ahead of it while streaming.
return tokens.map((token) => (
<span key={`${keyPrefix}-${token.start}`} className={TOKEN_CLASS[token.type]}>
{token.text}
</span>
));
}
/**
* Splits a token list at a character `offset`, cutting the one token that
* straddles the boundary in two so both halves keep the original type
* (and thus color). Used to separate the stable, already-rendered prefix
* from the newly-arrived tail.
*/
function splitTokens(tokens: JsonToken[], offset: number): [JsonToken[], JsonToken[]] {
const before: JsonToken[] = [];
const after: JsonToken[] = [];
for (const token of tokens) {
if (token.end <= offset) {
before.push(token);
} else if (token.start >= offset) {
after.push(token);
} else {
const cut = offset - token.start;
before.push({ ...token, text: token.text.slice(0, cut), end: offset });
after.push({ ...token, text: token.text.slice(cut), start: offset });
}
}
return [before, after];
}
/**
* Small breathing block cursor for in-progress streams. Reimplemented
* locally rather than imported from `agent-thread` — this block stays
* self-contained with no cross-component dependency, same reasoning as
* `ThreadThinking`'s independent header in that module.
*/
function StreamCaret({ className }: { className?: string }) {
const reduce = useReducedMotion() ?? false;
const base = "inline-block h-3.5 w-[7px] translate-y-[2px] rounded-[2px] bg-foreground/70";
if (reduce) {
return <span aria-hidden className={cn(base, "opacity-50", className)} />;
}
return (
<motion.span
aria-hidden
className={cn(base, className)}
animate={{ opacity: [1, 0.15, 1] }}
transition={{ duration: 1, repeat: Infinity, ease: "easeInOut" }}
/>
);
}
export interface StreamingJsonProps {
/** The (possibly incomplete) JSON text to render, colorized as it grows. */
text: string;
/** Appends a breathing block cursor at the tail while true. */
streaming?: boolean;
/** Drops the rounded background/padding shell for inline embedding, e.g. inside `StreamingFunctionCall`. */
bare?: boolean;
className?: string;
}
/**
* Fault-tolerant syntax-colored renderer for streaming JSON — tool inputs
* and structured outputs that arrive as a growing, possibly-unclosed
* string. Colors keys, strings, numbers and booleans/null as they appear,
* without ever calling `JSON.parse` (which throws on partial JSON).
*
* Only the newly-appended tail re-animates in: a ref remembers the text
* from the previous render, the stable prefix renders as plain colored
* spans, and just the new suffix is wrapped in a `motion.span` that fades
* in — so a long, already-settled block never reflows or re-flashes as
* more text streams in behind it. Under `useReducedMotion()` the fade is
* skipped entirely and the full text renders immediately.
*/
export function StreamingJson({ text, streaming = false, bare = false, className }: StreamingJsonProps) {
const reduce = useReducedMotion() ?? false;
const prevTextRef = useRef("");
const tokens = tokenizeJson(text);
// Track the previous render's text so only the newly-appended tail
// animates. If `text` isn't a continuation of the previous value (e.g. a
// caller resets it), treat the whole thing as fresh rather than diffing
// unrelated content.
const prevText = prevTextRef.current;
const prefixLen = text.startsWith(prevText) ? prevText.length : 0;
prevTextRef.current = text;
const [stable, fresh] = reduce ? [tokens, []] : splitTokens(tokens, prefixLen);
const content = (
<>
{renderTokens(stable, "s")}
{fresh.length > 0 ? (
<motion.span
key={text.length}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.15, ease: EASE_OUT }}
>
{renderTokens(fresh, "f")}
</motion.span>
) : null}
{streaming ? <StreamCaret className="ml-0.5" /> : null}
</>
);
if (bare) {
return <code className={cn("whitespace-pre-wrap break-words align-baseline", className)}>{content}</code>;
}
return (
<pre
className={cn(
"overflow-x-auto rounded-xl bg-black/[0.04] p-3 font-mono text-[13px] leading-[20px] whitespace-pre dark:bg-white/5",
className,
)}
>
<code>{content}</code>
</pre>
);
}
export interface StreamingFunctionCallProps {
/** Function / tool name shown before the argument list, e.g. `"web_search"`. */
name: string;
/** The (possibly incomplete) JSON argument text, rendered via `StreamingJson`'s bare variant. */
text: string;
streaming?: boolean;
className?: string;
}
/**
* Streaming tool/function-call row — `name(` then its arguments fading in
* through `StreamingJson`, matching how tool-call deltas actually arrive:
* the name first, then the argument JSON key by key. The caller drives
* completeness through `streaming` alone; this component never tries to
* detect a balanced closing brace itself, so the trailing `)` only renders
* once `streaming` is false — showing it mid-stream would misleadingly
* suggest the call had already finished.
*/
export function StreamingFunctionCall({
name,
text,
streaming = false,
className,
}: StreamingFunctionCallProps) {
return (
<div className={cn("font-mono text-[13px]", className)}>
<span className="font-medium text-foreground">{name}</span>
<span className="text-muted-foreground">(</span>
<StreamingJson text={text} streaming={streaming} bare />
{!streaming ? <span className="text-muted-foreground">)</span> : null}
</div>
);
}
API 参考
StreamingJson
textstringThe (possibly incomplete) JSON text to render, colorized as it grows.
—streaming?booleanAppends a breathing block cursor at the tail while true.
falsebare?booleanDrops the rounded background/padding shell for inline embedding, e.g. inside `StreamingFunctionCall`.
falseclassName?string—StreamingFunctionCall
namestringFunction / tool name shown before the argument list, e.g. `"web_search"`.
—textstringThe (possibly incomplete) JSON argument text, rendered via `StreamingJson`'s bare variant.
—streaming?booleanfalseclassName?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.