@intelligo-dev/cli 1.0.0-beta.13 → 1.0.0-beta.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -2
- package/dist/args.d.ts +10 -0
- package/dist/args.d.ts.map +1 -0
- package/dist/args.js +20 -0
- package/dist/args.js.map +1 -0
- package/dist/bin.js +103 -22
- package/dist/bin.js.map +1 -1
- package/dist/commands/add.d.ts +4 -0
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +28 -2
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/create-flow.d.ts +8 -1
- package/dist/commands/create-flow.d.ts.map +1 -1
- package/dist/commands/create-flow.js +101 -34
- package/dist/commands/create-flow.js.map +1 -1
- package/dist/commands/create.d.ts +24 -4
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +57 -14
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +123 -35
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/migrate-check.d.ts +20 -1
- package/dist/commands/migrate-check.d.ts.map +1 -1
- package/dist/commands/migrate-check.js +35 -6
- package/dist/commands/migrate-check.js.map +1 -1
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +2 -8
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/sync-messages.d.ts +37 -0
- package/dist/commands/sync-messages.d.ts.map +1 -0
- package/dist/commands/sync-messages.js +88 -0
- package/dist/commands/sync-messages.js.map +1 -0
- package/dist/commands/sync-scaffold.d.ts +36 -0
- package/dist/commands/sync-scaffold.d.ts.map +1 -0
- package/dist/commands/sync-scaffold.js +53 -0
- package/dist/commands/sync-scaffold.js.map +1 -0
- package/dist/commands/sync.d.ts +109 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +392 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/upgrade-check.d.ts.map +1 -1
- package/dist/commands/upgrade-check.js +4 -1
- package/dist/commands/upgrade-check.js.map +1 -1
- package/dist/env-files.d.ts +11 -0
- package/dist/env-files.d.ts.map +1 -1
- package/dist/env-files.js +28 -9
- package/dist/env-files.js.map +1 -1
- package/dist/manifest.d.ts +27 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +30 -3
- package/dist/manifest.js.map +1 -1
- package/dist/module-exports.d.ts +63 -0
- package/dist/module-exports.d.ts.map +1 -0
- package/dist/module-exports.js +231 -0
- package/dist/module-exports.js.map +1 -0
- package/dist/registry-bundle.d.ts +43 -0
- package/dist/registry-bundle.d.ts.map +1 -0
- package/dist/registry-bundle.js +74 -0
- package/dist/registry-bundle.js.map +1 -0
- package/dist/registry-items.d.ts +41 -13
- package/dist/registry-items.d.ts.map +1 -1
- package/dist/registry-items.js +117 -33
- package/dist/registry-items.js.map +1 -1
- package/package.json +1 -1
- package/src/args.ts +25 -0
- package/src/bin.ts +124 -22
- package/src/commands/add.ts +39 -12
- package/src/commands/create-flow.ts +119 -29
- package/src/commands/create.ts +82 -14
- package/src/commands/doctor.ts +156 -47
- package/src/commands/migrate-check.ts +51 -10
- package/src/commands/migrate.ts +2 -8
- package/src/commands/sync-messages.ts +114 -0
- package/src/commands/sync-scaffold.ts +83 -0
- package/src/commands/sync.ts +574 -0
- package/src/commands/upgrade-check.ts +4 -1
- package/src/env-files.ts +28 -7
- package/src/manifest.ts +57 -3
- package/src/module-exports.ts +266 -0
- package/src/registry-bundle.ts +103 -0
- package/src/registry-items.ts +141 -36
- package/templates/admin-page/admin-page.tsx.tpl +96 -24
- package/templates/app-scaffold/gitignore.tpl +25 -0
- package/templates/app-scaffold/intelligo.ts.tpl +6 -47
- package/templates/app-scaffold/next.config.mjs.tpl +31 -2
- package/templates/app-scaffold/package.json.tpl +2 -2
- package/templates/manifest.json +40 -3
- package/templates/pnpm-standalone/npmrc.tpl +6 -0
- package/templates/pnpm-standalone/pnpm-workspace.yaml.tpl +10 -0
- package/templates/registry/ai-agent-activity.json +23 -0
- package/templates/registry/ai-agent-progress.json +21 -0
- package/templates/registry/ai-approval-card.json +27 -0
- package/templates/registry/ai-artifact.json +22 -0
- package/templates/registry/ai-branch.json +21 -0
- package/templates/registry/ai-citations.json +24 -0
- package/templates/registry/ai-code-block.json +23 -0
- package/templates/registry/ai-composer-menu.json +19 -0
- package/templates/registry/ai-file-diff.json +25 -0
- package/templates/registry/ai-image-generation.json +23 -0
- package/templates/registry/ai-markdown.json +23 -0
- package/templates/registry/ai-message-bubble.json +23 -0
- package/templates/registry/ai-message-scroller.json +22 -0
- package/templates/registry/ai-message.json +21 -0
- package/templates/registry/ai-motion.json +19 -0
- package/templates/registry/ai-prompt-input.json +28 -0
- package/templates/registry/ai-reasoning-text.json +22 -0
- package/templates/registry/ai-reasoning.json +22 -0
- package/templates/registry/ai-shimmer-text.json +19 -0
- package/templates/registry/ai-sidebar.json +25 -0
- package/templates/registry/ai-speech-input.json +21 -0
- package/templates/registry/ai-streaming-response.json +24 -0
- package/templates/registry/ai-suggestion.json +19 -0
- package/templates/registry/ai-todo-list.json +22 -0
- package/templates/registry/ai-tool-approval.json +26 -0
- package/templates/registry/ai-tool-result.json +25 -0
- package/templates/registry/alert-dialog.json +23 -0
- package/templates/registry/animated-list.json +21 -0
- package/templates/registry/app-shell.json +93 -0
- package/templates/registry/artifacts.json +82 -0
- package/templates/registry/attachment.json +22 -0
- package/templates/registry/auth-email-verification.json +39 -0
- package/templates/registry/auth-login.json +72 -0
- package/templates/registry/auth-password-reset.json +53 -0
- package/templates/registry/auth-signup.json +41 -0
- package/templates/registry/billing-settings.json +66 -0
- package/templates/registry/button.json +22 -0
- package/templates/registry/chat-eve.json +19 -0
- package/templates/registry/chat-panel.json +30 -0
- package/templates/registry/chat-share.json +42 -0
- package/templates/registry/chat-widget.json +34 -0
- package/templates/registry/chat.json +326 -0
- package/templates/registry/checkbox.json +22 -0
- package/templates/registry/checkout.json +40 -0
- package/templates/registry/collapsible.json +19 -0
- package/templates/registry/command.json +23 -0
- package/templates/registry/copy-button.json +21 -0
- package/templates/registry/dashboard.json +69 -0
- package/templates/registry/dialog.json +24 -0
- package/templates/registry/document-viewer.json +22 -0
- package/templates/registry/dropdown-menu.json +23 -0
- package/templates/registry/expandable-tabs.json +22 -0
- package/templates/registry/feature-gating.json +73 -0
- package/templates/registry/file-upload.json +24 -0
- package/templates/registry/hold-action-button.json +22 -0
- package/templates/registry/input-group.json +23 -0
- package/templates/registry/input.json +19 -0
- package/templates/registry/intelligo.json +187 -0
- package/templates/registry/invitation-accept.json +64 -0
- package/templates/registry/language-switcher.json +29 -0
- package/templates/registry/morphing-modal.json +24 -0
- package/templates/registry/notification-stack.json +24 -0
- package/templates/registry/notifications.json +87 -0
- package/templates/registry/onboarding.json +83 -0
- package/templates/registry/otp-input.json +22 -0
- package/templates/registry/page-header.json +15 -0
- package/templates/registry/payment-poll.json +58 -0
- package/templates/registry/popover-morph.json +22 -0
- package/templates/registry/popover.json +22 -0
- package/templates/registry/pricing.json +117 -0
- package/templates/registry/privacy-settings.json +65 -0
- package/templates/registry/profile-settings.json +68 -0
- package/templates/registry/progress.json +19 -0
- package/templates/registry/radio-group.json +22 -0
- package/templates/registry/registry.json +2960 -0
- package/templates/registry/route-error.json +65 -0
- package/templates/registry/select-morph.json +23 -0
- package/templates/registry/select.json +23 -0
- package/templates/registry/settings-shell.json +47 -0
- package/templates/registry/sheet.json +24 -0
- package/templates/registry/sidebar.json +28 -0
- package/templates/registry/smoke.json +34 -0
- package/templates/registry/spinner.json +16 -0
- package/templates/registry/stat-card.json +18 -0
- package/templates/registry/status-badge.json +18 -0
- package/templates/registry/switch.json +20 -0
- package/templates/registry/tabs.json +23 -0
- package/templates/registry/team-settings.json +97 -0
- package/templates/registry/textarea.json +16 -0
- package/templates/registry/tooltip.json +22 -0
- package/templates/registry/trial-banner.json +43 -0
- package/templates/registry/usage.json +84 -0
- package/templates/registry/workspace-settings.json +71 -0
- package/templates/registry-items.json +3 -3
- package/templates/registry-requires.json +30 -4
- package/templates/vitest/server-only.ts.tpl +7 -0
- package/templates/vitest/vitest.config.ts.tpl +37 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "chat",
|
|
4
|
+
"title": "Chat",
|
|
5
|
+
"description": "A ChatGPT-level AI chat surface: a streaming thread with edit, regenerate and response versions, attachments, a model picker, reasoning, sources, tool cards and human-in-the-loop approvals, agent activity for Mastra and eve turns, a canvas beside the chat for documents a tool streams, a sidebar with pinned and dated groups, share links and feedback — on a two-line Route Handler that binds @intelligo-dev/chat's transport (auth, rate limit, feature gate, persistence, the execution boundary) to consumer-owned config (lib/chat-server-config.ts). Every tool renders through one entry in lib/chat-renderers.tsx, inline and in the canvas. Ships with a deterministic stub model so a clean install streams with no API keys. The config imports lib/intelligo.ts (the composition root) and lib/plans.ts, which are provided by the `intelligo create` scaffold, not by any registry item - in a hand-rolled app, create both before installing (see the CLI's app-scaffold templates).",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"ai@^7.0.103",
|
|
8
|
+
"@ai-sdk/react@^4.0.106",
|
|
9
|
+
"streamdown",
|
|
10
|
+
"lucide-react",
|
|
11
|
+
"sonner",
|
|
12
|
+
"server-only",
|
|
13
|
+
"@intelligo-dev/auth",
|
|
14
|
+
"@intelligo-dev/billing",
|
|
15
|
+
"@intelligo-dev/chat",
|
|
16
|
+
"@intelligo-dev/core",
|
|
17
|
+
"@intelligo-dev/next",
|
|
18
|
+
"next-intl",
|
|
19
|
+
"next-themes",
|
|
20
|
+
"prosemirror-state",
|
|
21
|
+
"prosemirror-view",
|
|
22
|
+
"prosemirror-markdown",
|
|
23
|
+
"prosemirror-example-setup",
|
|
24
|
+
"prosemirror-inputrules",
|
|
25
|
+
"codemirror",
|
|
26
|
+
"@codemirror/state",
|
|
27
|
+
"@codemirror/view",
|
|
28
|
+
"@codemirror/lang-javascript",
|
|
29
|
+
"@codemirror/lang-python",
|
|
30
|
+
"@codemirror/theme-one-dark",
|
|
31
|
+
"react-data-grid@7.0.0-beta.61",
|
|
32
|
+
"papaparse",
|
|
33
|
+
"@types/papaparse",
|
|
34
|
+
"motion"
|
|
35
|
+
],
|
|
36
|
+
"registryDependencies": [
|
|
37
|
+
"alert",
|
|
38
|
+
"@intelligo/alert-dialog",
|
|
39
|
+
"@intelligo/attachment",
|
|
40
|
+
"@intelligo/button",
|
|
41
|
+
"@intelligo/dialog",
|
|
42
|
+
"@intelligo/dropdown-menu",
|
|
43
|
+
"@intelligo/input",
|
|
44
|
+
"item",
|
|
45
|
+
"@intelligo/sheet",
|
|
46
|
+
"skeleton",
|
|
47
|
+
"@intelligo/spinner",
|
|
48
|
+
"@intelligo/textarea",
|
|
49
|
+
"@intelligo/tooltip",
|
|
50
|
+
"@intelligo/ai-agent-activity",
|
|
51
|
+
"@intelligo/ai-approval-card",
|
|
52
|
+
"@intelligo/ai-artifact",
|
|
53
|
+
"@intelligo/ai-branch",
|
|
54
|
+
"@intelligo/ai-citations",
|
|
55
|
+
"@intelligo/ai-code-block",
|
|
56
|
+
"@intelligo/ai-composer-menu",
|
|
57
|
+
"@intelligo/ai-markdown",
|
|
58
|
+
"@intelligo/ai-message",
|
|
59
|
+
"@intelligo/ai-message-bubble",
|
|
60
|
+
"@intelligo/ai-message-scroller",
|
|
61
|
+
"@intelligo/ai-prompt-input",
|
|
62
|
+
"@intelligo/ai-reasoning",
|
|
63
|
+
"@intelligo/ai-shimmer-text",
|
|
64
|
+
"@intelligo/ai-speech-input",
|
|
65
|
+
"@intelligo/ai-suggestion",
|
|
66
|
+
"@intelligo/ai-todo-list",
|
|
67
|
+
"@intelligo/ai-tool-approval",
|
|
68
|
+
"@intelligo/ai-tool-result",
|
|
69
|
+
"@intelligo/copy-button",
|
|
70
|
+
"@intelligo/document-viewer",
|
|
71
|
+
"@intelligo/status-badge",
|
|
72
|
+
"@intelligo/ai-motion",
|
|
73
|
+
"@intelligo/ai-sidebar",
|
|
74
|
+
"@intelligo/ai-file-diff",
|
|
75
|
+
"@intelligo/ai-image-generation",
|
|
76
|
+
"@intelligo/ai-reasoning-text",
|
|
77
|
+
"@intelligo/ai-streaming-response",
|
|
78
|
+
"@intelligo/ai-agent-progress"
|
|
79
|
+
],
|
|
80
|
+
"files": [
|
|
81
|
+
{
|
|
82
|
+
"path": "base/chat/page.tsx",
|
|
83
|
+
"content": "import { getLocale } from \"next-intl/server\";\n\nimport { redirect } from \"@/i18n/navigation\";\n\n/**\n * `/chat` landing — redirects into a freshly-minted conversation id.\n *\n * No `conversations` row is created here. The row is created lazily\n * by `app/api/chat/route.ts` on the first POST for this id, so\n * visiting `/chat` and then never sending a message leaves nothing\n * behind (conversations are a persistence concern the route\n * owns, not a page-load side effect).\n */\nexport default async function ChatIndexPage() {\n const locale = await getLocale();\n redirect({ href: `/chat/${crypto.randomUUID()}`, locale });\n}\n",
|
|
84
|
+
"type": "registry:page",
|
|
85
|
+
"target": "app/[locale]/(app)/chat/page.tsx"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"path": "base/chat/conversation-page.tsx",
|
|
89
|
+
"content": "import type { Metadata } from \"next\";\nimport { getTranslations } from \"next-intl/server\";\n\nimport { requireWorkspace } from \"@intelligo-dev/auth\";\n\nimport { ChatWorkspace } from \"@/components/chat/chat-workspace\";\nimport { ConversationHeader } from \"@/components/chat/conversation-header\";\nimport { loadConversationForChat } from \"@/actions/chat\";\nimport { getChatModelOptions } from \"@/lib/chat-models\";\nimport { getChatQuotaState, getChatQuotaStates } from \"@/lib/chat-quota\";\n\nexport async function generateMetadata(): Promise<Metadata> {\n const t = await getTranslations(\"chat\");\n return { title: t(\"conversationPage.title\") };\n}\n\n/**\n * `dynamic = \"force-dynamic\"`: `loadConversationForChat` reads\n * request-scoped session and workspace state (`requireWorkspace()`,\n * inside `@/actions/chat`), so this page can only ever render\n * per-request. History is the shell's (`ChatHistory` in the sidebar).\n */\nexport const dynamic = \"force-dynamic\";\n\ninterface ConversationPageProps {\n params: Promise<{ id: string; locale: string }>;\n}\n\nexport default async function ConversationPage({\n params,\n}: ConversationPageProps) {\n const { id } = await params;\n const t = await getTranslations(\"chat\");\n const { workspace } = await requireWorkspace();\n\n // In parallel: the quota read is an estimate that holds no credit,\n // so it cannot slow down or interfere with loading the conversation.\n // One estimate per offered model, because the pick lives in the\n // browser and each model has its own worst case.\n const modelOptions = getChatModelOptions(workspace.id);\n const [conversationResult, quotaState, quotaStates, models] =\n await Promise.all([\n loadConversationForChat(id),\n getChatQuotaState(),\n modelOptions.then((options) =>\n getChatQuotaStates(options.map((option) => option.id))\n ),\n modelOptions,\n ]);\n\n if (!conversationResult.success) {\n return (\n <div className=\"flex h-full flex-col items-center justify-center gap-2 p-8 text-center\">\n <h1 className=\"text-lg font-semibold\">\n {t(\"conversationPage.unavailableTitle\")}\n </h1>\n <p className=\"text-sm text-muted-foreground\">\n {conversationResult.error}\n </p>\n </div>\n );\n }\n\n const { conversation, messages, votes } = conversationResult.data;\n\n return (\n <div className=\"flex h-full min-h-0 flex-col\">\n <ConversationHeader\n conversationId={id}\n title={conversation?.title ?? null}\n />\n <ChatWorkspace\n conversationId={id}\n initialMessages={messages}\n quotaState={quotaState}\n quotaStates={quotaStates}\n votes={votes}\n models={models}\n />\n </div>\n );\n}\n",
|
|
90
|
+
"type": "registry:page",
|
|
91
|
+
"target": "app/[locale]/(app)/chat/[id]/page.tsx"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"path": "base/chat/conversation-loading.tsx",
|
|
95
|
+
"content": "import { Skeleton } from \"@/components/ui/skeleton\";\n\nexport default function ConversationLoading() {\n return (\n <div className=\"flex h-full flex-col\">\n <div className=\"flex-1 space-y-6 px-4 py-6\">\n <div className=\"mx-auto flex max-w-3xl flex-col gap-6\">\n <Skeleton className=\"ml-auto h-10 w-2/3 rounded-xl\" />\n <Skeleton className=\"h-24 w-3/4 rounded-lg\" />\n <Skeleton className=\"ml-auto h-10 w-1/2 rounded-xl\" />\n </div>\n </div>\n </div>\n );\n}\n",
|
|
96
|
+
"type": "registry:page",
|
|
97
|
+
"target": "app/[locale]/(app)/chat/[id]/loading.tsx"
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"path": "base/chat/conversation-error.tsx",
|
|
101
|
+
"content": "\"use client\";\n\n/**\n * Error boundary for a chat conversation. Renders the shared `RouteError` from the\n * `route-error` item — install it alongside this one.\n */\n\nimport { RouteError } from \"@/components/shared/route-error\";\n\nexport default function SegmentError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n return (\n <RouteError\n error={error}\n reset={reset}\n scope=\"chat-conversation\"\n fallback=\"conversation\"\n homeHref=\"/chat\"\n homeKey=\"goToChat\"\n />\n );\n}\n",
|
|
102
|
+
"type": "registry:page",
|
|
103
|
+
"target": "app/[locale]/(app)/chat/[id]/error.tsx"
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"path": "base/chat/route.ts",
|
|
107
|
+
"content": "/**\n * The chat Route Handler — two lines, on purpose.\n *\n * Every decision a turn involves (auth, the plan's rate limit, the\n * feature gate, conversation persistence, entitlement at\n * `executions.begin()`, streaming, settlement) lives in\n * `@intelligo-dev/chat`, and everything this deployment decides about\n * it — model, agent, tools, prompt, attachments, title, telemetry,\n * copy — lives in `@/lib/chat-server-config`, consumer-owned source you\n * edit instead of this file.\n *\n * `maxDuration` is the one thing that belongs here: it is a Next.js\n * route segment option, and a streamed reply with tools can outlast\n * the platform default.\n */\n\nimport { createChatHandler } from \"@intelligo-dev/chat\";\n\nimport { chatServerConfig } from \"@/lib/chat-server-config\";\n\nexport const maxDuration = 300;\n\nexport const { POST, DELETE } = createChatHandler(chatServerConfig);\n",
|
|
108
|
+
"type": "registry:file",
|
|
109
|
+
"target": "app/api/chat/route.ts"
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"path": "base/chat/actions.ts",
|
|
113
|
+
"content": "\"use server\";\n\n/**\n * Chat conversation actions — thin transport over\n * `@intelligo-dev/core/conversations`: resolve the caller's actor\n * via `requireWorkspace()`, call the core service, map any\n * `ConversationServiceError` to a friendly message, and reshape the\n * result for the page and its client components. Sending a message and\n * streaming a reply is NOT here — that is `app/api/chat/route.ts`,\n * because streaming needs a Route Handler, not a Server Action.\n *\n * `loadConversationForChat` is the one read that treats `not_found` as\n * success rather than failure: `app/[locale]/(app)/chat/page.tsx`\n * redirects to `/chat/<uuid>` without creating a row (see its doc\n * comment), so the very first render of a brand-new conversation\n * legitimately has no `conversations` row yet — the route handler\n * creates it on the first POST. Every other action here treats\n * `not_found`/`forbidden` as a genuine failure.\n */\n\nimport { getTranslations } from \"next-intl/server\";\nimport { revalidatePath } from \"next/cache\";\n\nimport { requireWorkspace } from \"@intelligo-dev/auth\";\nimport { recordChatFeedback, toUIMessages } from \"@intelligo-dev/chat\";\nimport {\n getDocumentVersions,\n isDocumentServiceError,\n saveDocument,\n} from \"@intelligo-dev/core/documents\";\nimport {\n deleteConversation as deleteConversationRow,\n getConversation,\n getMessages,\n getVotes,\n isConversationServiceError,\n listConversations,\n renameConversation as renameConversationRow,\n setConversationVisibility,\n updateConversationMetadata,\n} from \"@intelligo-dev/core/conversations\";\nimport type { ActionResult } from \"@intelligo-dev/next\";\n\nimport { chatServerConfig } from \"@/lib/chat-server-config\";\n\nexport type ChatActionResult<T> = ActionResult<T>;\n\ntype Translator = Awaited<ReturnType<typeof getTranslations>>;\n\nfunction friendlyMessageKey(code: string): string | undefined {\n switch (code) {\n case \"not_found\":\n return \"actions.conversationNotFound\";\n case \"forbidden\":\n return \"actions.conversationForbidden\";\n case \"invalid_input\":\n return \"actions.invalidTitle\";\n case \"database_error\":\n return \"actions.databaseError\";\n default:\n return undefined;\n }\n}\n\nfunction friendlyError(t: Translator, error: unknown): string {\n // Unknown errors deliberately map to the generic key — a raw\n // `Error#message` can carry internals (SQL, hostnames) to the UI.\n if (isConversationServiceError(error) || isDocumentServiceError(error)) {\n const key = friendlyMessageKey(error.code);\n return key ? t(key) : t(\"actions.genericError\");\n }\n return t(\"actions.genericError\");\n}\n\nasync function actor() {\n const { workspace, user } = await requireWorkspace();\n return { workspaceId: workspace.id, userId: user.id };\n}\n\nexport type ConversationSummary = {\n id: string;\n title: string | null;\n updatedAt: string;\n pinned: boolean;\n};\n\n/** Recent conversations for the sidebar, most recently updated first. */\nexport async function listConversationHistory(): Promise<\n ChatActionResult<ConversationSummary[]>\n> {\n try {\n // Enough to fill a sidebar and be worth searching. The cap is\n // deliberate: past a few hundred, filtering belongs in a query,\n // not in the browser (see `components/chat/chat-history-nav.tsx`).\n const rows = await listConversations(await actor(), { limit: 100 });\n return {\n success: true,\n data: rows.map((row) => ({\n id: row.id,\n title: row.title,\n updatedAt: row.updatedAt.toISOString(),\n pinned: (row.metadata as { pinned?: unknown } | null)?.pinned === true,\n })),\n };\n } catch (error) {\n return {\n success: false,\n error: friendlyError(await getTranslations(\"chat\"), error),\n };\n }\n}\n\nexport type LoadedConversation = {\n conversation: { id: string; title: string | null } | null;\n messages: ReturnType<typeof toUIMessages>;\n /** The reader's votes, by message id. */\n votes: Record<string, \"up\" | \"down\">;\n};\n\n/**\n * Loads a conversation and its messages for the thread's initial\n * state. A conversation id with no row yet resolves as a brand-new,\n * empty chat rather than an error — see the module doc comment above.\n */\nexport async function loadConversationForChat(\n id: string\n): Promise<ChatActionResult<LoadedConversation>> {\n try {\n const scoped = await actor();\n\n try {\n const conversation = await getConversation(scoped, id);\n const [rows, voteRows] = await Promise.all([\n getMessages(scoped, id),\n getVotes(scoped, id),\n ]);\n const votes: Record<string, \"up\" | \"down\"> = {};\n for (const vote of voteRows) {\n votes[vote.messageId] = vote.isUpvoted ? \"up\" : \"down\";\n }\n return {\n success: true,\n data: {\n conversation: { id: conversation.id, title: conversation.title },\n // Stored parts are a JSON string; a corrupt row costs one\n // message, not the conversation.\n messages: toUIMessages(rows),\n votes,\n },\n };\n } catch (error) {\n if (isConversationServiceError(error) && error.code === \"not_found\") {\n return {\n success: true,\n data: { conversation: null, messages: [], votes: {} },\n };\n }\n throw error;\n }\n } catch (error) {\n return {\n success: false,\n error: friendlyError(await getTranslations(\"chat\"), error),\n };\n }\n}\n\nexport async function renameConversation(\n id: string,\n title: string\n): Promise<ChatActionResult<{ title: string | null }>> {\n try {\n const updated = await renameConversationRow(await actor(), id, title);\n return { success: true, data: { title: updated.title } };\n } catch (error) {\n return {\n success: false,\n error: friendlyError(await getTranslations(\"chat\"), error),\n };\n }\n}\n\nexport async function deleteConversation(\n id: string\n): Promise<ChatActionResult<undefined>> {\n try {\n await deleteConversationRow(await actor(), id);\n return { success: true, data: undefined };\n } catch (error) {\n return {\n success: false,\n error: friendlyError(await getTranslations(\"chat\"), error),\n };\n }\n}\n\n/** Pin a conversation to the top of the sidebar, or unpin it. */\nexport async function setConversationPinned(\n id: string,\n pinned: boolean\n): Promise<ChatActionResult<undefined>> {\n try {\n await updateConversationMetadata(await actor(), id, { pinned });\n return { success: true, data: undefined };\n } catch (error) {\n return {\n success: false,\n error: friendlyError(await getTranslations(\"chat\"), error),\n };\n }\n}\n\n/** The reader's verdict on a reply; `null` withdraws it. */\nexport async function voteMessage(\n conversationId: string,\n messageId: string,\n vote: \"up\" | \"down\" | null\n): Promise<ChatActionResult<undefined>> {\n const t = await getTranslations(\"chat\");\n try {\n const result = await recordChatFeedback(chatServerConfig, await actor(), {\n conversationId,\n messageId,\n vote,\n });\n if (!result.ok) {\n return {\n success: false,\n error: t(\n result.code === \"not_found\"\n ? \"actions.conversationNotFound\"\n : \"actions.feedbackFailed\"\n ),\n };\n }\n return { success: true, data: undefined };\n } catch (error) {\n return { success: false, error: friendlyError(t, error) };\n }\n}\n\n/** Whether the conversation is published at `/share/<id>`. */\nexport async function getShareState(\n id: string\n): Promise<ChatActionResult<{ shared: boolean }>> {\n try {\n const row = await getConversation(await actor(), id);\n return { success: true, data: { shared: row.visibility === \"public\" } };\n } catch (error) {\n return {\n success: false,\n error: friendlyError(await getTranslations(\"chat\"), error),\n };\n }\n}\n\nexport async function setConversationShared(\n id: string,\n shared: boolean\n): Promise<ChatActionResult<{ shared: boolean }>> {\n try {\n const row = await setConversationVisibility(\n await actor(),\n id,\n shared ? \"public\" : \"private\"\n );\n revalidatePath(`/share/${id}`);\n return { success: true, data: { shared: row.visibility === \"public\" } };\n } catch (error) {\n return {\n success: false,\n error: friendlyError(await getTranslations(\"chat\"), error),\n };\n }\n}\n\n/**\n * Saves one assistant reply as a document artifact, so a useful answer\n * doesn't only live in a conversation. This is the manual counterpart\n * to a `saveArtifact` tool: same destination (`@intelligo-dev/core`'s\n * document persistence), same `/artifacts` page, just driven\n * by the reader instead of the model.\n *\n * The message id becomes the document id, which makes saving the same\n * reply twice a new *version* of one artifact rather than a duplicate.\n */\nexport async function saveMessageAsArtifact(params: {\n messageId: string;\n content: string;\n title?: string;\n /** Lets the artifacts page link back to where this came from. */\n conversationId?: string;\n}): Promise<ChatActionResult<{ id: string; title: string }>> {\n const t = await getTranslations(\"chat\");\n\n const content = params.content.trim();\n if (!content) {\n return { success: false, error: t(\"actions.emptyArtifact\") };\n }\n\n const title =\n params.title?.trim() ||\n deriveArtifactTitle(content) ||\n t(\"artifactCard.untitled\");\n\n try {\n const saved = await saveDocument(await actor(), {\n id: params.messageId,\n title,\n content,\n kind: \"text\",\n ...(params.conversationId\n ? { conversationId: params.conversationId }\n : {}),\n });\n revalidatePath(\"/artifacts\");\n return { success: true, data: { id: saved.id, title: saved.title } };\n } catch (error) {\n return { success: false, error: friendlyError(t, error) };\n }\n}\n\n/** Every version of a document the canvas shows, newest first. */\nexport async function listArtifactVersions(\n documentId: string\n): Promise<ChatActionResult<Array<{ createdAt: string; content: string }>>> {\n try {\n const versions = await getDocumentVersions(await actor(), documentId);\n return {\n success: true,\n data: versions.map((version) => ({\n createdAt: version.createdAt,\n content: version.content ?? \"\",\n })),\n };\n } catch (error) {\n return {\n success: false,\n error: friendlyError(await getTranslations(\"chat\"), error),\n };\n }\n}\n\n/** An edit in the canvas becomes a new version of the document. */\nexport async function saveArtifactVersion(params: {\n documentId: string;\n title: string;\n kind: string;\n content: string;\n}): Promise<ChatActionResult<{ id: string }>> {\n const t = await getTranslations(\"chat\");\n try {\n const saved = await saveDocument(await actor(), {\n id: params.documentId,\n title: params.title,\n content: params.content,\n kind: params.kind,\n });\n revalidatePath(\"/artifacts\");\n return { success: true, data: { id: saved.id } };\n } catch (error) {\n return { success: false, error: friendlyError(t, error) };\n }\n}\n\nconst MAX_ARTIFACT_TITLE = 60;\n\n/** First line of the reply, trimmed to something list-sized; \"\" when there is none. */\nfunction deriveArtifactTitle(content: string): string {\n const line =\n content\n .split(\"\\n\")\n .map((value) => value.replace(/^#+\\s*/, \"\").trim())\n .find(Boolean) ?? \"\";\n if (!line) return \"\";\n return line.length <= MAX_ARTIFACT_TITLE\n ? line\n : `${line.slice(0, MAX_ARTIFACT_TITLE - 1).trimEnd()}…`;\n}\n",
|
|
114
|
+
"type": "registry:file",
|
|
115
|
+
"target": "actions/chat.ts"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"path": "base/chat/lib/chat-model.ts",
|
|
119
|
+
"content": "import \"server-only\";\n\n/**\n * Consumer-owned model resolution for the chat transport.\n *\n * A fresh install has no AI provider keys, so `getChatModel` returns\n * the framework's deterministic stub (`@intelligo-dev/chat/testing`): it\n * echoes the last user message back through a real token-by-token\n * stream. No network call, no API key, and the app still builds,\n * boots and streams a real `createUIMessageStream` response end to end.\n *\n * `CHAT_MODEL_ID` is deliberately a real, registered id\n * (`@intelligo-dev/executions/pricing`) even though the stub never calls\n * that provider: the execution boundary bills whatever id the transport\n * settles with, and an id with no registered price is\n * refused at admission. Using a real id here means a clean install\n * exercises the *correct* pricing path — swap in your own model id the\n * moment you swap in a real provider below, and keep it one that is\n * registered.\n *\n * To use a real provider: install its AI SDK package (e.g.\n * `pnpm add @ai-sdk/anthropic` in this app) and replace the body of\n * `getChatModel` — the commented example below is the whole change.\n *\n * Two strings are in play and they are not interchangeable. The\n * registered id (`anthropic/claude-sonnet-4-6`) is what a turn is\n * admitted and billed under. The provider's own id is often dated\n * (`claude-sonnet-4-6-20260214`) and lives in the registry entry's\n * `model` field. Read it from there; never derive it by trimming the\n * prefix off the registered id, and never write it out a second time.\n */\n\nimport type { LanguageModel } from \"ai\";\nimport { createStubLanguageModel } from \"@intelligo-dev/chat/testing\";\nimport { cookies } from \"next/headers\";\nimport { getTranslations } from \"next-intl/server\";\n\nimport { routing } from \"@/i18n/routing\";\n\n/** Must be a registered model id in `@intelligo-dev/executions/pricing`. */\nexport const CHAT_MODEL_ID = \"google/gemini-2.5-flash\";\n\n/**\n * The stub replies in the caller's locale. This file lives outside the\n * `[locale]` segment, so the locale comes from the `NEXT_LOCALE` cookie\n * next-intl's middleware sets on every page navigation.\n */\nasync function resolveLocale(): Promise<string> {\n const cookieName =\n typeof routing.localeCookie === \"object\"\n ? (routing.localeCookie.name ?? \"NEXT_LOCALE\")\n : \"NEXT_LOCALE\";\n const value = (await cookies()).get(cookieName)?.value;\n const locales: readonly string[] = routing.locales;\n return value && locales.includes(value) ? value : routing.defaultLocale;\n}\n\n/**\n * Resolves the language model for a chat turn. `modelId` is unused by\n * the stub (it only ever returns one model). A real implementation\n * looks the id up in the registry, picks the SDK by the entry's\n * `provider`, and hands that SDK the entry's `model`:\n *\n * import { anthropic } from \"@ai-sdk/anthropic\";\n * import { google } from \"@ai-sdk/google\";\n * import { getModelPricing } from \"@intelligo-dev/executions/pricing\";\n *\n * export function getChatModel(modelId: string): LanguageModel {\n * const entry = getModelPricing(modelId);\n * if (!entry) throw new Error(`Model \"${modelId}\" is not registered.`);\n * switch (entry.provider) {\n * case \"anthropic\":\n * return anthropic(entry.model);\n * case \"google\":\n * return google(entry.model);\n * default:\n * throw new Error(`No AI SDK provider is bound for \"${entry.provider}\".`);\n * }\n * }\n *\n * One `case` per provider whose package this app installed. The\n * transport only calls this with an id admission already priced, so\n * the first `throw` is for callers outside the chat route. Set\n * `CHAT_MODEL_ID` above to the registered id turns run on by default;\n * a model registered with `registerModel` resolves the same way.\n */\nexport function getChatModel(modelId: string): LanguageModel {\n void modelId;\n return createStubLanguageModel({\n modelId: CHAT_MODEL_ID,\n reply: async (userText) => {\n const t = await getTranslations({\n locale: await resolveLocale(),\n namespace: \"chat\",\n });\n return `${t(\"stub.preface\")}\\n\\n${t(\"stub.youSaid\")} \"${userText}\"`;\n },\n });\n}\n",
|
|
120
|
+
"type": "registry:file",
|
|
121
|
+
"target": "lib/chat-model.ts"
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"path": "base/chat/lib/chat-models.ts",
|
|
125
|
+
"content": "import \"server-only\";\n\n/**\n * The models the composer offers — consumer-owned.\n *\n * Empty (the default) means the picker is hidden and every turn runs\n * on `lib/chat-model.ts`'s default. List two or more and the composer\n * shows a picker; the transport refuses anything not on the list, and\n * a model with a `featureKey` only to plans that have it. Every id\n * must be registered in `@intelligo-dev/executions/pricing` — an\n * architecture test holds this file to that.\n *\n * export const CHAT_MODELS: ChatModelOption[] = [\n * { id: \"google/gemini-2.5-flash\", label: \"Fast\" },\n * { id: \"anthropic/claude-sonnet-4-6\", label: \"Smart\", featureKey: PRO_MODELS },\n * ];\n *\n * Bind the same list in `lib/chat-server-config.ts` (`models`), which\n * is what makes the server's answer match the composer's offer.\n */\n\nimport { hasFeature } from \"@intelligo-dev/billing\";\nimport type { ChatModelOption } from \"@intelligo-dev/chat/client\";\n\nexport type { ChatModelOption };\n\nexport const CHAT_MODELS: ChatModelOption[] = [];\n\n/** The models this workspace may pick from — the list, minus what its plan lacks. */\nexport async function getChatModelOptions(\n workspaceId: string\n): Promise<ChatModelOption[]> {\n const allowed = await Promise.all(\n CHAT_MODELS.map(async (model) =>\n model.featureKey ? hasFeature(workspaceId, model.featureKey) : true\n )\n );\n return CHAT_MODELS.filter((_, index) => allowed[index]);\n}\n",
|
|
126
|
+
"type": "registry:file",
|
|
127
|
+
"target": "lib/chat-models.ts"
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"path": "base/chat/lib/chat-renderers.tsx",
|
|
131
|
+
"content": "\"use client\";\n\n/**\n * How a tool, and any runtime's data part, shows up in the chat.\n *\n * This is the extension point a product uses most. Adding a tool to\n * the agent is one entry in `TOOL_RENDERERS` below — a plain object\n * literal in source you own; there is no `register()` call, nothing\n * runs as an import side effect, and a product package's\n * card compiles against the structural props here rather than against\n * the AI SDK's types.\n *\n * A tool call is either a row in the activity stream — the one-line\n * \"Searched the web ▸\" the agent's work folds into — or its own card.\n * A tool with no entry is a row. An entry is a component (a card), or\n * `{ component?, label?, activity?, sources?, canvas? }`:\n *\n * - `component` draws the call as its own card, in every state the\n * SDK has (`input-streaming` → `output-available`, and the approval\n * states of a gated tool). Without one the call is a row.\n * - `label` is a message key naming the call in its row and in the\n * stream's status line, in place of the tool's raw name\n * (\"Generating report…\").\n * - `activity` builds the call's row when the default is not enough.\n * The default reads the input's first string as the target, and a\n * call whose input has a `query` renders as a search, its results\n * from `sources`.\n * - `sources` reads the sources the call's output carries, which\n * become the answer's citations. The default reads `output.sources`\n * (`{ url, title?, domain?, snippet?, index? }[]`).\n * - `canvas` says the tool's output also lives in the side panel\n * (`lib/chat-canvas-config.tsx` decides how a `kind` renders). A\n * document the tool streams with `createArtifactWriter` opens the\n * canvas by itself; a card's Open reopens it.\n *\n * `actions` is what a card can do back to the conversation: send the\n * next user turn (a quiz option, a suggested reply), answer a tool\n * that runs client-side, approve or deny a gated call, open or close\n * the canvas.\n *\n * `DATA_RENDERERS` is the same seam for `data-*` parts — what a\n * Mastra workflow, an eve subagent or a product's own `turn.write`\n * emits. The framework's `data-chat-*` parts have renderers in\n * `components/chat/data-parts.tsx`; Mastra's arrive under their own\n * names and render on the activity timeline.\n */\n\nimport type { ComponentType } from \"react\";\nimport type { FileUIPart } from \"ai\";\nimport { useTranslations } from \"next-intl\";\n\nimport { ArtifactCard } from \"@/components/chat/artifact-card\";\nimport type {\n AgentActivitySearch,\n AgentActivityTool,\n} from \"@/components/ui/ai-agent-activity\";\nimport { FileDiff, type FileDiffLine } from \"@/components/ui/ai-file-diff\";\nimport {\n ImageGeneration,\n type ImageGenerationStatus,\n} from \"@/components/ui/ai-image-generation\";\nimport {\n ToolResult,\n ToolResultOutput,\n type ToolResultStatus,\n} from \"@/components/ui/ai-tool-result\";\nimport type { SourceItem } from \"@/lib/message-parts\";\n\nimport {\n ChatAgentCard,\n ChatArtifactCard,\n ChatAuthorizationCard,\n ChatQuestionCard,\n ChatTaskCard,\n} from \"@/components/chat/data-parts\";\nimport {\n MastraNetworkActivity,\n MastraToolAgentActivity,\n MastraToolAgentStepActivity,\n MastraWorkflowActivity,\n MastraWorkflowStepActivity,\n} from \"@/components/chat/agent-activity\";\n\n/**\n * Mirrors the AI SDK's `ToolUIPart`/`DynamicToolUIPart` state union\n * structurally rather than importing it, so a product package does\n * not pin itself to the SDK's exact tool generics. The three\n * `approval-*`/`output-denied` states only appear for tools using the\n * SDK's human-in-the-loop approval flow — a plain tool never produces\n * them.\n */\nexport type ToolPartState =\n | \"input-streaming\"\n | \"input-available\"\n | \"approval-requested\"\n | \"approval-responded\"\n | \"output-available\"\n | \"output-error\"\n | \"output-denied\";\n\n/** What opens in the canvas. */\nexport type CanvasRef = {\n /** Stable across streamed updates — the artifact part's id or the document id. */\n id: string;\n kind: string;\n title: string;\n documentId?: string;\n /** Content already in hand, so the panel can open without a fetch. */\n content?: string;\n status?: \"streaming\" | \"ready\" | \"error\";\n};\n\n/** What a renderer can do back to the conversation. */\nexport interface ToolRendererActions {\n /** Send the next user turn — a picked option, a suggested reply. */\n sendMessage: (\n message: string | { text: string; files?: FileUIPart[] }\n ) => void;\n /** Answer a tool the client executes. */\n addToolResult: (args: {\n tool: string;\n toolCallId: string;\n output: unknown;\n }) => void;\n /** Approve or deny a gated call; the SDK continues the turn. */\n addToolApprovalResponse: (args: {\n id: string;\n approved: boolean;\n reason?: string;\n }) => void;\n openCanvas: (ref: CanvasRef) => void;\n closeCanvas: () => void;\n}\n\nexport interface ToolRendererProps {\n toolName: string;\n state: ToolPartState;\n input?: unknown;\n output?: unknown;\n errorText?: string;\n /** The AI SDK's id for this call — required by `addToolResult`. */\n toolCallId?: string;\n /** Set while the call awaits or received an approval. */\n approvalId?: string;\n messageId: string;\n /** The message this part belongs to is still streaming. */\n isStreaming: boolean;\n /** A read-only surface — the shared page. Cards hide their controls. */\n isReadonly: boolean;\n actions?: ToolRendererActions;\n}\n\n/** A call's row in the activity stream; the stream assigns the id. */\nexport type ToolActivityRow =\n Omit<AgentActivitySearch, \"id\"> | Omit<AgentActivityTool, \"id\">;\n\nexport interface ToolRenderer {\n /** Draws the call as its own card. Without one the call is a row in the activity stream. */\n component?: ComponentType<ToolRendererProps>;\n /** A message key (namespace-less, like `chatConfig.starters`) naming the call. */\n label?: string;\n /** The call's row, when the default row is not enough. `null` keeps the default. */\n activity?: (props: ToolRendererProps) => ToolActivityRow | null;\n /** The sources the call's output carries. Default: `output.sources`. */\n sources?: (output: unknown) => SourceItem[];\n /**\n * The tool's output also lives in the canvas, as this kind, when its\n * result does not say. A document the tool streams through\n * `createArtifactWriter` opens the canvas by itself.\n */\n canvas?: { kind: string };\n}\n\nexport interface DataRendererProps {\n /** The part name without the `data-` prefix. */\n name: string;\n id?: string;\n data: unknown;\n messageId: string;\n isStreaming: boolean;\n isReadonly: boolean;\n actions?: ToolRendererActions;\n}\n\n/**\n * Tool name → how its call renders.\n *\n * Ships with one entry: `saveArtifact`, the convention this catalogue\n * uses for \"the assistant produced a document.\" Its card links into\n * the `artifacts` page and opens the document in the canvas.\n *\n * Add your own the same way:\n *\n * import { WeatherCard } from \"@/components/chat/tools/weather-card\";\n *\n * export const TOOL_RENDERERS: Record<string, ToolRenderer | ComponentType<ToolRendererProps>> = {\n * saveArtifact: { component: ArtifactLinkCard, canvas: { kind: \"text\" } },\n * applyPatch: FileDiffCard, // any tool that returns a diff\n * createImage: ImageGenerationCard, // any tool that returns an image\n * getWeather: WeatherCard,\n * lookupInvoice: { label: \"invoices.lookingUp\" },\n * generateReport: { component: ReportCard, label: \"reports.generating\", canvas: { kind: \"text\" } },\n * };\n */\nexport const TOOL_RENDERERS: Record<\n string,\n ToolRenderer | ComponentType<ToolRendererProps>\n> = {\n saveArtifact: { component: ArtifactLinkCard, canvas: { kind: \"text\" } },\n editFile: FileDiffCard,\n generateImage: ImageGenerationCard,\n};\n\n/**\n * `data-*` part name (without the prefix) → its renderer. The\n * framework's own parts and Mastra's are shipped; a product adds the\n * parts its tools write with `turn.write`.\n */\nexport const DATA_RENDERERS: Record<\n string,\n ComponentType<DataRendererProps>\n> = {\n \"chat-task\": ChatTaskCard,\n \"chat-agent\": ChatAgentCard,\n \"chat-artifact\": ChatArtifactCard,\n \"chat-question\": ChatQuestionCard,\n \"chat-authorization\": ChatAuthorizationCard,\n workflow: MastraWorkflowActivity,\n \"workflow-step\": MastraWorkflowStepActivity,\n network: MastraNetworkActivity,\n \"tool-agent\": MastraToolAgentActivity,\n \"tool-agent-step\": MastraToolAgentStepActivity,\n};\n\nexport function resolveToolRenderer(\n toolName: string\n): ToolRenderer & { component: ComponentType<ToolRendererProps> } {\n const entry = TOOL_RENDERERS[toolName];\n if (!entry) return { component: DefaultToolCard };\n if (typeof entry === \"function\") return { component: entry };\n return { ...entry, component: entry.component ?? DefaultToolCard };\n}\n\n/** Whether a tool has an entry of its own. */\nexport function hasToolRenderer(toolName: string): boolean {\n return toolName in TOOL_RENDERERS;\n}\n\n/** Whether a tool's call draws its own card rather than a row in the activity stream. */\nexport function hasToolCard(toolName: string): boolean {\n const entry = TOOL_RENDERERS[toolName];\n if (!entry) return false;\n return typeof entry === \"function\" || Boolean(entry.component);\n}\n\n/** The component a tool's call renders with. */\nexport function getToolRenderer(\n toolName: string\n): ComponentType<ToolRendererProps> {\n return resolveToolRenderer(toolName).component;\n}\n\nexport function getDataRenderer(\n name: string\n): ComponentType<DataRendererProps> | null {\n return DATA_RENDERERS[name] ?? null;\n}\n\n/** Maps a tool part's state to its `chat.toolCard.*` message key. */\nconst STATE_MESSAGE_KEY: Record<ToolPartState, string> = {\n \"input-streaming\": \"toolCard.calling\",\n \"input-available\": \"toolCard.running\",\n \"approval-requested\": \"toolCard.needsApproval\",\n \"approval-responded\": \"toolCard.approved\",\n \"output-available\": \"toolCard.done\",\n \"output-error\": \"toolCard.error\",\n \"output-denied\": \"toolCard.denied\",\n};\n\nconst TOOL_RESULT_STATUS: Record<ToolPartState, ToolResultStatus> = {\n \"input-streaming\": \"running\",\n \"input-available\": \"running\",\n \"approval-requested\": \"running\",\n \"approval-responded\": \"running\",\n \"output-available\": \"success\",\n \"output-error\": \"error\",\n \"output-denied\": \"cancelled\",\n};\n\n/** `webSearch` → \"Web search\". */\nexport function toolLabel(toolName: string): string {\n const spaced = toolName\n .replace(/[_-]+/g, \" \")\n .replace(/([a-z0-9])([A-Z])/g, \"$1 $2\");\n return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();\n}\n\n/**\n * The call with its raw input and output. The activity stream shows\n * this under a row the reader opens; a card that has nothing better to\n * show yet can draw it too.\n */\nexport function DefaultToolCard({\n toolName,\n state,\n input,\n output,\n errorText,\n}: ToolRendererProps) {\n const t = useTranslations(\"chat\");\n const status = TOOL_RESULT_STATUS[state];\n const outputText =\n errorText ??\n (output === undefined\n ? undefined\n : typeof output === \"string\"\n ? output\n : JSON.stringify(output, null, 2));\n\n return (\n <ToolResult\n className=\"max-w-xl\"\n tool={toolName}\n title={toolLabel(toolName)}\n status={status}\n kind=\"custom\"\n statusLabels={{\n running: t(STATE_MESSAGE_KEY[state]),\n success: t(\"toolCard.done\"),\n error: t(\"toolCard.error\"),\n cancelled: t(\"toolCard.denied\"),\n }}\n copyLabel={t(\"actions.copy\")}\n copiedLabel={t(\"actions.copied\")}\n copyText={outputText}\n >\n {input !== undefined ? (\n <div className=\"grid gap-1 px-3 pt-2\">\n <span className=\"text-xs font-medium text-muted-foreground\">\n {t(\"toolCard.input\")}\n </span>\n <ToolResultOutput language=\"json\">\n {typeof input === \"string\" ? input : JSON.stringify(input, null, 2)}\n </ToolResultOutput>\n </div>\n ) : null}\n {outputText !== undefined ? (\n <div className=\"grid gap-1 px-3 py-2\">\n <span className=\"text-xs font-medium text-muted-foreground\">\n {errorText ? t(\"toolCard.errorHeading\") : t(\"toolCard.output\")}\n </span>\n <ToolResultOutput language={errorText ? \"text\" : \"json\"}>\n {outputText}\n </ToolResultOutput>\n </div>\n ) : null}\n </ToolResult>\n );\n}\n\nfunction stringField(record: Record<string, unknown> | null, key: string) {\n const value = record?.[key];\n return typeof value === \"string\" ? value : undefined;\n}\n\n/**\n * Renderer for a tool that produces a document. While the model writes\n * the call, the card shows the title and the last lines of the content\n * as they arrive; once the tool returns a `title` (and optionally a\n * `documentId`, `kind` and `content`) the whole card opens it. A\n * finished call with no title falls back to the generic card, so a tool\n * whose shape drifts renders honestly rather than blank.\n *\n * Opening lands in the canvas when the page has one, and on the\n * document's preview on the `/artifacts` page otherwise.\n */\nexport function ArtifactLinkCard(props: ToolRendererProps) {\n const {\n toolName,\n state,\n input,\n output,\n errorText,\n messageId,\n isReadonly,\n actions,\n } = props;\n const t = useTranslations(\"chat\");\n\n const result =\n output && typeof output === \"object\"\n ? (output as Record<string, unknown>)\n : null;\n const draft =\n input && typeof input === \"object\"\n ? (input as Record<string, unknown>)\n : null;\n const title = stringField(result, \"title\") ?? stringField(draft, \"title\");\n\n if (state === \"output-available\" && !title)\n return <DefaultToolCard {...props} />;\n\n const kind =\n stringField(result, \"kind\") ??\n stringField(draft, \"kind\") ??\n resolveToolRenderer(toolName).canvas?.kind ??\n \"text\";\n const status =\n state === \"output-error\" || state === \"output-denied\"\n ? \"error\"\n : state === \"output-available\"\n ? \"ready\"\n : \"streaming\";\n const documentId =\n stringField(result, \"documentId\") ?? stringField(result, \"id\");\n const content = stringField(result, \"content\");\n const ref: CanvasRef = {\n id: documentId ?? messageId,\n kind,\n title: title ?? \"\",\n ...(documentId ? { documentId } : {}),\n ...(content !== undefined ? { content } : {}),\n status: \"ready\",\n };\n const canOpen = status === \"ready\" && !isReadonly;\n\n return (\n <ArtifactCard\n title={title || t(\"artifactCard.untitled\")}\n kind={kind}\n status={status}\n error={errorText}\n preview={stringField(draft, \"content\")}\n onOpen={canOpen && actions ? () => actions.openCanvas(ref) : undefined}\n href={\n canOpen && !actions && documentId\n ? `/artifacts?document=${encodeURIComponent(documentId)}`\n : undefined\n }\n />\n );\n}\n\nfunction recordOf(value: unknown): Record<string, unknown> | null {\n return value && typeof value === \"object\"\n ? (value as Record<string, unknown>)\n : null;\n}\n\n/** A unified diff (`@@ -1,3 +1,4 @@` hunks) as the diff view's lines. */\nexport function parseUnifiedDiff(patch: string): FileDiffLine[] {\n const lines: FileDiffLine[] = [];\n let oldLine = 0;\n let newLine = 0;\n for (const raw of patch.split(\"\\n\")) {\n const hunk = raw.match(/^@@ -(\\d+)(?:,\\d+)? \\+(\\d+)(?:,\\d+)? @@/);\n if (hunk) {\n oldLine = Number(hunk[1]);\n newLine = Number(hunk[2]);\n continue;\n }\n if (raw.startsWith(\"+++\") || raw.startsWith(\"---\")) continue;\n if (raw.startsWith(\"diff \") || raw.startsWith(\"index \")) continue;\n const id = `${lines.length}`;\n if (raw.startsWith(\"+\")) {\n lines.push({\n id,\n type: \"added\",\n newLine: newLine++,\n content: raw.slice(1),\n });\n } else if (raw.startsWith(\"-\")) {\n lines.push({\n id,\n type: \"removed\",\n oldLine: oldLine++,\n content: raw.slice(1),\n });\n } else if (raw.startsWith(\" \") || (raw === \"\" && lines.length > 0)) {\n lines.push({\n id,\n type: \"context\",\n oldLine: oldLine++,\n newLine: newLine++,\n content: raw.slice(1),\n });\n }\n }\n return lines;\n}\n\n/**\n * A tool that edits a file, drawn as the diff it applied: added and\n * removed lines stream in while the call runs, then the card settles.\n * It reads `path` (or `file`) and either `lines` (the diff view's own\n * shape) or `diff`/`patch` (a unified diff) from the output, falling\n * back to the input while the call is still running. Anything else\n * falls back to the generic card.\n */\nexport function FileDiffCard(props: ToolRendererProps) {\n const { state, input, output } = props;\n const t = useTranslations(\"chat\");\n const result = recordOf(output) ?? recordOf(input);\n const file =\n stringField(result, \"path\") ??\n stringField(result, \"file\") ??\n stringField(recordOf(input), \"path\");\n const patch = stringField(result, \"diff\") ?? stringField(result, \"patch\");\n const given = result?.lines;\n const lines = Array.isArray(given)\n ? (given as FileDiffLine[])\n : patch\n ? parseUnifiedDiff(patch)\n : null;\n\n if (!file || !lines || state === \"output-error\" || state === \"output-denied\")\n return <DefaultToolCard {...props} />;\n\n return (\n <FileDiff\n className=\"max-w-2xl\"\n file={file}\n lines={lines}\n status={state === \"output-available\" ? \"complete\" : \"streaming\"}\n copyText={patch}\n statusLabels={{\n streaming: t(\"fileDiff.applying\"),\n complete: t(\"fileDiff.applied\"),\n }}\n copyLabel={t(\"actions.copy\")}\n copiedLabel={t(\"actions.copied\")}\n changesLabel={t(\"fileDiff.changes\")}\n />\n );\n}\n\nconst IMAGE_STATUS: Record<ToolPartState, ImageGenerationStatus> = {\n \"input-streaming\": \"queued\",\n \"input-available\": \"generating\",\n \"approval-requested\": \"queued\",\n \"approval-responded\": \"generating\",\n \"output-available\": \"complete\",\n \"output-error\": \"error\",\n \"output-denied\": \"error\",\n};\n\n/**\n * A tool that makes an image: a shimmering frame while it works, the\n * image resolving out of a blur when it lands. It reads `url` (or\n * `imageUrl`, `image`, or the first of `images`) and an optional `alt`\n * from the output, and the `prompt` and `aspectRatio` from the input.\n */\nexport function ImageGenerationCard(props: ToolRendererProps) {\n const { state, input, output } = props;\n const t = useTranslations(\"chat\");\n const request = recordOf(input);\n const result = recordOf(output);\n const first = Array.isArray(result?.images)\n ? recordOf((result.images as unknown[])[0])\n : null;\n const url =\n stringField(result, \"url\") ??\n stringField(result, \"imageUrl\") ??\n stringField(result, \"image\") ??\n stringField(first, \"url\");\n const status = IMAGE_STATUS[state];\n const prompt = stringField(request, \"prompt\");\n\n if (status === \"complete\" && !url) return <DefaultToolCard {...props} />;\n\n return (\n <ImageGeneration\n className=\"max-w-md\"\n status={status}\n prompt={prompt}\n aspectRatio={stringField(request, \"aspectRatio\")?.replace(\":\", \" / \")}\n statusLabels={{\n queued: t(\"imageGeneration.queued\"),\n generating: t(\"imageGeneration.generating\"),\n refining: t(\"imageGeneration.refining\"),\n complete: t(\"imageGeneration.complete\"),\n error: t(\"imageGeneration.error\"),\n }}\n >\n {url ? (\n <img\n src={url}\n alt={stringField(result, \"alt\") ?? prompt ?? \"\"}\n className=\"size-full object-cover\"\n />\n ) : null}\n </ImageGeneration>\n );\n}\n",
|
|
132
|
+
"type": "registry:file",
|
|
133
|
+
"target": "lib/chat-renderers.tsx"
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"path": "base/chat/lib/chat-canvas-config.tsx",
|
|
137
|
+
"content": "\"use client\";\n\n/**\n * Canvas kinds — how a document opened beside the chat is shown and\n * edited, by `kind`. Consumer-owned, a plain object literal.\n *\n * A kind is the content component plus, optionally, its glyph and name\n * (on the transcript's card and the panel's header), a rendered\n * `preview` beside the source for content that has one, the extension\n * a download gets, actions in the panel's header (run, publish) and\n * toolbar items that send a message about the document (\"Polish the\n * wording\"). The framework ships `text` (ProseMirror over markdown),\n * `code` (CodeMirror, with a live preview for HTML and SVG), `sheet`\n * (a CSV grid) and `image`; a product adds its own — a `report` kind\n * that renders its JSON as a designed page, a `diagram` kind on a graph\n * library — by adding an entry here:\n *\n * import { ReportCanvas } from \"@/components/reports/report-canvas\";\n *\n * export const CANVAS_KINDS: Record<string, CanvasKind> = {\n * ...DEFAULT_CANVAS_KINDS,\n * report: { content: ReportCanvas, icon: ChartIcon, labelKey: \"reports.kind\" },\n * };\n *\n * The editors load lazily: a document that is still streaming, or one\n * the reader may not edit, renders on the light viewers below and\n * never pulls the editor bundle. The chat's `saveArtifact` tool and\n * `createArtifactWriter` name a kind; the canvas looks it up here and\n * falls back to `text` for one it does not know, so nothing renders\n * blank.\n */\n\nimport { Suspense, lazy, type ComponentType, type ReactNode } from \"react\";\nimport { type LucideIcon } from \"lucide-react\";\n\nimport {\n CodeView,\n HtmlPreview,\n ImageView,\n SheetView,\n TextView,\n documentKindIcon,\n extensionOf,\n fileNameOf as fileNameFor,\n isPreviewableTitle,\n languageOf,\n} from \"@/components/ui/document-viewer\";\nimport { Spinner } from \"@/components/ui/spinner\";\n\nexport interface CanvasContentProps {\n content: string;\n /** Streaming in, or settled. */\n status: \"streaming\" | \"ready\" | \"error\";\n /** The document title, for a kind that shows it. */\n title: string;\n /** Read-only: the shared page, an older version, a stream in progress. */\n isReadonly: boolean;\n /** The reader edited the document; the canvas keeps the draft until saved. */\n onChange?: (content: string) => void;\n}\n\nexport interface CanvasActionContext {\n content: string;\n title: string;\n documentId?: string;\n}\n\nexport interface CanvasAction {\n /** Accessible name and tooltip — a message key, resolved by the canvas. */\n labelKey: string;\n icon: LucideIcon;\n onClick: (context: CanvasActionContext) => void | Promise<void>;\n}\n\nexport interface CanvasToolbarItem {\n /** A message key for the button's text. */\n labelKey: string;\n /** The message sent to the chat when clicked. */\n message: string;\n}\n\nexport interface CanvasKind {\n content: ComponentType<CanvasContentProps>;\n /** The kind's glyph on its card and in the panel's header. */\n icon?: LucideIcon;\n /** A message key naming the kind — \"Document\", \"Code\". */\n labelKey?: string;\n /**\n * A rendered view of the document, offered beside its source when\n * `previewable` says this document has one.\n */\n preview?: ComponentType<CanvasContentProps>;\n previewable?: (title: string) => boolean;\n /** The extension a download gets when the title has none. */\n extension?: string;\n actions?: CanvasAction[];\n toolbar?: CanvasToolbarItem[];\n}\n\nconst TextEditor = lazy(() => import(\"@/components/chat/canvas/text-editor\"));\nconst CodeEditor = lazy(() => import(\"@/components/chat/canvas/code-editor\"));\nconst SheetEditor = lazy(() => import(\"@/components/chat/canvas/sheet-editor\"));\n\nfunction Loading() {\n return (\n <div className=\"flex h-24 items-center justify-center text-muted-foreground\">\n <Spinner />\n </div>\n );\n}\n\n/**\n * The viewers below are the shared ones\n * (`@/components/ui/document-viewer`), so a document reads the same\n * here as it does in the artifacts library. What stays here is the\n * editing: which kinds have an editor, and when it is allowed to load.\n */\n\n/** Editable once settled; a rendered view while it streams or is read-only. */\nfunction TextCanvas(props: CanvasContentProps) {\n if (props.isReadonly || props.status === \"streaming\" || !props.onChange) {\n return (\n <TextView\n content={props.content}\n title={props.title}\n streaming={props.status === \"streaming\"}\n />\n );\n }\n return (\n <Suspense fallback={<Loading />}>\n <TextEditor {...props} />\n </Suspense>\n );\n}\n\nfunction CodeCanvas(props: CanvasContentProps) {\n if (props.isReadonly || props.status === \"streaming\" || !props.onChange) {\n return <CodeView content={props.content} title={props.title} />;\n }\n return (\n <Suspense fallback={<Loading />}>\n <CodeEditor {...props} />\n </Suspense>\n );\n}\n\nfunction CodePreview({ content, title }: CanvasContentProps) {\n return <HtmlPreview content={content} title={title} />;\n}\n\nfunction SheetCanvas(props: CanvasContentProps) {\n if (props.status === \"streaming\") {\n return <SheetView content={props.content} title={props.title} />;\n }\n return (\n <Suspense fallback={<Loading />}>\n <SheetEditor {...props} />\n </Suspense>\n );\n}\n\n/** A generated image: the content is a data URL or an https URL. */\nfunction ImageCanvas({ content, title }: CanvasContentProps): ReactNode {\n if (!content) return null;\n return <ImageView content={content} title={title} />;\n}\n\n/**\n * Re-exported, not redefined: the canvas and the transcript's artifact\n * card ask this module for them, and a kind config is where a reader\n * looks for \"how is this document named and highlighted\".\n */\nexport { extensionOf, languageOf };\n\n/** The name a downloaded document gets: its title, with an extension. */\nexport function fileNameOf(title: string, kind: CanvasKind): string {\n return fileNameFor(title, kind.extension);\n}\n\nexport const DEFAULT_CANVAS_KINDS: Record<string, CanvasKind> = {\n text: {\n content: TextCanvas,\n icon: documentKindIcon(\"text\"),\n labelKey: \"chat.canvas.kinds.text\",\n extension: \"md\",\n },\n code: {\n content: CodeCanvas,\n icon: documentKindIcon(\"code\"),\n labelKey: \"chat.canvas.kinds.code\",\n preview: CodePreview,\n previewable: isPreviewableTitle,\n extension: \"txt\",\n },\n sheet: {\n content: SheetCanvas,\n icon: documentKindIcon(\"sheet\"),\n labelKey: \"chat.canvas.kinds.sheet\",\n extension: \"csv\",\n },\n image: {\n content: ImageCanvas,\n icon: documentKindIcon(\"image\"),\n labelKey: \"chat.canvas.kinds.image\",\n extension: \"png\",\n },\n};\n\nexport const CANVAS_KINDS: Record<string, CanvasKind> = {\n ...DEFAULT_CANVAS_KINDS,\n};\n\nexport function resolveCanvasKind(kind: string): CanvasKind {\n return CANVAS_KINDS[kind] ?? CANVAS_KINDS.text ?? DEFAULT_CANVAS_KINDS.text!;\n}\n",
|
|
138
|
+
"type": "registry:file",
|
|
139
|
+
"target": "lib/chat-canvas-config.tsx"
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"path": "base/chat/lib/message-parts.ts",
|
|
143
|
+
"content": "/**\n * How one message's parts are laid out, as plain data — no React, so\n * the rules are pinned by tests.\n *\n * `groupParts` folds a run of the agent's own work — reasoning and the\n * tool calls between its words — into one activity segment, the way a\n * reader follows it: \"Searched the web ▸\", then the answer. A tool\n * that draws its own card (a document, a gated call awaiting the\n * reader) breaks the run and stands on its own. `step-start` and empty\n * text are boundaries the SDK keeps; they never break a run.\n *\n * `collectSources` gathers what the answer leans on — the AI SDK's\n * `source-url`/`source-document` parts and the sources a tool returned\n * — numbered the way `[n]` markers in the text refer to them.\n */\n\nexport type PartLike = { type: string };\n\nexport type PartAt<P> = { index: number; part: P };\n\nexport type MessageSegment<P> =\n | { kind: \"part\"; index: number; part: P }\n | { kind: \"activity\"; key: string; parts: PartAt<P>[] };\n\nconst SETTLED_TOOL_STATES = new Set([\n \"output-available\",\n \"output-error\",\n \"output-denied\",\n]);\n\nexport function isToolPart(part: PartLike): boolean {\n return part.type.startsWith(\"tool-\") || part.type === \"dynamic-tool\";\n}\n\nexport function isReasoningPart(part: PartLike): boolean {\n return part.type === \"reasoning\";\n}\n\nfunction isBoundary(part: PartLike): boolean {\n if (part.type === \"step-start\") return true;\n return part.type === \"text\" && !(part as { text?: string }).text;\n}\n\n/**\n * Ordered segments. `isCard(part)` says a tool part draws its own card\n * instead of a row in the activity stream.\n */\nexport function groupParts<P extends PartLike>(\n parts: readonly P[],\n isCard: (part: P) => boolean\n): MessageSegment<P>[] {\n const segments: MessageSegment<P>[] = [];\n let run: PartAt<P>[] | null = null;\n\n parts.forEach((part, index) => {\n if (isBoundary(part)) return;\n const joins =\n (isReasoningPart(part) && Boolean((part as { text?: string }).text)) ||\n (isToolPart(part) && !isCard(part));\n if (joins) {\n if (!run) {\n run = [];\n segments.push({\n kind: \"activity\",\n key: `activity-${index}`,\n parts: run,\n });\n }\n run.push({ index, part });\n return;\n }\n if (isReasoningPart(part)) return;\n run = null;\n segments.push({ kind: \"part\", index, part });\n });\n\n return segments;\n}\n\n/**\n * An activity segment is working while its message streams and it is\n * either the newest thing in the message or still waiting on a call.\n */\nexport function isActivityWorking<P extends PartLike>(\n segment: Extract<MessageSegment<P>, { kind: \"activity\" }>,\n isLastSegment: boolean,\n isStreaming: boolean\n): boolean {\n if (!isStreaming) return false;\n if (isLastSegment) return true;\n return segment.parts.some(\n ({ part }) =>\n isToolPart(part) &&\n !SETTLED_TOOL_STATES.has((part as { state?: string }).state ?? \"\")\n );\n}\n\nexport function isSettledToolState(state: string | undefined): boolean {\n return SETTLED_TOOL_STATES.has(state ?? \"\");\n}\n\nexport type SourceItem = {\n url?: string;\n title?: string;\n /** Where the source lives, when the url does not say (a redirect). */\n domain?: string;\n snippet?: string;\n /** The `[n]` the model was told to cite this by, when the tool numbered it. */\n index?: number;\n};\n\nexport type NumberedSource = SourceItem & { id: string; index: number };\n\n/**\n * The sources a tool output carries by convention: `{ sources: [{ url,\n * title?, domain?, snippet?, index? }] }`. Anything else yields none.\n */\nexport function sourcesFromToolOutput(output: unknown): SourceItem[] {\n if (!output || typeof output !== \"object\") return [];\n const raw = (output as { sources?: unknown }).sources;\n if (!Array.isArray(raw)) return [];\n return raw.flatMap((entry): SourceItem[] => {\n if (!entry || typeof entry !== \"object\") return [];\n const record = entry as Record<string, unknown>;\n const url = typeof record.url === \"string\" ? record.url : undefined;\n const title = typeof record.title === \"string\" ? record.title : undefined;\n if (!url && !title) return [];\n return [\n {\n ...(url ? { url } : {}),\n ...(title ? { title } : {}),\n ...(typeof record.domain === \"string\" ? { domain: record.domain } : {}),\n ...(typeof record.snippet === \"string\"\n ? { snippet: record.snippet }\n : {}),\n ...(typeof record.index === \"number\" && Number.isInteger(record.index)\n ? { index: record.index }\n : {}),\n },\n ];\n });\n}\n\n/**\n * Numbered sources for one message. A source the tool numbered keeps\n * its number (the model cites by it); the rest take the next free one\n * in part order. A url already numbered is not listed twice.\n */\nexport function collectSources<P extends PartLike>(\n parts: readonly P[],\n sourcesOf: (part: P) => SourceItem[]\n): NumberedSource[] {\n const byIndex = new Map<number, NumberedSource>();\n const seenUrls = new Set<string>();\n const unnumbered: SourceItem[] = [];\n\n for (const part of parts) {\n for (const source of sourcesOf(part)) {\n if (source.index !== undefined && source.index >= 1) {\n if (byIndex.has(source.index)) continue;\n byIndex.set(source.index, {\n ...source,\n id: String(source.index),\n index: source.index,\n });\n if (source.url) seenUrls.add(source.url);\n } else {\n unnumbered.push(source);\n }\n }\n }\n\n let next = 1;\n for (const source of unnumbered) {\n if (source.url) {\n if (seenUrls.has(source.url)) continue;\n seenUrls.add(source.url);\n }\n while (byIndex.has(next)) next += 1;\n byIndex.set(next, { ...source, id: String(next), index: next });\n }\n\n return [...byIndex.values()].sort((a, b) => a.index - b.index);\n}\n\n/** The AI SDK's own source parts, as source items. */\nexport function sourcesFromSourcePart(part: PartLike): SourceItem[] {\n if (part.type !== \"source-url\" && part.type !== \"source-document\") return [];\n const source = part as { url?: string; title?: string; filename?: string };\n const title = source.title ?? source.filename;\n if (!source.url && !title) return [];\n return [\n {\n ...(source.url ? { url: source.url } : {}),\n ...(title ? { title } : {}),\n },\n ];\n}\n\n/** The host a source lives on, without `www.`. */\nexport function hostnameOf(url: string | undefined): string | undefined {\n if (!url) return undefined;\n try {\n return new URL(url).hostname.replace(/^www\\./, \"\");\n } catch {\n return undefined;\n }\n}\n\n/** A source's display domain: what it says, else its url's host. */\nexport function sourceDomain(source: SourceItem): string | undefined {\n return source.domain ?? hostnameOf(source.url);\n}\n\nconst CITE_PREFIX = \"#cite-\";\n\n/**\n * `[3]`, `[3][4]` and `[3, 4]` in the text become one link each run —\n * `[3](#cite-3)`, `[3,4](#cite-3,4)` — which the markdown anchor\n * override renders as a citation pill. Only numbers the message has a\n * source for; a markdown link (`[3](…)`) is left alone.\n */\nexport function linkCitations(\n text: string,\n known: ReadonlySet<number>\n): string {\n if (known.size === 0) return text;\n return text.replace(/(?:\\[\\d{1,3}(?:\\s*,\\s*\\d{1,3})*\\])+(?!\\()/g, (run) => {\n const numbers = [...run.matchAll(/\\d{1,3}/g)]\n .map((match) => Number(match[0]))\n .filter((n) => known.has(n));\n if (numbers.length === 0) return run;\n const unique = [...new Set(numbers)];\n return `[${unique.join(\",\")}](${CITE_PREFIX}${unique.join(\",\")})`;\n });\n}\n\n/**\n * A readable name for a page that came with none — search grounding\n * often gives only the domain: the last meaningful path segment,\n * de-slugged. `…/blog-posts/node-js-end-of-life-dates` → \"Node js end\n * of life dates\". Undefined for a site's front page.\n */\nexport function titleFromUrl(url: string | undefined): string | undefined {\n if (!url) return undefined;\n let segments: string[];\n try {\n segments = new URL(url).pathname.split(\"/\").filter(Boolean);\n } catch {\n return undefined;\n }\n for (const segment of segments.reverse()) {\n const words = decodeURIComponent(segment)\n .replace(/\\.(html?|php|aspx?)$/i, \"\")\n .replace(/[-_+]+/g, \" \")\n .trim();\n // Ids, hashes and dates name nothing a reader recognises.\n if (\n words.length < 4 ||\n !/[a-z]{3}/i.test(words) ||\n /^[\\d\\s]+$/.test(words)\n ) {\n continue;\n }\n return words.charAt(0).toUpperCase() + words.slice(1);\n }\n return undefined;\n}\n\n/** The source numbers a citation link points at, or null for any other link. */\nexport function parseCitationHref(href: string | undefined): number[] | null {\n if (!href?.startsWith(CITE_PREFIX)) return null;\n const numbers = href\n .slice(CITE_PREFIX.length)\n .split(\",\")\n .map(Number)\n .filter((n) => Number.isInteger(n) && n >= 1);\n return numbers.length > 0 ? numbers : null;\n}\n\n/** The first string in a tool's input — the query, the path, the name. */\nexport function primaryInput(input: unknown): string | undefined {\n if (typeof input === \"string\") return input || undefined;\n if (!input || typeof input !== \"object\") return undefined;\n for (const value of Object.values(input as Record<string, unknown>)) {\n if (typeof value === \"string\" && value.trim()) return value;\n }\n return undefined;\n}\n",
|
|
144
|
+
"type": "registry:file",
|
|
145
|
+
"target": "lib/message-parts.ts"
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"path": "base/chat/lib/chat-config.tsx",
|
|
149
|
+
"content": "/**\n * Chat composition config — the consumer-owned extension point for\n * everything a product adds to the chat surface without editing an\n * installed component.\n *\n * Every seam is optional — a fresh install ships this file with an\n * empty `chatConfig`, so the chat surface renders its baseline UI:\n *\n * - `agent`: the identity shown in the conversation header — a\n * name and optional icon/emoji. Omit it (the default) and the\n * header falls back to this item's own translated \"Assistant\"\n * label (`messages/en/chat.json`'s `agent.defaultName`). `name`\n * and `icon` are plain strings rather than message keys because\n * an agent's display name is product copy, not framework copy.\n * - `starters`: conversation-starter prompts shown on an empty\n * conversation, as *message keys* — not literal strings — resolved\n * against your app's full message tree via next-intl's\n * namespace-less `useTranslations()`. E.g. `\"support.starters.refund\"`\n * resolves `t(\"support.starters.refund\")` from your own\n * `messages/<locale>/support.json`. Keeping this to keys is what\n * keeps starters translatable without editing `chat-thread.tsx`.\n * - `headerRight`: a component the header renders on its right side,\n * next to the History/New/Delete controls — a progress indicator,\n * an export button reading product state for `conversationId`.\n * - `attachments`: what the composer lets the reader attach. Unset,\n * the \"+\" control is hidden. Mirror the server's policy in\n * `lib/chat-server-config.ts` (`attachments.accept`, `maxBytes`,\n * `mode`): the composer keeps the reader from picking a file the\n * route would refuse, and in `stored` mode uploads it first.\n * - `commands`: slash commands the composer offers when a message\n * starts with `/`. Each names a message key for its label and either\n * inserts text or runs a callback.\n * - `mentions`: an `@` picker — files, pages, knowledge bases — with a\n * search you bind; the pick lands in the message as `@label` and in\n * the turn's `body.mentions` for `resolveAgent`.\n * - `sendAutomaticallyWhen`: an auto-continuation predicate, passed to\n * `useChat`. Default: continue after tool results and after approval\n * answers, which is what a tool loop and a gated tool need.\n *\n * Edit this file directly to point at your product's own components —\n * this is consumer-owned source, not a package import:\n *\n * import { ExportReportButton } from \"@/components/support/export-report-button\";\n *\n * export const chatConfig: ChatConfig = {\n * agent: { id: \"support-assistant\", name: \"Support Assistant\", icon: \"🎧\" },\n * starters: [\"support.starters.refund\", \"support.starters.shipping\"],\n * headerRight: ExportReportButton,\n * attachments: { accept: [\"image/png\", \"image/jpeg\", \"application/pdf\"], maxBytes: 5_000_000 },\n * };\n */\n\nimport type { ComponentType } from \"react\";\nimport type { UIMessage } from \"ai\";\n\n/** Props passed to a consumer-bound `headerRight` component. */\nexport interface ChatHeaderRightProps {\n conversationId: string;\n}\n\nexport interface ChatAgentIdentity {\n /** Stable id for the agent this chat surface represents. */\n id?: string;\n /** Display name. Falls back to this item's translated default when omitted. */\n name?: string;\n /** A short icon or emoji shown beside the name. */\n icon?: string;\n}\n\nexport interface ChatAttachmentsConfig {\n /** Media types the composer accepts, e.g. `[\"image/png\", \"application/pdf\"]`. */\n accept: string[];\n maxFiles?: number;\n /** In bytes. */\n maxBytes?: number;\n /**\n * `inline` (default): files travel in the message as data URLs.\n * `stored`: files upload to `/api/chat/upload` first and the message\n * carries their app URL — bind a storage adapter and mount the two\n * routes (see `@intelligo-dev/chat`'s `createChatUploadHandler`).\n */\n mode?: \"inline\" | \"stored\";\n /** Where the composer uploads in `stored` mode. Default `/api/chat/upload`. */\n uploadUrl?: string;\n}\n\nexport interface ChatCommand {\n /** Typed after `/`, e.g. `\"summarize\"`. */\n id: string;\n /** Message key for the label shown in the picker. */\n labelKey: string;\n /** Message key for a one-line description. */\n descriptionKey?: string;\n /** Text to put in the composer in place of `/id`. */\n insert?: string;\n /** Or run something — send a message, open a dialog. */\n run?: (context: {\n conversationId: string;\n setText: (text: string) => void;\n send: (text: string) => void;\n }) => void;\n}\n\nexport interface ChatMention {\n id: string;\n label: string;\n description?: string;\n /** Grouped in the picker under this heading (a message key). */\n groupKey?: string;\n}\n\nexport interface ChatMentionsConfig {\n /** Default `@`. */\n trigger?: string;\n search: (query: string) => Promise<ChatMention[]> | ChatMention[];\n}\n\nexport interface ChatConfig {\n /** Identity shown in the conversation header. Omit for the default \"Assistant\" label. */\n agent?: ChatAgentIdentity;\n /** Conversation-starter message keys — see the module doc comment above. */\n starters?: string[];\n /** Rendered on the right side of the conversation header. */\n headerRight?: ComponentType<ChatHeaderRightProps>;\n /** What the composer lets the reader attach. Unset: no attachments. */\n attachments?: ChatAttachmentsConfig;\n /** Slash commands the composer offers. */\n commands?: ChatCommand[];\n /** The `@` picker. */\n mentions?: ChatMentionsConfig;\n /**\n * Auto-continuation predicate, passed straight to `useChat`'s\n * `sendAutomaticallyWhen`. Default: continue when the reader answered\n * an approval, or when a card supplied a client-side tool's result.\n * A turn that ends on a server-run tool (a card waiting for a click)\n * is not re-sent.\n */\n sendAutomaticallyWhen?: (options: {\n messages: UIMessage[];\n }) => boolean | PromiseLike<boolean>;\n}\n\n/**\n * Default chat configuration — a fresh install has no product identity,\n * starters, attachments or commands to add.\n */\nexport const chatConfig: ChatConfig = {};\n",
|
|
150
|
+
"type": "registry:file",
|
|
151
|
+
"target": "lib/chat-config.tsx"
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"path": "base/chat/lib/chat-server-config.ts",
|
|
155
|
+
"content": "import \"server-only\";\n\n/**\n * Server-side chat config — consumer-owned, read by\n * `app/api/chat/route.ts` through `createChatHandler`.\n *\n * Separate from `lib/chat-config.tsx` on purpose: that file is\n * client-facing (it binds React components — a header slot, starter\n * keys, an agent identity), and importing it from the Route Handler\n * would drag client modules into the server bundle. Everything the\n * transport needs and the browser must never see lives here instead.\n *\n * Two fields are required — the execution boundary and a way to turn\n * a model id into a model — because those are the two things the\n * framework must never guess. Everything else has a default that gives\n * a fresh install a working chat with no API keys: one agent, one\n * prompt, no tools, a forty-message window, a truncated first line as\n * the title.\n *\n * The seam that matters most is `agent.tools`. Without it the model\n * runs with no tools, which makes the tool-renderer seam in\n * `lib/chat-renderers.tsx` unreachable — you could register a renderer\n * but nothing would ever call a tool for it to render. Bind tools here\n * and the whole path works without editing a shipped file:\n *\n * import { tool } from \"ai\";\n * import { z } from \"zod\";\n *\n * agent: {\n * systemPrompt: \"…\",\n * tools: ({ workspaceId }) => ({\n * searchDocs: tool({\n * description: \"Search the workspace's documents\",\n * inputSchema: z.object({ query: z.string() }),\n * execute: async ({ query }) => search(workspaceId, query),\n * }),\n * }),\n * },\n *\n * `tools` may be a plain `ToolSet` or a function of the turn (workspace,\n * user, conversation) — the function form is what lets a tool close\n * over the caller's tenancy instead of taking it as a model argument\n * the model could get wrong.\n *\n * Past one agent, bind `resolveAgent` instead of `agent`: it receives\n * the turn (the transport's extra body fields such as `agentId`, the\n * existing conversation row) and returns the prompt, tools and model\n * for this turn. `prepareMessages` decides what the model is shown —\n * windowing is the default; a product that summarises pruned history\n * or injects profile context does it there. See `ChatServerConfig` in\n * `@intelligo-dev/chat` for every seam.\n *\n * How the model samples is `agent.generation` — temperature, a token\n * ceiling, a tool choice, a seed, a retry count:\n *\n * agent: {\n * systemPrompt: \"…\",\n * generation: { temperature: 0.2, maxOutputTokens: 1024 },\n * },\n *\n * Nothing is set below on purpose. A sampling default is a product\n * decision, not a framework one, so the transport ships none and passes\n * only what you write here. It is an allowlist: the options that decide\n * what the model writes go through, and the ones settlement depends on\n * — the abort signal, the finish handler, the model itself, the step\n * cap — stay the transport's and cannot be overridden from here.\n * `providerOptions` is the neighbouring seam for a provider's own knobs\n * (a thinking budget, say), passed through untouched.\n *\n * Another runtime than `streamText` — a Mastra agent, an eve session —\n * binds `streamTurn` and keeps everything else: auth, the rate limit,\n * the gate, admission, persistence and settlement stay the transport's\n * (the framework carries no helper for any AI framework; the\n * binding is yours, here). Mastra, natively, through `@mastra/ai-sdk`:\n *\n * import { handleChatStream } from \"@mastra/ai-sdk\";\n * import { mastra } from \"@/lib/mastra\";\n *\n * streamTurn: async (turn, prepared, { abortSignal }) => {\n * const stream = await handleChatStream({\n * mastra,\n * agentId: turn.agent.id,\n * version: \"v6\",\n * params: {\n * messages: prepared.messages,\n * memory: { thread: turn.conversationId, resource: turn.userId },\n * abortSignal,\n * },\n * });\n * // `usage`: settle from the agent's whole-run totals. Mastra reports\n * // them on its finish chunk; read them off the stream, or run\n * // `agent.stream()` yourself and resolve `result.totalUsage`.\n * return { stream, usage };\n * },\n *\n * eve: install the `chat-eve` item and bind `eveStreamTurn` from\n * `@/lib/chat-eve` — the session id and cursor live in the\n * conversation's metadata, approvals and questions round-trip as eve\n * input responses, and its events render as this chat's parts.\n *\n * A tool that produces a document streams it into the canvas with\n * `createArtifactWriter(turn, { kind, title })` from `@intelligo-dev/chat`\n * — `append` deltas, `finish({ documentId })` — and any tool writes a\n * status line or a plan with `turn.write({ type: \"data-chat-status\", … })`.\n *\n * i18n: the route lives at `app/api/chat/route.ts`, outside the\n * `[locale]` segment, so there is no URL segment to read a\n * locale from. `messages` below reads the `NEXT_LOCALE` cookie\n * next-intl's middleware already sets on every page navigation, then\n * falls back to the configured default locale — the same \"works with\n * nothing extra\" guarantee a single-locale deployment gets everywhere\n * else.\n */\n\nimport type { ChatMessages, ChatServerConfig } from \"@intelligo-dev/chat\";\nimport { getTranslations } from \"next-intl/server\";\n\nimport { routing } from \"@/i18n/routing\";\nimport { CHAT_MODEL_ID, getChatModel } from \"@/lib/chat-model\";\nimport { CHAT_MODELS } from \"@/lib/chat-models\";\nimport { composeIntelligo, executions } from \"@/lib/intelligo\";\n\nfunction localeFrom(request: Request): string {\n const cookieName =\n typeof routing.localeCookie === \"object\"\n ? (routing.localeCookie.name ?? \"NEXT_LOCALE\")\n : \"NEXT_LOCALE\";\n const cookie = request.headers.get(\"cookie\") ?? \"\";\n const match = cookie.match(new RegExp(`(?:^|;\\\\s*)${cookieName}=([^;]+)`));\n const value = match?.[1] ? decodeURIComponent(match[1]) : undefined;\n const locales: readonly string[] = routing.locales;\n return value && locales.includes(value) ? value : routing.defaultLocale;\n}\n\n/** Refusal copy from this item's `chat` namespace, in the caller's locale. */\nasync function chatMessages(request: Request): Promise<ChatMessages> {\n const t = await getTranslations({\n locale: localeFrom(request),\n namespace: \"chat\",\n });\n return (key, params) => t(`route.${key}`, params);\n}\n\nexport const chatServerConfig: ChatServerConfig = {\n executions,\n onRequest: composeIntelligo,\n model: { defaultId: CHAT_MODEL_ID, resolve: getChatModel },\n // The composer offers `lib/chat-models.ts`'s list; the transport\n // refuses anything else. Empty: no picker, the default model.\n models: { options: CHAT_MODELS },\n messages: chatMessages,\n\n featureKey: \"chat\",\n capability: \"chat.message\",\n maxMessageLength: 8000,\n maxSteps: 5,\n\n agent: {\n id: \"assistant\",\n systemPrompt:\n \"You are a helpful assistant embedded in a SaaS product. Be concise and direct.\",\n },\n};\n",
|
|
156
|
+
"type": "registry:file",
|
|
157
|
+
"target": "lib/chat-server-config.ts"
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
"path": "base/chat/lib/chat-quota.ts",
|
|
161
|
+
"content": "import \"server-only\";\n\n/**\n * What the chat page knows about the caller's credit before they type.\n *\n * The transport already refuses a turn it cannot fund — admission runs\n * inside `executions.begin()` and answers 402. That is the correct\n * enforcement point and the wrong place to *tell someone*: by then they\n * have written a message and watched it fail. This is the read that\n * lets the page say so first — an estimate, never a reservation, priced\n * against the model a turn would run on: the default, and each model\n * the composer's picker offers, so the banner follows the pick.\n */\n\nimport {\n getChatQuotaState as readChatQuotaState,\n getChatQuotaStates as readChatQuotaStates,\n} from \"@intelligo-dev/chat\";\nimport type { ChatQuotaState } from \"@intelligo-dev/chat/client\";\n\nimport { CHAT_MODEL_ID } from \"@/lib/chat-model\";\n\nexport type { ChatQuotaState };\n\nconst UPGRADE_HREF = \"/pricing\";\n\n/** Never throws: a quota read that fails costs the reader a banner, not the conversation. */\nexport function getChatQuotaState(\n modelId: string = CHAT_MODEL_ID\n): Promise<ChatQuotaState | null> {\n return readChatQuotaState({ modelId, upgradeHref: UPGRADE_HREF });\n}\n\n/** The same read per offered model, keyed by model id. Never throws; empty for an empty list. */\nexport function getChatQuotaStates(\n modelIds: readonly string[]\n): Promise<Record<string, ChatQuotaState>> {\n return readChatQuotaStates({ modelIds, upgradeHref: UPGRADE_HREF });\n}\n",
|
|
162
|
+
"type": "registry:file",
|
|
163
|
+
"target": "lib/chat-quota.ts"
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"path": "base/chat/hooks/use-chat-versions.ts",
|
|
167
|
+
"content": "\"use client\";\n\n/**\n * Versions of a reply, kept where the reader can flip between them.\n *\n * Regenerating a reply or editing a message does not lose the earlier\n * attempt: the tail of the conversation from that point on is kept as\n * a version, and the message that starts the tail shows a `< 2 / 3 >`\n * pager. Versions live in this tab (mirrored to `sessionStorage`, so a\n * refresh within the tab keeps them); the server persists the latest\n * path only. That is a deliberate scope: the row stays a list, no\n * migration, and a reload shows what was last said.\n *\n * An *anchor* is the message just before the point that branched — the\n * user message a reply answers, or the message before an edited user\n * message. `ROOT` anchors a branch at the very start.\n */\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport type { UIMessage } from \"ai\";\n\nexport const ROOT_ANCHOR = \"__root__\";\n\ntype VersionSet = {\n tails: UIMessage[][];\n active: number;\n};\n\nexport type ChatVersion = { index: number; count: number };\n\ntype Pending = { anchorId: string };\n\nfunction storageKey(conversationId: string) {\n return `chat:versions:${conversationId}`;\n}\n\nfunction readStored(conversationId: string): Record<string, VersionSet> {\n try {\n const raw = sessionStorage.getItem(storageKey(conversationId));\n return raw ? (JSON.parse(raw) as Record<string, VersionSet>) : {};\n } catch {\n return {};\n }\n}\n\nfunction writeStored(\n conversationId: string,\n versions: Record<string, VersionSet>\n) {\n try {\n if (Object.keys(versions).length === 0) {\n sessionStorage.removeItem(storageKey(conversationId));\n } else {\n sessionStorage.setItem(\n storageKey(conversationId),\n JSON.stringify(versions)\n );\n }\n } catch {\n // Storage is a convenience; a private window without it still chats.\n }\n}\n\nfunction anchorIndex(messages: UIMessage[], anchorId: string): number {\n if (anchorId === ROOT_ANCHOR) return -1;\n return messages.findIndex((message) => message.id === anchorId);\n}\n\nexport function useChatVersions({\n conversationId,\n messages,\n setMessages,\n status,\n}: {\n conversationId: string;\n messages: UIMessage[];\n setMessages: (messages: UIMessage[]) => void;\n status: \"submitted\" | \"streaming\" | \"ready\" | \"error\";\n}) {\n const [versions, setVersions] = useState<Record<string, VersionSet>>({});\n const pending = useRef<Pending | null>(null);\n const hydrated = useRef(false);\n\n useEffect(() => {\n setVersions(readStored(conversationId));\n hydrated.current = true;\n }, [conversationId]);\n\n useEffect(() => {\n if (hydrated.current) writeStored(conversationId, versions);\n }, [conversationId, versions]);\n\n /** Keep the tail on screen before it is replaced. */\n const snapshot = useCallback(\n (anchorId: string) => {\n const at = anchorIndex(messages, anchorId);\n const tail = messages.slice(at + 1);\n setVersions((previous) => {\n const existing = previous[anchorId];\n if (existing) {\n const tails = [...existing.tails];\n tails[existing.active] = tail;\n return { ...previous, [anchorId]: { ...existing, tails } };\n }\n return { ...previous, [anchorId]: { tails: [tail], active: 0 } };\n });\n pending.current = { anchorId };\n },\n [messages]\n );\n\n /** Before `regenerate({ messageId })`: the message before the reply anchors it. */\n const beforeRegenerate = useCallback(\n (assistantMessageId: string) => {\n const at = messages.findIndex((m) => m.id === assistantMessageId);\n if (at === -1) return;\n snapshot(at === 0 ? ROOT_ANCHOR : messages[at - 1]!.id);\n },\n [messages, snapshot]\n );\n\n /** Before an edit re-sends a user message with a new id. */\n const beforeEdit = useCallback(\n (userMessageId: string) => {\n const at = messages.findIndex((m) => m.id === userMessageId);\n if (at === -1) return;\n snapshot(at === 0 ? ROOT_ANCHOR : messages[at - 1]!.id);\n },\n [messages, snapshot]\n );\n\n // When the turn that followed a snapshot settles, the tail on screen\n // becomes the newest version. While idle, the active version tracks\n // whatever the thread now holds after its anchor, so a follow-up sent\n // on an older version grows that version rather than being lost.\n useEffect(() => {\n if (status !== \"ready\") return;\n const commit = pending.current;\n pending.current = null;\n setVersions((previous) => {\n let next = previous;\n let changed = false;\n for (const [anchorId, set] of Object.entries(previous)) {\n const at = anchorIndex(messages, anchorId);\n if (at === -1 && anchorId !== ROOT_ANCHOR) continue;\n const tail = messages.slice(at + 1);\n if (commit?.anchorId === anchorId) {\n next = {\n ...next,\n [anchorId]: {\n tails: [...set.tails, tail],\n active: set.tails.length,\n },\n };\n changed = true;\n } else if (tail.length > 0) {\n const tails = [...set.tails];\n tails[set.active] = tail;\n next = { ...next, [anchorId]: { ...set, tails } };\n changed = true;\n }\n }\n return changed ? next : previous;\n });\n }, [status, messages]);\n\n const select = useCallback(\n (anchorId: string, index: number) => {\n const set = versions[anchorId];\n const tail = set?.tails[index];\n if (!set || !tail) return;\n const at = anchorIndex(messages, anchorId);\n if (at === -1 && anchorId !== ROOT_ANCHOR) return;\n setVersions((previous) => ({\n ...previous,\n [anchorId]: { ...set, active: index },\n }));\n setMessages([...messages.slice(0, at + 1), ...tail]);\n },\n [versions, messages, setMessages]\n );\n\n /** The pager state for the message that follows `anchorId`, if it has versions. */\n const versionOf = useMemo(() => {\n const byFirstMessage = new Map<\n string,\n ChatVersion & { anchorId: string }\n >();\n for (const [anchorId, set] of Object.entries(versions)) {\n if (set.tails.length < 2) continue;\n const at = anchorIndex(messages, anchorId);\n if (at === -1 && anchorId !== ROOT_ANCHOR) continue;\n const first = messages[at + 1];\n if (!first) continue;\n byFirstMessage.set(first.id, {\n anchorId,\n index: set.active,\n count: set.tails.length,\n });\n }\n return (messageId: string) => byFirstMessage.get(messageId) ?? null;\n }, [versions, messages]);\n\n return { beforeRegenerate, beforeEdit, select, versionOf };\n}\n",
|
|
168
|
+
"type": "registry:hook",
|
|
169
|
+
"target": "hooks/use-chat-versions.ts"
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
"path": "base/chat/hooks/use-chat-draft.ts",
|
|
173
|
+
"content": "\"use client\";\n\n/**\n * The composer's unsent text, kept per conversation.\n *\n * Switching models, opening another conversation and coming back, or a\n * tab that reloads should never cost a half-written message. The draft\n * lives in `localStorage` under the conversation id and is cleared\n * the moment it is sent.\n */\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nconst DEBOUNCE_MS = 300;\n\nfunction key(conversationId: string) {\n return `chat:draft:${conversationId}`;\n}\n\nexport function useChatDraft(conversationId: string) {\n const [value, setValue] = useState(\"\");\n const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n useEffect(() => {\n try {\n setValue(localStorage.getItem(key(conversationId)) ?? \"\");\n } catch {\n setValue(\"\");\n }\n }, [conversationId]);\n\n const update = useCallback(\n (next: string) => {\n setValue(next);\n if (timer.current) clearTimeout(timer.current);\n timer.current = setTimeout(() => {\n try {\n if (next) localStorage.setItem(key(conversationId), next);\n else localStorage.removeItem(key(conversationId));\n } catch {\n // A draft is a convenience, not a record.\n }\n }, DEBOUNCE_MS);\n },\n [conversationId]\n );\n\n const clear = useCallback(() => {\n if (timer.current) clearTimeout(timer.current);\n setValue(\"\");\n try {\n localStorage.removeItem(key(conversationId));\n } catch {\n // See above.\n }\n }, [conversationId]);\n\n useEffect(\n () => () => {\n if (timer.current) clearTimeout(timer.current);\n },\n []\n );\n\n return { value, setValue: update, clear };\n}\n",
|
|
174
|
+
"type": "registry:hook",
|
|
175
|
+
"target": "hooks/use-chat-draft.ts"
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
"path": "base/chat/hooks/use-chat-shortcuts.ts",
|
|
179
|
+
"content": "\"use client\";\n\n/**\n * The keyboard a chat is expected to have.\n *\n * ⌘/Ctrl+K new chat\n * Esc stop a reply that is streaming\n *\n * Enter / Shift+Enter live in the composer, and\n * ArrowUp-to-edit-the-last-message is the composer's too, because it\n * only means that when the composer is empty and focused.\n *\n * Window-level, but polite: a shortcut never fires while the reader is\n * typing in some other field, except Esc, which is always \"stop\".\n */\n\nimport { useEffect } from \"react\";\n\nexport function useChatShortcuts({\n onNewChat,\n onStop,\n isStreaming,\n}: {\n onNewChat: () => void;\n onStop: () => void;\n isStreaming: boolean;\n}) {\n useEffect(() => {\n function onKeyDown(event: KeyboardEvent) {\n if (event.defaultPrevented) return;\n const modifier = event.metaKey || event.ctrlKey;\n\n if (modifier && (event.key === \"k\" || event.key === \"K\")) {\n event.preventDefault();\n onNewChat();\n return;\n }\n if (event.key === \"Escape\" && isStreaming) {\n event.preventDefault();\n onStop();\n }\n }\n window.addEventListener(\"keydown\", onKeyDown);\n return () => window.removeEventListener(\"keydown\", onKeyDown);\n }, [onNewChat, onStop, isStreaming]);\n}\n",
|
|
180
|
+
"type": "registry:hook",
|
|
181
|
+
"target": "hooks/use-chat-shortcuts.ts"
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
"path": "base/chat/hooks/use-composer-menu.ts",
|
|
185
|
+
"content": "\"use client\";\n\n/**\n * `/` commands and `@` mentions in the composer.\n *\n * Watches the text at the caret: a `/` that starts the message opens\n * the command list, an `@` that starts a word opens the mention picker,\n * and what follows the trigger filters both. Picking a command inserts\n * its text or runs it; picking a mention writes `@label` and keeps the\n * pick, so the turn can carry `body.mentions` for `resolveAgent`.\n *\n * Both lists come from `lib/chat-config.tsx`; a deployment with\n * neither never sees a menu.\n */\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { useTranslations } from \"next-intl\";\n\nimport type { ComposerMenuOption } from \"@/components/ui/ai-composer-menu\";\nimport { chatConfig, type ChatMention } from \"@/lib/chat-config\";\n\nexport type ComposerMenuKind = \"command\" | \"mention\";\n\ntype Trigger = { kind: ComposerMenuKind; start: number; query: string };\n\nconst SEARCH_DEBOUNCE_MS = 150;\n\n/** The trigger under the caret, if the text at that point is one. */\nexport function detectTrigger(\n text: string,\n caret: number,\n mentionTrigger = \"@\"\n): Trigger | null {\n const before = text.slice(0, caret);\n if (/\\s/.test(before.slice(-1))) return null;\n\n const slash = before.match(/^\\/([\\w-]*)$/);\n if (slash) return { kind: \"command\", start: 0, query: slash[1] ?? \"\" };\n\n const at = before.lastIndexOf(mentionTrigger);\n if (at === -1) return null;\n if (at > 0 && !/\\s/.test(before[at - 1]!)) return null;\n const query = before.slice(at + mentionTrigger.length);\n if (/\\s/.test(query)) return null;\n return { kind: \"mention\", start: at, query };\n}\n\nexport function useComposerMenu({\n value,\n setValue,\n conversationId,\n send,\n textareaRef,\n}: {\n value: string;\n setValue: (text: string) => void;\n conversationId: string;\n send: (text: string) => void;\n textareaRef: React.RefObject<HTMLTextAreaElement | null>;\n}) {\n const tAny = useTranslations();\n const [trigger, setTrigger] = useState<Trigger | null>(null);\n const [mentionResults, setMentionResults] = useState<ChatMention[]>([]);\n const [picked, setPicked] = useState<ChatMention[]>([]);\n const dismissed = useRef<string | null>(null);\n const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const commands = chatConfig.commands ?? [];\n const mentions = chatConfig.mentions;\n const mentionTrigger = mentions?.trigger ?? \"@\";\n\n /** Re-read the caret after every change; the trigger follows it. */\n const refresh = useCallback(() => {\n const element = textareaRef.current;\n if (!element) return;\n const caret = element.selectionStart ?? element.value.length;\n const next = detectTrigger(element.value, caret, mentionTrigger);\n if (next?.kind === \"command\" && commands.length === 0) {\n setTrigger(null);\n return;\n }\n if (next?.kind === \"mention\" && !mentions) {\n setTrigger(null);\n return;\n }\n const key = next ? `${next.kind}:${next.start}` : null;\n if (key && dismissed.current === key) {\n setTrigger(null);\n return;\n }\n if (!next) dismissed.current = null;\n setTrigger(next);\n }, [textareaRef, mentionTrigger, commands.length, mentions]);\n\n useEffect(() => {\n refresh();\n }, [value, refresh]);\n\n useEffect(() => {\n if (trigger?.kind !== \"mention\" || !mentions) return;\n if (searchTimer.current) clearTimeout(searchTimer.current);\n const query = trigger.query;\n searchTimer.current = setTimeout(() => {\n void Promise.resolve(mentions.search(query)).then(setMentionResults);\n }, SEARCH_DEBOUNCE_MS);\n return () => {\n if (searchTimer.current) clearTimeout(searchTimer.current);\n };\n }, [trigger?.kind, trigger?.query, mentions]);\n\n const options = useMemo<ComposerMenuOption[]>(() => {\n if (trigger?.kind === \"command\") {\n return commands.map((command) => ({\n value: command.id,\n label: tAny(command.labelKey),\n description: command.descriptionKey\n ? tAny(command.descriptionKey)\n : `/${command.id}`,\n }));\n }\n if (trigger?.kind === \"mention\") {\n return mentionResults.map((mention) => ({\n value: mention.id,\n label: mention.label,\n description: mention.description,\n group: mention.groupKey ? tAny(mention.groupKey) : undefined,\n }));\n }\n return [];\n }, [trigger?.kind, commands, mentionResults, tAny]);\n\n const close = useCallback(() => {\n if (trigger) dismissed.current = `${trigger.kind}:${trigger.start}`;\n setTrigger(null);\n }, [trigger]);\n\n const select = useCallback(\n (option: ComposerMenuOption) => {\n if (!trigger) return;\n const element = textareaRef.current;\n const caret = element?.selectionStart ?? value.length;\n const after = value.slice(caret);\n\n if (trigger.kind === \"command\") {\n const command = commands.find((c) => c.id === option.value);\n setTrigger(null);\n if (!command) return;\n if (command.run) {\n setValue(\"\");\n command.run({ conversationId, setText: setValue, send });\n return;\n }\n setValue(`${command.insert ?? \"\"}${after}`);\n return;\n }\n\n const mention = mentionResults.find((m) => m.id === option.value);\n setTrigger(null);\n if (!mention) return;\n setPicked((previous) =>\n previous.some((m) => m.id === mention.id)\n ? previous\n : [...previous, mention]\n );\n const before = value.slice(0, trigger.start);\n setValue(`${before}${mentionTrigger}${mention.label} ${after}`);\n },\n [\n trigger,\n textareaRef,\n value,\n commands,\n setValue,\n conversationId,\n send,\n mentionResults,\n mentionTrigger,\n ]\n );\n\n /** The mentions still present in the text, for the turn's body. */\n const activeMentions = useMemo(\n () =>\n picked.filter((mention) =>\n value.includes(`${mentionTrigger}${mention.label}`)\n ),\n [picked, value, mentionTrigger]\n );\n\n const clearPicked = useCallback(() => setPicked([]), []);\n\n return {\n open:\n trigger !== null && (options.length > 0 || trigger.kind === \"mention\"),\n kind: trigger?.kind ?? null,\n query: trigger?.query ?? \"\",\n options,\n select,\n close,\n refresh,\n mentions: activeMentions,\n clearPicked,\n };\n}\n",
|
|
186
|
+
"type": "registry:hook",
|
|
187
|
+
"target": "hooks/use-composer-menu.ts"
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
"path": "base/chat/components/chat-workspace.tsx",
|
|
191
|
+
"content": "\"use client\";\n\n/**\n * The page's chat shell: the thread on the left, the canvas on the\n * right when a document is open. Owns the canvas state — which\n * document, its streamed content so far — so the thread and every\n * card can open, feed and close the same panel, and a card can show\n * the lines being written (`ArtifactStreamProvider`).\n *\n * From `md` up the two sit side by side: the reader drags the edge (or\n * uses the arrow keys on it) to resize the canvas, or expands it to\n * fill the page. Below, the canvas is a sheet over the thread. The\n * thread keeps one place in the tree either way: a layout that moved\n * or remounted it would drop the conversation in flight (a resizable\n * panel group did exactly that when its panel count changed), so the\n * edge is a plain handle on the canvas's width and an expanded canvas\n * hides the thread rather than replacing it.\n */\n\nimport {\n useCallback,\n useRef,\n useState,\n type KeyboardEvent,\n type PointerEvent,\n} from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport type { UIMessage } from \"ai\";\n\nimport type {\n ChatArtifactData,\n ChatModelOption,\n} from \"@intelligo-dev/chat/client\";\n\nimport { Sheet, SheetContent, SheetTitle } from \"@/components/ui/sheet\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\nimport type { CanvasRef } from \"@/lib/chat-renderers\";\nimport { cn } from \"@/lib/utils\";\n\nimport { ArtifactStreamProvider } from \"./artifact-card\";\nimport { ChatCanvas, type CanvasState } from \"./chat-canvas\";\nimport { ChatThread } from \"./chat-thread\";\nimport type { ChatQuotaState } from \"./credit-status-banner\";\nimport type { MessageVote } from \"./message-actions\";\n\ninterface ChatWorkspaceProps {\n conversationId: string;\n initialMessages: UIMessage[];\n quotaState?: ChatQuotaState | null;\n /** The same estimate per offered model, by model id. */\n quotaStates?: Record<string, ChatQuotaState>;\n votes?: Record<string, MessageVote>;\n models?: ChatModelOption[];\n}\n\n/** The canvas's share of the row, in percent. */\nconst WIDTH = { initial: 45, min: 30, max: 70, step: 2 };\n\nexport function ChatWorkspace({\n conversationId,\n initialMessages,\n quotaState = null,\n quotaStates,\n votes = {},\n models = [],\n}: ChatWorkspaceProps) {\n const t = useTranslations(\"chat\");\n const isMobile = useIsMobile();\n const [canvas, setCanvas] = useState<CanvasState | null>(null);\n const [width, setWidth] = useState(WIDTH.initial);\n const [expanded, setExpanded] = useState(false);\n const rowRef = useRef<HTMLDivElement>(null);\n const sendRef = useRef<((text: string) => void) | null>(null);\n\n const openCanvas = useCallback((ref: CanvasRef) => {\n setCanvas({\n ...ref,\n content: ref.content ?? \"\",\n status: ref.status ?? \"ready\",\n });\n }, []);\n\n const closeCanvas = useCallback(() => {\n setCanvas(null);\n setExpanded(false);\n }, []);\n\n /** The opening part opens the panel; deltas grow it; the last part settles it. */\n const onArtifact = useCallback((artifact: ChatArtifactData) => {\n setCanvas((current) => {\n const same = current?.id === artifact.id;\n if (artifact.delta !== undefined) {\n if (!same) return current;\n return { ...current!, content: current!.content + artifact.delta };\n }\n if (artifact.status === \"streaming\") {\n if (same) return current;\n return {\n id: artifact.id,\n kind: artifact.kind,\n title: artifact.title,\n ...(artifact.documentId ? { documentId: artifact.documentId } : {}),\n content: \"\",\n status: \"streaming\",\n };\n }\n if (!same) return current;\n return {\n ...current!,\n title: artifact.title,\n ...(artifact.documentId ? { documentId: artifact.documentId } : {}),\n ...(artifact.content !== undefined\n ? { content: artifact.content }\n : {}),\n status: artifact.status,\n };\n });\n }, []);\n\n function startResize(event: PointerEvent<HTMLDivElement>) {\n const row = rowRef.current;\n if (!row || event.button !== 0) return;\n event.preventDefault();\n const bounds = row.getBoundingClientRect();\n const move = (moveEvent: globalThis.PointerEvent) => {\n const share = ((bounds.right - moveEvent.clientX) / bounds.width) * 100;\n setWidth(Math.min(WIDTH.max, Math.max(WIDTH.min, share)));\n };\n const stop = () => {\n window.removeEventListener(\"pointermove\", move);\n window.removeEventListener(\"pointerup\", stop);\n document.body.style.removeProperty(\"cursor\");\n document.body.style.removeProperty(\"user-select\");\n };\n window.addEventListener(\"pointermove\", move);\n window.addEventListener(\"pointerup\", stop);\n document.body.style.cursor = \"col-resize\";\n document.body.style.userSelect = \"none\";\n }\n\n function resizeWithKeys(event: KeyboardEvent<HTMLDivElement>) {\n const delta =\n event.key === \"ArrowLeft\"\n ? WIDTH.step\n : event.key === \"ArrowRight\"\n ? -WIDTH.step\n : 0;\n if (!delta) return;\n event.preventDefault();\n setWidth((current) =>\n Math.min(WIDTH.max, Math.max(WIDTH.min, current + delta))\n );\n }\n\n const thread = (\n <ChatThread\n conversationId={conversationId}\n initialMessages={initialMessages}\n quotaState={quotaState}\n quotaStates={quotaStates}\n votes={votes}\n models={models}\n variant=\"page\"\n onArtifact={onArtifact}\n onOpenCanvas={openCanvas}\n onCloseCanvas={closeCanvas}\n sendRef={sendRef}\n />\n );\n\n const panel = canvas ? (\n <ChatCanvas\n canvas={canvas}\n onClose={closeCanvas}\n onSendMessage={(text) => sendRef.current?.(text)}\n onSaved={(documentId, content) =>\n setCanvas((current) =>\n current ? { ...current, documentId, content } : current\n )\n }\n expanded={expanded}\n onToggleExpand={\n isMobile ? undefined : () => setExpanded((value) => !value)\n }\n />\n ) : null;\n\n const stream =\n canvas?.status === \"streaming\"\n ? { id: canvas.id, content: canvas.content }\n : null;\n\n // One tree for every width: the thread is always the first child of\n // the same row, so crossing the breakpoint (a rotation, a resized\n // window) reconciles it in place and keeps the messages in flight.\n // Only what holds the canvas changes — a sheet over the thread on a\n // phone, a column beside it otherwise.\n return (\n <ArtifactStreamProvider value={stream}>\n <div ref={rowRef} className=\"flex min-h-0 flex-1\">\n <div\n className={cn(\n \"flex min-h-0 min-w-0 flex-1 flex-col\",\n canvas && expanded && !isMobile && \"hidden\"\n )}\n >\n {thread}\n </div>\n {isMobile ? (\n <Sheet\n open={canvas !== null}\n onOpenChange={(open) => !open && closeCanvas()}\n >\n <SheetContent side=\"right\" className=\"w-full p-0 sm:max-w-xl\">\n <SheetTitle className=\"sr-only\">{t(\"canvas.title\")}</SheetTitle>\n {panel}\n </SheetContent>\n </Sheet>\n ) : canvas ? (\n <aside\n className={cn(\n \"relative flex shrink-0 flex-col bg-background\",\n expanded ? \"flex-1\" : \"min-w-80 border-l\"\n )}\n style={expanded ? undefined : { width: `${width}%` }}\n >\n {expanded ? null : (\n <div\n role=\"separator\"\n aria-orientation=\"vertical\"\n aria-label={t(\"canvas.resize\")}\n aria-valuemin={WIDTH.min}\n aria-valuemax={WIDTH.max}\n aria-valuenow={Math.round(width)}\n tabIndex={0}\n onPointerDown={startResize}\n onKeyDown={resizeWithKeys}\n className=\"absolute inset-y-0 -left-1.5 z-10 w-3 cursor-col-resize outline-none after:absolute after:inset-y-0 after:left-1/2 after:w-0.5 after:-translate-x-1/2 after:rounded-full after:transition-colors hover:after:bg-ring/60 focus-visible:after:bg-ring\"\n />\n )}\n {panel}\n </aside>\n ) : null}\n </div>\n </ArtifactStreamProvider>\n );\n}\n",
|
|
192
|
+
"type": "registry:component",
|
|
193
|
+
"target": "components/chat/chat-workspace.tsx"
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
"path": "base/chat/components/chat-thread.tsx",
|
|
197
|
+
"content": "\"use client\";\n\n/**\n * The thread — one conversation, streaming, with everything a message\n * can do. The page, the side panel and the floating widget all mount\n * this; they differ only in the shell around it and in `variant`.\n *\n * Owns streaming state via the AI SDK's `useChat` (`@ai-sdk/react`),\n * talking to `app/api/chat/route.ts` over a `DefaultChatTransport`.\n * `initialMessages` comes from the server component so reloading a\n * conversation shows its history immediately, with no client-side\n * fetch-on-mount.\n *\n * Per-turn choices — the model, the agent, extra context a shell wants\n * to send — travel in the request body of each send, never in the\n * transport, so switching models mid-conversation rebuilds nothing and\n * loses no draft.\n *\n * A `?query=` search param seeds the first turn: another surface (the\n * dashboard's prompt bar, a marketing CTA) can mint a conversation id\n * and link straight into a started conversation. It is sent once, only\n * into an empty conversation, so a refresh doesn't replay it.\n */\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport type React from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport {\n DefaultChatTransport,\n lastAssistantMessageIsCompleteWithApprovalResponses,\n lastAssistantMessageIsCompleteWithToolCalls,\n} from \"ai\";\nimport { useChat } from \"@ai-sdk/react\";\nimport type { FileUIPart, UIMessage } from \"ai\";\nimport { useSearchParams } from \"next/navigation\";\n\nimport {\n isChatDataPart,\n parseChatError,\n type ChatArtifactData,\n type ChatModelOption,\n} from \"@intelligo-dev/chat/client\";\n\nimport { useRouter } from \"@/i18n/navigation\";\nimport { chatConfig, type ChatMention } from \"@/lib/chat-config\";\nimport type { CanvasRef, ToolRendererActions } from \"@/lib/chat-renderers\";\nimport { useChatDraft } from \"@/hooks/use-chat-draft\";\nimport { useChatShortcuts } from \"@/hooks/use-chat-shortcuts\";\nimport { useChatVersions } from \"@/hooks/use-chat-versions\";\n\nimport { ChatEmpty } from \"./chat-empty\";\nimport { ChatErrorStrip } from \"./chat-error-strip\";\nimport { ChatInput } from \"./chat-input\";\nimport {\n CreditStatusBanner,\n isDeploymentRefusal,\n type ChatBlock,\n type ChatQuotaState,\n} from \"./credit-status-banner\";\nimport { MessageList } from \"./message-list\";\nimport type { MessageVote } from \"./message-actions\";\n\n/**\n * A refusal that blocks the composer, or nothing. Admission answers 402\n * with the entitlement port's own code (`insufficient_credits`,\n * `allowance_depleted`…), which is what the banner keys its copy on; a\n * feature gate is a block too, and so is a 503 for what only the\n * deployment can fix (`billing_not_configured`, `unknown_model`), which\n * the banner words neutrally. Anything else — a rate limit, a stream\n * failure — is transient and belongs in the error strip.\n */\nfunction refusalFrom(chatError: Error): ChatBlock | null {\n const parsed = parseChatError(chatError);\n if (!parsed) return null;\n if (\n parsed.code !== \"QUOTA_EXCEEDED\" &&\n parsed.code !== \"BILLING_NOT_CONFIGURED\" &&\n parsed.code !== \"MODEL_UNAVAILABLE\" &&\n (parsed.code !== \"FEATURE_GATED\" ||\n parsed.reasonCode === \"model_not_allowed\")\n ) {\n return null;\n }\n return { code: parsed.reasonCode ?? parsed.code, message: parsed.error };\n}\n\nconst MODEL_STORAGE_KEY = \"chat:model\";\n\n/**\n * When the thread sends the next turn by itself: after the reader\n * answered an approval, or after a card supplied a client-side tool's\n * result. The SDK's tool-calls predicate alone is not enough — a\n * runtime that ends its turn on a server-run tool (a quiz card that\n * waits for a click, a `hasToolCall` stop) also leaves every tool\n * part complete, and re-sending would loop that turn forever. So the\n * tool-calls case counts only when the client put a result in.\n */\nfunction sendWhenClientAnswered(clientAnswered: React.RefObject<boolean>) {\n return (options: { messages: UIMessage[] }) => {\n if (lastAssistantMessageIsCompleteWithApprovalResponses(options))\n return true;\n if (!clientAnswered.current) return false;\n if (!lastAssistantMessageIsCompleteWithToolCalls(options)) return false;\n clientAnswered.current = false;\n return true;\n };\n}\n\nexport type ChatThreadVariant = \"page\" | \"panel\" | \"widget\";\n\nexport interface ChatThreadProps {\n conversationId: string;\n initialMessages: UIMessage[];\n /**\n * Server-rendered credit state. Optional so a deployment that has\n * not wired billing renders the chat unchanged.\n */\n quotaState?: ChatQuotaState | null;\n /**\n * The same estimate for each offered model, by model id. The banner\n * reads the picked model's entry and falls back to `quotaState`.\n */\n quotaStates?: Record<string, ChatQuotaState>;\n /** The reader's earlier votes, by message id. */\n votes?: Record<string, MessageVote>;\n /** Models the composer offers. One or none hides the picker. */\n models?: ChatModelOption[];\n variant?: ChatThreadVariant;\n /** Overrides `chatConfig.agent?.id` for this surface. */\n agentId?: string;\n /** Extra body fields on every turn — page context a panel wants the agent to have. */\n body?: Record<string, unknown>;\n autoFocus?: boolean;\n /** The server wrote a title after the first reply. */\n onTitle?: (title: string) => void;\n /** A document streaming into the canvas: the opening part, each delta, the final one. */\n onArtifact?: (artifact: ChatArtifactData) => void;\n /** A card asked for the canvas. */\n onOpenCanvas?: (ref: CanvasRef) => void;\n onCloseCanvas?: () => void;\n /** Read-only: no composer, no actions. The shared page. */\n readOnly?: boolean;\n /** Filled with the thread's send, for a shell that sends on its behalf (the canvas toolbar). */\n sendRef?: React.RefObject<((text: string) => void) | null>;\n className?: string;\n}\n\nexport function ChatThread({\n conversationId,\n initialMessages,\n quotaState = null,\n quotaStates,\n votes = {},\n models = [],\n variant = \"page\",\n agentId,\n body,\n autoFocus = false,\n onTitle,\n onArtifact,\n onOpenCanvas,\n onCloseCanvas,\n readOnly = false,\n sendRef,\n className,\n}: ChatThreadProps) {\n const t = useTranslations(\"chat\");\n // Namespace-less: `chatConfig.starters` entries are fully-qualified\n // message keys into the app's whole message tree (see\n // `@/lib/chat-config`'s doc comment), not keys under this item's own\n // \"chat\" namespace.\n const tAny = useTranslations();\n const searchParams = useSearchParams();\n const router = useRouter();\n const compact = variant !== \"page\";\n\n const transport = useMemo(\n () => new DefaultChatTransport({ api: \"/api/chat\" }),\n []\n );\n\n const resolvedAgentId = agentId ?? chatConfig.agent?.id;\n\n // The picked model survives a reload and a conversation switch; the\n // server still decides whether the plan allows it.\n const [modelId, setModelIdState] = useState<string | undefined>(undefined);\n useEffect(() => {\n if (models.length === 0) return;\n let stored: string | null = null;\n try {\n stored = localStorage.getItem(MODEL_STORAGE_KEY);\n } catch {\n stored = null;\n }\n const allowed = models.find((model) => model.id === stored);\n setModelIdState(allowed?.id ?? models[0]!.id);\n }, [models]);\n const setModelId = useCallback((next: string) => {\n setModelIdState(next);\n try {\n localStorage.setItem(MODEL_STORAGE_KEY, next);\n } catch {\n // Remembering the pick is a convenience.\n }\n }, []);\n\n const bodyRef = useRef<Record<string, unknown>>({});\n bodyRef.current = {\n id: conversationId,\n ...(resolvedAgentId ? { agentId: resolvedAgentId } : {}),\n ...(modelId ? { modelId } : {}),\n ...body,\n };\n\n // Set by a card that answers a client-side tool; read by the\n // auto-continue predicate.\n const clientAnswered = useRef(false);\n // A thread that opened empty creates its conversation row on the\n // first reply. The shell's history and this page's header are server\n // rendered, so they learn about it from a refresh — once.\n const startedEmpty = useRef(initialMessages.length === 0);\n // A transient `data-chat-status` — \"Searching…\", \"Writing…\" — shown\n // as the shimmer under the reply until the next one or the finish.\n const [statusLabel, setStatusLabel] = useState<string | null>(null);\n\n const {\n messages,\n setMessages,\n sendMessage,\n regenerate,\n addToolOutput,\n addToolApprovalResponse,\n status,\n stop,\n error,\n clearError,\n } = useChat({\n id: conversationId,\n messages: initialMessages,\n transport,\n sendAutomaticallyWhen:\n chatConfig.sendAutomaticallyWhen ??\n sendWhenClientAnswered(clientAnswered),\n onFinish: () => {\n if (!startedEmpty.current) return;\n startedEmpty.current = false;\n router.refresh();\n },\n onData: (part) => {\n if (isChatDataPart(part, \"chat-title\")) {\n onTitle?.(part.data);\n router.refresh();\n return;\n }\n if (isChatDataPart(part, \"chat-status\")) {\n setStatusLabel(part.data.done ? null : part.data.label);\n return;\n }\n if (isChatDataPart(part, \"chat-artifact\")) {\n // Every artifact part — the opening one, each delta, the final\n // `ready` — goes to the canvas, which opens on the first and\n // accumulates the rest.\n onArtifact?.(part.data);\n }\n },\n });\n\n const isStreaming = status === \"streaming\" || status === \"submitted\";\n\n useEffect(() => {\n if (!isStreaming) setStatusLabel(null);\n }, [isStreaming]);\n\n const versions = useChatVersions({\n conversationId,\n messages,\n setMessages,\n status,\n });\n\n const draft = useChatDraft(conversationId);\n\n /**\n * A refusal the route returned mid-conversation.\n *\n * The server-rendered state is what the page opened with; the\n * balance can run out three turns later, and the reader should not\n * have to reload to be told.\n */\n const [block, setBlock] = useState<ChatBlock | null>(null);\n // Each model has its own worst case, so the estimate that counts is\n // the picked model's.\n const activeQuotaState =\n (modelId ? quotaStates?.[modelId] : undefined) ?? quotaState;\n const blocked = block !== null || activeQuotaState?.allowed === false;\n\n const starters = useMemo(\n () => (chatConfig.starters ?? []).map((key) => tAny(key)),\n [tAny]\n );\n\n // In an effect, not in render: `useChat` surfaces the error as\n // state, and setting state while rendering from it is how a render\n // loop starts.\n useEffect(() => {\n if (!error) return;\n const refusal = refusalFrom(error);\n if (refusal) setBlock(refusal);\n }, [error]);\n\n function friendlyChatError(chatError: Error): string {\n // A non-JSON error response (a proxy's page, a network failure)\n // surfaces as raw text; the generic copy beats showing it.\n return parseChatError(chatError)?.error ?? t(\"error.generic\");\n }\n\n const send = useCallback(\n (text: string, files: FileUIPart[] = [], mentions: ChatMention[] = []) => {\n const trimmed = text.trim();\n if ((!trimmed && files.length === 0) || isStreaming || blocked) return;\n void sendMessage(\n { text: trimmed, ...(files.length > 0 ? { files } : {}) },\n {\n body: {\n ...bodyRef.current,\n ...(mentions.length > 0\n ? { mentions: mentions.map(({ id, label }) => ({ id, label })) }\n : {}),\n },\n }\n );\n draft.clear();\n },\n [isStreaming, blocked, sendMessage, draft]\n );\n\n useEffect(() => {\n if (!sendRef) return;\n sendRef.current = (text: string) => send(text);\n return () => {\n sendRef.current = null;\n };\n }, [sendRef, send]);\n\n const handleRegenerate = useCallback(\n (messageId: string) => {\n if (isStreaming) return;\n versions.beforeRegenerate(messageId);\n void regenerate({ messageId, body: bodyRef.current });\n },\n [isStreaming, versions, regenerate]\n );\n\n const handleEdit = useCallback(\n (messageId: string, text: string, files: FileUIPart[]) => {\n if (isStreaming) return;\n const at = messages.findIndex((message) => message.id === messageId);\n if (at === -1) return;\n versions.beforeEdit(messageId);\n setMessages(messages.slice(0, at));\n void sendMessage(\n { text, ...(files.length > 0 ? { files } : {}) },\n { body: bodyRef.current }\n );\n },\n [isStreaming, messages, versions, setMessages, sendMessage]\n );\n\n const editLastUserMessage = useCallback(() => {\n for (let i = messages.length - 1; i >= 0; i--) {\n const message = messages[i]!;\n if (message.role !== \"user\") continue;\n const text = message.parts\n .filter((part) => part.type === \"text\")\n .map((part) => (part as { text: string }).text)\n .join(\"\\n\\n\");\n draft.setValue(text);\n break;\n }\n }, [messages, draft]);\n\n useChatShortcuts({\n onNewChat: () => router.push(\"/chat\"),\n onStop: stop,\n isStreaming,\n });\n\n // Prefill from `?query=`, once, and only into a conversation that has\n // no messages yet. It waits for the stored model pick, so the turn\n // runs on the model the reader chose, and a thread that may not send\n // gets the text as a draft instead.\n const prefillSent = useRef(false);\n useEffect(() => {\n if (prefillSent.current) return;\n const query = searchParams.get(\"query\");\n if (!query || messages.length > 0) return;\n if (models.length > 0 && modelId === undefined) return;\n prefillSent.current = true;\n if (blocked) {\n draft.setValue(query);\n return;\n }\n void sendMessage({ text: query }, { body: bodyRef.current });\n }, [\n searchParams,\n messages.length,\n sendMessage,\n models.length,\n modelId,\n blocked,\n draft,\n ]);\n\n // Rebuilt each render on purpose: `send` closes over `isStreaming`,\n // and nothing downstream is memoized on this object.\n const toolActions: ToolRendererActions = {\n sendMessage: (message) =>\n typeof message === \"string\"\n ? send(message)\n : send(message.text, message.files),\n addToolResult: (args) => {\n clientAnswered.current = true;\n void addToolOutput(args);\n },\n addToolApprovalResponse: (args) => void addToolApprovalResponse(args),\n openCanvas: (ref) => onOpenCanvas?.(ref),\n closeCanvas: () => onCloseCanvas?.(),\n };\n\n const composer = readOnly ? null : (\n <ChatInput\n conversationId={conversationId}\n value={draft.value}\n onChange={draft.setValue}\n onSend={send}\n onStop={stop}\n onEditLast={editLastUserMessage}\n isStreaming={isStreaming}\n disabled={blocked}\n disabledPlaceholder={\n isDeploymentRefusal(block?.code ?? activeQuotaState?.code)\n ? t(\"input.unavailablePlaceholder\")\n : t(\"input.blockedPlaceholder\")\n }\n models={models}\n modelId={modelId}\n onModelChange={(next) => {\n // A refusal belongs to the model it was priced against.\n setModelId(next);\n setBlock(null);\n clearError();\n }}\n autoFocus={autoFocus}\n compact={compact}\n />\n );\n\n const isEmpty = messages.length === 0;\n\n return (\n <div\n className={[\"flex min-h-0 flex-1 flex-col\", className]\n .filter(Boolean)\n .join(\" \")}\n >\n {isEmpty && !readOnly ? (\n <ChatEmpty\n starters={starters}\n onStarterSelect={(text) => send(text)}\n composer={composer}\n compact={compact}\n />\n ) : (\n <MessageList\n conversationId={conversationId}\n messages={messages}\n isStreaming={status === \"streaming\"}\n statusLabel={statusLabel}\n readOnly={readOnly}\n votes={votes}\n versionOf={(messageId) => {\n const version = versions.versionOf(messageId);\n if (!version) return null;\n return {\n index: version.index,\n count: version.count,\n onIndexChange: (index) =>\n versions.select(version.anchorId, index),\n };\n }}\n onRegenerate={readOnly ? undefined : handleRegenerate}\n onEdit={readOnly ? undefined : handleEdit}\n toolActions={readOnly ? undefined : toolActions}\n compact={compact}\n />\n )}\n <CreditStatusBanner quotaState={activeQuotaState} block={block} />\n {error && !block ? (\n <ChatErrorStrip\n message={friendlyChatError(error)}\n onRetry={\n isStreaming\n ? undefined\n : () => {\n clearError();\n void regenerate({ body: bodyRef.current });\n }\n }\n onDismiss={clearError}\n />\n ) : null}\n {!isEmpty && !readOnly ? composer : null}\n </div>\n );\n}\n",
|
|
198
|
+
"type": "registry:component",
|
|
199
|
+
"target": "components/chat/chat-thread.tsx"
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
"path": "base/chat/components/chat-canvas.tsx",
|
|
203
|
+
"content": "\"use client\";\n\n/**\n * The canvas — a document beside the chat: the one a tool is streaming\n * right now, or one a card reopened. The frame is `ai-artifact`;\n * the body is the kind's own component from\n * `lib/chat-canvas-config.tsx`; versions come from the documents\n * table, and an edit saves as a new version.\n *\n * The header is two quiet rows: what the document is (glyph, title,\n * kind and state) with expand and close, then how to work with it — a\n * preview/code switch for content that renders, the version stepper,\n * copy, download, the artifacts page, restore and save.\n */\n\nimport { useEffect, useMemo, useState, useTransition } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport {\n ChevronLeftIcon,\n ChevronRightIcon,\n DownloadIcon,\n ExternalLinkIcon,\n FileTextIcon,\n HistoryIcon,\n Maximize2Icon,\n Minimize2Icon,\n SaveIcon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport {\n Artifact,\n ArtifactAction,\n ArtifactActions,\n ArtifactClose,\n ArtifactContent,\n ArtifactDescription,\n ArtifactHeader,\n ArtifactTitle,\n} from \"@/components/ui/ai-artifact\";\nimport { Button } from \"@/components/ui/button\";\nimport { CopyButton } from \"@/components/ui/copy-button\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { Link } from \"@/i18n/navigation\";\nimport {\n extensionOf,\n fileNameOf,\n resolveCanvasKind,\n} from \"@/lib/chat-canvas-config\";\nimport type { CanvasRef } from \"@/lib/chat-renderers\";\nimport { cn } from \"@/lib/utils\";\nimport { listArtifactVersions, saveArtifactVersion } from \"@/actions/chat\";\n\nexport type CanvasState = CanvasRef & {\n content: string;\n status: \"streaming\" | \"ready\" | \"error\";\n};\n\ntype Version = { createdAt: string; content: string };\n\ninterface ChatCanvasProps {\n canvas: CanvasState;\n onClose: () => void;\n /** Send a message about the document — a kind's toolbar item. */\n onSendMessage: (text: string) => void;\n /** The content changed on disk: a new version was saved. */\n onSaved?: (documentId: string, content: string) => void;\n /** The panel fills the page instead of sharing it with the thread. */\n expanded?: boolean;\n /** Offered where the panel can fill the page. */\n onToggleExpand?: () => void;\n}\n\nexport function ChatCanvas({\n canvas,\n onClose,\n onSendMessage,\n onSaved,\n expanded = false,\n onToggleExpand,\n}: ChatCanvasProps) {\n const t = useTranslations(\"chat\");\n const tAny = useTranslations();\n const kind = resolveCanvasKind(canvas.kind);\n const Content = kind.content;\n const Preview = kind.preview;\n const Icon = kind.icon ?? FileTextIcon;\n const [versions, setVersions] = useState<Version[]>([]);\n const [versionIndex, setVersionIndex] = useState(0);\n const [draft, setDraft] = useState<string | null>(null);\n const [view, setView] = useState<\"preview\" | \"source\">(\"preview\");\n const [isSaving, startSaving] = useTransition();\n\n const documentId = canvas.documentId;\n const canPreview =\n Boolean(Preview) && (kind.previewable?.(canvas.title) ?? true);\n\n // Versions load once the document is settled; a streaming document\n // has none yet, and a reopened one has whatever was saved.\n useEffect(() => {\n setVersions([]);\n setVersionIndex(0);\n setDraft(null);\n if (!documentId || canvas.status === \"streaming\") return;\n let cancelled = false;\n void listArtifactVersions(documentId).then((result) => {\n if (cancelled || !result.success) return;\n setVersions(result.data);\n });\n return () => {\n cancelled = true;\n };\n }, [documentId, canvas.status]);\n\n const content = useMemo(() => {\n if (draft !== null) return draft;\n if (versionIndex > 0 && versions[versionIndex]) {\n return versions[versionIndex]!.content;\n }\n if (canvas.content) return canvas.content;\n return versions[0]?.content ?? \"\";\n }, [draft, versionIndex, versions, canvas.content]);\n\n const isLatest = versionIndex === 0;\n const isReadonly = canvas.status === \"streaming\" || !isLatest || !documentId;\n const showPreview =\n canPreview && view === \"preview\" && canvas.status !== \"streaming\";\n\n function selectVersion(index: number) {\n setDraft(null);\n setVersionIndex(index);\n }\n\n /** An older version becomes the draft of the next one. */\n function restore() {\n const older = versions[versionIndex]?.content;\n if (older === undefined) return;\n setVersionIndex(0);\n setDraft(older);\n }\n\n function download() {\n const link = document.createElement(\"a\");\n if (canvas.kind === \"image\") {\n link.href = content;\n } else {\n link.href = URL.createObjectURL(\n new Blob([content], {\n type: canvas.kind === \"sheet\" ? \"text/csv\" : \"text/plain\",\n })\n );\n }\n link.download = fileNameOf(canvas.title, kind);\n link.click();\n if (link.href.startsWith(\"blob:\")) URL.revokeObjectURL(link.href);\n }\n\n function save() {\n if (!documentId || draft === null) return;\n startSaving(async () => {\n const result = await saveArtifactVersion({\n documentId,\n title: canvas.title,\n kind: canvas.kind,\n content: draft,\n });\n if (!result.success) {\n toast.error(result.error);\n return;\n }\n toast.success(t(\"canvas.saved\"));\n onSaved?.(documentId, draft);\n setDraft(null);\n const refreshed = await listArtifactVersions(documentId);\n if (refreshed.success) setVersions(refreshed.data);\n setVersionIndex(0);\n });\n }\n\n const extension =\n canvas.kind === \"code\" ? extensionOf(canvas.title) : undefined;\n const description =\n canvas.status === \"streaming\" ? (\n <>\n <Spinner className=\"size-3\" />\n {t(\"artifactCard.streaming\")}\n </>\n ) : draft !== null ? (\n <>\n <span aria-hidden=\"true\" className=\"size-1.5 rounded-full bg-warning\" />\n {t(\"canvas.unsavedChanges\")}\n </>\n ) : (\n [kind.labelKey ? tAny(kind.labelKey) : null, extension]\n .filter(Boolean)\n .join(\" · \")\n );\n\n return (\n <Artifact className=\"h-full rounded-none border-0 shadow-none\">\n <ArtifactHeader className=\"flex-col items-stretch gap-1.5 bg-transparent px-3 pt-2.5 pb-2\">\n <div className=\"flex min-w-0 items-center gap-2.5\">\n <span\n aria-hidden=\"true\"\n className=\"grid size-8 shrink-0 place-items-center rounded-lg bg-muted text-muted-foreground\"\n >\n <Icon className=\"size-4\" />\n </span>\n <div className=\"min-w-0 flex-1\">\n <ArtifactTitle className=\"truncate leading-5\">\n {canvas.title}\n </ArtifactTitle>\n <ArtifactDescription className=\"flex min-h-4 items-center gap-1.5 truncate text-xs leading-4\">\n {description}\n </ArtifactDescription>\n </div>\n {onToggleExpand ? (\n <ArtifactAction\n label={expanded ? t(\"canvas.collapse\") : t(\"canvas.expand\")}\n tooltip={expanded ? t(\"canvas.collapse\") : t(\"canvas.expand\")}\n icon={expanded ? Minimize2Icon : Maximize2Icon}\n onClick={onToggleExpand}\n />\n ) : null}\n <ArtifactClose label={t(\"canvas.close\")} onClick={onClose} />\n </div>\n\n <div className=\"flex min-w-0 flex-wrap items-center gap-1\">\n {canPreview ? (\n <div\n role=\"group\"\n aria-label={t(\"canvas.view\")}\n className=\"flex items-center rounded-lg bg-muted p-0.5\"\n >\n {([\"preview\", \"source\"] as const).map((option) => (\n <button\n key={option}\n type=\"button\"\n aria-pressed={view === option}\n onClick={() => setView(option)}\n className={cn(\n \"h-6 rounded-md px-2 text-xs font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\",\n view === option && \"bg-background text-foreground shadow-xs\"\n )}\n >\n {option === \"preview\"\n ? t(\"canvas.preview\")\n : t(\"canvas.source\")}\n </button>\n ))}\n </div>\n ) : null}\n\n {versions.length > 1 ? (\n <div\n role=\"group\"\n aria-label={t(\"canvas.version\")}\n className=\"flex items-center\"\n >\n <ArtifactAction\n label={t(\"canvas.olderVersion\")}\n icon={ChevronLeftIcon}\n disabled={versionIndex >= versions.length - 1}\n onClick={() => selectVersion(versionIndex + 1)}\n />\n <span className=\"min-w-10 text-center text-xs text-muted-foreground tabular-nums\">\n {t(\"canvas.versionOf\", {\n index: versions.length - versionIndex,\n count: versions.length,\n })}\n </span>\n <ArtifactAction\n label={t(\"canvas.newerVersion\")}\n icon={ChevronRightIcon}\n disabled={isLatest}\n onClick={() => selectVersion(versionIndex - 1)}\n />\n </div>\n ) : null}\n\n <ArtifactActions className=\"ml-auto\">\n {kind.actions?.map((action) => (\n <ArtifactAction\n key={action.labelKey}\n label={tAny(action.labelKey)}\n tooltip={tAny(action.labelKey)}\n icon={action.icon}\n onClick={() =>\n void action.onClick({\n content,\n title: canvas.title,\n ...(documentId ? { documentId } : {}),\n })\n }\n />\n ))}\n <CopyButton\n value={content}\n label={t(\"canvas.copy\")}\n copiedLabel={t(\"actions.copied\")}\n onCopyError={() => toast.error(t(\"actions.copyFailed\"))}\n size=\"icon-sm\"\n variant=\"ghost\"\n />\n <ArtifactAction\n label={t(\"canvas.download\")}\n tooltip={t(\"canvas.download\")}\n icon={DownloadIcon}\n disabled={!content || canvas.status === \"streaming\"}\n onClick={download}\n />\n {documentId ? (\n <ArtifactAction\n label={t(\"canvas.openInArtifacts\")}\n tooltip={t(\"canvas.openInArtifacts\")}\n icon={ExternalLinkIcon}\n render={\n <Link\n href={`/artifacts?document=${encodeURIComponent(documentId)}`}\n />\n }\n nativeButton={false}\n />\n ) : null}\n {!isLatest ? (\n <Button\n size=\"xs\"\n variant=\"outline\"\n type=\"button\"\n onClick={restore}\n >\n <HistoryIcon data-icon=\"inline-start\" />\n {t(\"canvas.restore\")}\n </Button>\n ) : null}\n {draft !== null ? (\n <Button\n size=\"xs\"\n type=\"button\"\n onClick={save}\n disabled={isSaving}\n aria-busy={isSaving || undefined}\n >\n {isSaving ? (\n <Spinner data-icon=\"inline-start\" />\n ) : (\n <SaveIcon data-icon=\"inline-start\" />\n )}\n {t(\"canvas.save\")}\n </Button>\n ) : null}\n </ArtifactActions>\n </div>\n </ArtifactHeader>\n <ArtifactContent className={cn(\"min-h-0\", showPreview && \"p-3\")}>\n {showPreview && Preview ? (\n <Preview\n content={content}\n status={canvas.status}\n title={canvas.title}\n isReadonly\n />\n ) : (\n <Content\n content={content}\n status={canvas.status}\n title={canvas.title}\n isReadonly={isReadonly}\n onChange={isReadonly ? undefined : setDraft}\n />\n )}\n </ArtifactContent>\n {kind.toolbar?.length ? (\n <div className=\"flex flex-wrap gap-2 border-t px-4 py-2\">\n {kind.toolbar.map((item) => (\n <Button\n key={item.labelKey}\n size=\"xs\"\n variant=\"outline\"\n type=\"button\"\n onClick={() => onSendMessage(item.message)}\n >\n {tAny(item.labelKey)}\n </Button>\n ))}\n </div>\n ) : null}\n </Artifact>\n );\n}\n",
|
|
204
|
+
"type": "registry:component",
|
|
205
|
+
"target": "components/chat/chat-canvas.tsx"
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
"path": "base/chat/components/canvas/text-editor.tsx",
|
|
209
|
+
"content": "\"use client\";\n\n/**\n * The canvas's text editor: ProseMirror over a markdown document.\n *\n * Markdown in, markdown out — `prosemirror-markdown`'s parser builds\n * the document and its serializer writes it back, so what the tool\n * streamed and what the reader saves are the same text the model\n * reads. While a document streams in, every update replaces the\n * document without touching the reader's draft; once settled, edits\n * flow up through `onChange`.\n *\n * Loaded lazily by `lib/chat-canvas-config.tsx`: a reader who never\n * opens a text document never downloads the editor.\n */\n\nimport { useEffect, useRef } from \"react\";\nimport { exampleSetup } from \"prosemirror-example-setup\";\nimport { inputRules, textblockTypeInputRule } from \"prosemirror-inputrules\";\nimport {\n defaultMarkdownParser,\n defaultMarkdownSerializer,\n schema,\n} from \"prosemirror-markdown\";\nimport { EditorState, type Transaction } from \"prosemirror-state\";\nimport { EditorView } from \"prosemirror-view\";\n\nimport type { CanvasContentProps } from \"@/lib/chat-canvas-config\";\n\nfunction headingRule(level: number) {\n return textblockTypeInputRule(\n new RegExp(`^(#{1,${level}})\\\\s$`),\n schema.nodes.heading!,\n () => ({ level })\n );\n}\n\nfunction parse(content: string) {\n return defaultMarkdownParser.parse(content) ?? schema.node(\"doc\", null, []);\n}\n\n/**\n * The document's typography, spelled out: the theme ships no typography\n * plugin, so `prose` classes would style nothing — lists lose their\n * numbers, headings their size.\n */\nconst EDITOR_CLASS = [\n \"text-sm leading-relaxed text-foreground\",\n \"[&_.ProseMirror]:min-h-64 [&_.ProseMirror]:outline-none\",\n \"[&_.ProseMirror>*+*]:mt-3\",\n \"[&_.ProseMirror_h1]:text-2xl [&_.ProseMirror_h1]:font-semibold [&_.ProseMirror_h1]:tracking-tight\",\n \"[&_.ProseMirror_h2]:text-xl [&_.ProseMirror_h2]:font-semibold\",\n \"[&_.ProseMirror_h3]:text-base [&_.ProseMirror_h3]:font-semibold\",\n \"[&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:pl-6\",\n \"[&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:pl-6\",\n \"[&_.ProseMirror_li]:my-1 [&_.ProseMirror_li>p]:m-0\",\n \"[&_.ProseMirror_blockquote]:border-l-2 [&_.ProseMirror_blockquote]:pl-4 [&_.ProseMirror_blockquote]:text-muted-foreground\",\n \"[&_.ProseMirror_code]:rounded [&_.ProseMirror_code]:bg-muted [&_.ProseMirror_code]:px-1 [&_.ProseMirror_code]:font-mono [&_.ProseMirror_code]:text-xs\",\n \"[&_.ProseMirror_pre]:overflow-x-auto [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:bg-muted [&_.ProseMirror_pre]:p-3\",\n \"[&_.ProseMirror_a]:underline [&_.ProseMirror_a]:underline-offset-4\",\n \"[&_.ProseMirror_hr]:border-border\",\n].join(\" \");\n\nexport default function TextEditor({\n content,\n status,\n isReadonly,\n onChange,\n}: CanvasContentProps) {\n const container = useRef<HTMLDivElement>(null);\n const view = useRef<EditorView | null>(null);\n const onChangeRef = useRef(onChange);\n onChangeRef.current = onChange;\n const readonlyRef = useRef(isReadonly);\n readonlyRef.current = isReadonly;\n\n useEffect(() => {\n if (!container.current || view.current) return;\n const state = EditorState.create({\n doc: parse(content),\n plugins: [\n ...exampleSetup({ schema, menuBar: false }),\n inputRules({ rules: [1, 2, 3, 4, 5, 6].map(headingRule) }),\n ],\n });\n view.current = new EditorView(container.current, {\n state,\n editable: () => !readonlyRef.current,\n dispatchTransaction(transaction: Transaction) {\n const editor = view.current;\n if (!editor) return;\n editor.updateState(editor.state.apply(transaction));\n if (transaction.docChanged && !transaction.getMeta(\"external\")) {\n onChangeRef.current?.(\n defaultMarkdownSerializer.serialize(editor.state.doc)\n );\n }\n },\n });\n return () => {\n view.current?.destroy();\n view.current = null;\n };\n // The editor mounts once; content updates flow through the effect below.\n }, []);\n\n // External content (a stream, a version switch) replaces the document.\n useEffect(() => {\n const editor = view.current;\n if (!editor) return;\n const current = defaultMarkdownSerializer.serialize(editor.state.doc);\n if (current === content) return;\n if (status !== \"streaming\" && !isReadonly && editor.hasFocus()) return;\n const next = parse(content);\n const transaction = editor.state.tr.replaceWith(\n 0,\n editor.state.doc.content.size,\n next.content\n );\n transaction.setMeta(\"external\", true);\n editor.dispatch(transaction);\n }, [content, status, isReadonly]);\n\n return <div ref={container} className={EDITOR_CLASS} />;\n}\n",
|
|
210
|
+
"type": "registry:component",
|
|
211
|
+
"target": "components/chat/canvas/text-editor.tsx"
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
"path": "base/chat/components/canvas/code-editor.tsx",
|
|
215
|
+
"content": "\"use client\";\n\n/**\n * The canvas's code editor: CodeMirror 6, with the language picked from\n * the document's file extension and the theme following the app's.\n * External content — a stream, a version switch — replaces the buffer\n * as a remote change so it never re-enters as an edit.\n *\n * Loaded lazily by `lib/chat-canvas-config.tsx`.\n */\n\nimport { useEffect, useMemo, useRef } from \"react\";\nimport { javascript } from \"@codemirror/lang-javascript\";\nimport { python } from \"@codemirror/lang-python\";\nimport { Compartment, EditorState, Transaction } from \"@codemirror/state\";\nimport { oneDark } from \"@codemirror/theme-one-dark\";\nimport { EditorView } from \"@codemirror/view\";\nimport { basicSetup } from \"codemirror\";\nimport { useTheme } from \"next-themes\";\n\nimport type { CanvasContentProps } from \"@/lib/chat-canvas-config\";\n\nfunction languageFor(title: string) {\n const extension = title.split(\".\").pop()?.toLowerCase() ?? \"\";\n switch (extension) {\n case \"ts\":\n return javascript({ typescript: true });\n case \"tsx\":\n return javascript({ typescript: true, jsx: true });\n case \"jsx\":\n return javascript({ jsx: true });\n case \"js\":\n case \"mjs\":\n case \"cjs\":\n return javascript();\n case \"py\":\n return python();\n default:\n return [];\n }\n}\n\nconst lightTheme = EditorView.theme({\n \"&\": { backgroundColor: \"transparent\", fontSize: \"0.875rem\" },\n \".cm-gutters\": { backgroundColor: \"transparent\", border: \"none\" },\n});\n\nexport default function CodeEditor({\n content,\n status,\n title,\n isReadonly,\n onChange,\n}: CanvasContentProps) {\n const container = useRef<HTMLDivElement>(null);\n const view = useRef<EditorView | null>(null);\n const onChangeRef = useRef(onChange);\n onChangeRef.current = onChange;\n const { resolvedTheme } = useTheme();\n const themeCompartment = useMemo(() => new Compartment(), []);\n const readonlyCompartment = useMemo(() => new Compartment(), []);\n const languageCompartment = useMemo(() => new Compartment(), []);\n\n useEffect(() => {\n if (!container.current || view.current) return;\n const state = EditorState.create({\n doc: content,\n extensions: [\n basicSetup,\n languageCompartment.of(languageFor(title)),\n themeCompartment.of(resolvedTheme === \"dark\" ? oneDark : lightTheme),\n readonlyCompartment.of(EditorState.readOnly.of(isReadonly)),\n EditorView.updateListener.of((update) => {\n if (!update.docChanged) return;\n const local = update.transactions.some(\n (transaction) => !transaction.annotation(Transaction.remote)\n );\n if (local) onChangeRef.current?.(update.state.doc.toString());\n }),\n ],\n });\n view.current = new EditorView({ state, parent: container.current });\n return () => {\n view.current?.destroy();\n view.current = null;\n };\n // Mounts once; the compartments below follow prop changes.\n }, []);\n\n useEffect(() => {\n view.current?.dispatch({\n effects: themeCompartment.reconfigure(\n resolvedTheme === \"dark\" ? oneDark : lightTheme\n ),\n });\n }, [resolvedTheme, themeCompartment]);\n\n useEffect(() => {\n view.current?.dispatch({\n effects: readonlyCompartment.reconfigure(\n EditorState.readOnly.of(isReadonly)\n ),\n });\n }, [isReadonly, readonlyCompartment]);\n\n useEffect(() => {\n view.current?.dispatch({\n effects: languageCompartment.reconfigure(languageFor(title)),\n });\n }, [title, languageCompartment]);\n\n useEffect(() => {\n const editor = view.current;\n if (!editor) return;\n const current = editor.state.doc.toString();\n if (current === content) return;\n if (status !== \"streaming\" && !isReadonly && editor.hasFocus) return;\n editor.dispatch({\n changes: { from: 0, to: current.length, insert: content },\n annotations: [Transaction.remote.of(true)],\n });\n }, [content, status, isReadonly]);\n\n return <div ref={container} className=\"min-h-64 text-sm\" />;\n}\n",
|
|
216
|
+
"type": "registry:component",
|
|
217
|
+
"target": "components/chat/canvas/code-editor.tsx"
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
"path": "base/chat/components/canvas/sheet-editor.tsx",
|
|
221
|
+
"content": "\"use client\";\n\n/**\n * The canvas's spreadsheet: a CSV document on react-data-grid. The\n * grid is padded to a working size so an empty sheet still looks like\n * one; every edit is serialised back to CSV for `onChange`.\n *\n * Loaded lazily by `lib/chat-canvas-config.tsx`.\n */\n\nimport { useEffect, useMemo, useState } from \"react\";\nimport { parse, unparse } from \"papaparse\";\nimport { DataGrid, renderTextEditor, type Column } from \"react-data-grid\";\nimport { useTheme } from \"next-themes\";\n\nimport \"react-data-grid/lib/styles.css\";\n\nimport type { CanvasContentProps } from \"@/lib/chat-canvas-config\";\n\nconst MIN_ROWS = 40;\nconst MIN_COLS = 16;\n\ntype Row = { id: number; [column: string]: string | number };\n\nfunction toRows(content: string): string[][] {\n const parsed = content\n ? parse<string[]>(content, { skipEmptyLines: true }).data\n : [];\n const width = Math.max(MIN_COLS, ...parsed.map((row) => row.length));\n const rows = parsed.map((row) => {\n const padded = [...row];\n while (padded.length < width) padded.push(\"\");\n return padded;\n });\n while (rows.length < MIN_ROWS) rows.push(new Array<string>(width).fill(\"\"));\n return rows;\n}\n\nfunction columnName(index: number): string {\n let name = \"\";\n let n = index;\n do {\n name = String.fromCharCode(65 + (n % 26)) + name;\n n = Math.floor(n / 26) - 1;\n } while (n >= 0);\n return name;\n}\n\nexport default function SheetEditor({\n content,\n isReadonly,\n onChange,\n}: CanvasContentProps) {\n const { resolvedTheme } = useTheme();\n const cells = useMemo(() => toRows(content), [content]);\n const width = cells[0]?.length ?? MIN_COLS;\n\n const columns = useMemo<Column<Row>[]>(() => {\n const rowNumber: Column<Row> = {\n key: \"rowNumber\",\n name: \"\",\n frozen: true,\n width: 48,\n renderCell: ({ rowIdx }) => rowIdx + 1,\n cellClass: \"bg-muted text-muted-foreground\",\n headerCellClass: \"bg-muted\",\n };\n const data = Array.from({ length: width }, (_, index): Column<Row> => ({\n key: String(index),\n name: columnName(index),\n width: 120,\n resizable: true,\n renderEditCell: isReadonly ? undefined : renderTextEditor,\n headerCellClass: \"bg-muted\",\n }));\n return [rowNumber, ...data];\n }, [width, isReadonly]);\n\n const initialRows = useMemo<Row[]>(\n () =>\n cells.map((row, rowIndex) => {\n const record: Row = { id: rowIndex };\n row.forEach((cell: string, columnIndex: number) => {\n record[String(columnIndex)] = cell;\n });\n return record;\n }),\n [cells]\n );\n\n const [rows, setRows] = useState<Row[]>(initialRows);\n useEffect(() => setRows(initialRows), [initialRows]);\n\n function handleRowsChange(next: Row[]) {\n setRows(next);\n const table = next.map((row) =>\n Array.from({ length: width }, (_, index) =>\n String(row[String(index)] ?? \"\")\n )\n );\n // Trailing empty rows and columns are padding, not content.\n while (table.length && table[table.length - 1]!.every((cell) => !cell)) {\n table.pop();\n }\n onChange?.(unparse(table));\n }\n\n return (\n <DataGrid\n className={\n resolvedTheme === \"dark\" ? \"rdg-dark h-full\" : \"rdg-light h-full\"\n }\n columns={columns}\n rows={rows}\n rowKeyGetter={(row) => row.id}\n onRowsChange={isReadonly ? undefined : handleRowsChange}\n defaultColumnOptions={{ resizable: true }}\n />\n );\n}\n",
|
|
222
|
+
"type": "registry:component",
|
|
223
|
+
"target": "components/chat/canvas/sheet-editor.tsx"
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
"path": "base/chat/components/chat-empty.tsx",
|
|
227
|
+
"content": "\"use client\";\n\n/**\n * An empty conversation, the way a chat product opens: the greeting\n * and the composer in the middle of the page, starters beneath. The\n * composer docks to the bottom the moment the first message is sent —\n * the thread renders this only while there is nothing to scroll.\n */\n\nimport type { ReactNode } from \"react\";\nimport { useTranslations } from \"next-intl\";\n\nimport { Suggestion } from \"@/components/ui/ai-suggestion\";\n\ninterface ChatEmptyProps {\n starters: string[];\n onStarterSelect: (text: string) => void;\n /** The composer, rendered in the centre of the page. */\n composer: ReactNode;\n /** A compact layout for a panel or widget: no greeting, starters as a list. */\n compact?: boolean;\n}\n\nexport function ChatEmpty({\n starters,\n onStarterSelect,\n composer,\n compact = false,\n}: ChatEmptyProps) {\n const t = useTranslations(\"chat\");\n\n if (compact) {\n return (\n <div className=\"flex min-h-0 flex-1 flex-col justify-end gap-3 p-3\">\n <p className=\"text-sm text-muted-foreground\">\n {t(\"emptyState.description\")}\n </p>\n {starters.length > 0 ? (\n <div className=\"flex flex-col items-start gap-1.5\">\n {starters.map((starter, index) => (\n <Suggestion\n key={index}\n suggestion={starter}\n onClick={onStarterSelect}\n className=\"h-auto max-w-full justify-start py-1.5 text-left whitespace-normal\"\n />\n ))}\n </div>\n ) : null}\n {composer}\n </div>\n );\n }\n\n return (\n <div className=\"flex min-h-0 flex-1 flex-col items-center justify-center gap-6 px-4 py-8\">\n <h2 className=\"text-center text-2xl font-semibold tracking-tight\">\n {t(\"emptyState.greeting\")}\n </h2>\n <div className=\"w-full\">{composer}</div>\n {starters.length > 0 ? (\n <div className=\"flex max-w-2xl flex-wrap justify-center gap-2\">\n {starters.map((starter, index) => (\n <Suggestion\n key={index}\n suggestion={starter}\n onClick={onStarterSelect}\n className=\"h-auto py-1.5 text-left whitespace-normal\"\n />\n ))}\n </div>\n ) : null}\n </div>\n );\n}\n",
|
|
228
|
+
"type": "registry:component",
|
|
229
|
+
"target": "components/chat/chat-empty.tsx"
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
"path": "base/chat/components/chat-error-strip.tsx",
|
|
233
|
+
"content": "\"use client\";\n\n/**\n * The transient error under the transcript — a stream that failed, a\n * rate limit, a network blip. Blocking refusals (credits, plan) are\n * the credit banner's; this strip is for what a retry can fix.\n */\n\nimport { useTranslations } from \"next-intl\";\nimport { AlertTriangleIcon, RefreshCwIcon, XIcon } from \"lucide-react\";\n\nimport { Alert, AlertAction, AlertDescription } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\n\ninterface ChatErrorStripProps {\n message: string;\n /** Regenerate the last reply; omitted while a reply streams. */\n onRetry?: () => void;\n onDismiss: () => void;\n}\n\nexport function ChatErrorStrip({\n message,\n onRetry,\n onDismiss,\n}: ChatErrorStripProps) {\n const t = useTranslations(\"chat\");\n\n return (\n <Alert variant=\"destructive\" className=\"mx-auto mb-2 w-full max-w-3xl\">\n <AlertTriangleIcon />\n <AlertDescription>{message}</AlertDescription>\n <AlertAction>\n {onRetry ? (\n <Button size=\"xs\" variant=\"outline\" type=\"button\" onClick={onRetry}>\n <RefreshCwIcon data-icon=\"inline-start\" />\n {t(\"error.retry\")}\n </Button>\n ) : null}\n <Button\n size=\"icon-xs\"\n variant=\"ghost\"\n type=\"button\"\n onClick={onDismiss}\n aria-label={t(\"error.dismiss\")}\n >\n <XIcon />\n </Button>\n </AlertAction>\n </Alert>\n );\n}\n",
|
|
234
|
+
"type": "registry:component",
|
|
235
|
+
"target": "components/chat/chat-error-strip.tsx"
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
"path": "base/chat/components/message-list.tsx",
|
|
239
|
+
"content": "\"use client\";\n\n/**\n * The conversation viewport. A reader-aware scroller follows streamed\n * output at the live edge and lets go the moment the reader scrolls up;\n * on the page it also draws a preview rail for jumping between turns.\n */\n\nimport { useTranslations } from \"next-intl\";\nimport type { FileUIPart, UIMessage } from \"ai\";\n\nimport { MessageScroller } from \"@/components/ui/ai-message-scroller\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\nimport type { ToolRendererActions } from \"@/lib/chat-renderers\";\nimport { Message, type MessageVersion } from \"./message\";\nimport type { MessageVote } from \"./message-actions\";\n\ninterface MessageListProps {\n conversationId: string;\n messages: UIMessage[];\n isStreaming: boolean;\n /** The runtime's transient status line, shown under the streaming reply. */\n statusLabel?: string | null;\n readOnly?: boolean;\n votes?: Record<string, MessageVote>;\n versionOf?: (messageId: string) => MessageVersion | null;\n onRegenerate?: (messageId: string) => void;\n onEdit?: (messageId: string, text: string, files: FileUIPart[]) => void;\n toolActions?: ToolRendererActions;\n /** Narrower measure for a panel or widget. */\n compact?: boolean;\n}\n\nexport function MessageList({\n conversationId,\n messages,\n isStreaming,\n statusLabel = null,\n readOnly = false,\n votes = {},\n versionOf,\n onRegenerate,\n onEdit,\n toolActions,\n compact = false,\n}: MessageListProps) {\n const t = useTranslations(\"chat\");\n // The rail is for a pointer: on a phone it only sits on the text.\n const isMobile = useIsMobile();\n // Sending brings the reader back to the end, even from far up.\n const lastUserId = messages.findLast(\n (message) => message.role === \"user\"\n )?.id;\n\n return (\n <MessageScroller\n className=\"min-h-0 flex-1\"\n busy={isStreaming}\n anchor={lastUserId}\n scrollToEndLabel={t(\"list.scrollToEnd\")}\n label={t(\"list.label\")}\n navigation={compact || isMobile ? undefined : \"rail\"}\n navigationLabel={t(\"list.navigation\")}\n navigationItemLabel={(sender, index, total) =>\n t(\"list.navigationItem\", { sender, index, total })\n }\n emptyPreviewLabel={t(\"list.emptyPreview\")}\n contentClassName={\n compact\n ? \"flex w-full flex-col gap-4 px-3 py-4\"\n : \"mx-auto flex w-full max-w-3xl flex-col gap-6 px-4 py-6\"\n }\n >\n {messages.map((message, index) => (\n <Message\n key={message.id}\n conversationId={conversationId}\n message={message}\n isLastMessage={index === messages.length - 1}\n isStreaming={isStreaming}\n statusLabel={index === messages.length - 1 ? statusLabel : null}\n readOnly={readOnly}\n vote={votes[message.id] ?? null}\n version={versionOf?.(message.id) ?? null}\n onRegenerate={onRegenerate}\n onEdit={onEdit}\n toolActions={toolActions}\n />\n ))}\n </MessageScroller>\n );\n}\n",
|
|
240
|
+
"type": "registry:component",
|
|
241
|
+
"target": "components/chat/message-list.tsx"
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
"path": "base/chat/components/message.tsx",
|
|
245
|
+
"content": "\"use client\";\n\n/**\n * Renders one `UIMessage`'s parts: Message and Bubble for the turn, the\n * `ai-*` components for the agent's activity, sources and tools, and the\n * seams in `@/lib/chat-renderers` for tool calls and data parts. Text renders\n * with `streamdown`, the markdown-while-streaming renderer, because\n * replies are routinely lists, code and headings arriving a token at a\n * time.\n *\n * Parts are laid out by `groupParts` (`@/lib/message-parts`): the\n * agent's reasoning and the tool calls between its words fold into one\n * activity stream (`ToolActivity`); a tool with its own card, a call\n * awaiting approval, text, files and `data-*` parts (through\n * `DATA_RENDERERS`) stand on their own. A Mastra or eve turn renders\n * through the same switch.\n *\n * Sources are collected by `collectSources` — the AI SDK's `source-*`\n * parts and the sources a tool returned — so a `[3]` in the text is an\n * inline pill naming the site as soon as the search behind it settled,\n * and the finished reply ends with a sources button.\n *\n * A finished assistant message carries the action row; a user message\n * can be edited in place, which re-sends it as a new turn and keeps\n * the earlier reply as a version.\n */\n\nimport { useEffect, useRef, useState } from \"react\";\nimport type React from \"react\";\nimport { getToolName, isTextUIPart, isToolUIPart } from \"ai\";\nimport type { FileUIPart, UIMessage } from \"ai\";\nimport { useTranslations } from \"next-intl\";\nimport { PaperclipIcon } from \"lucide-react\";\n\nimport { ToolApproval } from \"@/components/ui/ai-tool-approval\";\nimport {\n Branch,\n BranchNext,\n BranchPage,\n BranchPrevious,\n} from \"@/components/ui/ai-branch\";\nimport { CitationPill, type CitationItem } from \"@/components/ui/ai-citations\";\nimport { ImageGeneration } from \"@/components/ui/ai-image-generation\";\nimport { Markdown } from \"@/components/ui/ai-markdown\";\nimport { ReasoningText } from \"@/components/ui/ai-reasoning-text\";\nimport { StreamingResponse } from \"@/components/ui/ai-streaming-response\";\nimport {\n Attachment,\n AttachmentContent,\n AttachmentGroup,\n AttachmentMedia,\n AttachmentTitle,\n} from \"@/components/ui/attachment\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n MessageContent,\n MessageFooter,\n Message as MessageRow,\n} from \"@/components/ui/ai-message\";\nimport {\n MessageBubble,\n MessageBubbleContent,\n} from \"@/components/ui/ai-message-bubble\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport {\n getDataRenderer,\n hasToolCard,\n hasToolRenderer,\n resolveToolRenderer,\n type ToolRendererActions,\n type ToolRendererProps,\n} from \"@/lib/chat-renderers\";\nimport {\n collectSources,\n groupParts,\n isActivityWorking,\n isSettledToolState,\n linkCitations,\n parseCitationHref,\n sourceDomain,\n sourcesFromSourcePart,\n sourcesFromToolOutput,\n titleFromUrl,\n type NumberedSource,\n} from \"@/lib/message-parts\";\nimport { MessageActions, type MessageVote } from \"./message-actions\";\nimport { ToolActivity } from \"./tool-activity\";\n\nexport type MessageVersion = {\n index: number;\n count: number;\n onIndexChange: (index: number) => void;\n};\n\ninterface MessageProps {\n conversationId: string;\n message: UIMessage;\n isLastMessage: boolean;\n isStreaming: boolean;\n /** The runtime's transient status while this reply streams. */\n statusLabel?: string | null;\n /** A read-only surface: no actions, no edit, no branch pager. */\n readOnly?: boolean;\n vote?: MessageVote;\n /** Versions of the tail that starts here, when there are several. */\n version?: MessageVersion | null;\n onRegenerate?: (messageId: string) => void;\n /** Re-send this user message with new text; the thread branches. */\n onEdit?: (messageId: string, text: string, files: FileUIPart[]) => void;\n toolActions?: ToolRendererActions;\n}\n\ntype Part = UIMessage[\"parts\"][number];\n\nfunction isFilePart(part: unknown): part is FileUIPart {\n return (part as { type?: unknown }).type === \"file\";\n}\n\n/** The message's plain text, for copying, editing and artifact content. */\nfunction messageText(message: UIMessage): string {\n return message.parts\n .filter(isTextUIPart)\n .map((part) => part.text)\n .join(\"\\n\\n\")\n .trim();\n}\n\n/** The sources a part carries: a source part itself, or a settled tool's output. */\nfunction sourcesOf(part: Part) {\n if (isToolUIPart(part)) {\n if (part.state !== \"output-available\") return [];\n const read =\n resolveToolRenderer(getToolName(part)).sources ?? sourcesFromToolOutput;\n return read(part.output);\n }\n return sourcesFromSourcePart(part);\n}\n\nfunction citationItem(source: NumberedSource): CitationItem {\n const domain = sourceDomain(source);\n const named =\n source.title && source.title !== domain ? source.title : undefined;\n return {\n id: source.id,\n title: named ?? titleFromUrl(source.url) ?? domain ?? source.url ?? \"\",\n ...(domain ? { domain } : {}),\n ...(source.url ? { url: source.url } : {}),\n ...(source.snippet ? { snippet: source.snippet } : {}),\n };\n}\n\n/**\n * The streaming caret sits at the end of the last line of markdown,\n * not under it: markdown renders as blocks, so a sibling after the\n * renderer would start a new line. A pseudo-element on the last block\n * stays inline with its text.\n */\nconst STREAMING_CARET =\n \"[&>:last-child]:after:ml-0.5 [&>:last-child]:after:inline-block [&>:last-child]:after:h-4 [&>:last-child]:after:w-0.5 [&>:last-child]:after:animate-pulse [&>:last-child]:after:rounded-full [&>:last-child]:after:bg-foreground [&>:last-child]:after:align-text-bottom [&>:last-child]:after:content-['']\";\n\nexport function Message({\n conversationId,\n message,\n isLastMessage,\n isStreaming,\n statusLabel = null,\n readOnly = false,\n vote = null,\n version = null,\n onRegenerate,\n onEdit,\n toolActions,\n}: MessageProps) {\n const t = useTranslations(\"chat\");\n const isUser = message.role === \"user\";\n const isStreamingThis = isLastMessage && isStreaming;\n const text = messageText(message);\n const [editing, setEditing] = useState(false);\n\n const hasVisibleText = message.parts.some(\n (part) => isTextUIPart(part) && part.text\n );\n const isEmptyAssistant =\n !isUser &&\n message.parts.every(\n (part) => (isTextUIPart(part) && !part.text) || part.type === \"step-start\"\n );\n\n const files = message.parts.flatMap((part) =>\n isFilePart(part) ? [part] : []\n );\n const sources = isUser ? [] : collectSources(message.parts, sourcesOf);\n const citations = new Map(\n sources.map((source) => [source.index, citationItem(source)])\n );\n const lastTextIndex = message.parts.reduce(\n (last, part, index) => (isTextUIPart(part) ? index : last),\n -1\n );\n // A document a tool returned draws once: the tool's card. The\n // streamed `data-chat-artifact` part for the same document is what\n // opened the canvas, not a second card.\n const documentsShownByTools = new Set<string>();\n // While a document tool is still running its card already shows the\n // document being written; the part it streams would be a second card.\n let documentToolRunning = false;\n for (const part of message.parts) {\n if (!isToolUIPart(part)) continue;\n if (part.state !== \"output-available\") {\n if (\n resolveToolRenderer(getToolName(part)).canvas &&\n !isSettledToolState(part.state)\n ) {\n documentToolRunning = true;\n }\n continue;\n }\n const output = part.output as\n { documentId?: unknown; id?: unknown } | undefined;\n const id = output?.documentId ?? output?.id;\n if (typeof id === \"string\") documentsShownByTools.add(id);\n }\n\n function toolProps(part: Part): ToolRendererProps {\n if (!isToolUIPart(part)) throw new Error(\"not a tool part\");\n const approval =\n \"approval\" in part\n ? (part.approval as { id?: string } | undefined)\n : undefined;\n return {\n toolName: getToolName(part),\n state: part.state,\n input: \"input\" in part ? part.input : undefined,\n output: part.state === \"output-available\" ? part.output : undefined,\n errorText: part.state === \"output-error\" ? part.errorText : undefined,\n toolCallId: \"toolCallId\" in part ? part.toolCallId : undefined,\n approvalId: approval?.id,\n messageId: message.id,\n isStreaming: isStreamingThis,\n isReadonly: readOnly,\n actions: toolActions,\n };\n }\n\n // A call awaiting the reader, or a tool with a card of its own, stands\n // apart; every other call is a row in the activity stream.\n const segments = groupParts(\n message.parts,\n (part) =>\n isToolUIPart(part) &&\n (part.state === \"approval-requested\" || hasToolCard(getToolName(part)))\n );\n const lastSegment = segments.at(-1);\n const activityLive =\n lastSegment?.kind === \"activity\" &&\n isActivityWorking(lastSegment, true, isStreamingThis);\n\n if (isUser && editing) {\n return (\n <MessageRow from=\"user\" className=\"group/chat-message\">\n <MessageContent>\n <EditForm\n initial={text}\n onCancel={() => setEditing(false)}\n onSave={(next) => {\n setEditing(false);\n onEdit?.(message.id, next, files);\n }}\n />\n </MessageContent>\n </MessageRow>\n );\n }\n\n const markdownComponents =\n citations.size > 0\n ? {\n a: (props: React.ComponentProps<\"a\">) => (\n <CitationAnchor {...props} citations={citations} />\n ),\n }\n : undefined;\n\n function renderPart(part: Part, index: number) {\n const key = `${message.id}-${index}`;\n\n if (isTextUIPart(part)) {\n if (!part.text) return null;\n if (isUser) {\n return (\n <MessageBubble key={key} variant=\"solid\">\n <MessageBubbleContent className=\"whitespace-pre-wrap\">\n {part.text}\n </MessageBubbleContent>\n </MessageBubble>\n );\n }\n const streamingText = isStreamingThis && index === lastTextIndex;\n return (\n <MessageBubble key={key} variant=\"ghost\">\n <MessageBubbleContent>\n <Markdown\n className={streamingText ? STREAMING_CARET : undefined}\n mode={streamingText ? \"streaming\" : \"static\"}\n isAnimating={streamingText}\n components={markdownComponents}\n >\n {linkCitations(part.text, new Set(citations.keys()))}\n </Markdown>\n </MessageBubbleContent>\n </MessageBubble>\n );\n }\n\n if (isToolUIPart(part)) {\n const props = toolProps(part);\n if (\n part.state === \"approval-requested\" &&\n !hasToolRenderer(props.toolName)\n ) {\n return <ApprovalCard key={key} {...props} />;\n }\n const { component: Renderer } = resolveToolRenderer(props.toolName);\n return <Renderer key={key} {...props} />;\n }\n\n if (part.type.startsWith(\"data-\")) {\n const name = part.type.slice(\"data-\".length);\n if (name === \"chat-title\" || name === \"chat-status\") return null;\n const Renderer = getDataRenderer(name);\n if (!Renderer) return null;\n const data = part as { id?: string; data: unknown };\n if (name === \"chat-artifact\") {\n const artifact = data.data as { id?: string; documentId?: string };\n if (\n documentToolRunning ||\n (artifact.documentId &&\n documentsShownByTools.has(artifact.documentId)) ||\n (artifact.id && documentsShownByTools.has(artifact.id))\n ) {\n return null;\n }\n }\n return (\n <Renderer\n key={data.id ? `${message.id}-data-${data.id}` : key}\n name={name}\n id={data.id}\n data={data.data}\n messageId={message.id}\n isStreaming={isStreamingThis}\n isReadonly={readOnly}\n actions={toolActions}\n />\n );\n }\n\n return null;\n }\n\n const body = (\n <>\n {segments.map((segment, position) =>\n segment.kind === \"activity\" ? (\n <ToolActivity\n key={`${message.id}-${segment.key}`}\n parts={segment.parts}\n working={isActivityWorking(\n segment,\n position === segments.length - 1,\n isStreamingThis\n )}\n toolProps={toolProps}\n />\n ) : (\n renderPart(segment.part, segment.index)\n )\n )}\n\n {isStreamingThis && !activityLive && (isEmptyAssistant || statusLabel) ? (\n <ReasoningText\n phrases={[statusLabel ?? t(\"message.thinking\")]}\n suffix=\"\"\n variant=\"swap\"\n className=\"text-sm text-muted-foreground\"\n />\n ) : null}\n </>\n );\n\n const footerControls =\n !readOnly && !isStreamingThis && (hasVisibleText || version) ? (\n <>\n {version ? (\n <Branch\n index={version.index}\n count={version.count}\n onIndexChange={version.onIndexChange}\n className=\"mr-1\"\n >\n <BranchPrevious label={t(\"branch.previous\")} />\n <BranchPage />\n <BranchNext label={t(\"branch.next\")} />\n </Branch>\n ) : null}\n {hasVisibleText ? (\n <MessageActions\n conversationId={conversationId}\n messageId={message.id}\n role={isUser ? \"user\" : \"assistant\"}\n text={text}\n vote={vote}\n alwaysVisible={isLastMessage}\n onEdit={isUser && onEdit ? () => setEditing(true) : undefined}\n onRegenerate={\n !isUser && onRegenerate\n ? () => onRegenerate(message.id)\n : undefined\n }\n />\n ) : null}\n </>\n ) : null;\n\n return (\n <MessageRow\n from={isUser ? \"user\" : \"assistant\"}\n className=\"group/chat-message\"\n >\n <MessageContent>\n {files.length > 0 ? (\n <AttachmentGroup className=\"max-w-full\">\n {files.map((file, index) => (\n <FileAttachment\n key={`${message.id}-file-${index}`}\n file={file}\n generated={!isUser}\n />\n ))}\n </AttachmentGroup>\n ) : null}\n\n {isUser ? (\n <>\n {body}\n {footerControls ? (\n <MessageFooter>{footerControls}</MessageFooter>\n ) : null}\n </>\n ) : (\n // The reply's footer — its actions and a sources disclosure —\n // rises in once the stream settles.\n <StreamingResponse\n status={isStreamingThis ? \"streaming\" : \"complete\"}\n announce={false}\n prose={false}\n sources={isStreamingThis ? [] : [...citations.values()]}\n sourcesLabel={(count) => t(\"sources.title\", { count })}\n actions={footerControls}\n className=\"min-w-0\"\n contentClassName=\"flex min-w-0 flex-col items-start gap-1.5\"\n actionsClassName=\"gap-1\"\n >\n {body}\n </StreamingResponse>\n )}\n </MessageContent>\n </MessageRow>\n );\n}\n\n/** `[n]` markers render as source pills; every other link stays a link. */\nfunction CitationAnchor({\n href,\n children,\n citations,\n ...props\n}: React.ComponentProps<\"a\"> & { citations: Map<number, CitationItem> }) {\n const t = useTranslations(\"chat\");\n const numbers = parseCitationHref(href);\n if (numbers) {\n const cited = numbers.flatMap((n) => {\n const citation = citations.get(n);\n return citation ? [citation] : [];\n });\n const first = cited[0];\n if (first) {\n return (\n <CitationPill\n citations={cited}\n label={t(\"sources.pill\", {\n name: first.domain ?? String(first.title),\n })}\n />\n );\n }\n }\n return (\n <a href={href} target=\"_blank\" rel=\"noreferrer\" {...props}>\n {children}\n </a>\n );\n}\n\nfunction FileAttachment({\n file,\n generated,\n}: {\n file: FileUIPart;\n /** The assistant made it: an image resolves in rather than just appearing. */\n generated: boolean;\n}) {\n const t = useTranslations(\"chat\");\n const isImage = file.mediaType.startsWith(\"image/\") && Boolean(file.url);\n if (isImage && generated) {\n return (\n <ImageGeneration\n className=\"max-w-sm\"\n status=\"complete\"\n showStatus={false}\n interactive={false}\n >\n <img\n src={file.url}\n alt={file.filename ?? t(\"message.imageAlt\")}\n className=\"size-full object-cover\"\n />\n </ImageGeneration>\n );\n }\n if (isImage) {\n return (\n <a\n href={file.url}\n target=\"_blank\"\n rel=\"noreferrer\"\n className=\"block max-w-xs overflow-hidden rounded-xl border\"\n >\n <img\n src={file.url}\n alt={file.filename ?? t(\"message.imageAlt\")}\n className=\"block h-auto w-full\"\n />\n </a>\n );\n }\n return (\n <Attachment size=\"sm\">\n <AttachmentMedia variant=\"icon\">\n <PaperclipIcon />\n </AttachmentMedia>\n <AttachmentContent>\n <AttachmentTitle>\n {file.filename ?? t(\"message.attachment\")}\n </AttachmentTitle>\n </AttachmentContent>\n </Attachment>\n );\n}\n\n/** Edit a user message in place. ⌘/Ctrl+Enter saves, Esc cancels. */\nfunction EditForm({\n initial,\n onSave,\n onCancel,\n}: {\n initial: string;\n onSave: (text: string) => void;\n onCancel: () => void;\n}) {\n const t = useTranslations(\"chat\");\n const [value, setValue] = useState(initial);\n const ref = useRef<HTMLTextAreaElement>(null);\n\n useEffect(() => {\n const el = ref.current;\n if (!el) return;\n el.focus();\n el.setSelectionRange(el.value.length, el.value.length);\n }, []);\n\n function save() {\n const trimmed = value.trim();\n if (!trimmed) return;\n onSave(trimmed);\n }\n\n return (\n <form\n className=\"flex w-full max-w-2xl flex-col gap-2\"\n onSubmit={(event) => {\n event.preventDefault();\n save();\n }}\n >\n <Textarea\n ref={ref}\n value={value}\n onChange={(event) => setValue(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === \"Escape\") {\n event.preventDefault();\n onCancel();\n }\n if (event.key === \"Enter\" && (event.metaKey || event.ctrlKey)) {\n event.preventDefault();\n save();\n }\n }}\n className=\"min-h-20\"\n aria-label={t(\"actions.edit\")}\n />\n <div className=\"flex justify-end gap-2\">\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={onCancel}>\n {t(\"actions.editCancel\")}\n </Button>\n <Button type=\"submit\" size=\"sm\" disabled={!value.trim()}>\n {t(\"actions.editSave\")}\n </Button>\n </div>\n </form>\n );\n}\n\n/**\n * The default for a call that waits on the reader: a permission card\n * with the tool's input as parameters. Allow, or deny with a reason —\n * the answer rides the approval response, the part moves on to\n * `approval-responded`, and the call joins the activity stream as a\n * row. The decision lives in the part, so it survives a reload.\n */\nfunction ApprovalCard({\n toolName,\n input,\n approvalId,\n isReadonly,\n actions,\n}: ToolRendererProps) {\n const t = useTranslations(\"chat\");\n const parameters =\n input && typeof input === \"object\"\n ? Object.entries(input as Record<string, unknown>).map(\n ([key, value]) => ({\n id: key,\n label: key,\n value: typeof value === \"string\" ? value : JSON.stringify(value),\n })\n )\n : [];\n const canDecide = !isReadonly && Boolean(actions) && Boolean(approvalId);\n\n function decide(approved: boolean, reason?: string) {\n if (!approvalId || !actions) return;\n actions.addToolApprovalResponse({\n id: approvalId,\n approved,\n ...(reason?.trim() ? { reason: reason.trim() } : {}),\n });\n }\n\n return (\n <ToolApproval\n className=\"max-w-xl\"\n tool={toolName}\n title={t(\"approval.title\", { tool: toolName })}\n description={t(\"approval.description\")}\n parameters={parameters}\n status=\"pending\"\n denyReason\n onAllow={canDecide ? () => decide(true) : undefined}\n onDeny={canDecide ? (reason) => decide(false, reason) : undefined}\n allowOnceLabel={t(\"approval.allow\")}\n denyLabel={t(\"approval.deny\")}\n cancelLabel={t(\"approval.cancel\")}\n detailsLabel={t(\"approval.details\")}\n denyReasonPlaceholder={t(\"approval.reasonPlaceholder\")}\n statusLabels={{\n pending: t(\"approval.pending\"),\n approved: t(\"approval.approved\"),\n denied: t(\"approval.denied\"),\n }}\n />\n );\n}\n",
|
|
246
|
+
"type": "registry:component",
|
|
247
|
+
"target": "components/chat/message.tsx"
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
"path": "base/chat/components/message-actions.tsx",
|
|
251
|
+
"content": "\"use client\";\n\n/**\n * The row under a message: copy, edit, regenerate, feedback, save.\n *\n * Revealed on hover and on focus — and always shown on the last\n * message, because on a touch screen there is no hover. Every control\n * is a labelled icon button with a tooltip; the icons alone are not\n * the accessible name.\n */\n\nimport { useState, useTransition } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport {\n FileDownIcon,\n PencilIcon,\n RefreshCwIcon,\n ThumbsDownIcon,\n ThumbsUpIcon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { CopyButton } from \"@/components/ui/copy-button\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport { saveMessageAsArtifact, voteMessage } from \"@/actions/chat\";\n\nexport type MessageVote = \"up\" | \"down\" | null;\n\ninterface MessageActionsProps {\n conversationId: string;\n messageId: string;\n role: \"user\" | \"assistant\";\n text: string;\n vote: MessageVote;\n /** The row stays visible without hover. */\n alwaysVisible?: boolean;\n onEdit?: () => void;\n onRegenerate?: () => void;\n onVote?: (vote: MessageVote) => void;\n}\n\nfunction IconAction({\n label,\n onClick,\n pressed,\n disabled,\n children,\n}: {\n label: string;\n onClick: () => void;\n pressed?: boolean;\n disabled?: boolean;\n children: React.ReactNode;\n}) {\n return (\n <Tooltip>\n <TooltipTrigger\n render={\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-xs\"\n className={cn(\n \"text-muted-foreground\",\n pressed && \"bg-accent text-foreground\"\n )}\n aria-label={label}\n aria-pressed={pressed}\n disabled={disabled}\n onClick={onClick}\n />\n }\n >\n {children}\n </TooltipTrigger>\n <TooltipContent>{label}</TooltipContent>\n </Tooltip>\n );\n}\n\nexport function MessageActions({\n conversationId,\n messageId,\n role,\n text,\n vote,\n alwaysVisible = false,\n onEdit,\n onRegenerate,\n onVote,\n}: MessageActionsProps) {\n const t = useTranslations(\"chat\");\n const [isSaving, startSaving] = useTransition();\n const [current, setCurrent] = useState<MessageVote>(vote);\n\n function handleSave() {\n startSaving(async () => {\n // The conversation travels with it, so the artifacts page can\n // link the saved reply back to where it was said.\n const result = await saveMessageAsArtifact({\n conversationId,\n messageId,\n content: text,\n });\n if (result.success) toast.success(t(\"actions.savedToArtifacts\"));\n else toast.error(result.error);\n });\n }\n\n function handleVote(next: Exclude<MessageVote, null>) {\n const value: MessageVote = current === next ? null : next;\n setCurrent(value);\n onVote?.(value);\n void voteMessage(conversationId, messageId, value).then((result) => {\n if (!result.success) {\n setCurrent(current);\n toast.error(result.error);\n } else if (value) {\n toast.success(t(\"actions.feedbackSent\"));\n }\n });\n }\n\n return (\n <div\n data-slot=\"message-actions\"\n className={cn(\n \"flex items-center gap-0.5 transition-opacity focus-within:opacity-100 group-hover/chat-message:opacity-100\",\n alwaysVisible ? \"opacity-100\" : \"opacity-0\"\n )}\n >\n {text ? (\n <Tooltip>\n <TooltipTrigger\n render={\n <CopyButton\n value={text}\n label={t(\"actions.copy\")}\n copiedLabel={t(\"actions.copied\")}\n onCopyError={() => toast.error(t(\"actions.copyFailed\"))}\n size=\"icon-xs\"\n variant=\"ghost\"\n className=\"text-muted-foreground\"\n />\n }\n />\n <TooltipContent>{t(\"actions.copy\")}</TooltipContent>\n </Tooltip>\n ) : null}\n\n {role === \"user\" && onEdit ? (\n <IconAction label={t(\"actions.edit\")} onClick={onEdit}>\n <PencilIcon />\n </IconAction>\n ) : null}\n\n {role === \"assistant\" && onRegenerate ? (\n <IconAction label={t(\"actions.regenerate\")} onClick={onRegenerate}>\n <RefreshCwIcon />\n </IconAction>\n ) : null}\n\n {role === \"assistant\" ? (\n <>\n <IconAction\n label={t(\"actions.feedbackUp\")}\n pressed={current === \"up\"}\n onClick={() => handleVote(\"up\")}\n >\n <ThumbsUpIcon />\n </IconAction>\n <IconAction\n label={t(\"actions.feedbackDown\")}\n pressed={current === \"down\"}\n onClick={() => handleVote(\"down\")}\n >\n <ThumbsDownIcon />\n </IconAction>\n {text ? (\n <IconAction\n label={t(\"actions.save\")}\n onClick={handleSave}\n disabled={isSaving}\n >\n {isSaving ? <Spinner /> : <FileDownIcon />}\n </IconAction>\n ) : null}\n </>\n ) : null}\n </div>\n );\n}\n",
|
|
252
|
+
"type": "registry:component",
|
|
253
|
+
"target": "components/chat/message-actions.tsx"
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
"path": "base/chat/components/data-parts.tsx",
|
|
257
|
+
"content": "\"use client\";\n\n/**\n * Renderers for the framework's own `data-chat-*` parts — the ones a\n * tool writes with `turn.write`, or a runtime binding maps to: a plan,\n * a delegated agent, a document streaming into the canvas, a question\n * the run paused on, a connection that needs a sign-in.\n *\n * Wired through `lib/chat-renderers.tsx`'s `DATA_RENDERERS`, so a\n * product can swap any of them without touching this file.\n */\n\nimport { useState } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport { BotIcon, ExternalLinkIcon } from \"lucide-react\";\n\nimport type {\n ChatAgentData,\n ChatArtifactData,\n ChatAuthorizationData,\n ChatQuestionData,\n ChatTaskData,\n} from \"@intelligo-dev/chat/client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n Item,\n ItemActions,\n ItemContent,\n ItemDescription,\n ItemMedia,\n ItemTitle,\n} from \"@/components/ui/item\";\nimport {\n ApprovalCard,\n type ApprovalCardAnswers,\n} from \"@/components/ui/ai-approval-card\";\nimport { StatusBadge } from \"@/components/ui/status-badge\";\nimport { AgentProgress } from \"@/components/ui/ai-agent-progress\";\nimport { TodoList, type TodoItemStatus } from \"@/components/ui/ai-todo-list\";\nimport {\n ArtifactCard,\n useArtifactStream,\n} from \"@/components/chat/artifact-card\";\nimport type { DataRendererProps } from \"@/lib/chat-renderers\";\n\nexport function ChatTaskCard({ data }: DataRendererProps) {\n const t = useTranslations(\"chat\");\n const task = data as ChatTaskData;\n const items = (task.items ?? []).map((item) => ({\n id: item.id,\n title: item.title,\n status: todoStatus(item.status),\n }));\n\n return (\n <TodoList\n className=\"max-w-xl\"\n items={items}\n title={task.title || t(\"task.title\")}\n label={t(\"task.title\")}\n emptyLabel={t(\"task.empty\")}\n completedLabel={(done, total) => t(\"task.progress\", { done, total })}\n statusLabels={{\n pending: t(\"activity.pending\"),\n \"in-progress\": t(\"activity.running\"),\n completed: t(\"activity.done\"),\n cancelled: t(\"activity.failed\"),\n }}\n />\n );\n}\n\nfunction todoStatus(status: ChatTaskData[\"status\"]): TodoItemStatus {\n switch (status) {\n case \"in_progress\":\n return \"in-progress\";\n case \"done\":\n return \"completed\";\n case \"failed\":\n return \"cancelled\";\n default:\n return \"pending\";\n }\n}\n\nexport function ChatAgentCard({ data }: DataRendererProps) {\n const t = useTranslations(\"chat\");\n const agent = data as ChatAgentData;\n const status =\n agent.status === \"completed\"\n ? \"success\"\n : agent.status === \"failed\"\n ? \"destructive\"\n : \"info\";\n\n return (\n <Item variant=\"outline\" size=\"sm\" className=\"max-w-xl\">\n <ItemMedia variant=\"icon\">\n <BotIcon />\n </ItemMedia>\n <ItemContent>\n <ItemTitle>{agent.name}</ItemTitle>\n {agent.summary ? (\n <ItemDescription>{agent.summary}</ItemDescription>\n ) : null}\n </ItemContent>\n <ItemActions>\n {agent.status === \"started\" ? (\n // A working subagent shows its pulse and how long it has run.\n <AgentProgress\n label={t(\"activity.running\")}\n running\n className=\"text-xs\"\n />\n ) : (\n <StatusBadge status={status} dot>\n {t(\n agent.status === \"completed\"\n ? \"activity.done\"\n : agent.status === \"failed\"\n ? \"activity.failed\"\n : \"activity.running\"\n )}\n </StatusBadge>\n )}\n </ItemActions>\n </Item>\n );\n}\n\n/**\n * The card for a document that streamed (or is streaming) into the\n * canvas. Opening it hands the canvas the part's own id, so the panel\n * that opened for the stream and the one opened from the card are the\n * same panel.\n */\nexport function ChatArtifactCard({\n data,\n isReadonly,\n actions,\n}: DataRendererProps) {\n const artifact = data as ChatArtifactData;\n const streamed = useArtifactStream(artifact.id);\n\n return (\n <ArtifactCard\n title={artifact.title}\n kind={artifact.kind}\n status={artifact.status}\n error={artifact.error}\n preview={streamed ?? artifact.content}\n onOpen={\n !isReadonly && actions && artifact.status !== \"error\"\n ? () =>\n actions.openCanvas({\n id: artifact.id,\n kind: artifact.kind,\n title: artifact.title,\n ...(artifact.documentId\n ? { documentId: artifact.documentId }\n : {}),\n ...(artifact.content !== undefined\n ? { content: artifact.content }\n : {}),\n status: artifact.status,\n })\n : undefined\n }\n />\n );\n}\n\n/**\n * A question the run paused on — eve's `ask_question`, or a product's\n * own. The answer goes back as the next user turn, so the runtime sees\n * it the way it sees any reply. The Questionnaire gives the\n * choices their keyboard shortcuts and the single/multiple semantics.\n */\nexport function ChatQuestionCard({\n data,\n isReadonly,\n actions,\n}: DataRendererProps) {\n const t = useTranslations(\"chat\");\n const question = data as ChatQuestionData;\n const [answered, setAnswered] = useState(question.answered ?? false);\n const canAnswer = !answered && !isReadonly && Boolean(actions);\n\n function submit(answers: ApprovalCardAnswers) {\n if (!canAnswer) return;\n const picked = answers[question.id] ?? { selected: [], custom: \"\" };\n const labels = picked.selected.map(\n (value) =>\n question.options?.find((option) => option.id === value)?.label ?? value\n );\n const answer = [...labels, picked.custom?.trim() ?? \"\"]\n .filter(Boolean)\n .join(\", \")\n .trim();\n if (!answer) return;\n setAnswered(true);\n actions?.sendMessage(answer);\n }\n\n return (\n <ApprovalCard\n className=\"max-w-xl\"\n status={answered ? \"answered\" : \"pending\"}\n questions={[\n {\n id: question.id,\n title: question.prompt,\n options: question.options?.map((option) => ({\n value: option.id,\n label: option.description\n ? `${option.label} — ${option.description}`\n : option.label,\n })),\n multiple: Boolean(question.options && question.multiple),\n allowCustom: !question.options || question.allowFreeform === true,\n autoAdvance: false,\n },\n ]}\n onSubmit={submit}\n submitLabel={t(\"question.answer\")}\n customPlaceholder={t(\"question.freeformPlaceholder\")}\n statusLabels={{\n pending: t(\"question.pending\"),\n answered: t(\"question.answered\"),\n }}\n />\n );\n}\n\nexport function ChatAuthorizationCard({ data }: DataRendererProps) {\n const t = useTranslations(\"chat\");\n const auth = data as ChatAuthorizationData;\n\n if (auth.status === \"completed\") {\n return (\n <StatusBadge status=\"success\" dot>\n {t(\"authorization.completed\", { name: auth.name })}\n </StatusBadge>\n );\n }\n\n return (\n <Item variant=\"outline\" size=\"sm\" className=\"max-w-xl\">\n <ItemMedia variant=\"icon\">\n <ExternalLinkIcon />\n </ItemMedia>\n <ItemContent>\n <ItemTitle>\n {t(\"authorization.required\", { name: auth.name })}\n </ItemTitle>\n {auth.instructions || auth.description ? (\n <ItemDescription>\n {auth.instructions ?? auth.description}\n </ItemDescription>\n ) : null}\n </ItemContent>\n {auth.url ? (\n <ItemActions>\n <Button\n size=\"sm\"\n variant=\"outline\"\n render={<a href={auth.url} target=\"_blank\" rel=\"noreferrer\" />}\n nativeButton={false}\n >\n {t(\"authorization.signIn\")}\n </Button>\n </ItemActions>\n ) : null}\n </Item>\n );\n}\n",
|
|
258
|
+
"type": "registry:component",
|
|
259
|
+
"target": "components/chat/data-parts.tsx"
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
"path": "base/chat/components/agent-activity.tsx",
|
|
263
|
+
"content": "\"use client\";\n\n/**\n * Mastra's data parts, on the activity timeline.\n *\n * `@mastra/ai-sdk` streams a workflow, an agent network and a nested\n * agent as `data-workflow`, `data-workflow-step`, `data-network`,\n * `data-tool-agent` and `data-tool-agent-step` parts, each a snapshot\n * reconciled by id. They arrive here untouched and render as steps on\n * `ai-agent-activity`. The shapes are read defensively — Mastra's payloads carry\n * more than what is shown, and a field that is missing costs a label,\n * not a render.\n */\n\nimport { useTranslations } from \"next-intl\";\n\nimport {\n AgentActivity,\n type AgentActivityItem,\n type AgentStepStatus,\n} from \"@/components/ui/ai-agent-activity\";\n\ntype ChainOfThoughtStepStatus = AgentStepStatus | \"error\";\nimport type { DataRendererProps } from \"@/lib/chat-renderers\";\n\ntype Snapshot = Record<string, unknown>;\n\nfunction asRecord(value: unknown): Snapshot {\n return typeof value === \"object\" && value !== null ? (value as Snapshot) : {};\n}\n\nfunction stepStatus(raw: unknown): ChainOfThoughtStepStatus {\n const status = typeof raw === \"string\" ? raw : \"\";\n if (status === \"success\" || status === \"completed\" || status === \"done\") {\n return \"complete\";\n }\n if (status === \"failed\" || status === \"error\") return \"error\";\n if (\n status === \"running\" ||\n status === \"in_progress\" ||\n status === \"streaming\"\n ) {\n return \"active\";\n }\n return \"pending\";\n}\n\nfunction statusMessageKey(status: ChainOfThoughtStepStatus) {\n switch (status) {\n case \"complete\":\n return \"activity.done\";\n case \"error\":\n return \"activity.failed\";\n case \"active\":\n return \"activity.running\";\n default:\n return \"activity.pending\";\n }\n}\n\n/** Mastra's `steps` map or array → ordered rows. */\nfunction stepsOf(snapshot: Snapshot): Array<{\n id: string;\n label: string;\n status: ChainOfThoughtStepStatus;\n}> {\n const raw = snapshot.steps;\n const entries: Array<[string, Snapshot]> = Array.isArray(raw)\n ? raw.map((step, index) => {\n const record = asRecord(step);\n return [String(record.id ?? record.stepId ?? index), record];\n })\n : Object.entries(asRecord(raw)).map(([id, step]) => [id, asRecord(step)]);\n return entries.map(([id, step]) => ({\n id,\n label: String(step.name ?? step.id ?? step.stepId ?? id),\n status: stepStatus(step.status),\n }));\n}\n\nfunction Timeline({\n title,\n status,\n steps,\n isStreaming,\n}: {\n title: string;\n status: ChainOfThoughtStepStatus;\n steps: ReturnType<typeof stepsOf>;\n isStreaming: boolean;\n}) {\n const t = useTranslations(\"chat\");\n const items: AgentActivityItem[] =\n steps.length === 0\n ? [\n {\n id: \"status\",\n type: \"step\",\n label: t(statusMessageKey(status)),\n status: activityStatus(status),\n },\n ]\n : steps.map((step) => ({\n id: step.id,\n type: \"step\" as const,\n label: step.label,\n status: activityStatus(step.status),\n meta: t(statusMessageKey(step.status)),\n }));\n const complete = status === \"complete\" || status === \"error\";\n\n return (\n <AgentActivity\n className=\"max-w-xl\"\n items={items}\n contentType=\"mixed\"\n status={complete || !isStreaming ? \"complete\" : \"working\"}\n defaultOpen={isStreaming}\n activeLabel={title}\n summary={\n <>\n {title}\n {steps.length > 0 ? (\n <span className=\"ml-2 font-normal text-muted-foreground\">\n {t(\"activity.steps\", { count: steps.length })}\n </span>\n ) : null}\n </>\n }\n />\n );\n}\n\n/** The activity stream knows three step states; a failure reads as complete with its label. */\nfunction activityStatus(status: ChainOfThoughtStepStatus): AgentStepStatus {\n return status === \"error\" ? \"complete\" : status;\n}\n\nexport function MastraWorkflowActivity({\n data,\n isStreaming,\n}: DataRendererProps) {\n const t = useTranslations(\"chat\");\n const snapshot = asRecord(data);\n return (\n <Timeline\n title={String(\n snapshot.name ?? snapshot.workflowId ?? t(\"activity.workflow\")\n )}\n status={stepStatus(snapshot.status)}\n steps={stepsOf(snapshot)}\n isStreaming={isStreaming}\n />\n );\n}\n\nexport function MastraWorkflowStepActivity({\n data,\n isStreaming,\n}: DataRendererProps) {\n const t = useTranslations(\"chat\");\n const snapshot = asRecord(data);\n return (\n <Timeline\n title={String(\n snapshot.name ??\n snapshot.stepId ??\n snapshot.id ??\n t(\"activity.workflow\")\n )}\n status={stepStatus(snapshot.status)}\n steps={[]}\n isStreaming={isStreaming}\n />\n );\n}\n\nexport function MastraNetworkActivity({\n data,\n isStreaming,\n}: DataRendererProps) {\n const t = useTranslations(\"chat\");\n const snapshot = asRecord(data);\n return (\n <Timeline\n title={String(\n snapshot.name ?? snapshot.networkId ?? t(\"activity.network\")\n )}\n status={stepStatus(snapshot.status)}\n steps={stepsOf(snapshot)}\n isStreaming={isStreaming}\n />\n );\n}\n\nexport function MastraToolAgentActivity({\n data,\n isStreaming,\n}: DataRendererProps) {\n const t = useTranslations(\"chat\");\n const snapshot = asRecord(data);\n return (\n <Timeline\n title={String(\n snapshot.name ?? snapshot.agentId ?? snapshot.id ?? t(\"activity.agent\")\n )}\n status={stepStatus(snapshot.status)}\n steps={stepsOf(snapshot)}\n isStreaming={isStreaming}\n />\n );\n}\n\nexport function MastraToolAgentStepActivity({\n data,\n isStreaming,\n}: DataRendererProps) {\n const t = useTranslations(\"chat\");\n const snapshot = asRecord(data);\n return (\n <Timeline\n title={String(snapshot.name ?? snapshot.agentId ?? t(\"activity.agent\"))}\n status={stepStatus(snapshot.status)}\n steps={[]}\n isStreaming={isStreaming}\n />\n );\n}\n",
|
|
264
|
+
"type": "registry:component",
|
|
265
|
+
"target": "components/chat/agent-activity.tsx"
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
"path": "base/chat/components/tool-activity.tsx",
|
|
269
|
+
"content": "\"use client\";\n\n/**\n * A run of the agent's own work — its reasoning and the tool calls\n * between its words — as one activity stream: live while it runs, then\n * a one-line summary (\"Searched the web ▸\") the reader opens on demand.\n *\n * Each call becomes a row. The default reads the call itself: an input\n * with a `query` is a search whose results are the sources the output\n * carries; anything else is the tool's label and its first string\n * input. A tool's entry in `TOOL_RENDERERS` can name the call (`label`)\n * or build its row (`activity`). The raw input and output stay one\n * click away, under the row — never on the page by default.\n */\n\nimport { getToolName, isReasoningUIPart, isToolUIPart } from \"ai\";\nimport type { UIMessage } from \"ai\";\nimport { useTranslations } from \"next-intl\";\nimport { Streamdown } from \"streamdown\";\n\nimport {\n AgentActivity,\n type AgentActivityItem,\n} from \"@/components/ui/ai-agent-activity\";\nimport { CodeBlock } from \"@/components/ui/ai-code-block\";\nimport {\n Reasoning,\n ReasoningContent,\n ReasoningTrigger,\n} from \"@/components/ui/ai-reasoning\";\nimport {\n resolveToolRenderer,\n toolLabel,\n type ToolActivityRow,\n type ToolRendererProps,\n} from \"@/lib/chat-renderers\";\nimport {\n isSettledToolState,\n primaryInput,\n sourceDomain,\n sourcesFromToolOutput,\n titleFromUrl,\n type PartAt,\n} from \"@/lib/message-parts\";\n\ntype Part = UIMessage[\"parts\"][number];\n\n/** Results shown under a search before \"+N more\". */\nconst VISIBLE_RESULTS = 4;\n\ninterface ToolActivityProps {\n parts: PartAt<Part>[];\n working: boolean;\n /** The renderer props for one tool part — the same ones a card gets. */\n toolProps: (part: Part) => ToolRendererProps;\n}\n\nexport function ToolActivity({ parts, working, toolProps }: ToolActivityProps) {\n const t = useTranslations(\"chat\");\n const tAny = useTranslations();\n\n // Thinking alone reads as thinking: the reasoning disclosure, timed.\n if (parts.every(({ part }) => isReasoningUIPart(part))) {\n const text = parts\n .map(({ part }) => (part as { text: string }).text)\n .join(\"\\n\\n\");\n return (\n <Reasoning isStreaming={working}>\n <ReasoningTrigger\n getThinkingMessage={(streaming, duration) =>\n streaming\n ? t(\"message.thinking\")\n : duration === undefined\n ? t(\"message.thoughtBriefly\")\n : t(\"message.thoughtFor\", { seconds: duration })\n }\n />\n <ReasoningContent>{text}</ReasoningContent>\n </Reasoning>\n );\n }\n\n const items: AgentActivityItem[] = [];\n let searches = 0;\n let tools = 0;\n let liveLabel: string | null = null;\n\n for (const { index, part } of parts) {\n const id = `part-${index}`;\n\n if (isReasoningUIPart(part)) {\n items.push({\n id,\n type: \"text\",\n content: (\n <Streamdown className=\"text-sm text-muted-foreground [&_p]:my-1 first:[&_p]:mt-0 last:[&_p]:mb-0\">\n {part.text}\n </Streamdown>\n ),\n });\n continue;\n }\n\n if (!isToolUIPart(part)) continue;\n const props = toolProps(part);\n const renderer = resolveToolRenderer(getToolName(part));\n const name = renderer.label\n ? tAny(renderer.label)\n : toolLabel(props.toolName);\n const row =\n renderer.activity?.(props) ??\n defaultRow(props, name, renderer.sources, t);\n const details = <ToolCallDetails {...props} />;\n\n if (row.type === \"search\") searches += 1;\n else tools += 1;\n if (row.status === \"running\") {\n liveLabel =\n row.type === \"search\" ? t(\"toolActivity.searching\") : `${name}…`;\n }\n items.push({\n ...row,\n id,\n details: row.details ?? details,\n } as AgentActivityItem);\n }\n\n const summary =\n searches > 0 && tools > 0\n ? t(\"toolActivity.searchedAndRan\", { count: tools })\n : searches > 0\n ? t(\"toolActivity.searched\")\n : tools > 0\n ? t(\"toolActivity.ranTools\", { count: tools })\n : t(\"toolActivity.thought\");\n\n return (\n <AgentActivity\n className=\"max-w-2xl\"\n // Room for a row the reader opened onto its call.\n maxHeight={working ? 208 : 480}\n items={items}\n status={working ? \"working\" : \"complete\"}\n activeLabel={liveLabel ?? t(\"toolActivity.working\")}\n summary={summary}\n moreLabel={(count) => t(\"toolActivity.more\", { count })}\n />\n );\n}\n\nfunction defaultRow(\n props: ToolRendererProps,\n name: string,\n readSources:\n ((output: unknown) => ReturnType<typeof sourcesFromToolOutput>) | undefined,\n t: (key: string, values?: Record<string, string | number>) => string\n): ToolActivityRow {\n const status =\n props.state === \"output-error\" || props.state === \"output-denied\"\n ? \"error\"\n : isSettledToolState(props.state)\n ? \"complete\"\n : \"running\";\n const input = props.input as Record<string, unknown> | undefined;\n const query = typeof input?.query === \"string\" ? input.query : undefined;\n\n if (query !== undefined && status !== \"error\") {\n const sources = (readSources ?? sourcesFromToolOutput)(props.output);\n return {\n type: \"search\",\n query,\n status,\n results: sources.slice(0, VISIBLE_RESULTS).map((source, index) => {\n const domain = sourceDomain(source);\n const title =\n (source.title && source.title !== domain\n ? source.title\n : undefined) ?? titleFromUrl(source.url);\n return {\n id: source.url ?? `${index}`,\n title: title ?? domain ?? source.url ?? \"\",\n ...(domain && title ? { domain } : {}),\n ...(source.url ? { url: source.url } : {}),\n };\n }),\n moreCount: Math.max(0, sources.length - VISIBLE_RESULTS),\n };\n }\n\n return {\n type: \"tool\",\n action: name,\n status,\n target:\n props.state === \"output-denied\"\n ? t(\"toolActivity.denied\", { tool: name })\n : props.state === \"output-error\"\n ? (props.errorText ?? t(\"toolActivity.failed\", { tool: name }))\n : primaryInput(props.input),\n };\n}\n\n/** The call as it happened: what went in, what came back. */\nfunction ToolCallDetails({ input, output, errorText }: ToolRendererProps) {\n const t = useTranslations(\"chat\");\n const blocks: Array<{\n label: string;\n code: string;\n language: \"json\" | \"text\";\n }> = [];\n if (input !== undefined) {\n blocks.push({\n label: t(\"toolActivity.input\"),\n code: stringify(input),\n language: \"json\",\n });\n }\n if (errorText) {\n blocks.push({\n label: t(\"toolActivity.error\"),\n code: errorText,\n language: \"text\",\n });\n } else if (output !== undefined) {\n blocks.push({\n label: t(\"toolActivity.output\"),\n code: stringify(output),\n language: \"json\",\n });\n }\n if (blocks.length === 0) return null;\n\n return (\n <div className=\"grid gap-2\">\n {blocks.map((block) => (\n <div key={block.label} className=\"grid gap-1\">\n <span className=\"text-xs font-medium text-muted-foreground\">\n {block.label}\n </span>\n <CodeBlock\n code={block.code}\n language={block.language}\n className=\"text-xs [&_pre]:max-h-48 [&_pre]:overflow-auto [&_pre]:p-2.5! [&_pre]:text-xs! [&_pre]:break-words [&_pre]:whitespace-pre-wrap\"\n />\n </div>\n ))}\n </div>\n );\n}\n\nfunction stringify(value: unknown): string {\n return typeof value === \"string\" ? value : JSON.stringify(value, null, 2);\n}\n",
|
|
270
|
+
"type": "registry:component",
|
|
271
|
+
"target": "components/chat/tool-activity.tsx"
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
"path": "base/chat/components/artifact-card.tsx",
|
|
275
|
+
"content": "\"use client\";\n\n/**\n * A document in the transcript: the kind's glyph, its title, what it\n * is (\"Code · py\"), and — while it is being written — the last lines\n * arriving under a fade. The whole card opens the document in the\n * canvas, or on the artifacts page where there is no canvas.\n *\n * The streamed text reaches a card two ways: a tool's own input as the\n * model writes it (`saveArtifact`), or the canvas's live content for a\n * document `createArtifactWriter` streams, which `ChatWorkspace`\n * shares through `ArtifactStreamProvider`.\n */\n\nimport { createContext, useContext, type ReactNode } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport { ChevronRightIcon, FileTextIcon } from \"lucide-react\";\n\nimport { ShimmerText } from \"@/components/ui/ai-shimmer-text\";\nimport { Link } from \"@/i18n/navigation\";\nimport { extensionOf, resolveCanvasKind } from \"@/lib/chat-canvas-config\";\nimport { cn } from \"@/lib/utils\";\n\ntype ArtifactStream = { id: string; content: string } | null;\n\nconst ArtifactStreamContext = createContext<ArtifactStream>(null);\n\n/** Shares the document streaming into the canvas with its card in the transcript. */\nexport function ArtifactStreamProvider({\n value,\n children,\n}: {\n value: ArtifactStream;\n children: ReactNode;\n}) {\n return (\n <ArtifactStreamContext.Provider value={value}>\n {children}\n </ArtifactStreamContext.Provider>\n );\n}\n\n/** The streamed content of the document with this id, while it streams. */\nexport function useArtifactStream(id: string | undefined): string | undefined {\n const stream = useContext(ArtifactStreamContext);\n return id && stream?.id === id ? stream.content : undefined;\n}\n\n/** The lines shown under a card while the document is written. */\nconst PREVIEW_LINES = 4;\n\nfunction tail(text: string): string {\n return text.trimEnd().split(\"\\n\").slice(-PREVIEW_LINES).join(\"\\n\");\n}\n\n/** Alpha-only: the older lines fade out above the newest. */\nconst PREVIEW_MASK = \"linear-gradient(to bottom, transparent, black 70%)\";\n\nexport interface ArtifactCardProps {\n title: string;\n kind: string;\n status: \"streaming\" | \"ready\" | \"error\";\n error?: string;\n /** The document's text so far, for the live preview while it streams. */\n preview?: string;\n /** Opens the document — the canvas. */\n onOpen?: () => void;\n /** Where the document lives when there is no canvas to open it in. */\n href?: string;\n className?: string;\n}\n\nexport function ArtifactCard({\n title,\n kind: kindName,\n status,\n error,\n preview,\n onOpen,\n href,\n className,\n}: ArtifactCardProps) {\n const t = useTranslations(\"chat\");\n const tAny = useTranslations();\n const kind = resolveCanvasKind(kindName);\n const Icon = kind.icon ?? FileTextIcon;\n const extension = kindName === \"code\" ? extensionOf(title) : undefined;\n const interactive = Boolean(onOpen || href);\n const previewText = status === \"streaming\" && preview ? tail(preview) : \"\";\n\n const subtitle =\n status === \"streaming\" ? (\n <ShimmerText role={undefined}>{t(\"artifactCard.streaming\")}</ShimmerText>\n ) : status === \"error\" ? (\n (error ?? t(\"artifactCard.failed\"))\n ) : (\n [kind.labelKey ? tAny(kind.labelKey) : null, extension]\n .filter(Boolean)\n .join(\" · \")\n );\n\n const content = (\n <>\n <span className=\"flex min-w-0 items-center gap-3 p-3\">\n <span\n aria-hidden=\"true\"\n className=\"grid size-10 shrink-0 place-items-center rounded-lg bg-muted text-muted-foreground\"\n >\n <Icon className=\"size-5\" />\n </span>\n <span className=\"grid min-w-0 flex-1 gap-0.5\">\n <span className=\"truncate text-sm leading-5 font-medium text-foreground\">\n {title}\n </span>\n <span\n className={cn(\n \"truncate text-xs leading-4 text-muted-foreground\",\n status === \"error\" && \"text-destructive\"\n )}\n >\n {subtitle}\n </span>\n </span>\n {interactive ? (\n <ChevronRightIcon\n aria-hidden=\"true\"\n className=\"size-4 shrink-0 text-muted-foreground transition-transform group-hover/artifact:translate-x-0.5\"\n />\n ) : null}\n </span>\n {previewText ? (\n <span\n aria-hidden=\"true\"\n className=\"block max-h-20 overflow-hidden border-t px-3 py-2 font-mono text-xs leading-4 whitespace-pre-wrap text-muted-foreground\"\n style={{ maskImage: PREVIEW_MASK, WebkitMaskImage: PREVIEW_MASK }}\n >\n {previewText}\n </span>\n ) : null}\n </>\n );\n\n const classes = cn(\n \"group/artifact flex w-full max-w-md flex-col overflow-hidden rounded-xl border bg-card text-left\",\n interactive &&\n \"outline-none transition-colors hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring\",\n className\n );\n\n if (onOpen) {\n return (\n <button\n type=\"button\"\n data-slot=\"artifact-card\"\n className={classes}\n onClick={onOpen}\n >\n {content}\n </button>\n );\n }\n if (href) {\n return (\n <Link data-slot=\"artifact-card\" href={href} className={classes}>\n {content}\n </Link>\n );\n }\n return (\n <div\n data-slot=\"artifact-card\"\n className={classes}\n aria-busy={status === \"streaming\" || undefined}\n >\n {content}\n </div>\n );\n}\n",
|
|
276
|
+
"type": "registry:component",
|
|
277
|
+
"target": "components/chat/artifact-card.tsx"
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
"path": "base/chat/components/chat-input.tsx",
|
|
281
|
+
"content": "\"use client\";\n\n/**\n * The composer, on `ai-prompt-input`: an autosizing\n * textarea that sends on Enter, attachments by \"+\" / paste / drop when\n * `chatConfig.attachments` allows them, a model picker when the page\n * offers more than one, and a submit that becomes stop while a reply\n * streams. The draft is the thread's, so nothing typed is lost to a\n * model switch or a navigation.\n *\n * ArrowUp in an empty composer loads the last message the reader sent,\n * to edit and re-send — the shell habit a chat inherits.\n */\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport { toast } from \"sonner\";\nimport type { FileUIPart } from \"ai\";\n\nimport type { ChatModelOption } from \"@intelligo-dev/chat/client\";\n\nimport { ComposerMenu } from \"@/components/ui/ai-composer-menu\";\nimport { SpeechInput } from \"@/components/ui/ai-speech-input\";\nimport { useComposerMenu } from \"@/hooks/use-composer-menu\";\n\nimport {\n PromptInput,\n PromptInputActionAddAttachments,\n PromptInputActionMenu,\n PromptInputActionMenuContent,\n PromptInputActionMenuTrigger,\n PromptInputAttachment,\n PromptInputAttachments,\n PromptInputFooter,\n PromptInputSelect,\n PromptInputSelectContent,\n PromptInputSelectItem,\n PromptInputSelectTrigger,\n PromptInputSelectValue,\n PromptInputSubmit,\n PromptInputTextarea,\n PromptInputTools,\n type PromptInputError,\n} from \"@/components/ui/ai-prompt-input\";\nimport { chatConfig, type ChatMention } from \"@/lib/chat-config\";\n\ninterface ChatInputProps {\n conversationId: string;\n value: string;\n onChange: (value: string) => void;\n onSend: (text: string, files: FileUIPart[], mentions: ChatMention[]) => void;\n onStop: () => void;\n onEditLast?: () => void;\n isStreaming: boolean;\n /**\n * Stops the composer accepting a turn that will be refused — out of\n * credits, or behind a feature gate. The banner above says why; this\n * is what keeps someone from writing a paragraph into a request the\n * route has already told us it will reject.\n */\n disabled?: boolean;\n /** Placeholder to show instead of the usual one while disabled. */\n disabledPlaceholder?: string;\n models?: ChatModelOption[];\n modelId?: string;\n onModelChange?: (modelId: string) => void;\n autoFocus?: boolean;\n compact?: boolean;\n}\n\nconst DEFAULT_UPLOAD_URL = \"/api/chat/upload\";\n\n/** In `stored` mode a picked file uploads first; the message carries its app URL. */\nasync function upload(\n file: FileUIPart,\n uploadUrl: string\n): Promise<FileUIPart> {\n const blob = await (await fetch(file.url)).blob();\n const form = new FormData();\n form.set(\"file\", blob, file.filename ?? \"file\");\n const response = await fetch(uploadUrl, { method: \"POST\", body: form });\n if (!response.ok) throw new Error(`upload failed: ${response.status}`);\n const result = (await response.json()) as {\n url: string;\n mediaType: string;\n filename: string;\n };\n return {\n type: \"file\",\n url: result.url,\n mediaType: result.mediaType,\n filename: result.filename,\n };\n}\n\nexport function ChatInput({\n conversationId,\n value,\n onChange,\n onSend,\n onStop,\n onEditLast,\n isStreaming,\n disabled = false,\n disabledPlaceholder,\n models = [],\n modelId,\n onModelChange,\n autoFocus = false,\n compact = false,\n}: ChatInputProps) {\n const t = useTranslations(\"chat\");\n const [uploading, setUploading] = useState(false);\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n const attachments = chatConfig.attachments;\n const placeholder =\n disabled && disabledPlaceholder\n ? disabledPlaceholder\n : t(\"input.placeholder\");\n\n useEffect(() => {\n if (autoFocus) textareaRef.current?.focus();\n }, [autoFocus]);\n\n const menu = useComposerMenu({\n value,\n setValue: onChange,\n conversationId,\n send: (text) => onSend(text, [], []),\n textareaRef,\n });\n\n function reportError(error: PromptInputError) {\n if (error.code === \"max_files\") {\n toast.error(\n t(\"attachments.tooMany\", { max: attachments?.maxFiles ?? 1 })\n );\n } else if (error.code === \"max_file_size\") {\n toast.error(t(\"attachments.tooLarge\"));\n } else {\n toast.error(t(\"attachments.unsupported\"));\n }\n }\n\n return (\n <div\n className={\n compact ? \"bg-background p-3 pt-2\" : \"bg-background px-4 pt-2 pb-4\"\n }\n >\n <PromptInput\n className={compact ? \"w-full\" : \"mx-auto max-w-3xl\"}\n accept={attachments?.accept.join(\",\")}\n multiple={(attachments?.maxFiles ?? 1) !== 1}\n maxFiles={attachments?.maxFiles}\n maxFileSize={attachments?.maxBytes}\n globalDrop={Boolean(attachments) && !compact}\n onError={reportError}\n onSubmit={async ({ text, files }) => {\n const trimmed = text.trim();\n if ((!trimmed && files.length === 0) || isStreaming || disabled) {\n return;\n }\n let parts = files;\n if (attachments?.mode === \"stored\" && files.length > 0) {\n setUploading(true);\n try {\n parts = await Promise.all(\n files.map((file) =>\n upload(file, attachments.uploadUrl ?? DEFAULT_UPLOAD_URL)\n )\n );\n } catch {\n toast.error(t(\"attachments.uploadFailed\"));\n throw new Error(\"upload failed\");\n } finally {\n setUploading(false);\n }\n }\n onSend(trimmed, parts, menu.mentions);\n menu.clearPicked();\n }}\n >\n {attachments ? (\n <PromptInputAttachments>\n {(file) => (\n <PromptInputAttachment\n key={file.id}\n data={file}\n removeLabel={t(\"attachments.remove\")}\n />\n )}\n </PromptInputAttachments>\n ) : null}\n <PromptInputTextarea\n ref={textareaRef}\n value={value}\n onChange={(event) => onChange(event.target.value)}\n onKeyDown={(event) => {\n if (menu.open && (event.key === \"Escape\" || event.key === \"Tab\")) {\n event.preventDefault();\n menu.close();\n return;\n }\n if (\n event.key === \"ArrowUp\" &&\n event.currentTarget.value === \"\" &&\n onEditLast\n ) {\n event.preventDefault();\n onEditLast();\n }\n }}\n onKeyUp={menu.refresh}\n onClick={menu.refresh}\n disabled={disabled}\n placeholder={placeholder}\n aria-label={placeholder}\n />\n <PromptInputFooter>\n <PromptInputTools>\n {attachments ? (\n <PromptInputActionMenu>\n <PromptInputActionMenuTrigger\n aria-label={t(\"attachments.add\")}\n />\n <PromptInputActionMenuContent>\n <PromptInputActionAddAttachments\n label={t(\"attachments.add\")}\n />\n </PromptInputActionMenuContent>\n </PromptInputActionMenu>\n ) : null}\n <SpeechInput\n startLabel={t(\"composer.voiceStart\")}\n stopLabel={t(\"composer.voiceStop\")}\n onTranscript={(text, isFinal) => {\n if (!isFinal) return;\n const spoken = text.trim();\n if (!spoken) return;\n onChange(\n value ? `${value.replace(/\\s+$/, \"\")} ${spoken}` : spoken\n );\n }}\n />\n {models.length > 1 && modelId && onModelChange ? (\n <PromptInputSelect\n value={modelId}\n onValueChange={(next) => {\n if (typeof next === \"string\") onModelChange(next);\n }}\n >\n <PromptInputSelectTrigger\n size=\"sm\"\n aria-label={t(\"model.label\")}\n >\n <PromptInputSelectValue>\n {(value: string) =>\n models.find((model) => model.id === value)?.label ?? value\n }\n </PromptInputSelectValue>\n </PromptInputSelectTrigger>\n <PromptInputSelectContent>\n {models.map((model) => (\n <PromptInputSelectItem key={model.id} value={model.id}>\n {model.label}\n </PromptInputSelectItem>\n ))}\n </PromptInputSelectContent>\n </PromptInputSelect>\n ) : null}\n </PromptInputTools>\n <PromptInputSubmit\n status={\n isStreaming ? \"streaming\" : uploading ? \"submitted\" : \"ready\"\n }\n label={isStreaming ? t(\"input.stop\") : t(\"input.send\")}\n disabled={!isStreaming && (disabled || uploading || !value.trim())}\n onClick={isStreaming ? onStop : undefined}\n />\n </PromptInputFooter>\n </PromptInput>\n <div className={compact ? \"relative\" : \"relative mx-auto max-w-3xl\"}>\n <ComposerMenu\n open={menu.open}\n options={menu.options}\n query={menu.query}\n onSelect={menu.select}\n emptyLabel={t(\"composer.noResults\")}\n />\n </div>\n {compact ? null : (\n <p className=\"mx-auto mt-1 max-w-3xl px-1 text-xs text-muted-foreground\">\n {t(\"input.hint\")}\n </p>\n )}\n </div>\n );\n}\n",
|
|
282
|
+
"type": "registry:component",
|
|
283
|
+
"target": "components/chat/chat-input.tsx"
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
"path": "base/chat/components/conversation-header.tsx",
|
|
287
|
+
"content": "\"use client\";\n\n/**\n * Conversation header — the agent's name, the title with inline\n * rename, the consumer's `headerRight` slot, share, and delete.\n * History lives in the shell's sidebar (`ChatHistory`), which already\n * opens as a sheet on a phone.\n *\n * Under the app shell it renders into the shell header's\n * `shell-header-slot`, so the page has one bar, not two; anywhere\n * without that slot it is a header of its own.\n */\n\nimport { useState, useSyncExternalStore, useTransition } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { useTranslations } from \"next-intl\";\nimport { CheckIcon, PencilIcon, Trash2Icon, XIcon } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { useRouter } from \"@/i18n/navigation\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n AlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\n\nimport { deleteConversation, renameConversation } from \"@/actions/chat\";\nimport { chatConfig } from \"@/lib/chat-config\";\nimport { ShareDialog } from \"./share-dialog\";\n\ninterface ConversationHeaderProps {\n conversationId: string;\n title: string | null;\n}\n\nexport function ConversationHeader({\n conversationId,\n title,\n}: ConversationHeaderProps) {\n const t = useTranslations(\"chat\");\n const router = useRouter();\n const [isPending, startTransition] = useTransition();\n const [isEditing, setIsEditing] = useState(false);\n const [draftTitle, setDraftTitle] = useState(title ?? \"\");\n const slot = useSyncExternalStore(\n subscribeToNothing,\n findSlot,\n noSlotOnServer\n );\n const HeaderRight = chatConfig.headerRight;\n const agentName = chatConfig.agent?.name ?? t(\"agent.defaultName\");\n const agentIcon = chatConfig.agent?.icon;\n\n function startEditing() {\n setDraftTitle(title ?? \"\");\n setIsEditing(true);\n }\n\n function commitRename() {\n const trimmed = draftTitle.trim();\n setIsEditing(false);\n if (!trimmed || trimmed === title) return;\n\n startTransition(async () => {\n const result = await renameConversation(conversationId, trimmed);\n if (!result.success) {\n toast.error(result.error);\n return;\n }\n router.refresh();\n });\n }\n\n function handleDelete() {\n startTransition(async () => {\n const result = await deleteConversation(conversationId);\n if (!result.success) {\n toast.error(result.error);\n return;\n }\n router.push(\"/chat\");\n // The shell's history is rendered by the layout, which a\n // navigation does not re-render.\n router.refresh();\n });\n }\n\n const bar = (\n <div\n data-slot=\"conversation-header\"\n className=\"flex min-w-0 flex-1 items-center justify-between gap-2\"\n >\n <div className=\"flex min-w-0 flex-1 items-center gap-1\">\n <span className=\"mr-1 flex shrink-0 items-center gap-1 text-xs text-muted-foreground\">\n {agentIcon ? <span aria-hidden>{agentIcon}</span> : null}\n <span className=\"max-w-24 truncate\">{agentName}</span>\n </span>\n {isEditing ? (\n <>\n <Input\n autoFocus\n aria-label={t(\"header.renameEdit\")}\n value={draftTitle}\n onChange={(event) => setDraftTitle(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === \"Enter\") commitRename();\n if (event.key === \"Escape\") setIsEditing(false);\n }}\n className=\"h-8 max-w-xs\"\n />\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n className=\"shrink-0\"\n onClick={commitRename}\n aria-label={t(\"header.renameSave\")}\n >\n <CheckIcon />\n </Button>\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n className=\"shrink-0\"\n onClick={() => setIsEditing(false)}\n aria-label={t(\"header.renameCancel\")}\n >\n <XIcon />\n </Button>\n </>\n ) : (\n <>\n <h1 className=\"truncate text-sm font-medium\">\n {title || t(\"header.newChatTitle\")}\n </h1>\n <Button\n size=\"icon-xs\"\n variant=\"ghost\"\n className=\"shrink-0\"\n onClick={startEditing}\n aria-label={t(\"header.renameEdit\")}\n >\n <PencilIcon />\n </Button>\n </>\n )}\n </div>\n\n <div className=\"flex shrink-0 items-center gap-2\">\n {HeaderRight ? <HeaderRight conversationId={conversationId} /> : null}\n\n <ShareDialog conversationId={conversationId} />\n\n {/* Confirmed: deleting a conversation is unrecoverable. */}\n <AlertDialog>\n <AlertDialogTrigger\n render={\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n className=\"text-muted-foreground hover:text-destructive\"\n disabled={isPending}\n aria-label={t(\"header.delete\")}\n />\n }\n >\n {isPending ? <Spinner /> : <Trash2Icon />}\n </AlertDialogTrigger>\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>{t(\"deleteDialog.title\")}</AlertDialogTitle>\n <AlertDialogDescription>\n {t(\"deleteDialog.description\")}\n </AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter>\n <AlertDialogCancel>{t(\"deleteDialog.cancel\")}</AlertDialogCancel>\n <AlertDialogAction onClick={handleDelete}>\n {t(\"deleteDialog.confirm\")}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n </div>\n </div>\n );\n\n // Undefined while rendering on the server and hydrating: the slot is\n // only known in the browser, and the bar appears once it is.\n if (slot === undefined) return null;\n if (slot) return createPortal(bar, slot);\n return (\n <header className=\"flex items-center border-b px-4 py-3\">{bar}</header>\n );\n}\n\nconst SLOT_SELECTOR = '[data-slot=\"shell-header-slot\"]';\n\nfunction subscribeToNothing() {\n return () => {};\n}\n\nfunction findSlot(): HTMLElement | null {\n return document.querySelector<HTMLElement>(SLOT_SELECTOR);\n}\n\nfunction noSlotOnServer(): undefined {\n return undefined;\n}\n",
|
|
288
|
+
"type": "registry:component",
|
|
289
|
+
"target": "components/chat/conversation-header.tsx"
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
"path": "base/chat/components/chat-history.tsx",
|
|
293
|
+
"content": "/**\n * The conversation history for the shell's sidebar — bind it as\n * `sidebarContent` in `lib/shell-config.tsx`:\n *\n * import { ChatHistory } from \"@/components/chat/chat-history\";\n *\n * export const shellConfig: ShellConfig = { sidebarContent: ChatHistory };\n *\n * A server component: the list arrives with the page, and anything that\n * changes it — a new conversation's title, a rename, a delete — calls\n * `router.refresh()`, which renders it again.\n */\n\nimport { listConversationHistory } from \"@/actions/chat\";\nimport { ChatHistoryNav } from \"./chat-history-nav\";\n\nexport async function ChatHistory() {\n const result = await listConversationHistory();\n if (!result.success) return null;\n return <ChatHistoryNav conversations={result.data} />;\n}\n",
|
|
294
|
+
"type": "registry:component",
|
|
295
|
+
"target": "components/chat/chat-history.tsx"
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
"path": "base/chat/components/chat-history-nav.tsx",
|
|
299
|
+
"content": "\"use client\";\n\n/**\n * Conversation history in the app's own sidebar, under the navigation —\n * grouped by recency with pinned on top, filterable, and every row with\n * rename, pin and delete (with a few seconds to change your mind). The\n * row for the conversation on screen is highlighted from the URL, and\n * the group hides when the sidebar collapses to icons. A deleted row\n * folds away and comes back on undo; a new one slides in.\n *\n * Keyboard: F2 renames the focused row, Delete deletes it.\n *\n * Filtering is client-side over what the server sent (the newest 100).\n * Past that size it belongs in a query.\n */\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n MoreHorizontalIcon,\n PencilIcon,\n PinIcon,\n PinOffIcon,\n PlusIcon,\n Trash2Icon,\n} from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { AnimatePresence, useReducedMotion } from \"motion/react\";\nimport { toast } from \"sonner\";\n\nimport { Link, usePathname, useRouter } from \"@/i18n/navigation\";\nimport { listItem } from \"@/components/ui/ai-motion\";\nimport {\n AISidebarItem,\n AISidebarMenu,\n AISidebarMenuItem,\n AISidebarSection,\n useAISidebar,\n useAISidebarPanel,\n} from \"@/components/ui/ai-sidebar\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n deleteConversation,\n renameConversation,\n setConversationPinned,\n type ConversationSummary,\n} from \"@/actions/chat\";\n\ntype Bucket = \"pinned\" | \"today\" | \"yesterday\" | \"week\" | \"month\" | \"older\";\n\nconst BUCKET_ORDER: Bucket[] = [\n \"pinned\",\n \"today\",\n \"yesterday\",\n \"week\",\n \"month\",\n \"older\",\n];\n\nconst UNDO_MS = 5000;\n/** The search field shows once the list is long enough to need it. */\nconst SEARCH_FROM = 8;\n\nfunction bucketFor(conversation: ConversationSummary, now: number): Bucket {\n if (conversation.pinned) return \"pinned\";\n const ageMs = now - new Date(conversation.updatedAt).getTime();\n const day = 24 * 60 * 60 * 1000;\n if (ageMs < day) return \"today\";\n if (ageMs < 2 * day) return \"yesterday\";\n if (ageMs < 7 * day) return \"week\";\n if (ageMs < 30 * day) return \"month\";\n return \"older\";\n}\n\nexport function ChatHistoryNav({\n conversations,\n}: {\n conversations: ConversationSummary[];\n}) {\n const t = useTranslations(\"chat\");\n const router = useRouter();\n const pathname = usePathname();\n const { isMobile, setOpenMobile } = useAISidebar();\n const { collapsed } = useAISidebarPanel();\n const reduced = useReducedMotion() ?? false;\n const activeId = pathname?.match(/^\\/chat\\/([^/]+)/)?.[1] ?? null;\n\n const [query, setQuery] = useState(\"\");\n const [renamingId, setRenamingId] = useState<string | null>(null);\n const [draft, setDraft] = useState(\"\");\n const [hidden, setHidden] = useState<Set<string>>(new Set());\n const [titles, setTitles] = useState<Record<string, string>>({});\n const [pins, setPins] = useState<Record<string, boolean>>({});\n const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());\n // Read when a delete lands, seconds after the click: by then the\n // reader may have opened another conversation.\n const activeIdRef = useRef(activeId);\n activeIdRef.current = activeId;\n\n // Leaving with a delete still in its undo window keeps the delete:\n // the reader asked for it and did not take it back.\n useEffect(() => {\n const pending = timers.current;\n return () => {\n for (const [id, timer] of pending) {\n clearTimeout(timer);\n void deleteConversation(id);\n }\n pending.clear();\n };\n }, []);\n\n const rows = useMemo(\n () =>\n conversations\n .filter((conversation) => !hidden.has(conversation.id))\n .map((conversation) => ({\n ...conversation,\n title: titles[conversation.id] ?? conversation.title,\n pinned: pins[conversation.id] ?? conversation.pinned,\n })),\n [conversations, hidden, titles, pins]\n );\n\n const grouped = useMemo(() => {\n const needle = query.trim().toLowerCase();\n const now = Date.now();\n const buckets = new Map<Bucket, typeof rows>();\n for (const conversation of rows) {\n if (\n needle &&\n conversation.id !== activeId &&\n !(conversation.title ?? \"\").toLowerCase().includes(needle)\n ) {\n continue;\n }\n const bucket = bucketFor(conversation, now);\n buckets.set(bucket, [...(buckets.get(bucket) ?? []), conversation]);\n }\n return buckets;\n }, [rows, query, activeId]);\n\n const empty = BUCKET_ORDER.every((bucket) => !grouped.get(bucket)?.length);\n\n function closeOnMobile() {\n if (isMobile) setOpenMobile(false);\n }\n\n function startRename(conversation: ConversationSummary) {\n setRenamingId(conversation.id);\n setDraft(titles[conversation.id] ?? conversation.title ?? \"\");\n }\n\n function commitRename(id: string) {\n const trimmed = draft.trim();\n setRenamingId(null);\n if (!trimmed) return;\n setTitles((previous) => ({ ...previous, [id]: trimmed }));\n void renameConversation(id, trimmed).then((result) => {\n if (!result.success) {\n setTitles((previous) => {\n const next = { ...previous };\n delete next[id];\n return next;\n });\n toast.error(result.error);\n return;\n }\n router.refresh();\n });\n }\n\n function togglePin(conversation: ConversationSummary) {\n const next = !(pins[conversation.id] ?? conversation.pinned);\n setPins((previous) => ({ ...previous, [conversation.id]: next }));\n void setConversationPinned(conversation.id, next).then((result) => {\n if (!result.success) {\n setPins((previous) => ({ ...previous, [conversation.id]: !next }));\n toast.error(result.error);\n }\n });\n }\n\n function remove(id: string) {\n setHidden((previous) => new Set(previous).add(id));\n const timer = setTimeout(() => {\n timers.current.delete(id);\n void deleteConversation(id).then((result) => {\n if (!result.success) {\n setHidden((previous) => {\n const next = new Set(previous);\n next.delete(id);\n return next;\n });\n toast.error(result.error);\n return;\n }\n if (id === activeIdRef.current) router.push(\"/chat\");\n router.refresh();\n });\n }, UNDO_MS);\n timers.current.set(id, timer);\n toast(t(\"sidebar.deleted\"), {\n duration: UNDO_MS,\n action: {\n label: t(\"sidebar.undo\"),\n onClick: () => {\n const pendingTimer = timers.current.get(id);\n if (pendingTimer) clearTimeout(pendingTimer);\n timers.current.delete(id);\n setHidden((previous) => {\n const next = new Set(previous);\n next.delete(id);\n return next;\n });\n },\n },\n });\n }\n\n // In the icon rail there is no room for titles; the nav rows stay.\n if (collapsed) return null;\n\n return (\n <AISidebarSection\n label={t(\"sidebar.label\")}\n action={\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={t(\"sidebar.newChat\")}\n title={t(\"sidebar.newChat\")}\n render={<Link href=\"/chat\" onClick={closeOnMobile} />}\n nativeButton={false}\n >\n <PlusIcon />\n </Button>\n }\n >\n <div className=\"flex flex-col gap-1\">\n {rows.length >= SEARCH_FROM ? (\n <Input\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n placeholder={t(\"sidebar.searchPlaceholder\")}\n aria-label={t(\"sidebar.searchPlaceholder\")}\n className=\"mb-1 h-8 bg-background\"\n />\n ) : null}\n\n {empty ? (\n <p className=\"px-2 py-1 text-xs text-muted-foreground\">\n {query ? t(\"sidebar.noMatches\") : t(\"header.historyEmpty\")}\n </p>\n ) : (\n BUCKET_ORDER.map((bucket) => {\n const items = grouped.get(bucket);\n if (!items?.length) return null;\n return (\n <div key={bucket} className=\"flex flex-col\">\n <p className=\"px-2 pt-2 pb-1 text-xs text-muted-foreground\">\n {t(`sidebar.groups.${bucket}`)}\n </p>\n <AISidebarMenu>\n <AnimatePresence initial={false}>\n {items.map((conversation) => {\n const label =\n conversation.title ?? t(\"header.historyUntitled\");\n const motionProps = reduced\n ? {}\n : {\n variants: listItem,\n initial: \"hidden\",\n animate: \"shown\",\n exit: \"exit\",\n };\n if (renamingId === conversation.id) {\n return (\n <AISidebarMenuItem\n key={conversation.id}\n {...motionProps}\n >\n <Input\n autoFocus\n value={draft}\n onChange={(event) => setDraft(event.target.value)}\n onBlur={() => commitRename(conversation.id)}\n onKeyDown={(event) => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n commitRename(conversation.id);\n }\n if (event.key === \"Escape\") setRenamingId(null);\n }}\n aria-label={t(\"sidebar.rename\")}\n className=\"h-8 bg-background\"\n />\n </AISidebarMenuItem>\n );\n }\n return (\n <AISidebarMenuItem\n key={conversation.id}\n {...motionProps}\n >\n <AISidebarItem\n isActive={conversation.id === activeId}\n title={label}\n render={<Link href={`/chat/${conversation.id}`} />}\n onKeyDown={(event) => {\n if (event.key === \"F2\") {\n event.preventDefault();\n startRename(conversation);\n } else if (event.key === \"Delete\") {\n event.preventDefault();\n remove(conversation.id);\n }\n }}\n action={\n <DropdownMenu>\n <DropdownMenuTrigger\n render={\n <Button\n variant=\"ghost\"\n size=\"icon-xs\"\n aria-label={t(\"sidebar.menu\")}\n />\n }\n >\n <MoreHorizontalIcon />\n </DropdownMenuTrigger>\n <DropdownMenuContent side=\"right\" align=\"start\">\n <DropdownMenuItem\n onClick={() => togglePin(conversation)}\n >\n {conversation.pinned ? (\n <PinOffIcon />\n ) : (\n <PinIcon />\n )}\n {conversation.pinned\n ? t(\"sidebar.unpin\")\n : t(\"sidebar.pin\")}\n </DropdownMenuItem>\n <DropdownMenuItem\n onClick={() => startRename(conversation)}\n >\n <PencilIcon />\n {t(\"sidebar.rename\")}\n </DropdownMenuItem>\n <DropdownMenuItem\n variant=\"destructive\"\n onClick={() => remove(conversation.id)}\n >\n <Trash2Icon />\n {t(\"sidebar.delete\")}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n }\n >\n {label}\n </AISidebarItem>\n </AISidebarMenuItem>\n );\n })}\n </AnimatePresence>\n </AISidebarMenu>\n </div>\n );\n })\n )}\n </div>\n </AISidebarSection>\n );\n}\n",
|
|
300
|
+
"type": "registry:component",
|
|
301
|
+
"target": "components/chat/chat-history-nav.tsx"
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
"path": "base/chat/components/share-dialog.tsx",
|
|
305
|
+
"content": "\"use client\";\n\n/**\n * Publish a conversation as a read-only page anyone with the link can\n * open, and stop sharing it again. What the page shows is decided\n * server-side (`sanitizeForShare` in `@intelligo-dev/chat`): the\n * reader's reasoning, tool details and attachments never leave.\n */\n\nimport { useEffect, useState, useTransition } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport { Share2Icon } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { CopyButton } from \"@/components/ui/copy-button\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { getShareState, setConversationShared } from \"@/actions/chat\";\n\ninterface ShareDialogProps {\n conversationId: string;\n}\n\nexport function ShareDialog({ conversationId }: ShareDialogProps) {\n const t = useTranslations(\"chat\");\n const [open, setOpen] = useState(false);\n const [shared, setShared] = useState<boolean | null>(null);\n const [isPending, startTransition] = useTransition();\n\n useEffect(() => {\n if (!open) return;\n void getShareState(conversationId).then((result) => {\n setShared(result.success ? result.data.shared : false);\n });\n }, [open, conversationId]);\n\n const url =\n typeof window === \"undefined\"\n ? \"\"\n : `${window.location.origin}/share/${conversationId}`;\n\n function toggle(next: boolean) {\n startTransition(async () => {\n const result = await setConversationShared(conversationId, next);\n if (!result.success) {\n toast.error(result.error);\n return;\n }\n setShared(next);\n });\n }\n\n return (\n <Dialog open={open} onOpenChange={setOpen}>\n <DialogTrigger\n render={\n <Button\n variant=\"ghost\"\n size=\"icon-sm\"\n className=\"text-muted-foreground\"\n aria-label={t(\"header.share\")}\n />\n }\n >\n <Share2Icon />\n </DialogTrigger>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>{t(\"share.title\")}</DialogTitle>\n <DialogDescription>{t(\"share.description\")}</DialogDescription>\n </DialogHeader>\n {shared === null ? (\n <Spinner />\n ) : shared ? (\n <div className=\"flex items-center gap-2\">\n <Input readOnly value={url} aria-label={t(\"share.copyLink\")} />\n <CopyButton\n value={url}\n label={t(\"share.copyLink\")}\n copiedLabel={t(\"share.linkCopied\")}\n onCopyError={() => toast.error(t(\"actions.copyFailed\"))}\n size=\"sm\"\n variant=\"outline\"\n >\n {t(\"share.copyLink\")}\n </CopyButton>\n </div>\n ) : null}\n <DialogFooter>\n {shared ? (\n <Button\n variant=\"outline\"\n type=\"button\"\n disabled={isPending}\n aria-busy={isPending || undefined}\n onClick={() => toggle(false)}\n >\n {isPending ? <Spinner data-icon=\"inline-start\" /> : null}\n {t(\"share.disable\")}\n </Button>\n ) : (\n <Button\n type=\"button\"\n disabled={isPending || shared === null}\n aria-busy={isPending || undefined}\n onClick={() => toggle(true)}\n >\n {isPending ? <Spinner data-icon=\"inline-start\" /> : null}\n {t(\"share.enable\")}\n </Button>\n )}\n </DialogFooter>\n </DialogContent>\n </Dialog>\n );\n}\n",
|
|
306
|
+
"type": "registry:component",
|
|
307
|
+
"target": "components/chat/share-dialog.tsx"
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
"path": "base/chat/components/credit-status-banner.tsx",
|
|
311
|
+
"content": "\"use client\";\n\n/**\n * Tells the reader about their credit before they spend a message on\n * finding out.\n *\n * Three states:\n *\n * - **blocked** — the next turn will be refused. Server-rendered\n * from `getChatQuotaState()`, or seeded from the route's own 402\n * when the balance runs out mid-conversation.\n * - **unavailable** — refused for a reason only the deployment can\n * fix: no billing configured, or a model with no registered price.\n * Neutral copy and no upgrade button, because paying changes\n * nothing.\n * - **running low** — allowed, but one more turn would take most of\n * what is left. A warning nobody asked for beats a refusal nobody\n * expected.\n *\n * Copy lives in this item's `chat` namespace, not in props.\n *\n * Amounts are credits, not money. That is the unit the balance and the\n * estimate are both in; rendering it as currency would need an\n * exchange rate this component has no business choosing.\n */\n\nimport { AlertTriangleIcon, SparklesIcon } from \"lucide-react\";\nimport { useFormatter, useTranslations } from \"next-intl\";\n\nimport type { ChatQuotaState } from \"@intelligo-dev/chat/client\";\n\nimport {\n Alert,\n AlertAction,\n AlertDescription,\n AlertTitle,\n} from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Link } from \"@/i18n/navigation\";\n\n/**\n * What the page opened with, read by `getChatQuotaState()`. The shape\n * is the transport's (`@intelligo-dev/chat/client`, which imports\n * nothing, so a client component can read it without pulling a server\n * module into its graph). Re-exported so `lib/chat-quota.ts` and the\n * page name it from the component that renders it.\n */\nexport type { ChatQuotaState };\n\n/**\n * A refusal that arrived from the route mid-conversation, rather than\n * from the server render.\n */\nexport type ChatBlock = {\n code: string;\n message: string;\n};\n\nconst DEPLOYMENT_REFUSALS: ReadonlySet<string> = new Set([\n \"unknown_model\",\n \"billing_not_configured\",\n \"MODEL_UNAVAILABLE\",\n \"BILLING_NOT_CONFIGURED\",\n]);\n\n/**\n * Whether a refusal is the deployment's to fix rather than the\n * reader's. Takes the entitlement port's code or the transport's.\n */\nexport function isDeploymentRefusal(code: string | null | undefined): boolean {\n return typeof code === \"string\" && DEPLOYMENT_REFUSALS.has(code);\n}\n\ninterface CreditStatusBannerProps {\n quotaState: ChatQuotaState | null;\n block: ChatBlock | null;\n}\n\nexport function CreditStatusBanner({\n quotaState,\n block,\n}: CreditStatusBannerProps) {\n const t = useTranslations(\"chat\");\n const format = useFormatter();\n\n // A block from the route wins: it is the newer fact. The\n // server-rendered state is what the page opened with, and the reader\n // may have spent their last credits since.\n const refusal: ChatBlock | null =\n block ??\n (quotaState && !quotaState.allowed\n ? { code: quotaState.code ?? \"\", message: quotaState.reason ?? \"\" }\n : null);\n\n if (refusal && isDeploymentRefusal(refusal.code)) {\n // The refusal's own message names what is misconfigured; that is\n // for the server log, not for the reader.\n return (\n <Alert\n data-testid=\"chat-unavailable-banner\"\n className=\"mx-auto mb-2 w-full max-w-3xl\"\n >\n <AlertTriangleIcon />\n <AlertTitle>{t(\"creditBanner.unavailable\")}</AlertTitle>\n <AlertDescription>\n {t(\"creditBanner.unavailableDescription\")}\n </AlertDescription>\n </Alert>\n );\n }\n\n if (refusal) {\n return (\n <Alert\n variant=\"destructive\"\n data-testid=\"credit-status-banner\"\n className=\"mx-auto mb-2 w-full max-w-3xl\"\n >\n <AlertTriangleIcon />\n <AlertTitle>\n {refusal.code === \"insufficient_credits\" ||\n refusal.code === \"allowance_depleted\"\n ? t(\"creditBanner.outOfCredits\")\n : t(\"creditBanner.upgradeRequired\")}\n </AlertTitle>\n {refusal.message ? (\n <AlertDescription>{refusal.message}</AlertDescription>\n ) : null}\n <AlertAction>\n <Button\n size=\"sm\"\n variant=\"destructive\"\n render={<Link href={quotaState?.upgradeHref ?? \"/pricing\"} />}\n nativeButton={false}\n >\n {t(\"creditBanner.upgrade\")}\n </Button>\n </AlertAction>\n </Alert>\n );\n }\n\n // \"Running low\" is one turn's worth of headroom, not a percentage: a\n // share of a large allowance can still be plenty, while twice the\n // estimate is exactly the point where the next message might be the\n // last one.\n const runningLow =\n quotaState !== null &&\n quotaState.allowed &&\n quotaState.estimated > 0 &&\n quotaState.remaining < quotaState.estimated * 2;\n\n if (!runningLow || !quotaState) return null;\n\n return (\n <Alert\n role=\"status\"\n data-testid=\"credit-status-warning\"\n className=\"mx-auto mb-2 w-full max-w-3xl border-warning/30 bg-warning/10 text-warning\"\n >\n <SparklesIcon />\n <AlertTitle>\n {t(\"creditBanner.runningLow\", {\n count: quotaState.remaining,\n amount: format.number(quotaState.remaining),\n })}\n </AlertTitle>\n <AlertAction>\n <Button\n size=\"xs\"\n variant=\"ghost\"\n className=\"text-warning hover:text-warning\"\n render={<Link href={quotaState.upgradeHref} />}\n nativeButton={false}\n >\n {t(\"creditBanner.topUp\")}\n </Button>\n </AlertAction>\n </Alert>\n );\n}\n",
|
|
312
|
+
"type": "registry:component",
|
|
313
|
+
"target": "components/chat/credit-status-banner.tsx"
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
"path": "base/chat/messages/en.json",
|
|
317
|
+
"content": "{\n \"conversationPage\": {\n \"title\": \"Chat\",\n \"unavailableTitle\": \"Chat unavailable\"\n },\n \"header\": {\n \"newChatTitle\": \"New chat\",\n \"renameSave\": \"Save title\",\n \"renameCancel\": \"Cancel rename\",\n \"renameEdit\": \"Rename conversation\",\n \"historyEmpty\": \"No past conversations\",\n \"historyUntitled\": \"Untitled conversation\",\n \"delete\": \"Delete conversation\",\n \"share\": \"Share\"\n },\n \"agent\": {\n \"defaultName\": \"Assistant\"\n },\n \"input\": {\n \"placeholder\": \"Send a message…\",\n \"stop\": \"Stop generating\",\n \"send\": \"Send message\",\n \"blockedPlaceholder\": \"Add credits to keep chatting\",\n \"unavailablePlaceholder\": \"Chat is temporarily unavailable\",\n \"hint\": \"Enter to send, Shift+Enter for a new line\"\n },\n \"emptyState\": {\n \"title\": \"Start a conversation\",\n \"description\": \"Ask a question to get started. Your conversation is saved automatically.\",\n \"greeting\": \"What can I help with?\"\n },\n \"list\": {\n \"label\": \"Conversation\",\n \"navigation\": \"Jump to a message\",\n \"navigationItem\": \"Go to {sender} message {index} of {total}\",\n \"emptyPreview\": \"Message\",\n \"scrollToEnd\": \"Scroll to the latest message\"\n },\n \"message\": {\n \"reasoning\": \"Reasoning\",\n \"thinking\": \"Thinking…\",\n \"thoughtBriefly\": \"Thought for a few seconds\",\n \"thoughtFor\": \"{seconds, plural, one {Thought for # second} other {Thought for # seconds}}\",\n \"attachment\": \"Attachment\",\n \"imageAlt\": \"Image attached to the message\"\n },\n \"stub\": {\n \"preface\": \"This is the stub chat model from lib/chat-model.ts — it echoes your message instead of calling a real provider. Edit getChatModel() to wire up @ai-sdk/anthropic, @ai-sdk/openai, or @ai-sdk/google.\",\n \"youSaid\": \"You said:\",\n \"savingArtifact\": \"Saving that as an artifact — check the Artifacts page in a moment.\"\n },\n \"error\": {\n \"generic\": \"Something went wrong. Please try again.\",\n \"retry\": \"Retry\",\n \"dismiss\": \"Dismiss\"\n },\n \"toolCard\": {\n \"calling\": \"calling…\",\n \"running\": \"running…\",\n \"needsApproval\": \"needs approval\",\n \"approved\": \"approved…\",\n \"done\": \"done\",\n \"error\": \"error\",\n \"denied\": \"denied\",\n \"input\": \"Input\",\n \"output\": \"Result\",\n \"errorHeading\": \"Error\"\n },\n \"toolActivity\": {\n \"working\": \"Working…\",\n \"searching\": \"Searching the web…\",\n \"searched\": \"Searched the web\",\n \"ranTools\": \"{count, plural, one {Ran a tool} other {Ran # tools}}\",\n \"searchedAndRan\": \"{count, plural, one {Searched the web and ran a tool} other {Searched the web and ran # tools}}\",\n \"thought\": \"Thought it through\",\n \"more\": \"+{count} more\",\n \"failed\": \"{tool} failed\",\n \"denied\": \"{tool} was denied\",\n \"input\": \"Input\",\n \"output\": \"Result\",\n \"error\": \"Error\"\n },\n \"approval\": {\n \"title\": \"Allow {tool} to run?\",\n \"description\": \"Review what it is about to do before it runs.\",\n \"allow\": \"Allow\",\n \"deny\": \"Deny\",\n \"reasonPlaceholder\": \"Why not? (optional)\",\n \"approved\": \"Approved\",\n \"denied\": \"Denied\",\n \"cancel\": \"Cancel\",\n \"details\": \"Details\",\n \"pending\": \"Waiting for you\"\n },\n \"question\": {\n \"answer\": \"Answer\",\n \"answered\": \"Answered\",\n \"freeformPlaceholder\": \"Type your answer…\",\n \"pending\": \"Input required\"\n },\n \"authorization\": {\n \"required\": \"{name} needs you to sign in\",\n \"signIn\": \"Sign in\",\n \"completed\": \"Connected to {name}\"\n },\n \"activity\": {\n \"title\": \"Activity\",\n \"steps\": \"{count, plural, one {# step} other {# steps}}\",\n \"workflow\": \"Workflow\",\n \"network\": \"Agent network\",\n \"agent\": \"Agent\",\n \"running\": \"Running\",\n \"done\": \"Done\",\n \"failed\": \"Failed\",\n \"pending\": \"Pending\"\n },\n \"task\": {\n \"title\": \"Plan\",\n \"empty\": \"No tasks yet\",\n \"progress\": \"{done} of {total} done\"\n },\n \"sources\": {\n \"title\": \"{count, plural, one {# source} other {# sources}}\",\n \"heading\": \"Sources\",\n \"citation\": \"Source {index}\",\n \"pill\": \"Source: {name}\"\n },\n \"actions\": {\n \"conversationNotFound\": \"That conversation no longer exists.\",\n \"conversationForbidden\": \"You don't have permission to view this conversation.\",\n \"invalidTitle\": \"That title isn't valid.\",\n \"databaseError\": \"Something went wrong. Please try again.\",\n \"genericError\": \"Something went wrong.\",\n \"copy\": \"Copy\",\n \"copied\": \"Copied\",\n \"copyFailed\": \"Couldn't copy to the clipboard.\",\n \"retry\": \"Retry\",\n \"regenerate\": \"Regenerate\",\n \"edit\": \"Edit\",\n \"editSave\": \"Save and send\",\n \"editCancel\": \"Cancel\",\n \"feedbackUp\": \"Good response\",\n \"feedbackDown\": \"Bad response\",\n \"feedbackSent\": \"Thanks for the feedback.\",\n \"feedbackFailed\": \"Couldn't save your feedback.\",\n \"save\": \"Save\",\n \"savedToArtifacts\": \"Saved to artifacts.\",\n \"emptyArtifact\": \"There's nothing in this reply to save.\"\n },\n \"branch\": {\n \"previous\": \"Previous version\",\n \"next\": \"Next version\"\n },\n \"route\": {\n \"invalidBody\": \"Invalid request body.\",\n \"unauthorized\": \"Unauthorized.\",\n \"rateLimited\": \"Rate limit exceeded. Try again in {seconds}s.\",\n \"featureGated\": \"Chat isn't included in your current plan.\",\n \"internalError\": \"Something went wrong.\",\n \"quotaExceeded\": \"Usage quota exceeded. Upgrade your plan or purchase credits.\",\n \"messageTooLong\": \"Message is too long (max {max, plural, one {# character} other {# characters}}).\",\n \"streamError\": \"Something went wrong while generating a response.\",\n \"attachmentRejected\": \"That attachment type isn't accepted.\",\n \"notFound\": \"That conversation no longer exists.\",\n \"billingNotConfigured\": \"Billing is not configured for this deployment.\",\n \"modelUnavailable\": \"Chat is temporarily unavailable. Please try again later.\"\n },\n \"deleteDialog\": {\n \"title\": \"Delete this conversation?\",\n \"description\": \"The conversation and its messages are deleted permanently. This can't be undone.\",\n \"cancel\": \"Cancel\",\n \"confirm\": \"Delete\"\n },\n \"artifactCard\": {\n \"open\": \"Open\",\n \"streaming\": \"Writing…\",\n \"failed\": \"Couldn't create the document\",\n \"untitled\": \"Untitled document\"\n },\n \"attachments\": {\n \"add\": \"Add photos or files\",\n \"remove\": \"Remove attachment\",\n \"tooMany\": \"You can attach {max, plural, one {# file} other {# files}} at most.\",\n \"tooLarge\": \"That file is too large.\",\n \"unsupported\": \"That file type isn't accepted.\",\n \"uploadFailed\": \"Couldn't upload that file.\"\n },\n \"model\": {\n \"label\": \"Model\"\n },\n \"composer\": {\n \"commands\": \"Commands\",\n \"mentions\": \"Mentions\",\n \"noResults\": \"No matches\",\n \"voiceStart\": \"Start voice input\",\n \"voiceStop\": \"Stop voice input\"\n },\n \"sidebar\": {\n \"label\": \"Conversations\",\n \"searchPlaceholder\": \"Search conversations\",\n \"noMatches\": \"No conversations match that.\",\n \"count\": \"{count, plural, =0 {No conversations} one {# conversation} other {# conversations}}\",\n \"groups\": {\n \"pinned\": \"Pinned\",\n \"today\": \"Today\",\n \"yesterday\": \"Yesterday\",\n \"week\": \"Previous 7 days\",\n \"month\": \"Previous 30 days\",\n \"older\": \"Older\"\n },\n \"menu\": \"Conversation options\",\n \"pin\": \"Pin\",\n \"unpin\": \"Unpin\",\n \"rename\": \"Rename\",\n \"delete\": \"Delete\",\n \"deleted\": \"Conversation deleted\",\n \"undo\": \"Undo\",\n \"newChat\": \"New chat\"\n },\n \"canvas\": {\n \"title\": \"Canvas\",\n \"close\": \"Close canvas\",\n \"version\": \"Version\",\n \"openInArtifacts\": \"Open in Artifacts\",\n \"copy\": \"Copy content\",\n \"download\": \"Download\",\n \"save\": \"Save\",\n \"saved\": \"Saved as a new version.\",\n \"unsavedChanges\": \"Unsaved changes\",\n \"view\": \"View\",\n \"preview\": \"Preview\",\n \"source\": \"Code\",\n \"olderVersion\": \"Older version\",\n \"newerVersion\": \"Newer version\",\n \"versionOf\": \"{index} / {count}\",\n \"restore\": \"Restore\",\n \"expand\": \"Fill the page\",\n \"collapse\": \"Show the conversation\",\n \"resize\": \"Resize the canvas\",\n \"kinds\": {\n \"text\": \"Document\",\n \"code\": \"Code\",\n \"sheet\": \"Spreadsheet\",\n \"image\": \"Image\"\n }\n },\n \"share\": {\n \"title\": \"Share this conversation\",\n \"description\": \"Anyone with the link can read it. Reasoning, tool details and attachments stay private.\",\n \"enable\": \"Create link\",\n \"disable\": \"Stop sharing\",\n \"copyLink\": \"Copy link\",\n \"linkCopied\": \"Link copied\",\n \"readOnly\": \"You are viewing a shared, read-only conversation.\",\n \"openApp\": \"Start your own\"\n },\n \"creditBanner\": {\n \"outOfCredits\": \"You're out of credits\",\n \"upgradeRequired\": \"This needs a plan upgrade\",\n \"upgrade\": \"Upgrade\",\n \"unavailable\": \"Chat is temporarily unavailable\",\n \"unavailableDescription\": \"Something on our side needs fixing. Please try again later.\",\n \"runningLow\": \"{count, plural, one {{amount} credit left} other {{amount} credits left}}\",\n \"topUp\": \"Top up\"\n },\n \"fileDiff\": {\n \"applying\": \"Applying changes\",\n \"applied\": \"Changes applied\",\n \"changes\": \"Changes\"\n },\n \"imageGeneration\": {\n \"queued\": \"Waiting to generate\",\n \"generating\": \"Generating image\",\n \"refining\": \"Refining details\",\n \"complete\": \"Image ready\",\n \"error\": \"Generation failed\"\n }\n}\n",
|
|
318
|
+
"type": "registry:file",
|
|
319
|
+
"target": "messages/en/chat.json"
|
|
320
|
+
}
|
|
321
|
+
],
|
|
322
|
+
"css": {
|
|
323
|
+
"@source \"../node_modules/streamdown/dist/*.js\"": {}
|
|
324
|
+
},
|
|
325
|
+
"type": "registry:block"
|
|
326
|
+
}
|