18 lines
11 KiB
JSON
18 lines
11 KiB
JSON
{
|
|
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
"name": "streaming-text",
|
|
"type": "registry:component",
|
|
"title": "Streaming Text",
|
|
"description": "Streamed answer with inline sources, actions, and follow-ups.",
|
|
"registryDependencies": [
|
|
"https://www.beautifului.dev/r/foundation.json"
|
|
],
|
|
"files": [
|
|
{
|
|
"path": "components/primitives/StreamingText.tsx",
|
|
"content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * STREAMING TEXT\n * Words resolve out of blur, inline citations appear in\n * context, then actions and follow-up prompts become usable.\n * ───────────────────────────────────────────────────────── */\n\nconst WORD_MS = 55;\nconst HOLD_MS = 3400;\n\n/* one streamed word, or a `cite` placeholder that renders an inline source chip */\nexport type StreamingToken = { text: string; cite?: boolean };\n\nconst TOKENS: StreamingToken[] = [\n ...\"Pistachio is your fastest-growing flavor — sales are up 23% this month and margins beat vanilla by 8 points.\"\n .split(\" \")\n .map((text) => ({ text })),\n { text: \"\", cite: true },\n ...\"Stone-fruit flavors are trending in the same range.\"\n .split(\" \")\n .map((text) => ({ text })),\n];\n\nconst FOLLOW_UPS = [\n \"Which flavors sell best in winter\",\n \"Compare gelato and soft serve margins\",\n];\n\nconst SOURCE_IMAGES = {\n scoop:\n \"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%231f7a5f'/%3E%3Cpath d='M20 36c0 7 5.4 12 12 12s12-5 12-12H20Z' fill='%23fff'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23bff3dd'/%3E%3Cpath d='M24 24c4-7 13-7 17 0' fill='none' stroke='%231f7a5f' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E\",\n trends:\n \"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%232f6fec'/%3E%3Cpath d='M15 43 27 31l8 7 14-18' fill='none' stroke='%23fff' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='49' cy='20' r='5' fill='%23bfe0ff'/%3E%3C/svg%3E\",\n market:\n \"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23e56d24'/%3E%3Cpath d='M17 45V25h8v20h-8Zm11 0V16h8v29h-8Zm11 0V30h8v15h-8Z' fill='%23fff'/%3E%3Cpath d='M16 49h32' stroke='%23ffd6b8' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E\",\n};\n\n/* one cited source rendered as an inline chip and in the sources list */\nexport type StreamingSource = { name: string; domain: string; href: string; image: string };\n\nconst SOURCES: StreamingSource[] = [\n { name: \"Scoop Data\", domain: \"scoopdata.io\", href: \"https://scoopdata.io/\", image: SOURCE_IMAGES.scoop },\n { name: \"Trends Index\", domain: \"trends.google.com\", href: \"https://trends.google.com/trends/\", image: SOURCE_IMAGES.trends },\n { name: \"Market Basket\", domain: \"marketbasket.io\", href: \"https://marketbasket.io/\", image: SOURCE_IMAGES.market },\n];\n\nfunction sourceImage(source: StreamingSource) {\n return source.image;\n}\n\nfunction SourceChip({ source }: { source?: StreamingSource }) {\n if (!source) return null;\n return (\n <a\n href={source.href}\n target=\"_blank\"\n rel=\"noreferrer\"\n className=\"ml-0 mr-1 inline-flex h-4.5 translate-y-[-1px] items-center gap-1 rounded-[5px]\n bg-inset pr-[3px] pl-[3px] align-middle font-mono text-[10.5px] text-ink-2 shadow-hairline\n transition-colors duration-150 hover:bg-hover hover:text-ink\"\n style={{ animation: \"pop-in 250ms cubic-bezier(0.23,1,0.32,1) both\" }}\n >\n <img src={sourceImage(source)} alt=\"\" className=\"source-avatar size-3 rounded-[3px]\" />\n <span>{source.domain}</span>\n </a>\n );\n}\n\nconst ACTION_ICONS: React.ReactNode[] = [\n <g key=\"copy\"><rect x=\"9\" y=\"9\" width=\"12\" height=\"12\" rx=\"2.5\" /><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\" /></g>,\n <path key=\"retry\" d=\"M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6\" />,\n <path key=\"up\" d=\"M7 10v12M15 5.88L14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88z\" />,\n <path key=\"down\" d=\"M17 14V2M9 18.12L10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88z\" />,\n];\n\nexport type StreamingLabels = {\n /** label on the collapsed sources toggle */\n sources: string;\n /** heading above the follow-up prompts */\n followUps: string;\n};\n\nconst DEFAULT_LABELS: StreamingLabels = {\n sources: \"10 sources\",\n followUps: \"Follow-ups\",\n};\n\nexport default function StreamingText({\n content = TOKENS,\n sources = SOURCES,\n followUps = FOLLOW_UPS,\n labels,\n loop = true,\n fill = false,\n onDone,\n onFollowUp,\n}: {\n variant?: string;\n /** the streamed tokens; `cite` tokens render an inline source chip */\n content?: StreamingToken[];\n /** cited sources shown in the chip, avatar stack, and expanded list */\n sources?: StreamingSource[];\n /** follow-up prompt suggestions shown once the stream completes */\n followUps?: string[];\n /** prominent copy strings */\n labels?: Partial<StreamingLabels>;\n /** restart the stream after a hold; turn off when embedding in a real thread */\n loop?: boolean;\n /** fill the parent width instead of the gallery's fixed measure */\n fill?: boolean;\n onDone?: () => void;\n /** fired when a follow-up prompt is chosen */\n onFollowUp?: (text: string, index: number) => void;\n} = {}) {\n const l = { ...DEFAULT_LABELS, ...labels };\n const [count, setCount] = useState(0);\n const [sourcesOpen, setSourcesOpen] = useState(false);\n const done = count >= content.length;\n\n useEffect(() => {\n if (done && !loop) {\n onDone?.();\n return;\n }\n const t = setTimeout(\n () => setCount((c) => (c >= content.length ? 0 : c + 1)),\n done ? HOLD_MS : WORD_MS,\n );\n return () => clearTimeout(t);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [count, done, loop]);\n\n return (\n <div className={fill ? \"w-full\" : \"min-h-[15.5rem] w-full max-w-95\"}>\n <p className=\"text-[13px] leading-relaxed text-ink\">\n {content.slice(0, count).map((token, i) =>\n token.cite ? (\n <SourceChip key={i} source={sources[0]} />\n ) : (\n <span key={i} className=\"inline\">\n {token.text}{\" \"}\n </span>\n ),\n )}\n {!done && (\n <span\n className=\"ml-0.5 inline-block h-3 w-0.5 translate-y-0.5 rounded-full bg-ink\"\n style={{ animation: \"fade-in 150ms ease-out both\" }}\n />\n )}\n </p>\n\n {/* action icons row */}\n <div\n className=\"mt-2 flex items-center gap-0.5 transition-opacity duration-400\"\n style={{ opacity: done ? 1 : 0, pointerEvents: done ? \"auto\" : \"none\" }}\n >\n {ACTION_ICONS.map((icon, i) => (\n <button\n key={i}\n type=\"button\"\n aria-label=\"Action\"\n className=\"flex size-6 items-center justify-center rounded-[6px] text-ink-3\n transition-colors duration-100 hover:bg-hover-2 hover:text-ink-2\"\n >\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n {icon}\n </svg>\n </button>\n ))}\n <button\n type=\"button\"\n aria-expanded={sourcesOpen}\n onClick={() => setSourcesOpen((current) => !current)}\n className=\"ml-1.5 flex items-center gap-1.5 rounded-[6px] px-1 py-0.5 text-left transition-colors duration-150 hover:bg-hover\"\n >\n <span className=\"flex -space-x-1\">\n {sources.map((source) => (\n <img\n key={source.domain}\n src={sourceImage(source)}\n alt=\"\"\n className=\"source-avatar size-3.5 rounded-full bg-surface shadow-[0_0_0_1.5px_var(--canvas)]\"\n />\n ))}\n </span>\n <span className=\"text-[12px] text-ink-2\">{l.sources}</span>\n </button>\n </div>\n\n <div\n className=\"grid transition-[grid-template-rows,opacity] duration-300\"\n style={{\n gridTemplateRows: done && sourcesOpen ? \"1fr\" : \"0fr\",\n opacity: done && sourcesOpen ? 1 : 0,\n transitionTimingFunction: \"cubic-bezier(0.23, 1, 0.32, 1)\",\n }}\n >\n <div className=\"overflow-hidden\">\n <div className=\"mt-1.5 flex flex-col rounded-[10px] bg-inset p-1 shadow-hairline\">\n {sources.map((source) => (\n <a\n key={source.domain}\n href={source.href}\n target=\"_blank\"\n rel=\"noreferrer\"\n className=\"flex items-center gap-2 rounded-[6px] px-1.5 py-1 text-[12px] text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink\"\n >\n <img src={sourceImage(source)} alt=\"\" className=\"source-avatar size-4 rounded-[4px]\" />\n <span className=\"animated-underline\">{source.name}</span>\n <span className=\"ml-auto font-mono text-[10.5px] text-ink-3\">{source.domain}</span>\n </a>\n ))}\n </div>\n </div>\n </div>\n\n {/* follow-ups */}\n <div\n className=\"mt-2.5 transition-opacity duration-400\"\n style={{ opacity: done ? 1 : 0, pointerEvents: done ? \"auto\" : \"none\" }}\n >\n <p className=\"text-[12px] font-medium text-ink-2\">{l.followUps}</p>\n <div className=\"mt-0.5 flex flex-col\">\n {followUps.map((text, i) => (\n <button\n key={text}\n onClick={() => onFollowUp?.(text, i)}\n className=\"-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line\n px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors\n duration-100 hover:bg-hover-2\"\n style={\n done\n ? { animation: `fade-up 350ms cubic-bezier(0.23,1,0.32,1) ${i * 90}ms both` }\n : { opacity: 0 }\n }\n >\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"var(--ink-3)\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"shrink-0\">\n <path d=\"M9 10l-5 5 5 5\" />\n <path d=\"M20 4v7a4 4 0 0 1-4 4H4\" />\n </svg>\n {text}\n </button>\n ))}\n </div>\n </div>\n </div>\n );\n}\n",
|
|
"type": "registry:component",
|
|
"target": "components/primitives/StreamingText.tsx"
|
|
}
|
|
]
|
|
} |