From 87e3c4e13b13d706aaa92c6554ef09f8270a7594 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 26 Feb 2026 01:02:20 +0100 Subject: [PATCH 1/6] init --- .gitignore | 1 + apps/api/src/index.ts | 7 +- apps/api/src/mcp/lib/resolve-tenant.ts | 33 + apps/api/src/services/Env.ts | 1 + apps/chat-agent/alchemy.run.ts | 46 + apps/chat-agent/package.json | 24 + apps/chat-agent/src/index.ts | 93 ++ apps/chat-agent/src/lib/system-prompt.ts | 31 + apps/chat-agent/src/lib/types.ts | 8 + apps/chat-agent/tsconfig.json | 22 + apps/chat-agent/wrangler.jsonc | 27 + apps/web/alchemy.run.ts | 10 + apps/web/package.json | 15 +- .../components/ai-elements/conversation.tsx | 167 ++ .../src/components/ai-elements/message.tsx | 359 +++++ .../components/ai-elements/prompt-input.tsx | 1338 +++++++++++++++++ .../src/components/ai-elements/shimmer.tsx | 78 + .../src/components/ai-elements/suggestion.tsx | 58 + .../attributes/attributes-table.tsx | 2 +- apps/web/src/components/attributes/index.ts | 3 +- .../src/components/attributes/json-value.tsx | 2 +- .../src/components/chat/chat-conversation.tsx | 160 ++ apps/web/src/components/chat/chat-page.tsx | 36 + apps/web/src/components/chat/chat-tabs.tsx | 69 + apps/web/src/components/chat/chat-trigger.tsx | 45 + apps/web/src/components/chat/index.ts | 4 + .../src/components/chat/tool-call-display.tsx | 60 + .../src/components/dashboard/app-sidebar.tsx | 6 + .../components/icons/chat-bubble-sparkle.tsx | 12 + apps/web/src/components/icons/index.ts | 1 + apps/web/src/hooks/use-chat-tabs.ts | 101 ++ .../src/lib/services/common/chat-agent-url.ts | 3 + apps/web/src/lib/utils.ts | 1 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/__root.tsx | 2 + apps/web/src/routes/chat.tsx | 17 + apps/web/src/styles.css | 1 + bun.lock | 541 ++++++- 38 files changed, 3378 insertions(+), 27 deletions(-) create mode 100644 apps/chat-agent/alchemy.run.ts create mode 100644 apps/chat-agent/package.json create mode 100644 apps/chat-agent/src/index.ts create mode 100644 apps/chat-agent/src/lib/system-prompt.ts create mode 100644 apps/chat-agent/src/lib/types.ts create mode 100644 apps/chat-agent/tsconfig.json create mode 100644 apps/chat-agent/wrangler.jsonc create mode 100644 apps/web/src/components/ai-elements/conversation.tsx create mode 100644 apps/web/src/components/ai-elements/message.tsx create mode 100644 apps/web/src/components/ai-elements/prompt-input.tsx create mode 100644 apps/web/src/components/ai-elements/shimmer.tsx create mode 100644 apps/web/src/components/ai-elements/suggestion.tsx create mode 100644 apps/web/src/components/chat/chat-conversation.tsx create mode 100644 apps/web/src/components/chat/chat-page.tsx create mode 100644 apps/web/src/components/chat/chat-tabs.tsx create mode 100644 apps/web/src/components/chat/chat-trigger.tsx create mode 100644 apps/web/src/components/chat/index.ts create mode 100644 apps/web/src/components/chat/tool-call-display.tsx create mode 100644 apps/web/src/components/icons/chat-bubble-sparkle.tsx create mode 100644 apps/web/src/hooks/use-chat-tabs.ts create mode 100644 apps/web/src/lib/services/common/chat-agent-url.ts create mode 100644 apps/web/src/lib/utils.ts create mode 100644 apps/web/src/routes/chat.tsx diff --git a/.gitignore b/.gitignore index 5aa8d82..d1a3139 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ apps/api/.data/ .alchemy .mcp.json +.dev.vars diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 85dafe7..3f1fc14 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -20,12 +20,17 @@ const HealthRouter = HttpLayerRouter.use((router) => router.add("GET", "/health", HttpServerResponse.text("OK")), ) +// Return 405 for GET /mcp so MCP Streamable HTTP clients skip SSE gracefully +const McpGetFallback = HttpLayerRouter.use((router) => + router.add("GET", "/mcp", HttpServerResponse.empty({ status: 405 })), +) + const DocsRoute = HttpApiScalar.layerHttpLayerRouter({ api: MapleApi, path: "/docs", }) -const AllRoutes = Layer.mergeAll(HttpApiRoutes, HealthRouter, DocsRoute, AutumnRouter, McpLive).pipe( +const AllRoutes = Layer.mergeAll(HttpApiRoutes, HealthRouter, McpGetFallback, DocsRoute, AutumnRouter, McpLive).pipe( Layer.provideMerge( HttpLayerRouter.cors({ allowedOrigins: ["*"], diff --git a/apps/api/src/mcp/lib/resolve-tenant.ts b/apps/api/src/mcp/lib/resolve-tenant.ts index ea87ebf..185abfe 100644 --- a/apps/api/src/mcp/lib/resolve-tenant.ts +++ b/apps/api/src/mcp/lib/resolve-tenant.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from "node:crypto" import { ManagedRuntime, Effect, Layer } from "effect" import type { TenantContext as McpTenantContext } from "@/lib/tenant-context" import { AuthService } from "@/services/AuthService" @@ -5,6 +6,9 @@ import { ApiKeysService } from "@/services/ApiKeysService" import { Env } from "@/services/Env" import { API_KEY_PREFIX } from "@maple/db" +const INTERNAL_SERVICE_PREFIX = "maple_svc_" + +const EnvRuntime = ManagedRuntime.make(Env.Default) const ApiKeyResolutionRuntime = ManagedRuntime.make( ApiKeysService.Live.pipe(Layer.provide(Env.Default)), ) @@ -31,6 +35,35 @@ const getBearerToken = (headers: Headers): string | undefined => { export async function resolveMcpTenantContext(request: Request): Promise { const token = getBearerToken(request.headers) + // Internal service auth (e.g. chat agent) + if (token && token.startsWith(INTERNAL_SERVICE_PREFIX)) { + const provided = token.slice(INTERNAL_SERVICE_PREFIX.length) + const env = await EnvRuntime.runPromise(Env) + const expected = env.INTERNAL_SERVICE_TOKEN + + if ( + expected.length > 0 && + provided.length === expected.length && + timingSafeEqual(Buffer.from(provided), Buffer.from(expected)) + ) { + const orgId = env.MAPLE_ORG_ID_OVERRIDE.length > 0 + ? env.MAPLE_ORG_ID_OVERRIDE + : request.headers.get("x-org-id") + if (!orgId) { + throw new Error("X-Org-Id header is required for internal service auth") + } + + return { + orgId, + userId: "internal-service", + roles: [], + authMode: "self_hosted", + } + } + + throw new Error("Invalid internal service token") + } + if (token && token.startsWith(API_KEY_PREFIX)) { const resolved = await ApiKeyResolutionRuntime.runPromise( ApiKeysService.resolveByKey(token), diff --git a/apps/api/src/services/Env.ts b/apps/api/src/services/Env.ts index 3fb6a61..fb6e9c2 100644 --- a/apps/api/src/services/Env.ts +++ b/apps/api/src/services/Env.ts @@ -21,6 +21,7 @@ export class Env extends Effect.Service()("Env", { MAPLE_ORG_ID_OVERRIDE: yield* Config.string("MAPLE_ORG_ID_OVERRIDE").pipe(Config.withDefault("")), AUTUMN_SECRET_KEY: yield* Config.string("AUTUMN_SECRET_KEY").pipe(Config.withDefault("")), SD_INTERNAL_TOKEN: yield* Config.string("SD_INTERNAL_TOKEN").pipe(Config.withDefault("")), + INTERNAL_SERVICE_TOKEN: yield* Config.string("INTERNAL_SERVICE_TOKEN").pipe(Config.withDefault("")), } as const if (env.MAPLE_AUTH_MODE.toLowerCase() !== "clerk" && env.MAPLE_ROOT_PASSWORD.trim().length === 0) { diff --git a/apps/chat-agent/alchemy.run.ts b/apps/chat-agent/alchemy.run.ts new file mode 100644 index 0000000..38cf642 --- /dev/null +++ b/apps/chat-agent/alchemy.run.ts @@ -0,0 +1,46 @@ +import alchemy from "alchemy" +import { Worker, DurableObjectNamespace } from "alchemy/cloudflare" +import { CloudflareStateStore } from "alchemy/state" + +const app = await alchemy("maple-chat-agent", { + ...(process.env.ALCHEMY_STATE_TOKEN + ? { stateStore: (scope) => new CloudflareStateStore(scope) } + : {}), +}) + +const chatAgentDO = DurableObjectNamespace("chat-agent-do", { + className: "ChatAgent", + sqlite: true, +}) + +const domains = + app.stage === "prd" + ? [{ domainName: "chat.maple.dev", adopt: true }] + : app.stage === "stg" + ? [{ domainName: "chat-staging.maple.dev", adopt: true }] + : undefined + +const workerName = + app.stage === "prd" + ? "maple-chat-agent" + : app.stage === "stg" + ? "maple-chat-agent-stg" + : `maple-chat-agent-${app.stage}` + +export const chatWorker = await Worker("chat-agent", { + name: workerName, + entrypoint: "./src/index.ts", + compatibility: "node", + url: true, + bindings: { + ChatAgent: chatAgentDO, + MAPLE_API_URL: process.env.MAPLE_API_URL ?? "http://localhost:3472", + INTERNAL_SERVICE_TOKEN: alchemy.secret(process.env.INTERNAL_SERVICE_TOKEN), + OPENROUTER_API_KEY: alchemy.secret(process.env.OPENROUTER_API_KEY), + }, + domains, + adopt: true, +}) + +console.log({ stage: app.stage, chatWorkerUrl: chatWorker.url }) +await app.finalize() diff --git a/apps/chat-agent/package.json b/apps/chat-agent/package.json new file mode 100644 index 0000000..e3cf19e --- /dev/null +++ b/apps/chat-agent/package.json @@ -0,0 +1,24 @@ +{ + "name": "@maple/chat-agent", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy:stack": "alchemy deploy", + "destroy:stack": "alchemy destroy", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@ai-sdk/mcp": "^1.0.21", + "@ai-sdk/openai-compatible": "^2.0.30", + "@cloudflare/ai-chat": "^0.1.3", + "agents": "^0.5.1", + "ai": "^6.0.97" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250214.0", + "alchemy": "https://pkg.pr.new/Makisuo/alchemy@e3f48848", + "typescript": "^5.9.3", + "wrangler": "^4.14.4" + } +} diff --git a/apps/chat-agent/src/index.ts b/apps/chat-agent/src/index.ts new file mode 100644 index 0000000..38efb9d --- /dev/null +++ b/apps/chat-agent/src/index.ts @@ -0,0 +1,93 @@ +import { AIChatAgent } from "@cloudflare/ai-chat" +import { createOpenAICompatible } from "@ai-sdk/openai-compatible" +import { createMCPClient } from "@ai-sdk/mcp" +import { convertToModelMessages, streamText, stepCountIs, type StreamTextOnFinishCallback } from "ai" +import { routeAgentRequest } from "agents" +import type { Env } from "./lib/types" +import { SYSTEM_PROMPT } from "./lib/system-prompt" + +export { ChatAgent } + +class ChatAgent extends AIChatAgent { + async onChatMessage( + onFinish: Parameters["onChatMessage"]>[0], + options?: Parameters["onChatMessage"]>[1], + ) { + const orgId = (options?.body as Record)?.orgId as string | undefined + if (!orgId) { + throw new Error("orgId is required in the request body") + } + + const mcpUrl = `${this.env.MAPLE_API_URL}/mcp` + console.log(`[chat-agent] Connecting to MCP server at ${mcpUrl} for org ${orgId}`) + + const mcpClient = await createMCPClient({ + transport: { + type: "http", + url: mcpUrl, + headers: { + Authorization: `Bearer maple_svc_${this.env.INTERNAL_SERVICE_TOKEN}`, + "X-Org-Id": orgId, + }, + }, + onUncaughtError: (error) => { + console.error("[chat-agent] MCP uncaught error:", error) + }, + }) + + let tools: Awaited> + try { + tools = await mcpClient.tools() + console.log(`[chat-agent] Loaded ${Object.keys(tools).length} tools from MCP server`) + } catch (error) { + await mcpClient.close() + console.error("[chat-agent] Error loading tools:", error) + throw error + } + + const openrouter = createOpenAICompatible({ + name: "openrouter", + baseURL: "https://openrouter.ai/api/v1", + apiKey: this.env.OPENROUTER_API_KEY, + }) + + const result = streamText({ + model: openrouter.chatModel("moonshotai/kimi-k2.5"), + system: SYSTEM_PROMPT, + messages: await convertToModelMessages(this.messages), + tools, + stopWhen: stepCountIs(10), + onFinish: async (event) => { + await mcpClient.close() + ;(onFinish as unknown as StreamTextOnFinishCallback)(event) + }, + }) + + return result.toUIMessageStreamResponse() + } +} + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", +} + +export default { + async fetch(request: Request, env: Env, _ctx: ExecutionContext) { + if (request.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders }) + } + + const response = await routeAgentRequest(request, env) + if (response) { + const newResponse = new Response(response.body, response) + for (const [key, value] of Object.entries(corsHeaders)) { + newResponse.headers.set(key, value) + } + return newResponse + } + + return new Response("Not Found", { status: 404 }) + }, +} satisfies ExportedHandler diff --git a/apps/chat-agent/src/lib/system-prompt.ts b/apps/chat-agent/src/lib/system-prompt.ts new file mode 100644 index 0000000..16685ec --- /dev/null +++ b/apps/chat-agent/src/lib/system-prompt.ts @@ -0,0 +1,31 @@ +export const SYSTEM_PROMPT = `You are Maple AI, an observability debugging assistant embedded in the Maple platform. + +You help users investigate and understand their distributed systems by analyzing traces, logs, metrics, and errors collected via OpenTelemetry. + +## Capabilities +- Check overall system health and error rates +- List and compare services with latency/throughput metrics +- Deep-dive into individual services (errors, logs, traces, Apdex) +- Find and categorize errors across the system +- Investigate specific error types with sample traces and logs +- Search and filter traces by duration, status, service, HTTP method +- Find the slowest traces with percentile benchmarks +- Inspect individual traces with full span trees and correlated logs +- Search logs by service, severity, text content, or trace ID +- Discover available metrics with type and data point counts + +## Guidelines +- When the user asks about system health or "how things are going", start with the system_health tool +- When investigating a specific service, use diagnose_service for a comprehensive view +- When the user mentions an error, use find_errors first, then error_detail for specifics +- If the user is on a specific service or trace page (indicated by pageContext), use that context automatically +- When showing trace IDs, mention the user can click them in the Maple UI for full details + +## Response Style +- Be concise. Lead with findings, not preamble +- DO NOT suggest next steps or follow-up actions unless the user explicitly asks what to do +- DO NOT narrate your tool calls or explain your investigation process +- Present data with context (time ranges, percentiles, comparisons) but skip unnecessary commentary +- Use markdown formatting: tables for comparisons, bold for key metrics, code for IDs +- Highlight anomalies and issues clearly, but let the user decide what to investigate next +` diff --git a/apps/chat-agent/src/lib/types.ts b/apps/chat-agent/src/lib/types.ts new file mode 100644 index 0000000..79dbf8d --- /dev/null +++ b/apps/chat-agent/src/lib/types.ts @@ -0,0 +1,8 @@ +import type { ChatAgent } from "../index" + +export interface Env { + ChatAgent: DurableObjectNamespace + MAPLE_API_URL: string + INTERNAL_SERVICE_TOKEN: string + OPENROUTER_API_KEY: string +} diff --git a/apps/chat-agent/tsconfig.json b/apps/chat-agent/tsconfig.json new file mode 100644 index 0000000..cdb287e --- /dev/null +++ b/apps/chat-agent/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/apps/chat-agent/wrangler.jsonc b/apps/chat-agent/wrangler.jsonc new file mode 100644 index 0000000..9ac8e9a --- /dev/null +++ b/apps/chat-agent/wrangler.jsonc @@ -0,0 +1,27 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "maple-chat-agent", + "main": "src/index.ts", + "compatibility_date": "2025-02-04", + "compatibility_flags": ["nodejs_compat"], + "assets": { + "directory": "public" + }, + "durable_objects": { + "bindings": [ + { + "name": "ChatAgent", + "class_name": "ChatAgent" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["ChatAgent"] + } + ], + "vars": { + "MAPLE_API_URL": "http://localhost:3472" + } +} diff --git a/apps/web/alchemy.run.ts b/apps/web/alchemy.run.ts index 23639fc..9caf064 100644 --- a/apps/web/alchemy.run.ts +++ b/apps/web/alchemy.run.ts @@ -31,6 +31,16 @@ if (!process.env.VITE_CLERK_PUBLISHABLE_KEY) { process.env.VITE_API_BASE_URL = railway.apiUrl process.env.VITE_INGEST_URL = railway.ingestUrl +const chatAgentUrl = + deploymentTarget.kind === "prd" + ? "https://chat.maple.dev" + : deploymentTarget.kind === "stg" + ? "https://chat-staging.maple.dev" + : process.env.VITE_CHAT_AGENT_URL ?? "" +if (chatAgentUrl) { + process.env.VITE_CHAT_AGENT_URL = chatAgentUrl +} + const webDomains = deploymentTarget.kind === "prd" ? [ diff --git a/apps/web/package.json b/apps/web/package.json index 0a82ce1..7723f00 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,18 +16,27 @@ "@base-ui/react": "^1.1.0", "@clerk/clerk-react": "^5.60.0", "@clerk/themes": "^2.4.55", + "@cloudflare/ai-chat": "^0.1.3", "@effect-atom/atom-react": "^0.5.0", "@effect/platform": "^0.94.4", "@fontsource-variable/geist-mono": "^5.2.7", + "@hugeicons/core-free-icons": "^3.1.1", + "@hugeicons/react": "^1.1.5", "@maple/domain": "workspace:*", "@maple/infra": "workspace:*", "@maple/ui": "workspace:*", + "@streamdown/cjk": "^1.0.2", + "@streamdown/code": "^1.0.3", + "@streamdown/math": "^1.0.2", + "@streamdown/mermaid": "^1.0.2", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-devtools": "^0.9.5", "@tanstack/react-router": "^1.159.5", "@tanstack/react-router-devtools": "^1.159.5", "@tinybirdco/sdk": "0.0.54", "@xyflow/react": "^12.10.0", + "agents": "^0.5.1", + "ai": "^6.0.100", "alchemy": "https://pkg.pr.new/Makisuo/alchemy@e3f48848", "autumn-js": "^0.1.75", "class-variance-authority": "^0.7.1", @@ -38,7 +47,9 @@ "effect": "^3.19.16", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", - "motion": "^12.34.1", + "lucide-react": "^0.575.0", + "motion": "^12.34.3", + "nanoid": "^5.1.6", "next-themes": "^0.4.6", "react": "^19.2.4", "react-day-picker": "^9.13.0", @@ -47,10 +58,12 @@ "react-resizable-panels": "^4.6.2", "recharts": "2.15.4", "sonner": "^2.0.7", + "streamdown": "^2.3.0", "sugar-high": "^0.9.5", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", "tw-animate-css": "^1.4.0", + "use-stick-to-bottom": "^1.1.3", "vaul": "^1.1.2", "web-vitals": "^5.1.0" }, diff --git a/apps/web/src/components/ai-elements/conversation.tsx b/apps/web/src/components/ai-elements/conversation.tsx new file mode 100644 index 0000000..d9d7ad9 --- /dev/null +++ b/apps/web/src/components/ai-elements/conversation.tsx @@ -0,0 +1,167 @@ +"use client"; + +import type { ComponentProps } from "react"; + +import { Button } from "@maple/ui/components/ui/button"; +import { cn } from "@/lib/utils"; +import { ArrowDownIcon, DownloadIcon } from "lucide-react"; +import { useCallback } from "react"; +import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; + +export type ConversationProps = ComponentProps; + +export const Conversation = ({ className, ...props }: ConversationProps) => ( + +); + +export type ConversationContentProps = ComponentProps< + typeof StickToBottom.Content +>; + +export const ConversationContent = ({ + className, + ...props +}: ConversationContentProps) => ( + +); + +export type ConversationEmptyStateProps = ComponentProps<"div"> & { + title?: string; + description?: string; + icon?: React.ReactNode; +}; + +export const ConversationEmptyState = ({ + className, + title = "No messages yet", + description = "Start a conversation to see messages here", + icon, + children, + ...props +}: ConversationEmptyStateProps) => ( +
+ {children ?? ( + <> + {icon &&
{icon}
} +
+

{title}

+ {description && ( +

{description}

+ )} +
+ + )} +
+); + +export type ConversationScrollButtonProps = ComponentProps; + +export const ConversationScrollButton = ({ + className, + ...props +}: ConversationScrollButtonProps) => { + const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + + const handleScrollToBottom = useCallback(() => { + scrollToBottom(); + }, [scrollToBottom]); + + return ( + !isAtBottom && ( + + ) + ); +}; + +export interface ConversationMessage { + role: "user" | "assistant" | "system" | "data" | "tool"; + content: string; +} + +export type ConversationDownloadProps = Omit< + ComponentProps, + "onClick" +> & { + messages: ConversationMessage[]; + filename?: string; + formatMessage?: (message: ConversationMessage, index: number) => string; +}; + +const defaultFormatMessage = (message: ConversationMessage): string => { + const roleLabel = + message.role.charAt(0).toUpperCase() + message.role.slice(1); + return `**${roleLabel}:** ${message.content}`; +}; + +export const messagesToMarkdown = ( + messages: ConversationMessage[], + formatMessage: ( + message: ConversationMessage, + index: number + ) => string = defaultFormatMessage +): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n"); + +export const ConversationDownload = ({ + messages, + filename = "conversation.md", + formatMessage = defaultFormatMessage, + className, + children, + ...props +}: ConversationDownloadProps) => { + const handleDownload = useCallback(() => { + const markdown = messagesToMarkdown(messages, formatMessage); + const blob = new Blob([markdown], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + }, [messages, filename, formatMessage]); + + return ( + + ); +}; diff --git a/apps/web/src/components/ai-elements/message.tsx b/apps/web/src/components/ai-elements/message.tsx new file mode 100644 index 0000000..a85f5e7 --- /dev/null +++ b/apps/web/src/components/ai-elements/message.tsx @@ -0,0 +1,359 @@ +"use client"; + +import type { UIMessage } from "ai"; +import type { ComponentProps, HTMLAttributes, ReactElement } from "react"; + +import { Button } from "@maple/ui/components/ui/button"; +import { + ButtonGroup, + ButtonGroupText, +} from "@maple/ui/components/ui/button-group"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@maple/ui/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { cjk } from "@streamdown/cjk"; +import { code } from "@streamdown/code"; +import { math } from "@streamdown/math"; +import { mermaid } from "@streamdown/mermaid"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { + createContext, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { Streamdown } from "streamdown"; + +export type MessageProps = HTMLAttributes & { + from: UIMessage["role"]; +}; + +export const Message = ({ className, from, ...props }: MessageProps) => ( +
+); + +export type MessageContentProps = HTMLAttributes; + +export const MessageContent = ({ + children, + className, + ...props +}: MessageContentProps) => ( +
+ {children} +
+); + +export type MessageActionsProps = ComponentProps<"div">; + +export const MessageActions = ({ + className, + children, + ...props +}: MessageActionsProps) => ( +
+ {children} +
+); + +export type MessageActionProps = ComponentProps & { + tooltip?: string; + label?: string; +}; + +export const MessageAction = ({ + tooltip, + children, + label, + variant = "ghost", + size = "icon-sm", + ...props +}: MessageActionProps) => { + const button = ( + + ); + + if (tooltip) { + return ( + + + {button} + +

{tooltip}

+
+
+
+ ); + } + + return button; +}; + +interface MessageBranchContextType { + currentBranch: number; + totalBranches: number; + goToPrevious: () => void; + goToNext: () => void; + branches: ReactElement[]; + setBranches: (branches: ReactElement[]) => void; +} + +const MessageBranchContext = createContext( + null +); + +const useMessageBranch = () => { + const context = useContext(MessageBranchContext); + + if (!context) { + throw new Error( + "MessageBranch components must be used within MessageBranch" + ); + } + + return context; +}; + +export type MessageBranchProps = HTMLAttributes & { + defaultBranch?: number; + onBranchChange?: (branchIndex: number) => void; +}; + +export const MessageBranch = ({ + defaultBranch = 0, + onBranchChange, + className, + ...props +}: MessageBranchProps) => { + const [currentBranch, setCurrentBranch] = useState(defaultBranch); + const [branches, setBranches] = useState([]); + + const handleBranchChange = useCallback( + (newBranch: number) => { + setCurrentBranch(newBranch); + onBranchChange?.(newBranch); + }, + [onBranchChange] + ); + + const goToPrevious = useCallback(() => { + const newBranch = + currentBranch > 0 ? currentBranch - 1 : branches.length - 1; + handleBranchChange(newBranch); + }, [currentBranch, branches.length, handleBranchChange]); + + const goToNext = useCallback(() => { + const newBranch = + currentBranch < branches.length - 1 ? currentBranch + 1 : 0; + handleBranchChange(newBranch); + }, [currentBranch, branches.length, handleBranchChange]); + + const contextValue = useMemo( + () => ({ + branches, + currentBranch, + goToNext, + goToPrevious, + setBranches, + totalBranches: branches.length, + }), + [branches, currentBranch, goToNext, goToPrevious] + ); + + return ( + +
div]:pb-0", className)} + {...props} + /> + + ); +}; + +export type MessageBranchContentProps = HTMLAttributes; + +export const MessageBranchContent = ({ + children, + ...props +}: MessageBranchContentProps) => { + const { currentBranch, setBranches, branches } = useMessageBranch(); + const childrenArray = useMemo( + () => (Array.isArray(children) ? children : [children]), + [children] + ); + + // Use useEffect to update branches when they change + useEffect(() => { + if (branches.length !== childrenArray.length) { + setBranches(childrenArray); + } + }, [childrenArray, branches, setBranches]); + + return childrenArray.map((branch, index) => ( +
div]:pb-0", + index === currentBranch ? "block" : "hidden" + )} + key={branch.key} + {...props} + > + {branch} +
+ )); +}; + +export type MessageBranchSelectorProps = ComponentProps; + +export const MessageBranchSelector = ({ + className, + ...props +}: MessageBranchSelectorProps) => { + const { totalBranches } = useMessageBranch(); + + // Don't render if there's only one branch + if (totalBranches <= 1) { + return null; + } + + return ( + *:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md", + className + )} + orientation="horizontal" + {...props} + /> + ); +}; + +export type MessageBranchPreviousProps = ComponentProps; + +export const MessageBranchPrevious = ({ + children, + ...props +}: MessageBranchPreviousProps) => { + const { goToPrevious, totalBranches } = useMessageBranch(); + + return ( + + ); +}; + +export type MessageBranchNextProps = ComponentProps; + +export const MessageBranchNext = ({ + children, + ...props +}: MessageBranchNextProps) => { + const { goToNext, totalBranches } = useMessageBranch(); + + return ( + + ); +}; + +export type MessageBranchPageProps = HTMLAttributes; + +export const MessageBranchPage = ({ + className, + ...props +}: MessageBranchPageProps) => { + const { currentBranch, totalBranches } = useMessageBranch(); + + return ( + + {currentBranch + 1} of {totalBranches} + + ); +}; + +export type MessageResponseProps = ComponentProps; + +const streamdownPlugins = { cjk, code, math, mermaid }; + +export const MessageResponse = memo( + ({ className, ...props }: MessageResponseProps) => ( + *:first-child]:mt-0 [&>*:last-child]:mb-0", + className + )} + plugins={streamdownPlugins} + {...props} + /> + ), + (prevProps, nextProps) => prevProps.children === nextProps.children +); + +MessageResponse.displayName = "MessageResponse"; + +export type MessageToolbarProps = ComponentProps<"div">; + +export const MessageToolbar = ({ + className, + children, + ...props +}: MessageToolbarProps) => ( +
+ {children} +
+); diff --git a/apps/web/src/components/ai-elements/prompt-input.tsx b/apps/web/src/components/ai-elements/prompt-input.tsx new file mode 100644 index 0000000..06e8937 --- /dev/null +++ b/apps/web/src/components/ai-elements/prompt-input.tsx @@ -0,0 +1,1338 @@ +"use client"; + +import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai"; +import type { + ChangeEvent, + ChangeEventHandler, + ClipboardEventHandler, + ComponentProps, + FormEvent, + FormEventHandler, + HTMLAttributes, + KeyboardEventHandler, + PropsWithChildren, + ReactNode, + RefObject, +} from "react"; + +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@maple/ui/components/ui/command"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@maple/ui/components/ui/dropdown-menu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@maple/ui/components/ui/hover-card"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupTextarea, +} from "@maple/ui/components/ui/input-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@maple/ui/components/ui/select"; +import { Spinner } from "@maple/ui/components/ui/spinner"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@maple/ui/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { + CornerDownLeftIcon, + ImageIcon, + PlusIcon, + SquareIcon, + XIcon, +} from "lucide-react"; +import { nanoid } from "nanoid"; +import { + Children, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +// ============================================================================ +// Helpers +// ============================================================================ + +const convertBlobUrlToDataUrl = async (url: string): Promise => { + try { + const response = await fetch(url); + const blob = await response.blob(); + // FileReader uses callback-based API, wrapping in Promise is necessary + // oxlint-disable-next-line eslint-plugin-promise(avoid-new) + return new Promise((resolve) => { + const reader = new FileReader(); + // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener) + reader.onloadend = () => resolve(reader.result as string); + // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener) + reader.onerror = () => resolve(null); + reader.readAsDataURL(blob); + }); + } catch { + return null; + } +}; + +// ============================================================================ +// Provider Context & Types +// ============================================================================ + +export interface AttachmentsContext { + files: (FileUIPart & { id: string })[]; + add: (files: File[] | FileList) => void; + remove: (id: string) => void; + clear: () => void; + openFileDialog: () => void; + fileInputRef: RefObject; +} + +export interface TextInputContext { + value: string; + setInput: (v: string) => void; + clear: () => void; +} + +export interface PromptInputControllerProps { + textInput: TextInputContext; + attachments: AttachmentsContext; + /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ + __registerFileInput: ( + ref: RefObject, + open: () => void + ) => void; +} + +const PromptInputController = createContext( + null +); +const ProviderAttachmentsContext = createContext( + null +); + +export const usePromptInputController = () => { + const ctx = useContext(PromptInputController); + if (!ctx) { + throw new Error( + "Wrap your component inside to use usePromptInputController()." + ); + } + return ctx; +}; + +// Optional variants (do NOT throw). Useful for dual-mode components. +const useOptionalPromptInputController = () => + useContext(PromptInputController); + +export const useProviderAttachments = () => { + const ctx = useContext(ProviderAttachmentsContext); + if (!ctx) { + throw new Error( + "Wrap your component inside to use useProviderAttachments()." + ); + } + return ctx; +}; + +const useOptionalProviderAttachments = () => + useContext(ProviderAttachmentsContext); + +export type PromptInputProviderProps = PropsWithChildren<{ + initialInput?: string; +}>; + +/** + * Optional global provider that lifts PromptInput state outside of PromptInput. + * If you don't use it, PromptInput stays fully self-managed. + */ +export const PromptInputProvider = ({ + initialInput: initialTextInput = "", + children, +}: PromptInputProviderProps) => { + // ----- textInput state + const [textInput, setTextInput] = useState(initialTextInput); + const clearInput = useCallback(() => setTextInput(""), []); + + // ----- attachments state (global when wrapped) + const [attachmentFiles, setAttachmentFiles] = useState< + (FileUIPart & { id: string })[] + >([]); + const fileInputRef = useRef(null); + // oxlint-disable-next-line eslint(no-empty-function) + const openRef = useRef<() => void>(() => {}); + + const add = useCallback((files: File[] | FileList) => { + const incoming = [...files]; + if (incoming.length === 0) { + return; + } + + setAttachmentFiles((prev) => [ + ...prev, + ...incoming.map((file) => ({ + filename: file.name, + id: nanoid(), + mediaType: file.type, + type: "file" as const, + url: URL.createObjectURL(file), + })), + ]); + }, []); + + const remove = useCallback((id: string) => { + setAttachmentFiles((prev) => { + const found = prev.find((f) => f.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((f) => f.id !== id); + }); + }, []); + + const clear = useCallback(() => { + setAttachmentFiles((prev) => { + for (const f of prev) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + return []; + }); + }, []); + + // Keep a ref to attachments for cleanup on unmount (avoids stale closure) + const attachmentsRef = useRef(attachmentFiles); + + useEffect(() => { + attachmentsRef.current = attachmentFiles; + }, [attachmentFiles]); + + // Cleanup blob URLs on unmount to prevent memory leaks + useEffect( + () => () => { + for (const f of attachmentsRef.current) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + }, + [] + ); + + const openFileDialog = useCallback(() => { + openRef.current?.(); + }, []); + + const attachments = useMemo( + () => ({ + add, + clear, + fileInputRef, + files: attachmentFiles, + openFileDialog, + remove, + }), + [attachmentFiles, add, remove, clear, openFileDialog] + ); + + const __registerFileInput = useCallback( + (ref: RefObject, open: () => void) => { + fileInputRef.current = ref.current; + openRef.current = open; + }, + [] + ); + + const controller = useMemo( + () => ({ + __registerFileInput, + attachments, + textInput: { + clear: clearInput, + setInput: setTextInput, + value: textInput, + }, + }), + [textInput, clearInput, attachments, __registerFileInput] + ); + + return ( + + + {children} + + + ); +}; + +// ============================================================================ +// Component Context & Hooks +// ============================================================================ + +const LocalAttachmentsContext = createContext(null); + +export const usePromptInputAttachments = () => { + // Prefer local context (inside PromptInput) as it has validation, fall back to provider + const provider = useOptionalProviderAttachments(); + const local = useContext(LocalAttachmentsContext); + const context = local ?? provider; + if (!context) { + throw new Error( + "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider" + ); + } + return context; +}; + +// ============================================================================ +// Referenced Sources (Local to PromptInput) +// ============================================================================ + +export interface ReferencedSourcesContext { + sources: (SourceDocumentUIPart & { id: string })[]; + add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void; + remove: (id: string) => void; + clear: () => void; +} + +export const LocalReferencedSourcesContext = + createContext(null); + +export const usePromptInputReferencedSources = () => { + const ctx = useContext(LocalReferencedSourcesContext); + if (!ctx) { + throw new Error( + "usePromptInputReferencedSources must be used within a LocalReferencedSourcesContext.Provider" + ); + } + return ctx; +}; + +export type PromptInputActionAddAttachmentsProps = ComponentProps< + typeof DropdownMenuItem +> & { + label?: string; +}; + +export const PromptInputActionAddAttachments = ({ + label = "Add photos or files", + ...props +}: PromptInputActionAddAttachmentsProps) => { + const attachments = usePromptInputAttachments(); + + const handleSelect = useCallback( + (e: Event) => { + e.preventDefault(); + attachments.openFileDialog(); + }, + [attachments] + ); + + return ( + + {label} + + ); +}; + +export interface PromptInputMessage { + text: string; + files: FileUIPart[]; +} + +export type PromptInputProps = Omit< + HTMLAttributes, + "onSubmit" | "onError" +> & { + // e.g., "image/*" or leave undefined for any + accept?: string; + multiple?: boolean; + // When true, accepts drops anywhere on document. Default false (opt-in). + globalDrop?: boolean; + // Render a hidden input with given name and keep it in sync for native form posts. Default false. + syncHiddenInput?: boolean; + // Minimal constraints + maxFiles?: number; + // bytes + maxFileSize?: number; + onError?: (err: { + code: "max_files" | "max_file_size" | "accept"; + message: string; + }) => void; + onSubmit: ( + message: PromptInputMessage, + event: FormEvent + ) => void | Promise; +}; + +export const PromptInput = ({ + className, + accept, + multiple, + globalDrop, + syncHiddenInput, + maxFiles, + maxFileSize, + onError, + onSubmit, + children, + ...props +}: PromptInputProps) => { + // Try to use a provider controller if present + const controller = useOptionalPromptInputController(); + const usingProvider = !!controller; + + // Refs + const inputRef = useRef(null); + const formRef = useRef(null); + + // ----- Local attachments (only used when no provider) + const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); + const files = usingProvider ? controller.attachments.files : items; + + // ----- Local referenced sources (always local to PromptInput) + const [referencedSources, setReferencedSources] = useState< + (SourceDocumentUIPart & { id: string })[] + >([]); + + // Keep a ref to files for cleanup on unmount (avoids stale closure) + const filesRef = useRef(files); + + useEffect(() => { + filesRef.current = files; + }, [files]); + + const openFileDialogLocal = useCallback(() => { + inputRef.current?.click(); + }, []); + + const matchesAccept = useCallback( + (f: File) => { + if (!accept || accept.trim() === "") { + return true; + } + + const patterns = accept + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + + return patterns.some((pattern) => { + if (pattern.endsWith("/*")) { + // e.g: image/* -> image/ + const prefix = pattern.slice(0, -1); + return f.type.startsWith(prefix); + } + return f.type === pattern; + }); + }, + [accept] + ); + + const addLocal = useCallback( + (fileList: File[] | FileList) => { + const incoming = [...fileList]; + const accepted = incoming.filter((f) => matchesAccept(f)); + if (incoming.length && accepted.length === 0) { + onError?.({ + code: "accept", + message: "No files match the accepted types.", + }); + return; + } + const withinSize = (f: File) => + maxFileSize ? f.size <= maxFileSize : true; + const sized = accepted.filter(withinSize); + if (accepted.length > 0 && sized.length === 0) { + onError?.({ + code: "max_file_size", + message: "All files exceed the maximum size.", + }); + return; + } + + setItems((prev) => { + const capacity = + typeof maxFiles === "number" + ? Math.max(0, maxFiles - prev.length) + : undefined; + const capped = + typeof capacity === "number" ? sized.slice(0, capacity) : sized; + if (typeof capacity === "number" && sized.length > capacity) { + onError?.({ + code: "max_files", + message: "Too many files. Some were not added.", + }); + } + const next: (FileUIPart & { id: string })[] = []; + for (const file of capped) { + next.push({ + filename: file.name, + id: nanoid(), + mediaType: file.type, + type: "file", + url: URL.createObjectURL(file), + }); + } + return [...prev, ...next]; + }); + }, + [matchesAccept, maxFiles, maxFileSize, onError] + ); + + const removeLocal = useCallback( + (id: string) => + setItems((prev) => { + const found = prev.find((file) => file.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((file) => file.id !== id); + }), + [] + ); + + // Wrapper that validates files before calling provider's add + const addWithProviderValidation = useCallback( + (fileList: File[] | FileList) => { + const incoming = [...fileList]; + const accepted = incoming.filter((f) => matchesAccept(f)); + if (incoming.length && accepted.length === 0) { + onError?.({ + code: "accept", + message: "No files match the accepted types.", + }); + return; + } + const withinSize = (f: File) => + maxFileSize ? f.size <= maxFileSize : true; + const sized = accepted.filter(withinSize); + if (accepted.length > 0 && sized.length === 0) { + onError?.({ + code: "max_file_size", + message: "All files exceed the maximum size.", + }); + return; + } + + const currentCount = files.length; + const capacity = + typeof maxFiles === "number" + ? Math.max(0, maxFiles - currentCount) + : undefined; + const capped = + typeof capacity === "number" ? sized.slice(0, capacity) : sized; + if (typeof capacity === "number" && sized.length > capacity) { + onError?.({ + code: "max_files", + message: "Too many files. Some were not added.", + }); + } + + if (capped.length > 0) { + controller?.attachments.add(capped); + } + }, + [matchesAccept, maxFileSize, maxFiles, onError, files.length, controller] + ); + + const clearAttachments = useCallback( + () => + usingProvider + ? controller?.attachments.clear() + : setItems((prev) => { + for (const file of prev) { + if (file.url) { + URL.revokeObjectURL(file.url); + } + } + return []; + }), + [usingProvider, controller] + ); + + const clearReferencedSources = useCallback( + () => setReferencedSources([]), + [] + ); + + const add = usingProvider ? addWithProviderValidation : addLocal; + const remove = usingProvider ? controller.attachments.remove : removeLocal; + const openFileDialog = usingProvider + ? controller.attachments.openFileDialog + : openFileDialogLocal; + + const clear = useCallback(() => { + clearAttachments(); + clearReferencedSources(); + }, [clearAttachments, clearReferencedSources]); + + // Let provider know about our hidden file input so external menus can call openFileDialog() + useEffect(() => { + if (!usingProvider) { + return; + } + controller.__registerFileInput(inputRef, () => inputRef.current?.click()); + }, [usingProvider, controller]); + + // Note: File input cannot be programmatically set for security reasons + // The syncHiddenInput prop is no longer functional + useEffect(() => { + if (syncHiddenInput && inputRef.current && files.length === 0) { + inputRef.current.value = ""; + } + }, [files, syncHiddenInput]); + + // Attach drop handlers on nearest form and document (opt-in) + useEffect(() => { + const form = formRef.current; + if (!form) { + return; + } + if (globalDrop) { + // when global drop is on, let the document-level handler own drops + return; + } + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + form.addEventListener("dragover", onDragOver); + form.addEventListener("drop", onDrop); + return () => { + form.removeEventListener("dragover", onDragOver); + form.removeEventListener("drop", onDrop); + }; + }, [add, globalDrop]); + + useEffect(() => { + if (!globalDrop) { + return; + } + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + document.addEventListener("dragover", onDragOver); + document.addEventListener("drop", onDrop); + return () => { + document.removeEventListener("dragover", onDragOver); + document.removeEventListener("drop", onDrop); + }; + }, [add, globalDrop]); + + useEffect( + () => () => { + if (!usingProvider) { + for (const f of filesRef.current) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current + [usingProvider] + ); + + const handleChange: ChangeEventHandler = useCallback( + (event) => { + if (event.currentTarget.files) { + add(event.currentTarget.files); + } + // Reset input value to allow selecting files that were previously removed + event.currentTarget.value = ""; + }, + [add] + ); + + const attachmentsCtx = useMemo( + () => ({ + add, + clear: clearAttachments, + fileInputRef: inputRef, + files: files.map((item) => ({ ...item, id: item.id })), + openFileDialog, + remove, + }), + [files, add, remove, clearAttachments, openFileDialog] + ); + + const refsCtx = useMemo( + () => ({ + add: (incoming: SourceDocumentUIPart[] | SourceDocumentUIPart) => { + const array = Array.isArray(incoming) ? incoming : [incoming]; + setReferencedSources((prev) => [ + ...prev, + ...array.map((s) => ({ ...s, id: nanoid() })), + ]); + }, + clear: clearReferencedSources, + remove: (id: string) => { + setReferencedSources((prev) => prev.filter((s) => s.id !== id)); + }, + sources: referencedSources, + }), + [referencedSources, clearReferencedSources] + ); + + const handleSubmit: FormEventHandler = useCallback( + async (event) => { + event.preventDefault(); + + const form = event.currentTarget; + const text = usingProvider + ? controller.textInput.value + : (() => { + const formData = new FormData(form); + return (formData.get("message") as string) || ""; + })(); + + // Reset form immediately after capturing text to avoid race condition + // where user input during async blob conversion would be lost + if (!usingProvider) { + form.reset(); + } + + try { + // Convert blob URLs to data URLs asynchronously + const convertedFiles: FileUIPart[] = await Promise.all( + files.map(async ({ id: _id, ...item }) => { + if (item.url?.startsWith("blob:")) { + const dataUrl = await convertBlobUrlToDataUrl(item.url); + // If conversion failed, keep the original blob URL + return { + ...item, + url: dataUrl ?? item.url, + }; + } + return item; + }) + ); + + const result = onSubmit({ files: convertedFiles, text }, event); + + // Handle both sync and async onSubmit + if (result instanceof Promise) { + try { + await result; + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + } catch { + // Don't clear on error - user may want to retry + } + } else { + // Sync function completed without throwing, clear inputs + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + } + } catch { + // Don't clear on error - user may want to retry + } + }, + [usingProvider, controller, files, onSubmit, clear] + ); + + // Render with or without local provider + const inner = ( + <> + +
+ {children} +
+ + ); + + const withReferencedSources = ( + + {inner} + + ); + + // Always provide LocalAttachmentsContext so children get validated add function + return ( + + {withReferencedSources} + + ); +}; + +export type PromptInputBodyProps = HTMLAttributes; + +export const PromptInputBody = ({ + className, + ...props +}: PromptInputBodyProps) => ( +
+); + +export type PromptInputTextareaProps = ComponentProps< + typeof InputGroupTextarea +>; + +export const PromptInputTextarea = ({ + onChange, + onKeyDown, + className, + placeholder = "What would you like to know?", + ...props +}: PromptInputTextareaProps) => { + const controller = useOptionalPromptInputController(); + const attachments = usePromptInputAttachments(); + const [isComposing, setIsComposing] = useState(false); + + const handleKeyDown: KeyboardEventHandler = useCallback( + (e) => { + // Call the external onKeyDown handler first + onKeyDown?.(e); + + // If the external handler prevented default, don't run internal logic + if (e.defaultPrevented) { + return; + } + + if (e.key === "Enter") { + if (isComposing || e.nativeEvent.isComposing) { + return; + } + if (e.shiftKey) { + return; + } + e.preventDefault(); + + // Check if the submit button is disabled before submitting + const { form } = e.currentTarget; + const submitButton = form?.querySelector( + 'button[type="submit"]' + ) as HTMLButtonElement | null; + if (submitButton?.disabled) { + return; + } + + form?.requestSubmit(); + } + + // Remove last attachment when Backspace is pressed and textarea is empty + if ( + e.key === "Backspace" && + e.currentTarget.value === "" && + attachments.files.length > 0 + ) { + e.preventDefault(); + const lastAttachment = attachments.files.at(-1); + if (lastAttachment) { + attachments.remove(lastAttachment.id); + } + } + }, + [onKeyDown, isComposing, attachments] + ); + + const handlePaste: ClipboardEventHandler = useCallback( + (event) => { + const items = event.clipboardData?.items; + + if (!items) { + return; + } + + const files: File[] = []; + + for (const item of items) { + if (item.kind === "file") { + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + } + + if (files.length > 0) { + event.preventDefault(); + attachments.add(files); + } + }, + [attachments] + ); + + const handleCompositionEnd = useCallback(() => setIsComposing(false), []); + const handleCompositionStart = useCallback(() => setIsComposing(true), []); + + const controlledProps = controller + ? { + onChange: (e: ChangeEvent) => { + controller.textInput.setInput(e.currentTarget.value); + onChange?.(e); + }, + value: controller.textInput.value, + } + : { + onChange, + }; + + return ( + + ); +}; + +export type PromptInputHeaderProps = Omit< + ComponentProps, + "align" +>; + +export const PromptInputHeader = ({ + className, + ...props +}: PromptInputHeaderProps) => ( + +); + +export type PromptInputFooterProps = Omit< + ComponentProps, + "align" +>; + +export const PromptInputFooter = ({ + className, + ...props +}: PromptInputFooterProps) => ( + +); + +export type PromptInputToolsProps = HTMLAttributes; + +export const PromptInputTools = ({ + className, + ...props +}: PromptInputToolsProps) => ( +
+); + +export type PromptInputButtonTooltip = + | string + | { + content: ReactNode; + shortcut?: string; + side?: ComponentProps["side"]; + }; + +export type PromptInputButtonProps = ComponentProps & { + tooltip?: PromptInputButtonTooltip; +}; + +export const PromptInputButton = ({ + variant = "ghost", + className, + size, + tooltip, + ...props +}: PromptInputButtonProps) => { + const newSize = + size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm"); + + const button = ( + + ); + + if (!tooltip) { + return button; + } + + const tooltipContent = + typeof tooltip === "string" ? tooltip : tooltip.content; + const shortcut = typeof tooltip === "string" ? undefined : tooltip.shortcut; + const side = typeof tooltip === "string" ? "top" : (tooltip.side ?? "top"); + + return ( + + {button} + + {tooltipContent} + {shortcut && ( + {shortcut} + )} + + + ); +}; + +export type PromptInputActionMenuProps = ComponentProps; +export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => ( + +); + +export type PromptInputActionMenuTriggerProps = PromptInputButtonProps; + +export const PromptInputActionMenuTrigger = ({ + className, + children, + ...props +}: PromptInputActionMenuTriggerProps) => ( + }>{children ?? } +); + +export type PromptInputActionMenuContentProps = ComponentProps< + typeof DropdownMenuContent +>; +export const PromptInputActionMenuContent = ({ + className, + ...props +}: PromptInputActionMenuContentProps) => ( + +); + +export type PromptInputActionMenuItemProps = ComponentProps< + typeof DropdownMenuItem +>; +export const PromptInputActionMenuItem = ({ + className, + ...props +}: PromptInputActionMenuItemProps) => ( + +); + +// Note: Actions that perform side-effects (like opening a file dialog) +// are provided in opt-in modules (e.g., prompt-input-attachments). + +export type PromptInputSubmitProps = ComponentProps & { + status?: ChatStatus; + onStop?: () => void; +}; + +export const PromptInputSubmit = ({ + className, + variant = "default", + size = "icon-sm", + status, + onStop, + onClick, + children, + ...props +}: PromptInputSubmitProps) => { + const isGenerating = status === "submitted" || status === "streaming"; + + let Icon = ; + + if (status === "submitted") { + Icon = ; + } else if (status === "streaming") { + Icon = ; + } else if (status === "error") { + Icon = ; + } + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (isGenerating && onStop) { + e.preventDefault(); + onStop(); + return; + } + onClick?.(e as never); + }, + [isGenerating, onStop, onClick] + ); + + return ( + + {children ?? Icon} + + ); +}; + +export type PromptInputSelectProps = ComponentProps; + +export const PromptInputSelect = (props: PromptInputSelectProps) => ( +