diff --git a/components.json b/components.json index da76fec1..d41bdb73 100644 --- a/components.json +++ b/components.json @@ -19,7 +19,6 @@ "hooks": "@/hooks" }, "registries": { - "@assistant-ui": "https://r.assistant-ui.com/{name}.json", "@react-bits": "https://reactbits.dev/r/{name}.json", "@reui": "https://reui.io/r/{name}.json", "@magicui": "https://magicui.design/r/{name}.json", diff --git a/package.json b/package.json index 92ea88fa..17a336ac 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,9 @@ "dependencies": { "@ai-sdk/devtools": "^0.0.15", "@ai-sdk/google": "^3.0.62", - "@assistant-ui/react": "^0.12.24", - "@assistant-ui/react-ai-sdk": "^1.3.18", - "@assistant-ui/react-devtools": "^1.0.5", - "@assistant-ui/react-markdown": "^0.12.8", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23", + "@ai-sdk/react": "^3.0.170", "@contextcompany/otel": "^1.0.15", "@e2b/code-interpreter": "^2.4.0", "@embedpdf/core": "^2.14.0", @@ -62,10 +61,10 @@ "@google/genai": "^1.49.0", "@lottiefiles/dotlottie-react": "^0.18.9", "@opentelemetry/api": "^1.9.1", - "@opentelemetry/api-logs": "^0.214.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", - "@posthog/ai": "^7.14.0", - "@posthog/nextjs-config": "^1.9.2", + "@opentelemetry/api-logs": "^0.215.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.215.0", + "@posthog/ai": "^7.16.1", + "@posthog/nextjs-config": "^1.9.4", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-avatar": "^1.1.11", @@ -81,20 +80,21 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "1.1.13", - "@react-email/components": "^1.0.8", + "@radix-ui/react-use-controllable-state": "^1.2.2", "@react-email/render": "^2.0.4", + "@rive-app/react-webgl2": "^4.28.1", "@rocicorp/zero": "^1.1.1", "@shikijs/types": "^4.0.2", + "@streamdown/cjk": "^1.0.3", "@streamdown/code": "^1.1.0", "@streamdown/math": "^1.0.2", "@streamdown/mermaid": "^1.0.2", - "@supabase/supabase-js": "^2.101.1", + "@supabase/supabase-js": "^2.104.0", "@supermemory/tools": "^1.4.3", "@tanstack/pacer": "^0.20.1", "@tanstack/react-pacer": "^0.21.1", - "@tanstack/react-query": "^5.96.1", - "@tanstack/react-query-devtools": "^5.96.1", - "@tanstack/react-virtual": "^3.13.23", + "@tanstack/react-query": "^5.99.2", + "@tanstack/react-query-devtools": "^5.99.2", "@tiptap/core": "3.22.3", "@tiptap/extension-code-block": "3.22.3", "@tiptap/extension-highlight": "3.22.3", @@ -113,10 +113,11 @@ "@tiptap/react": "3.22.3", "@tiptap/starter-kit": "3.22.3", "@vercel/otel": "^2.1.1", - "@workflow/errors": "4.1.0", - "ai": "^6.0.158", + "@workflow/errors": "4.1.1", + "@xyflow/react": "^12.10.2", + "ai": "^6.0.168", + "ansi-to-react": "^6.2.6", "assemblyai": "^4.29.0", - "assistant-stream": "^0.3.10", "better-auth": "^1.6.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -124,51 +125,59 @@ "diff": "^8.0.4", "drizzle-kit": "^0.31.10", "drizzle-orm": "^0.45.2", + "embla-carousel-react": "^8.6.0", "gsap": "^3.14.2", "heic-to": "^1.4.2", "jsonrepair": "^3.13.2", "katex": "^0.16.45", "lodash.throttle": "^4.1.1", "lucide-react": "^1.7.0", - "next": "16.2.3", + "media-chrome": "^4.19.0", + "motion": "^12.38.0", + "nanoid": "^5.1.9", + "next": "16.2.4", "next-themes": "^0.4.6", "parse-diff": "^0.11.1", "postcss": "^8.5.8", "postgres": "^3.4.9", - "posthog-js": "^1.364.7", - "posthog-node": "^5.28.11", + "posthog-js": "^1.369.5", + "posthog-node": "^5.29.4", "prosemirror-highlight": "^0.15.1", "radix-ui": "^1.4.3", - "react": "19.2.4", + "react": "19.2.5", "react-color": "^2.19.3", - "react-dom": "19.2.4", + "react-dom": "19.2.5", "react-dropzone": "^15.0.0", + "react-email": "^6.0.0", "react-grid-layout": "git+https://github.com/ThinkEx-OSS/thinkex-grid.git", "react-hotkeys-hook": "^5.2.4", "react-icons": "^5.5.0", + "react-jsx-parser": "^2.4.1", "react-markdown": "^10.1.0", "react-quizlet-flashcard": "^4.0.22", "react-resizable-panels": "^4.9.0", "react-shiki": "^0.9.1", - "react-speech-recognition": "^4.0.1", "regenerator-runtime": "^0.14.1", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", - "resend": "^6.10.0", + "resend": "^6.12.2", "server-only": "^0.0.1", "shiki": "^4.0.2", "sonner": "^2.0.7", "streamdown": "^2.4.0", "tailwind-merge": "^3.5.0", + "tokenlens": "^1.3.1", "tw-shimmer": "^0.4.9", "unicodeit": "^0.7.5", - "workflow": "4.2.1", + "use-stick-to-bottom": "^1.1.3", + "virtua": "^0.49.1", + "workflow": "4.2.4", "zod": "^4.3.6", "zustand": "^5.0.11" }, "devDependencies": { - "@tailwindcss/postcss": "^4.2.1", + "@tailwindcss/postcss": "^4.2.4", "@types/bun": "^1.3.11", "@types/lodash.throttle": "^4.1.9", "@types/node": "^25.3.0", @@ -176,19 +185,18 @@ "@types/react": "19.2.14", "@types/react-color": "^3.0.13", "@types/react-dom": "19.2.3", - "@types/react-speech-recognition": "^3.9.6", "@typescript/native-preview": "7.0.0-dev.20260408.1", "babel-plugin-react-compiler": "^1.0.0", "concurrently": "^9.2.1", "cross-env": "^10.1.0", "drizzle-zero": "^0.18.0", "knip": "^6.0.6", - "oxlint": "^1.59.0", + "oxlint": "^1.61.0", "prettier": "^3.8.1", "sass": "^1.98.0", - "tailwindcss": "^4.2.1", + "tailwindcss": "^4.2.4", "tw-animate-css": "^1.4.0", "typescript": "^6.0.2", - "vitest": "^4.1.2" + "vitest": "^4.1.5" } } diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index dc2e83b4..1319f96a 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -6,13 +6,16 @@ import { safeValidateUIMessages, stepCountIs, wrapLanguageModel, + createUIMessageStream, + createUIMessageStreamResponse, + type ModelMessage, + type ProviderMetadata, } from "ai"; import { devToolsMiddleware } from "@ai-sdk/devtools"; import { withTracing } from "@posthog/ai"; -import type { UIMessage } from "ai"; +import { and, asc, eq, inArray } from "drizzle-orm"; import { logger } from "@/lib/utils/logger"; -import { auth } from "@/lib/auth"; -import { headers } from "next/headers"; +import { after } from "next/server"; import { createChatTools } from "@/lib/ai/tools"; import { capturePostHogServerException, @@ -21,6 +24,11 @@ import { import { withServerObservability } from "@/lib/with-server-observability"; import { normalizeLegacyToolMessages } from "@/lib/ai/legacy-tool-message-compat"; import { maybeWithSupermemory } from "@/lib/ai/supermemory"; +import { + requireAuth, + verifyThreadOwnership, + verifyWorkspaceAccess, +} from "@/lib/api/workspace-helpers"; import type { ReplySelection } from "@/lib/stores/ui-store"; import { getDefaultChatModelId, resolveGatewayModelId } from "@/lib/ai/models"; import { @@ -28,40 +36,23 @@ import { createGatewayLanguageModel, getGatewayAttributionHeaders, } from "@/lib/ai/gateway-provider-options"; - -/** - * Extract workspaceId from system context or request body - */ -function extractWorkspaceId(body: any): string | null { - if (body.workspaceId) { - return body.workspaceId; - } - - const system = body.system || ""; - const workspaceIdMatch = system.match(/Workspace ID: ([a-f0-9-]{36})/); - if (workspaceIdMatch) { - return workspaceIdMatch[1]; - } - - return null; -} - -/** - * Selected cards context is now formatted on the client side and sent directly. - * This eliminates the need for server-side database fetch. - * If selectedCardsContext is provided, use it; otherwise return empty string. - */ -function getSelectedCardsContext(body: any): string { - // Client now sends pre-formatted context string - return body.selectedCardsContext || ""; -} +import { db } from "@/lib/db/client"; +import { chatThreads, chatMessages } from "@/lib/db/schema"; +import { CHAT_MESSAGE_FORMAT, type ChatMessage } from "@/lib/chat/types"; +import { + CHAT_DEBUG_TAG, + summarizeMessage, + summarizeRoster, +} from "@/lib/chat/debug"; +import { generateThreadTitle } from "@/lib/chat/generate-title"; +import { chatRequestBodySchema } from "./schema"; /** * Inject user-selected context (selected cards + reply quotes / workspace passages) into the last user message. - * `custom` is body.metadata.custom from the composer's runConfig (replySelections only). + * `custom` is `lastUserMessage.metadata.custom` (replySelections only). */ function injectSelectionContext( - messages: any[], + messages: ModelMessage[], custom?: { replySelections?: ReplySelection[]; }, @@ -69,7 +60,6 @@ function injectSelectionContext( ): void { const parts: string[] = []; - // Selected cards (pre-formatted from client) if (selectedCardsContext && selectedCardsContext.trim()) { parts.push(`[Selected cards context:\n${selectedCardsContext.trim()}]`); } @@ -87,18 +77,17 @@ function injectSelectionContext( const prefix = parts.join("\n") + "\n\n"; - // Find the last user message and prepend the context for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role !== "user") continue; if (Array.isArray(msg.content)) { - const textIdx = msg.content.findIndex((p: any) => p.type === "text"); + const textIdx = msg.content.findIndex((p) => p.type === "text"); if (textIdx !== -1) { - msg.content[textIdx] = { - ...msg.content[textIdx], - text: prefix + msg.content[textIdx].text, - }; + const part = msg.content[textIdx]; + if (part.type === "text") { + msg.content[textIdx] = { ...part, text: prefix + part.text }; + } } } else if (typeof msg.content === "string") { messages[i] = { ...msg, content: prefix + msg.content }; @@ -107,67 +96,250 @@ function injectSelectionContext( } } +/** + * Hydrate prior turns of a thread from `chat_messages`. Only post-migration + * rows (`format = 'ai-sdk-ui/v1'`) are returned; legacy rows stay hidden + * until backfill. Mirrors the GET `/api/threads/[id]/messages` response so + * the server-side conversation context matches what the client sees. + */ +async function loadThreadHistory(threadId: string): Promise { + const rows = await db + .select({ content: chatMessages.content }) + .from(chatMessages) + .where( + and( + eq(chatMessages.threadId, threadId), + eq(chatMessages.format, CHAT_MESSAGE_FORMAT), + ), + ) + .orderBy(asc(chatMessages.createdAt)); + return rows.map((r) => r.content) as ChatMessage[]; +} + +async function getThreadById(threadId: string) { + const [thread] = await db + .select({ + id: chatThreads.id, + workspaceId: chatThreads.workspaceId, + userId: chatThreads.userId, + }) + .from(chatThreads) + .where(eq(chatThreads.id, threadId)) + .limit(1); + + return thread; +} + async function handlePOST(req: Request) { let workspaceId: string | null = null; let userId: string | null = null; - let activeFolderId: string | undefined; - - // Check for API key early (Standardizing on Google Key for now if not using OIDC) - // With Gateway, you can check for other keys too, or rely on Gateway's auth - if ( - !process.env.GOOGLE_GENERATIVE_AI_API_KEY && - !process.env.AI_GATEWAY_API_KEY - ) { - // Optional: make this check more robust or permissive if using OIDC - } try { - // FIX: Parallelize headers() and req.json() to eliminate waterfall - const [headersObj, body] = await Promise.all([headers(), req.json()]); - - // Get authenticated user ID - const session = await auth.api.getSession({ headers: headersObj }); - userId = session?.user?.id || null; - - const { messages = [] }: { messages?: UIMessage[] } = body; - const system = body.system || ""; - workspaceId = extractWorkspaceId(body); - activeFolderId = body.activeFolderId; - // AssistantChatTransport passes thread remoteId as body.id (see assistant-ui react-ai-sdk) - const threadId = body.id ?? body.threadId ?? null; - // Client-controlled memory toggle (composer settings menu). Server double-checks auth/api key. + const rawBody = await req.json(); + const parsed = chatRequestBodySchema.safeParse(rawBody); + if (!parsed.success) { + logger.warn("[CHAT-API] request body failed validation", { + issues: parsed.error.issues, + }); + return new Response( + JSON.stringify({ + error: "Bad request", + message: "Invalid chat request body.", + details: parsed.error.issues, + code: "BAD_REQUEST", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + const body = parsed.data; + + workspaceId = body.workspaceId; + const threadId = body.id; + const newMessage = body.message; const memoryEnabled = body.memoryEnabled === true; - logger.info("🧡 [CHAT-API] Thread ID:", { + const trigger = body.trigger; + const triggeringMessageId = body.messageId; + const activeFolderId = body.activeFolderId ?? undefined; + const system = body.system ?? ""; + const selectedCardsContext = body.selectedCardsContext ?? ""; + + userId = await requireAuth(); + await verifyWorkspaceAccess(workspaceId, userId, "editor"); + + const existingThread = await getThreadById(threadId); + if (existingThread) { + await verifyWorkspaceAccess(existingThread.workspaceId, userId, "editor"); + verifyThreadOwnership(existingThread, userId); + + if (existingThread.workspaceId !== workspaceId) { + return new Response( + JSON.stringify({ + error: "Bad request", + message: "Thread does not belong to the requested workspace.", + code: "THREAD_WORKSPACE_MISMATCH", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + } + + logger.info("🧡 [CHAT-API] Thread ID:", { threadId }); + + // [chat-debug] Inbound just shows the single new message + trigger now β€” + // history is reconstructed from DB below. + logger.info(`${CHAT_DEBUG_TAG} POST /api/chat inbound`, { threadId, - isDefault: threadId === "DEFAULT_THREAD_ID", + trigger, + triggeringMessageId, + newMessage: summarizeMessage(newMessage), }); - // Create tools using the modular factory (before convertToModelMessages so - // toModelOutput can sanitize historical tool results for the model) + // Upsert the thread row on first write. Client generates the UUID and + // sends it as `body.id`; we insert or no-op so the client never has to + // make a separate create call. + const inserted = await db + .insert(chatThreads) + .values({ id: threadId, workspaceId, userId }) + .onConflictDoNothing({ target: chatThreads.id }) + .returning({ id: chatThreads.id }); + const isNewThread = inserted.length > 0; + + // SDK-aligned regenerate semantics: when the client fires + // `regenerate-message`, hard-truncate persisted history at the targeted + // message (inclusive) BEFORE loading. The new turn then lands on top of + // a clean slate. + // + // Cases: + // - User edited a user msg: messageId is that user. Delete it + + // everything after; the new `body.message` is the edited user, which + // gets re-saved below. + // - User refreshed an assistant: messageId is that assistant. Delete + // it + everything after. `body.message` is the user that prompted + // the regen β€” it's already in DB (createdAt < target), so it survives + // the delete and is hydrated in the history below. + if (trigger === "regenerate-message" && triggeringMessageId) { + const historyBeforeTruncate = await loadThreadHistory(threadId); + const triggeringMessage = historyBeforeTruncate.find( + (message) => message.id === triggeringMessageId, + ); + if (triggeringMessage?.role === "user") { + const latestUserMessage = [...historyBeforeTruncate] + .reverse() + .find((message) => message.role === "user"); + if ( + latestUserMessage && + latestUserMessage.id !== triggeringMessageId + ) { + return new Response( + JSON.stringify({ + error: "Conflict", + message: "Only the latest user message can be edited.", + code: "MESSAGE_EDIT_CONFLICT", + }), + { + status: 409, + headers: { "Content-Type": "application/json" }, + }, + ); + } + } + + const rowsToTruncate = await db + .select({ + id: chatMessages.id, + messageId: chatMessages.messageId, + }) + .from(chatMessages) + .where(eq(chatMessages.threadId, threadId)) + .orderBy(asc(chatMessages.createdAt), asc(chatMessages.id)); + + const truncateIndex = rowsToTruncate.findIndex( + (row) => row.messageId === triggeringMessageId, + ); + + if (truncateIndex >= 0) { + const deleteIds = rowsToTruncate + .slice(truncateIndex) + .map((row) => row.id); + const deleted = await db + .delete(chatMessages) + .where( + and( + eq(chatMessages.threadId, threadId), + inArray(chatMessages.id, deleteIds), + ), + ) + .returning({ messageId: chatMessages.messageId }); + logger.info(`${CHAT_DEBUG_TAG} regenerate: truncated history`, { + threadId, + triggeringMessageId, + deletedCount: deleted.length, + }); + } else { + logger.info(`${CHAT_DEBUG_TAG} regenerate: no row to truncate`, { + threadId, + triggeringMessageId, + }); + } + } + + // Reconstruct the conversation from DB + the new message. The client + // never sends the full roster β€” server is the source of truth. + const historyFromDb = await loadThreadHistory(threadId); + const newMessageInDb = historyFromDb.some((m) => m.id === newMessage.id); + const uiMessages: ChatMessage[] = newMessageInDb + ? historyFromDb + : [...historyFromDb, newMessage as ChatMessage]; + + logger.info(`${CHAT_DEBUG_TAG} hydrated conversation`, { + threadId, + historyCount: historyFromDb.length, + appendedNew: !newMessageInDb, + roster: summarizeRoster(uiMessages), + }); + + // Persist the new user message before streaming so it survives a + // mid-stream error. `onConflictDoNothing` on (threadId, messageId) + // makes this idempotent β€” no-op when the regenerate-refresh-assistant + // case sends a user that's already in the DB. + if (!newMessageInDb) { + await db + .insert(chatMessages) + .values({ + threadId, + messageId: String(newMessage.id), + parentId: null, + format: CHAT_MESSAGE_FORMAT, + content: newMessage, + }) + .onConflictDoNothing({ + target: [chatMessages.threadId, chatMessages.messageId], + }); + } + + // Tool factory (depends on workspace context). Built before + // convertToModelMessages so toModelOutput can sanitize historical tool + // results for the model. const tools = createChatTools({ workspaceId, userId, activeFolderId, threadId, - clientTools: body.tools, }); - const compatibleMessages = normalizeLegacyToolMessages(messages, { + const compatibleMessages = normalizeLegacyToolMessages(uiMessages, { availableToolNames: Object.keys(tools), }); - const validation = await safeValidateUIMessages({ + const validation = await safeValidateUIMessages({ messages: compatibleMessages, tools, }); - if (!validation.success) { throw validation.error; } - const validatedMessages = validation.data; - // Convert messages (pass tools so toModelOutput strips event from historical tool results) let convertedMessages; try { convertedMessages = await convertToModelMessages(validatedMessages, { @@ -184,7 +356,6 @@ async function handlePOST(req: Request) { throw convertError; } - // Prune older reasoning and tool calls to save context convertedMessages = pruneMessages({ messages: convertedMessages, reasoning: "before-last-message", @@ -192,24 +363,31 @@ async function handlePOST(req: Request) { emptyMessages: "remove", }); - // Get pre-formatted selected cards context from client (no DB fetch needed) - const selectedCardsContext = getSelectedCardsContext(body); - - const modelId = resolveGatewayModelId( - body.modelId || getDefaultChatModelId(), - ); + // Reply selections live on `lastUserMessage.metadata.custom.replySelections` + // so they survive a thread reload. Selected cards stay ephemeral. + const replySelectionsForInjection = (() => { + for (let i = validatedMessages.length - 1; i >= 0; i--) { + const m = validatedMessages[i] as ChatMessage; + if (m.role !== "user") continue; + return m.metadata?.custom?.replySelections; + } + return undefined; + })(); - // Inject selected cards + reply selections into the last user message injectSelectionContext( convertedMessages, - body.metadata?.custom, + { replySelections: replySelectionsForInjection }, selectedCardsContext, ); + const modelId = resolveGatewayModelId( + body.modelId || getDefaultChatModelId(), + ); + const posthogClient = getPostHogServerClient(); const baseGatewayModel = createGatewayLanguageModel(modelId); const tracedModel = posthogClient - ? withTracing(baseGatewayModel as any, posthogClient, { + ? withTracing(baseGatewayModel, posthogClient, { posthogDistinctId: userId || "anonymous", posthogProperties: { workspaceId, @@ -218,27 +396,20 @@ async function handlePOST(req: Request) { memoryEnabled, }, }) - : (baseGatewayModel as any); + : baseGatewayModel; - // Supermemory: personalizes prompts via user profile + search, and - // auto-saves conversation turns when userId + SUPERMEMORY_API_KEY present. - // Sits inside wrapLanguageModel (devtools stays outermost) and outside - // withTracing so PostHog telemetry captures the *final* prompt the model - // actually receives (with memories already injected). const memoryWrappedModel = maybeWithSupermemory(tracedModel, { userId: userId ?? "", threadId, memoryEnabled, }); - // Use AI Gateway const model = wrapLanguageModel({ model: memoryWrappedModel, middleware: process.env.NODE_ENV === "development" ? devToolsMiddleware() : [], }); - // Stream the response logger.debug("πŸ” [CHAT-API] Final messages before streamText:", { count: convertedMessages.length, modelId, @@ -246,86 +417,196 @@ async function handlePOST(req: Request) { const providerOptions = buildGatewayProviderOptions(modelId, { userId }); - const result = streamText({ - model: model, - temperature: 1.0, - system, - messages: convertedMessages, - stopWhen: stepCountIs(25), - tools, - providerOptions: providerOptions as any, - headers: getGatewayAttributionHeaders(), - experimental_telemetry: { - isEnabled: true, - metadata: { - "tcc.conversational": "true", - ...(threadId ? { "tcc.sessionId": String(threadId) } : {}), - ...(userId ? { userId } : {}), - }, - }, - experimental_transform: smoothStream({ chunking: "word", delayInMs: 15 }), - onFinish: ({ usage, finishReason }) => { - const usageInfo = { - inputTokens: usage?.inputTokens, - outputTokens: usage?.outputTokens, - totalTokens: usage?.totalTokens, - cachedInputTokens: usage?.cachedInputTokens, // Standard property - reasoningTokens: usage?.reasoningTokens, - // Note: Extended provider-specific properties might not be available consistently via Gateway - finishReason, - }; - - logger.info("πŸ“Š [CHAT-API] Final Token Usage:", usageInfo); + // Kick off title generation in parallel for newly-created threads. We + // stream the title when it resolves if the SSE is still open, and persist + // it after the response so title generation never holds the stream open. + let titlePromise: Promise | null = null; + if (isNewThread && userId && newMessage.role === "user") { + const firstUserText = (newMessage.parts ?? []) + .filter( + (p): p is { type: "text"; text: string } => + p.type === "text" && typeof (p as { text?: string }).text === "string", + ) + .map((p) => p.text) + .join(" ") + .trim(); + if (firstUserText) { + titlePromise = generateThreadTitle({ + userId, + firstUserMessageText: firstUserText, + }).catch((err) => { + logger.warn("[chat] title generation failed", err); + return ""; + }); + + after(async () => { + const title = await titlePromise; + if (!title) return; + + await db + .update(chatThreads) + .set({ title, updatedAt: new Date().toISOString() }) + .where(eq(chatThreads.id, threadId)); + }); + } + } + + const stream = createUIMessageStream({ + originalMessages: validatedMessages, + // CRITICAL: when `originalMessages` ends with a user message (always + // true for us β€” we just appended the new one), the SDK does NOT + // auto-assign an id to the streamed assistant turn. Without this + // generator, every assistant arrives in `onFinish` with `id: ''`, + // and the second insert silently no-ops via `onConflictDoNothing` on + // (threadId, messageId), dropping every assistant after the first. + generateId: () => + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `asst-${Date.now()}-${Math.random().toString(36).slice(2)}`, + execute: async ({ writer: dataStream }) => { + const result = streamText({ + model, + temperature: 1.0, + system, + messages: convertedMessages, + stopWhen: stepCountIs(25), + tools, + providerOptions, + headers: getGatewayAttributionHeaders(), + experimental_telemetry: { + isEnabled: true, + metadata: { + "tcc.conversational": "true", + "tcc.sessionId": String(threadId), + ...(userId ? { userId } : {}), + }, + }, + experimental_transform: smoothStream({ + chunking: "word", + delayInMs: 15, + }), + onFinish: ({ usage, finishReason }) => { + logger.info("πŸ“Š [CHAT-API] Final Token Usage:", { + inputTokens: usage?.inputTokens, + outputTokens: usage?.outputTokens, + totalTokens: usage?.totalTokens, + cachedInputTokens: usage?.cachedInputTokens, + reasoningTokens: usage?.reasoningTokens, + finishReason, + }); + }, + onStepFinish: (stepResult) => { + const r = stepResult as typeof stepResult & { + stepType?: "initial" | "continue" | "tool-result"; + }; + if (r.usage) { + logger.debug( + `πŸ“Š [CHAT-API] Step Usage (${r.stepType || "unknown"}):`, + { + stepType: r.stepType || "unknown", + inputTokens: r.usage.inputTokens, + outputTokens: r.usage.outputTokens, + totalTokens: r.usage.totalTokens, + cachedInputTokens: r.usage.cachedInputTokens, + reasoningTokens: r.usage.reasoningTokens, + finishReason: r.finishReason, + }, + ); + } + }, + }); + + dataStream.merge(result.toUIMessageStream()); + + // Log Gateway resolved provider when the metadata is ready. + void Promise.resolve(result.providerMetadata).then( + (meta: ProviderMetadata | undefined) => { + const routing = (meta?.gateway as + | { + routing?: { + resolvedProvider?: string; + finalProvider?: string; + }; + } + | undefined)?.routing; + const provider = + routing?.resolvedProvider ?? routing?.finalProvider; + if (provider) { + logger.info("πŸ” [CHAT-API] Gateway resolved provider:", provider); + } + }, + ); + + if (titlePromise) { + void titlePromise.then((title) => { + if (title) { + dataStream.write({ type: "data-chat-title", data: title }); + } + }); + } }, - onStepFinish: (result) => { - // stepType exists in runtime but may not be in type definitions - const stepResult = result as typeof result & { - stepType?: "initial" | "continue" | "tool-result"; - }; - const { stepType, usage, finishReason } = stepResult; - - if (usage) { - const stepUsageInfo = { - stepType: stepType || "unknown", - inputTokens: usage?.inputTokens, - outputTokens: usage?.outputTokens, - totalTokens: usage?.totalTokens, - cachedInputTokens: usage?.cachedInputTokens, // Standard property - reasoningTokens: usage?.reasoningTokens, - finishReason, - }; - - logger.debug( - `πŸ“Š [CHAT-API] Step Usage (${stepType || "unknown"}):`, - stepUsageInfo, - ); + onFinish: async ({ responseMessage, isAborted }) => { + if (isAborted || !userId) { + logger.warn(`${CHAT_DEBUG_TAG} onFinish skipped`, { + isAborted, + hasUserId: !!userId, + }); + return; + } + try { + logger.info(`${CHAT_DEBUG_TAG} onFinish responseMessage`, { + threadId, + assistant: summarizeMessage(responseMessage), + }); + + const result = await db + .insert(chatMessages) + .values({ + threadId, + messageId: String(responseMessage.id), + parentId: String(newMessage.id), + format: CHAT_MESSAGE_FORMAT, + content: responseMessage, + }) + .onConflictDoNothing({ + target: [chatMessages.threadId, chatMessages.messageId], + }) + .returning({ messageId: chatMessages.messageId }); + + if (result.length === 0) { + logger.warn(`${CHAT_DEBUG_TAG} assistant insert skipped`, { + threadId, + messageId: String(responseMessage.id), + }); + } + + const now = new Date().toISOString(); + await db + .update(chatThreads) + .set({ + lastMessageAt: now, + updatedAt: now, + headMessageId: String(responseMessage.id), + }) + .where(eq(chatThreads.id, threadId)); + } catch (persistErr) { + logger.error("[CHAT-API] onFinish persistence error", persistErr); } }, + onError: (error) => { + logger.error("[CHAT-API] stream error", error); + return error instanceof Error ? error.message : String(error); + }, }); - logger.debug( - "πŸ” [CHAT-API] streamText returned, calling toUIMessageStreamResponse...", - ); - // Log which provider the Gateway actually used (resolves when stream completes) - void Promise.resolve((result as any).providerMetadata).then((meta: any) => { - const provider = - meta?.gateway?.routing?.resolvedProvider ?? - meta?.gateway?.routing?.finalProvider; - if (provider) { - logger.info("πŸ” [CHAT-API] Gateway resolved provider:", provider); - } - }); - // assistant-ui already persists and rehydrates message history via the - // thread history adapter. Passing originalMessages here enables a second - // persistence flow in AI SDK that can relink the same ids into a different - // parent chain when history loads, triggering duplicate-id repository errors. - const response = result.toUIMessageStreamResponse(); - logger.debug("πŸ” [CHAT-API] toUIMessageStreamResponse succeeded"); - return response; + return createUIMessageStreamResponse({ stream }); } catch (error) { + if (error instanceof Response) { + return error; + } + const errorMessage = error instanceof Error ? error.message : String(error); - // Detect timeout errors const isTimeout = errorMessage.includes("timeout") || errorMessage.includes("TIMEOUT") || @@ -358,14 +639,10 @@ async function handlePOST(req: Request) { "The request took too long to process (exceeded 30 seconds). This can happen with complex queries that require multiple tool calls or extensive processing. Please try breaking your question into smaller parts or simplifying your request.", code: "TIMEOUT", }), - { - status: 504, - headers: { "Content-Type": "application/json" }, - }, + { status: 504, headers: { "Content-Type": "application/json" } }, ); } - // Log other errors logger.error("❌ [CHAT-API] Error processing request", { errorMessage, errorStack: error instanceof Error ? error.stack : undefined, @@ -390,10 +667,7 @@ async function handlePOST(req: Request) { process.env.NODE_ENV === "development" ? errorMessage : undefined, code: "INTERNAL_ERROR", }), - { - status: 500, - headers: { "Content-Type": "application/json" }, - }, + { status: 500, headers: { "Content-Type": "application/json" } }, ); } } diff --git a/src/app/api/chat/schema.ts b/src/app/api/chat/schema.ts new file mode 100644 index 00000000..9dc93a52 --- /dev/null +++ b/src/app/api/chat/schema.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; + +/** + * Runtime contract for `POST /api/chat`. Mirrors the body our transport + * sends in `src/lib/chat/transport.ts` and is parsed at the route boundary + * with `zod` so we get input validation (not just compile-time types) and + * never have to touch `as any` again. + * + * The wire format is "send only the new message" (Γ  la the Vercel + * `ai-chatbot` reference): the route hydrates prior turns from + * `chat_messages` rather than trusting a client-side roster. + */ + +const textPartSchema = z.object({ + type: z.literal("text"), + text: z.string().min(1).max(10_000), +}); + +const filePartSchema = z.object({ + type: z.literal("file"), + mediaType: z.string().max(120), + filename: z.string().max(200).optional(), + url: z.string().max(8192), +}); + +const userMessagePartSchema = z.union([textPartSchema, filePartSchema]); + +const replySelectionSchema = z.object({ + text: z.string().max(4000), + title: z.string().max(200).optional(), + itemId: z.string().max(200).optional(), + range: z + .object({ start: z.number(), end: z.number() }) + .partial() + .optional(), +}); + +const userMessageMetadataSchema = z + .object({ + custom: z + .object({ + replySelections: z.array(replySelectionSchema).optional(), + }) + .partial() + .optional(), + }) + .partial() + .optional(); + +/** + * The single message the client just produced. Always a `user` message in + * practice β€” the SDK's `regenerate({ messageId })` path also sends the user + * message immediately preceding the regenerated assistant turn (the + * "prompt" of the regeneration). + */ +const newMessageSchema = z.object({ + id: z.string().min(1).max(200), + role: z.literal("user"), + parts: z.array(userMessagePartSchema).min(1), + metadata: userMessageMetadataSchema, +}); + +export const chatRequestBodySchema = z.object({ + /** SDK chat id (= thread id). Used to upsert `chat_threads.id`. */ + id: z.string().uuid(), + message: newMessageSchema, + + trigger: z.enum(["submit-message", "regenerate-message"]).optional(), + messageId: z.string().min(1).max(200).optional(), + + workspaceId: z.string().uuid(), + modelId: z.string().max(200).nullish(), + memoryEnabled: z.boolean().nullish(), + activeFolderId: z.string().max(200).nullish(), + selectedCardsContext: z.string().max(200_000).nullish(), + system: z.string().max(200_000).nullish(), +}); + +export type ChatRequestBody = z.infer; +export type ChatRequestNewMessage = z.infer; diff --git a/src/app/api/threads/[id]/messages/[messageId]/route.ts b/src/app/api/threads/[id]/messages/[messageId]/route.ts deleted file mode 100644 index ef810028..00000000 --- a/src/app/api/threads/[id]/messages/[messageId]/route.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { db } from "@/lib/db/client"; -import { chatThreads, chatMessages } from "@/lib/db/schema"; -import { - requireAuth, - verifyWorkspaceAccess, - verifyThreadOwnership, -} from "@/lib/api/workspace-helpers"; -import { eq, and } from "drizzle-orm"; -import { withServerObservability } from "@/lib/with-server-observability"; - -async function getThreadAndVerify(threadId: string, userId: string) { - const [thread] = await db - .select() - .from(chatThreads) - .where(eq(chatThreads.id, threadId)) - .limit(1); - - if (!thread) { - throw NextResponse.json({ error: "Thread not found" }, { status: 404 }); - } - - await verifyWorkspaceAccess(thread.workspaceId, userId); - verifyThreadOwnership(thread, userId); - - return thread; -} - -/** - * PATCH /api/threads/[id]/messages/[messageId] - * Update an existing message (e.g. step timestamps/duration from useExternalHistory) - */ -export const PATCH = withServerObservability(async function PATCH( - req: NextRequest, - { params }: { params: Promise<{ id: string; messageId: string }> } -) { - try { - const userId = await requireAuth(); - const { id: threadId, messageId } = await params; - const body = await req.json().catch(() => ({})); - const { content } = body; - - if (content === undefined) { - return NextResponse.json( - { error: "content is required" }, - { status: 400 } - ); - } - - await getThreadAndVerify(threadId, userId); - - const [updated] = await db - .update(chatMessages) - .set({ - content: typeof content === "object" ? content : { raw: content }, - }) - .where( - and( - eq(chatMessages.threadId, threadId), - eq(chatMessages.messageId, messageId) - ) - ) - .returning({ messageId: chatMessages.messageId }); - - if (!updated) { - return NextResponse.json({ error: "Message not found" }, { status: 404 }); - } - - return NextResponse.json({ ok: true }); - } catch (error) { - if (error instanceof Response) return error; - console.error("[threads] messages PATCH error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -}, { routeName: "PATCH /api/threads/[id]/messages/[messageId]" }); diff --git a/src/app/api/threads/[id]/messages/route.ts b/src/app/api/threads/[id]/messages/route.ts index cf9a2113..02df082e 100644 --- a/src/app/api/threads/[id]/messages/route.ts +++ b/src/app/api/threads/[id]/messages/route.ts @@ -6,8 +6,15 @@ import { verifyWorkspaceAccess, verifyThreadOwnership, } from "@/lib/api/workspace-helpers"; -import { eq, and, desc } from "drizzle-orm"; +import { eq, and, asc } from "drizzle-orm"; import { withServerObservability } from "@/lib/with-server-observability"; +import { logger } from "@/lib/utils/logger"; +import { + CHAT_DEBUG_TAG, + summarizeMessage, + summarizeRoster, +} from "@/lib/chat/debug"; +import { CHAT_MESSAGE_FORMAT } from "@/lib/chat/types"; async function getThreadAndVerify(id: string, userId: string) { const [thread] = await db @@ -27,120 +34,75 @@ async function getThreadAndVerify(id: string, userId: string) { } /** - * GET /api/threads/[id]/messages?format=ai-sdk/v6 - * Load messages for a thread. Format filter is strict (ai-sdk/v6 only). - */ -export const GET = withServerObservability(async function GET( - req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const userId = await requireAuth(); - const { id } = await params; - const { searchParams } = new URL(req.url); - const format = searchParams.get("format") ?? "ai-sdk/v6"; - - const thread = await getThreadAndVerify(id, userId); - - const rows = await db - .select() - .from(chatMessages) - .where(and(eq(chatMessages.threadId, id), eq(chatMessages.format, format))) - .orderBy(desc(chatMessages.createdAt)); - - const messages = rows.map((r) => ({ - id: r.messageId, - parent_id: r.parentId, - format: r.format, - content: r.content, - created_at: r.createdAt, - })); - - return NextResponse.json({ - messages, - headId: thread.headMessageId ?? undefined, - }); - } catch (error) { - if (error instanceof Response) return error; - console.error("[threads] messages GET error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -}, { routeName: "GET /api/threads/[id]/messages" }); - -/** - * POST /api/threads/[id]/messages - * Append a message to a thread + * GET /api/threads/[id]/messages + * Loads the persisted UIMessage history for the new chat runtime. Only rows + * written by the post-migration chat API (`format = 'ai-sdk-ui/v1'`) are + * returned. Legacy `ai-sdk/v6` rows stay on disk and are intentionally hidden + * from the new UI until a backfill script rewrites them. */ -export const POST = withServerObservability(async function POST( - req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const userId = await requireAuth(); - const { id } = await params; - const body = await req.json().catch(() => ({})); - const { messageId, parentId, format, content } = body; +export const GET = withServerObservability( + async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, + ) { + try { + const userId = await requireAuth(); + const { id } = await params; + + await getThreadAndVerify(id, userId); + + // Pull `messageId` and `format` too so the debug log can show the full + // row identity if hydration ever returns something unexpected. + const rows = await db + .select({ + messageId: chatMessages.messageId, + format: chatMessages.format, + content: chatMessages.content, + createdAt: chatMessages.createdAt, + }) + .from(chatMessages) + .where( + and( + eq(chatMessages.threadId, id), + eq(chatMessages.format, CHAT_MESSAGE_FORMAT), + ), + ) + .orderBy(asc(chatMessages.createdAt)); + + // content is a stored UIMessage object; no reshaping required. + const messages = rows.map((r) => r.content); + + // [chat-debug] Snapshot what's actually in the DB for this thread so + // we can correlate against what the client receives. If the assistant + // rows here have `partCount=0`, the bug is in /api/chat onFinish; if + // they look correct, the bug is downstream (transport, hydration, + // render). + logger.info(`${CHAT_DEBUG_TAG} GET /api/threads/[id]/messages`, { + threadId: id, + rowCount: rows.length, + roster: summarizeRoster(messages as unknown[]), + rows: rows.map((r) => ({ + messageId: r.messageId, + format: r.format, + createdAt: r.createdAt, + content: summarizeMessage(r.content), + })), + }); - if (!messageId || !format || content === undefined) { + return NextResponse.json({ messages }); + } catch (error) { + if (error instanceof Response) return error; + console.error("[threads] messages GET error:", error); return NextResponse.json( - { error: "messageId, format, and content are required" }, - { status: 400 } + { error: "Internal server error" }, + { status: 500 }, ); } - - const thread = await getThreadAndVerify(id, userId); - - try { - await db.transaction(async (tx) => { - await tx.insert(chatMessages).values({ - threadId: id, - messageId: String(messageId), - parentId: parentId ?? null, - format: String(format), - content: typeof content === "object" ? content : { raw: content }, - }); - - // Only update headMessageId when appending to the current head - // (avoids overwriting explicit branch head set via PATCH) - const shouldUpdateHead = - thread.headMessageId == null || - (parentId != null && parentId === thread.headMessageId); - const updates: { - lastMessageAt: string; - updatedAt: string; - headMessageId?: string; - } = { - lastMessageAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - if (shouldUpdateHead) updates.headMessageId = String(messageId); - - await tx - .update(chatThreads) - .set(updates) - .where(eq(chatThreads.id, id)); - }); - } catch (txError: unknown) { - const err = txError as { code?: string }; - if (err?.code === "23505") { - return NextResponse.json( - { error: "Message already exists (duplicate messageId)" }, - { status: 409 } - ); - } - throw txError; - } - - return NextResponse.json({ ok: true }); - } catch (error) { - if (error instanceof Response) return error; - console.error("[threads] messages POST error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -}, { routeName: "POST /api/threads/[id]/messages" }); + }, + { routeName: "GET /api/threads/[id]/messages" }, +); + +// NOTE: a DELETE handler used to live here for the edit-user-message flow. +// It was removed once the chat route started honoring the SDK's native +// `regenerate-message` trigger and truncating persisted history server-side +// in `onFinish`. See `src/app/api/chat/route.ts`. diff --git a/src/app/api/threads/[id]/title/route.ts b/src/app/api/threads/[id]/title/route.ts deleted file mode 100644 index 96db1999..00000000 --- a/src/app/api/threads/[id]/title/route.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { generateText } from "ai"; -import { db } from "@/lib/db/client"; -import { chatThreads } from "@/lib/db/schema"; -import { - requireAuth, - verifyWorkspaceAccess, - verifyThreadOwnership, -} from "@/lib/api/workspace-helpers"; -import { eq } from "drizzle-orm"; -import { withServerObservability } from "@/lib/with-server-observability"; -import { getGatewayModelIdForPurpose } from "@/lib/ai/models"; -import { - buildGatewayProviderOptions, - createGatewayLanguageModel, - getGatewayAttributionHeaders, -} from "@/lib/ai/gateway-provider-options"; - -function extractTextFromMessage(msg: { content?: unknown[] }): string { - if (!msg.content || !Array.isArray(msg.content)) return ""; - return (msg.content as { type?: string; text?: string }[]) - .filter((c) => c.type === "text") - .map((c) => c.text ?? "") - .join(" ") - .trim(); -} - -/** - * POST /api/threads/[id]/title - * Generate a title from messages using Gemini Flash Lite. - * Body: { messages: ThreadMessage[] } - */ -export const POST = withServerObservability( - async function POST( - req: NextRequest, - { params }: { params: Promise<{ id: string }> }, - ) { - try { - const userId = await requireAuth(); - const { id } = await params; - const body = await req.json().catch(() => ({})); - const { messages } = body; - - const [thread] = await db - .select() - .from(chatThreads) - .where(eq(chatThreads.id, id)) - .limit(1); - - if (!thread) { - return NextResponse.json( - { error: "Thread not found" }, - { status: 404 }, - ); - } - - await verifyWorkspaceAccess(thread.workspaceId, userId); - verifyThreadOwnership(thread, userId); - - let title = "New Chat"; - - if (messages && Array.isArray(messages) && messages.length > 0) { - const conversationText = messages - .slice(0, 6) - .map((m: { role?: string; content?: unknown[] }) => { - const text = extractTextFromMessage(m); - if (!text) return ""; - const role = m.role === "user" ? "User" : "Assistant"; - return `${role}: ${text}`; - }) - .filter(Boolean) - .join("\n\n"); - - if (conversationText.trim()) { - try { - const gatewayModelId = - getGatewayModelIdForPurpose("title-generation"); - const { text, providerMetadata } = await generateText({ - model: createGatewayLanguageModel(gatewayModelId), - providerOptions: buildGatewayProviderOptions(gatewayModelId, { - userId, - }) as any, - headers: getGatewayAttributionHeaders(), - system: `Generate a very short chat title (2-6 words) that captures the topic. Output ONLY the title, no quotes or punctuation.`, - prompt: `Conversation:\n\n${conversationText}\n\nTitle:`, - experimental_telemetry: { - isEnabled: true, - metadata: { - "tcc.sessionId": id, - ...(userId ? { userId } : {}), - }, - }, - }); - const provider = - (providerMetadata as any)?.gateway?.routing?.resolvedProvider ?? - (providerMetadata as any)?.gateway?.routing?.finalProvider; - if (provider) { - console.log( - "[threads/title] Gateway resolved provider:", - provider, - ); - } - const generated = text.trim().slice(0, 60); - if (generated) title = generated; - } catch (err) { - console.warn("[threads] title Gemini fallback:", err); - const firstUser = messages.find( - (m: { role?: string }) => m.role === "user", - ); - const fallback = extractTextFromMessage(firstUser ?? {}); - if (fallback) { - title = - fallback.slice(0, 50) + (fallback.length > 50 ? "..." : ""); - } - } - } - } - - await db.update(chatThreads).set({ title }).where(eq(chatThreads.id, id)); - - return NextResponse.json({ title }); - } catch (error) { - if (error instanceof Response) return error; - console.error("[threads] title error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, - ); - } - }, - { routeName: "POST /api/threads/[id]/title" }, -); diff --git a/src/app/globals.css b/src/app/globals.css index 3adf9f04..09a9081a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -35,24 +35,6 @@ font-size: 1rem; } -/* Streamdown code block: language and action buttons on same row (override default flex-col) */ -.streamdown-content [data-streamdown="code-block"] { - display: grid !important; - grid-template-areas: "header actions" "body body"; -} -.streamdown-content [data-streamdown="code-block"] > [data-streamdown="code-block-header"] { - grid-area: header; -} -.streamdown-content [data-streamdown="code-block"] > div:nth-child(2) { - grid-area: actions; - margin-top: 0 !important; -} -.streamdown-content [data-streamdown="code-block"] > [data-streamdown="code-block-body"] { - grid-area: body; - padding-inline: 0; - overflow-x: auto !important; -} - /* Prosemirror-search citation highlight - theme-aware */ .ProseMirror-search-match { background-color: rgba(234, 179, 8, 0.35); @@ -218,14 +200,6 @@ } } -/* Prevent CSS smooth scrolling from interfering with assistant-ui's programmatic - scrollTo calls. The library uses scrollTo({ behavior: "auto" }) which defers - to this CSS property β€” smooth animation races with DOM updates during streaming, - causing missed/janky scroll positions. See: assistant-ui/assistant-ui#3648 */ -.aui-thread-viewport { - scroll-behavior: auto; -} - body { font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; } @@ -1180,20 +1154,3 @@ body:has(.card-detail-modal) .workspace-grid-container { opacity: 1; } } - -/* User message breathe-in animation */ -@keyframes breatheIn { - from { - opacity: 0; - transform: scale(0.7); - } - to { - opacity: 1; - transform: scale(1); - } -} - -.animate-breathe-in { - transform-origin: right center; - animation: breatheIn 0.7s cubic-bezier(0.16, 1.1, 0.3, 1.02) forwards; -} diff --git a/src/components/ai-elements/code-block.tsx b/src/components/ai-elements/code-block.tsx deleted file mode 100644 index 0a845559..00000000 --- a/src/components/ai-elements/code-block.tsx +++ /dev/null @@ -1,555 +0,0 @@ -"use client"; - -import type { ComponentProps, CSSProperties, HTMLAttributes } from "react"; -import type { - BundledLanguage, - BundledTheme, - HighlighterGeneric, - ThemedToken, -} from "shiki"; - -import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { cn } from "@/lib/utils"; -import { CheckIcon, CopyIcon } from "lucide-react"; -import { - createContext, - memo, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { createHighlighter } from "shiki"; - -// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline -// biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check - -const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1; -// biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check - -// oxlint-disable-next-line eslint(no-bitwise) -const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2; -const isUnderline = (fontStyle: number | undefined) => - // biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check - // oxlint-disable-next-line eslint(no-bitwise) - fontStyle && fontStyle & 4; - -// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint -interface KeyedToken { - token: ThemedToken; - key: string; -} -interface KeyedLine { - tokens: KeyedToken[]; - key: string; -} - -const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] => - lines.map((line, lineIdx) => ({ - key: `line-${lineIdx}`, - tokens: line.map((token, tokenIdx) => ({ - key: `line-${lineIdx}-${tokenIdx}`, - token, - })), - })); - -// Token rendering component -const TokenSpan = ({ token }: { token: ThemedToken }) => ( - - {token.content} - -); - -// Line rendering component -const LineSpan = ({ - keyedLine, - showLineNumbers, -}: { - keyedLine: KeyedLine; - showLineNumbers: boolean; -}) => ( - - {keyedLine.tokens.length === 0 - ? "\n" - : keyedLine.tokens.map(({ token, key }) => ( - - ))} - -); - -// Types -type CodeBlockProps = HTMLAttributes & { - code: string; - language: BundledLanguage; - showLineNumbers?: boolean; -}; - -interface TokenizedCode { - tokens: ThemedToken[][]; - fg: string; - bg: string; -} - -interface CodeBlockContextType { - code: string; -} - -// Context -const CodeBlockContext = createContext({ - code: "", -}); - -// Highlighter cache (singleton per language) -const highlighterCache = new Map< - string, - Promise> ->(); - -// Token cache -const tokensCache = new Map(); - -// Subscribers for async token updates -const subscribers = new Map void>>(); - -const getTokensCacheKey = (code: string, language: BundledLanguage) => { - const start = code.slice(0, 100); - const end = code.length > 100 ? code.slice(-100) : ""; - return `${language}:${code.length}:${start}:${end}`; -}; - -const getHighlighter = ( - language: BundledLanguage -): Promise> => { - const cached = highlighterCache.get(language); - if (cached) { - return cached; - } - - const highlighterPromise = createHighlighter({ - langs: [language], - themes: ["github-light", "github-dark"], - }); - - highlighterCache.set(language, highlighterPromise); - return highlighterPromise; -}; - -// Create raw tokens for immediate display while highlighting loads -const createRawTokens = (code: string): TokenizedCode => ({ - bg: "transparent", - fg: "inherit", - tokens: code.split("\n").map((line) => - line === "" - ? [] - : [ - { - color: "inherit", - content: line, - } as ThemedToken, - ] - ), -}); - -// Synchronous highlight with callback for async results -export const highlightCode = ( - code: string, - language: BundledLanguage, - // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks) - callback?: (result: TokenizedCode) => void -): TokenizedCode | null => { - const tokensCacheKey = getTokensCacheKey(code, language); - - // Return cached result if available - const cached = tokensCache.get(tokensCacheKey); - if (cached) { - return cached; - } - - // Subscribe callback if provided - if (callback) { - if (!subscribers.has(tokensCacheKey)) { - subscribers.set(tokensCacheKey, new Set()); - } - subscribers.get(tokensCacheKey)?.add(callback); - } - - // Start highlighting in background - fire-and-forget async pattern - getHighlighter(language) - // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then) - .then((highlighter) => { - const availableLangs = highlighter.getLoadedLanguages(); - const langToUse = availableLangs.includes(language) ? language : "text"; - - const result = highlighter.codeToTokens(code, { - lang: langToUse, - themes: { - dark: "github-dark", - light: "github-light", - }, - }); - - const tokenized: TokenizedCode = { - bg: result.bg ?? "transparent", - fg: result.fg ?? "inherit", - tokens: result.tokens, - }; - - // Cache the result - tokensCache.set(tokensCacheKey, tokenized); - - // Notify all subscribers - const subs = subscribers.get(tokensCacheKey); - if (subs) { - for (const sub of subs) { - sub(tokenized); - } - subscribers.delete(tokensCacheKey); - } - }) - // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks) - .catch((error) => { - console.error("Failed to highlight code:", error); - subscribers.delete(tokensCacheKey); - }); - - return null; -}; - -// Line number styles using CSS counters -const LINE_NUMBER_CLASSES = cn( - "block", - "before:content-[counter(line)]", - "before:inline-block", - "before:[counter-increment:line]", - "before:w-8", - "before:mr-4", - "before:text-right", - "before:text-muted-foreground/50", - "before:font-mono", - "before:select-none" -); - -const CodeBlockBody = memo( - ({ - tokenized, - showLineNumbers, - className, - }: { - tokenized: TokenizedCode; - showLineNumbers: boolean; - className?: string; - }) => { - const preStyle = useMemo( - () => ({ - backgroundColor: tokenized.bg, - color: tokenized.fg, - }), - [tokenized.bg, tokenized.fg] - ); - - const keyedLines = useMemo( - () => addKeysToTokens(tokenized.tokens), - [tokenized.tokens] - ); - - return ( -
-        
-          {keyedLines.map((keyedLine) => (
-            
-          ))}
-        
-      
- ); - }, - (prevProps, nextProps) => - prevProps.tokenized === nextProps.tokenized && - prevProps.showLineNumbers === nextProps.showLineNumbers && - prevProps.className === nextProps.className -); - -CodeBlockBody.displayName = "CodeBlockBody"; - -export const CodeBlockContainer = ({ - className, - language, - style, - ...props -}: HTMLAttributes & { language: string }) => ( -
-); - -export const CodeBlockHeader = ({ - children, - className, - ...props -}: HTMLAttributes) => ( -
- {children} -
-); - -export const CodeBlockTitle = ({ - children, - className, - ...props -}: HTMLAttributes) => ( -
- {children} -
-); - -export const CodeBlockFilename = ({ - children, - className, - ...props -}: HTMLAttributes) => ( - - {children} - -); - -export const CodeBlockActions = ({ - children, - className, - ...props -}: HTMLAttributes) => ( -
- {children} -
-); - -export const CodeBlockContent = ({ - code, - language, - showLineNumbers = false, -}: { - code: string; - language: BundledLanguage; - showLineNumbers?: boolean; -}) => { - // Memoized raw tokens for immediate display - const rawTokens = useMemo(() => createRawTokens(code), [code]); - - // Try to get cached result synchronously, otherwise use raw tokens - const [tokenized, setTokenized] = useState( - () => highlightCode(code, language) ?? rawTokens - ); - - useEffect(() => { - let cancelled = false; - - // Reset to raw tokens when code changes (shows current code, not stale tokens) - setTokenized(highlightCode(code, language) ?? rawTokens); - - // Subscribe to async highlighting result - highlightCode(code, language, (result) => { - if (!cancelled) { - setTokenized(result); - } - }); - - return () => { - cancelled = true; - }; - }, [code, language, rawTokens]); - - return ( -
- -
- ); -}; - -export const CodeBlock = ({ - code, - language, - showLineNumbers = false, - className, - children, - ...props -}: CodeBlockProps) => { - const contextValue = useMemo(() => ({ code }), [code]); - - return ( - - - {children} - - - - ); -}; - -export type CodeBlockCopyButtonProps = ComponentProps & { - onCopy?: () => void; - onError?: (error: Error) => void; - timeout?: number; -}; - -export const CodeBlockCopyButton = ({ - onCopy, - onError, - timeout = 2000, - children, - className, - ...props -}: CodeBlockCopyButtonProps) => { - const [isCopied, setIsCopied] = useState(false); - const timeoutRef = useRef(0); - const { code } = useContext(CodeBlockContext); - - const copyToClipboard = useCallback(async () => { - if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { - onError?.(new Error("Clipboard API not available")); - return; - } - - try { - if (!isCopied) { - await navigator.clipboard.writeText(code); - setIsCopied(true); - onCopy?.(); - timeoutRef.current = window.setTimeout( - () => setIsCopied(false), - timeout - ); - } - } catch (error) { - onError?.(error as Error); - } - }, [code, onCopy, onError, timeout, isCopied]); - - useEffect( - () => () => { - window.clearTimeout(timeoutRef.current); - }, - [] - ); - - const Icon = isCopied ? CheckIcon : CopyIcon; - - return ( - - ); -}; - -export type CodeBlockLanguageSelectorProps = ComponentProps; - -export const CodeBlockLanguageSelector = ( - props: CodeBlockLanguageSelectorProps -) =>