From f32b206854f62c91a478a0cb230c62cc8663b5de Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Mon, 11 May 2026 19:47:57 -0700 Subject: [PATCH] refactor(dashboard): unify AI chat surfaces on assistant-ui Thread Replaces the bespoke ai-chat-shared chat UI used by ask-ai, the stack companion widget, vibe coding chat, and the create-dashboard preview with the shared assistant-ui Thread component. Extracts the streaming request/format helpers into a new chat-stream module and the tool call UI into a reusable ToolFallback. --- .../analytics/tables/use-ai-query-chat.ts | 31 +- .../components/assistant-ui/chat-stream.ts | 214 ++++++ .../src/components/assistant-ui/thread.tsx | 109 +-- .../components/assistant-ui/tool-fallback.tsx | 108 +++ .../assistant-ui/tooltip-icon-button.tsx | 5 +- .../components/commands/ai-chat-shared.tsx | 558 ---------------- .../src/components/commands/ask-ai.tsx | 317 +++------ .../create-dashboard-preview.tsx | 33 +- .../stack-companion/ai-chat-widget.tsx | 629 +++++++----------- .../components/vibe-coding/chat-adapters.ts | 129 +--- 10 files changed, 747 insertions(+), 1386 deletions(-) create mode 100644 apps/dashboard/src/components/assistant-ui/chat-stream.ts create mode 100644 apps/dashboard/src/components/assistant-ui/tool-fallback.tsx delete mode 100644 apps/dashboard/src/components/commands/ai-chat-shared.tsx diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-query-chat.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-query-chat.ts index 72cdfaf1fa..01f976fe39 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-query-chat.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-query-chat.ts @@ -1,11 +1,10 @@ "use client"; -import { buildStackAuthHeaders } from "@/lib/api-headers"; +import { createUnifiedAiTransport } from "@/components/assistant-ui/chat-stream"; import { getPublicEnvVar } from "@/lib/env"; import { useChat, type UIMessage } from "@ai-sdk/react"; import { useUser } from "@stackframe/stack"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; -import { convertToModelMessages, DefaultChatTransport } from "ai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useProjectId } from "../../use-admin-app"; @@ -110,26 +109,14 @@ export function useAiQueryChat(): AiQueryChat { const transport = useMemo( () => - new DefaultChatTransport({ - api: `${backendBaseUrl}/api/latest/ai/query/stream`, - headers: () => buildStackAuthHeaders(currentUser), - prepareSendMessagesRequest: async ({ messages: uiMessages, headers }) => { - const modelMessages = await convertToModelMessages(uiMessages); - return { - body: { - systemPrompt: "build-analytics-query", - tools: ["sql-query"], - quality: "smart", - speed: "fast", - projectId, - messages: modelMessages.map((m) => ({ - role: m.role, - content: m.content, - })), - }, - headers, - }; - }, + createUnifiedAiTransport({ + backendBaseUrl, + currentUser, + systemPrompt: "build-analytics-query", + tools: ["sql-query"], + quality: "smart", + speed: "fast", + projectId, }), // The transport only needs to be rebuilt if the backend URL or // project changes; current user is read via closure on each diff --git a/apps/dashboard/src/components/assistant-ui/chat-stream.ts b/apps/dashboard/src/components/assistant-ui/chat-stream.ts new file mode 100644 index 0000000000..b6f790e5ab --- /dev/null +++ b/apps/dashboard/src/components/assistant-ui/chat-stream.ts @@ -0,0 +1,214 @@ +import { buildStackAuthHeaders, type CurrentUser } from "@/lib/api-headers"; +import type { ChatContent } from "@stackframe/stack-shared/dist/interface/admin-interface"; +import { captureError } from "@stackframe/stack-shared/dist/utils/errors"; +import { + convertToModelMessages, + DefaultChatTransport, + parseJsonEventStream, + uiMessageChunkSchema, + type UIMessage, + type UIMessageChunk, +} from "ai"; + +type ContentPart = { type: string }; +type AttachmentLike = { content?: readonly unknown[] }; +type ThreadMessageLikeForBackend = { + role: string, + content: readonly ContentPart[], + attachments?: readonly AttachmentLike[], +}; + +const isToolCall = (content: ContentPart): boolean => content.type === "tool-call"; + +/** Maps thread messages to the backend wire format; merges `attachments` into `content`. */ +export function formatThreadMessagesForBackend( + messages: readonly ThreadMessageLikeForBackend[], +): Array<{ role: string, content: unknown }> { + const formatted: Array<{ role: string, content: unknown }> = []; + for (const msg of messages) { + const textContent = msg.content.filter((c) => !isToolCall(c)); + const attachmentContent: unknown[] = []; + if (msg.attachments) { + for (const attachment of msg.attachments) { + if (Array.isArray(attachment.content)) { + attachmentContent.push(...attachment.content); + } + } + } + const combined = [...textContent, ...attachmentContent]; + if (combined.length > 0) { + formatted.push({ role: msg.role, content: combined }); + } + } + return formatted; +} + +export type AiStreamRequestBody = { + quality: string, + speed: string, + systemPrompt: string, + tools: string[], + messages: Array<{ role: string, content: unknown }>, + projectId?: string, +}; + +/** + * Sends a request to the AI streaming endpoint and returns a stream of UIMessageChunks + * (as produced by the Vercel AI SDK's `streamText().toUIMessageStreamResponse()`). + */ +export async function sendAiStreamRequest( + backendBaseUrl: string, + currentUser: CurrentUser | undefined, + body: AiStreamRequestBody, + abortSignal?: AbortSignal, +): Promise> { + const authHeaders = await buildStackAuthHeaders(currentUser); + + const response = await fetch(`${backendBaseUrl}/api/latest/ai/query/stream`, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "text/event-stream", + ...authHeaders, + }, + ...(abortSignal ? { signal: abortSignal } : {}), + body: JSON.stringify(body), + }); + + if (!response.ok || !response.body) { + throw new Error(`AI stream request failed: ${response.status} ${response.statusText}`); + } + + return parseJsonEventStream({ + stream: response.body, + schema: uiMessageChunkSchema, + }).pipeThrough( + new TransformStream< + { success: true, value: UIMessageChunk, rawValue: unknown } | { success: false, error: unknown, rawValue: unknown }, + UIMessageChunk + >({ + transform(parseResult, controller) { + if (parseResult.success) { + controller.enqueue(parseResult.value); + } + }, + }), + ); +} + +/** + * Converts a UIMessage's parts (as emitted by `readUIMessageStream`) into our + * ChatContent shape — compatible with assistant-ui's `ThreadAssistantContentPart[]`. + */ +export function uiPartsToChatContent(parts: UIMessage["parts"]): ChatContent { + const result: ChatContent = []; + for (const part of parts) { + if (part.type === "text") { + if (part.text) { + result.push({ type: "text", text: part.text }); + } + continue; + } + + if (part.type === "dynamic-tool") { + const toolPart = part as { toolCallId: string, toolName: string, input?: unknown, output?: unknown }; + const input = toolPart.input ?? {}; + result.push({ + type: "tool-call", + toolCallId: toolPart.toolCallId, + toolName: toolPart.toolName, + args: input, + argsText: typeof input === "string" ? input : JSON.stringify(input), + result: toolPart.output ?? null, + }); + continue; + } + + if (typeof part.type === "string" && part.type.startsWith("tool-")) { + const toolName = part.type.slice("tool-".length); + const toolPart = part as { toolCallId: string, input?: unknown, output?: unknown }; + const input = toolPart.input ?? {}; + result.push({ + type: "tool-call", + toolCallId: toolPart.toolCallId, + toolName, + args: input, + argsText: typeof input === "string" ? input : JSON.stringify(input), + result: toolPart.output ?? null, + }); + continue; + } + } + return result; +} + +export type WireMessage = { role: string, content: unknown }; + +/** + * `DefaultChatTransport` configured for the unified `/api/latest/ai/query/stream` + * endpoint. Shared by `useChat`-style callers (analytics, create-dashboard). + * `transformMessages` runs after `convertToModelMessages` and can prepend + * extra context messages. + */ +export function createUnifiedAiTransport(opts: { + backendBaseUrl: string, + /** Either a value (closed at creation) or a getter called at request time for liveness. */ + currentUser: CurrentUser | null | (() => CurrentUser | null), + systemPrompt: string, + tools: string[], + quality: "smart" | "fast", + speed: "fast" | "slow", + projectId: string | undefined, + transformMessages?: (messages: WireMessage[]) => Promise, +}): DefaultChatTransport { + const resolveUser = () => + typeof opts.currentUser === "function" ? opts.currentUser() : opts.currentUser; + return new DefaultChatTransport({ + api: `${opts.backendBaseUrl}/api/latest/ai/query/stream`, + headers: () => buildStackAuthHeaders(resolveUser()), + prepareSendMessagesRequest: async ({ messages: uiMessages, headers }) => { + const modelMessages = await convertToModelMessages(uiMessages); + const userMessages: WireMessage[] = modelMessages.map((m) => ({ + role: m.role, + content: m.content, + })); + const finalMessages = opts.transformMessages + ? await opts.transformMessages(userMessages) + : userMessages; + return { + body: { + systemPrompt: opts.systemPrompt, + tools: opts.tools, + quality: opts.quality, + speed: opts.speed, + projectId: opts.projectId, + messages: finalMessages, + }, + headers, + }; + }, + }); +} + +/** + * Classifies raw AI provider errors into user-friendly messages. + * Unclassified errors are reported to Sentry via `captureError`. + */ +export function getFriendlyAiErrorMessage(error: Error): string { + const causeMessage = (error as { cause?: { message?: string } }).cause?.message ?? ""; + const blob = `${error.message} ${causeMessage}`; + if (/maximum context length|context_length_exceeded|too many tokens|context length/i.test(blob)) { + return "The conversation got too long. Try starting a new chat or asking a more focused question."; + } + if (/rate limit|429|quota|too many requests/i.test(blob)) { + return "Service is busy. Please try again in a moment."; + } + if (/timeout|ECONNRESET|fetch failed|network/i.test(blob)) { + return "Request timed out. Please try again."; + } + if (/result too large|limit \d+/i.test(blob)) { + return "The query returned too much data. Try narrowing your question or requesting fewer rows."; + } + captureError("ai-chat", error); + return "Something went wrong. Please try again."; +} diff --git a/apps/dashboard/src/components/assistant-ui/thread.tsx b/apps/dashboard/src/components/assistant-ui/thread.tsx index af27c79e4b..873aee00da 100644 --- a/apps/dashboard/src/components/assistant-ui/thread.tsx +++ b/apps/dashboard/src/components/assistant-ui/thread.tsx @@ -27,7 +27,10 @@ import { MAX_IMAGE_MB_PER_FILE, } from "@stackframe/stack-shared/dist/ai/image-limits"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; -import { createContext, useContext, useEffect, useMemo, useRef, useState, type FC } from "react"; +import { createContext, useContext, useEffect, useMemo, useRef, useState, type ComponentProps, type FC, type ReactNode } from "react"; + +type AssistantContentComponents = ComponentProps["components"]; +const AssistantContentComponentsContext = createContext(undefined); const HideMessageActionsContext = createContext(false); const HasRunningStatusContext = createContext(false); @@ -62,60 +65,59 @@ export const Thread: FC<{ runningStatusMessages?: string[], composerAttachments?: boolean, attachmentAdapter?: AttachmentAdapter, -}> = ({ useOffWhiteLightMode = false, composerPlaceholder, hideMessageActions = false, runningStatusMessages, composerAttachments = false, attachmentAdapter }) => { + /** Custom welcome / empty-state node. Defaults to the email-template welcome. */ + welcome?: ReactNode, + /** Overrides for the assistant message content slots (Text / tools / etc.). */ + assistantContentComponents?: AssistantContentComponents, +}> = ({ useOffWhiteLightMode = false, composerPlaceholder, hideMessageActions = false, runningStatusMessages, composerAttachments = false, attachmentAdapter, welcome, assistantContentComponents }) => { return ( - - - - + + + - - - - - {runningStatusMessages && ( - - + + {welcome ? ( + {welcome} + ) : ( + + )} + + + + {runningStatusMessages && ( + + + + )} + + +
- )} - - -
- - -
- - -
- - - - + +
+ + +
+ + + + + ); @@ -439,6 +441,7 @@ const ComposerAttachmentsAddButton: FC = () => { return ( = ({ placeholder }) => { const Composer: FC<{ placeholder?: ComposerPlaceholder }> = ({ placeholder }) => { const attachmentsEnabled = useComposerAttachmentsEnabled(); return ( - + {attachmentsEnabled && } {typeof placeholder === "object" ? ( { const AssistantMessage: FC = () => { const hasRunningStatus = useContext(HasRunningStatusContext); + const overrideComponents = useContext(AssistantContentComponentsContext); + const contentComponents: AssistantContentComponents = overrideComponents ?? { Text: MarkdownText }; return (
@@ -723,7 +728,7 @@ const AssistantMessage: FC = () => {
- +
diff --git a/apps/dashboard/src/components/assistant-ui/tool-fallback.tsx b/apps/dashboard/src/components/assistant-ui/tool-fallback.tsx new file mode 100644 index 0000000000..ed676f9653 --- /dev/null +++ b/apps/dashboard/src/components/assistant-ui/tool-fallback.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { cn } from "@/components/ui"; +import { type ToolCallContentPartProps } from "@assistant-ui/react"; +import { CaretDownIcon, DatabaseIcon, SpinnerGapIcon } from "@phosphor-icons/react"; +import { useState } from "react"; + +/** + * Shared assistant-ui tool fallback. Renders a collapsible card for any + * tool call streamed from the unified AI endpoint (sql-query, docs, etc.). + */ +export function ToolFallback({ toolName, args, result, status, argsText }: ToolCallContentPartProps) { + const [isExpanded, setIsExpanded] = useState(false); + + const isRunning = status.type === "running" || status.type === "requires-action"; + const isComplete = status.type === "complete"; + const isIncomplete = status.type === "incomplete"; + + const typed = (result ?? undefined) as { success?: boolean, result?: unknown[], error?: string, rowCount?: number } | undefined; + const hasOutput = typed !== undefined; + const isSuccess = hasOutput && typed.success !== false && !isIncomplete; + const errorMessage = hasOutput && typed.success === false + ? typed.error + : isIncomplete + ? (status.error as { message?: string } | undefined)?.message + : undefined; + + const label = toolName === "queryAnalytics" ? "Analytics Query" : toolName; + const queryArg = (args as { query?: string } | undefined)?.query ?? (argsText ? argsText : undefined); + + return ( +
+ + +
+
+
+ {queryArg && ( +
+ + Query + +
+                  {queryArg}
+                
+
+ )} + {hasOutput && isSuccess && ( +
+ Result +
+                  {JSON.stringify(typed.result ?? typed, null, 2)}
+                
+
+ )} + {errorMessage && ( +
+ Error +
+ {errorMessage} +
+
+ )} + {isRunning && ( +
+ + Running query... +
+ )} +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/components/assistant-ui/tooltip-icon-button.tsx b/apps/dashboard/src/components/assistant-ui/tooltip-icon-button.tsx index 9213190e79..9e01a6e18e 100644 --- a/apps/dashboard/src/components/assistant-ui/tooltip-icon-button.tsx +++ b/apps/dashboard/src/components/assistant-ui/tooltip-icon-button.tsx @@ -1,6 +1,7 @@ "use client"; import { ComponentPropsWithoutRef, forwardRef } from "react"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import { Tooltip, @@ -35,7 +36,9 @@ export const TooltipIconButton = forwardRef< {tooltip} - {tooltip} + + {tooltip} + ); diff --git a/apps/dashboard/src/components/commands/ai-chat-shared.tsx b/apps/dashboard/src/components/commands/ai-chat-shared.tsx deleted file mode 100644 index 75723abb4b..0000000000 --- a/apps/dashboard/src/components/commands/ai-chat-shared.tsx +++ /dev/null @@ -1,558 +0,0 @@ -import { cn } from "@/components/ui"; -import { buildStackAuthHeaders, type CurrentUser } from "@/lib/api-headers"; -import { getPublicEnvVar } from "@/lib/env"; -import type { UIMessage } from "@ai-sdk/react"; -import { ArrowSquareOutIcon, CaretDownIcon, CheckIcon, CopyIcon, DatabaseIcon, SparkleIcon, SpinnerGapIcon, UserIcon } from "@phosphor-icons/react"; -import { captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; -import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; -import { convertToModelMessages, DefaultChatTransport } from "ai"; -import { memo, useCallback, useEffect, useRef, useState } from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; - - -export function createAskAiTransport({ - currentUser, - projectId, -}: { - currentUser: CurrentUser | null, - projectId: string | undefined, -}): DefaultChatTransport { - const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set"); - return new DefaultChatTransport({ - api: `${backendBaseUrl}/api/latest/ai/query/stream`, - headers: () => buildStackAuthHeaders(currentUser), - prepareSendMessagesRequest: async ({ messages: uiMessages, headers }) => { - const modelMessages = await convertToModelMessages(uiMessages); - return { - body: { - systemPrompt: "command-center-ask-ai", - tools: ["docs", "sql-query"], - quality: "smart", - speed: "slow", - projectId, - messages: modelMessages.map(m => ({ - role: m.role, - content: m.content, - })), - }, - headers, - }; - }, - }); -} - -// Memoized copy button for performance -export const CopyButton = memo(function CopyButton({ text, className, size = "sm" }: { - text: string, - className?: string, - size?: "sm" | "xs", -}) { - const [copied, setCopied] = useState(false); - - const handleCopy = useCallback(async () => { - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }, [text]); - - const iconSize = size === "xs" ? "h-2.5 w-2.5" : "h-3 w-3"; - - return ( - - ); -}); - -// Truncate URL for display while keeping full URL for copy -export function truncateUrl(url: string, maxLength = 50): string { - if (url.length <= maxLength) return url; - const match = url.match(/^https?:\/\/([^/?#]+)(\/[^?#]*)?(\?[^#]*)?(#.*)?$/); - if (match) { - const host = match[1]; - const path = (match[2] || "") + (match[3] || ""); - if (path.length > 30) { - return host + path.slice(0, 20) + "..." + path.slice(-10); - } - } - return url.slice(0, maxLength - 3) + "..."; -} - -// Inline code with smart copy button -export const InlineCode = memo(function InlineCode({ children }: { children?: React.ReactNode }) { - const text = String(children || ""); - const isUrl = /^https?:\/\//.test(text); - const isCommand = /^(npm|npx|pnpm|yarn|curl|git|docker|cd|mkdir|ls|brew|apt|pip)/.test(text); - const isPath = /^[./~]/.test(text) && text.includes("/"); - const showCopy = isUrl || isCommand || isPath || text.length > 15; - - // For very long URLs, show truncated version - const displayText = isUrl && text.length > 60 ? truncateUrl(text, 55) : text; - - return ( - - - {displayText} - - {showCopy && } - - ); -}); - -// Code block with language label and copy -export const CodeBlock = memo(function CodeBlock({ children, className }: { - children?: React.ReactNode, - className?: string, -}) { - const text = String(children || "").replace(/\n$/, ""); - const language = className?.replace("language-", "").toUpperCase() ?? ""; - - return ( -
- {/* Header with language and copy */} -
- - {language || "CODE"} - - -
- {/* Code content */} -
-
-          {children}
-        
-
-
- ); -}); - -// Smart link component with copy and external icon -export const SmartLink = memo(function SmartLink({ href, children }: { - href?: string, - children?: React.ReactNode, -}) { - const displayText = String(children || href || ""); - const isFullUrl = href?.startsWith("http"); - const isDocsLink = href?.includes("docs.stack-auth.com"); - - // Truncate long URLs for display - const truncatedDisplay = displayText.length > 55 ? truncateUrl(displayText, 50) : displayText; - - return ( - - - {truncatedDisplay} - {isFullUrl && !isDocsLink && ( - - )} - - {isFullUrl && href && } - - ); -}); - -export type ToolInvocationPart = Extract; - - -// Expandable tool invocation card -export const ToolInvocationCard = memo(function ToolInvocationCard({ - invocation, -}: { - invocation: ToolInvocationPart, -}) { - const [isExpanded, setIsExpanded] = useState(false); - const isLoading = invocation.state === "input-streaming" || invocation.state === "input-available"; - const hasResult = invocation.state === "output-available"; - const hasError = invocation.state === "output-error"; - - // Extract tool name from type (e.g., "tool-queryAnalytics" → "queryAnalytics") - const toolName = invocation.type.replace(/^tool-/, ""); - - // Format the tool name for display - const getToolDisplay = () => { - if (toolName === "queryAnalytics") { - return { label: "Analytics Query", icon: DatabaseIcon }; - } - return { label: toolName, icon: DatabaseIcon }; - }; - - const { label, icon: Icon } = getToolDisplay(); - - const input = invocation.input as { query?: string } | undefined; - const queryArg = input?.query; - const result = invocation.output as { success?: boolean, result?: unknown[], error?: string, rowCount?: number }; - - return ( -
- {/* Header - always visible */} - - - {/* Expandable content */} -
-
-
- {/* Query */} - {queryArg && ( -
-
- - Query - - -
-
-                  {queryArg}
-                
-
- )} - - {/* Result */} - {hasResult && ( -
-
- - {result.success ? "Result" : "Error"} - - {result.success && result.result && ( - - )} -
- {result.success ? ( -
-                    {JSON.stringify(result.result, null, 2)}
-                  
- ) : ( -
- {result.error || "Query failed"} -
- )} -
- )} - - {/* Error state from SDK */} - {hasError && invocation.errorText && ( -
-
- - Error - -
-
- {invocation.errorText} -
-
- )} - - {/* Loading state */} - {isLoading && ( -
- - Running query... -
- )} -
-
-
-
- ); -}); - -// Memoized markdown components for consistent rendering -export const markdownComponents = { - p: ({ children }: { children?: React.ReactNode }) => ( -

- {children} -

- ), - ul: ({ children }: { children?: React.ReactNode }) => ( -
    - {children} -
- ), - ol: ({ children }: { children?: React.ReactNode }) => ( -
    - {children} -
- ), - li: ({ children }: { children?: React.ReactNode }) => ( -
  • {children}
  • - ), - code: ({ children, className }: { children?: React.ReactNode, className?: string }) => { - if (className) { - return {children}; - } - return {children}; - }, - pre: ({ children }: { children?: React.ReactNode }) => <>{children}, - strong: ({ children }: { children?: React.ReactNode }) => ( - {children} - ), - em: ({ children }: { children?: React.ReactNode }) => ( - {children} - ), - a: SmartLink, - table: ({ children }: { children?: React.ReactNode }) => ( -
    - {children}
    -
    - ), - thead: ({ children }: { children?: React.ReactNode }) => ( - {children} - ), - tbody: ({ children }: { children?: React.ReactNode }) => ( - {children} - ), - tr: ({ children }: { children?: React.ReactNode }) => {children}, - th: ({ children }: { children?: React.ReactNode }) => ( - - {children} - - ), - td: ({ children }: { children?: React.ReactNode }) => ( - {children} - ), - h1: ({ children }: { children?: React.ReactNode }) => ( -

    {children}

    - ), - h2: ({ children }: { children?: React.ReactNode }) => ( -

    {children}

    - ), - h3: ({ children }: { children?: React.ReactNode }) => ( -

    {children}

    - ), - blockquote: ({ children }: { children?: React.ReactNode }) => ( -
    - {children} -
    - ), - hr: () =>
    , -}; - -// Helper to count words in a string -export function countWords(text: string): number { - return text.split(/\s+/).filter(Boolean).length; -} - -// Helper to get first N words from text -export function getFirstNWords(text: string, n: number): string { - const words = text.split(/(\s+)/); // Split but keep whitespace - let wordCount = 0; - let result = ""; - for (const part of words) { - if (part.trim()) { - wordCount++; - if (wordCount > n) break; - } - result += part; - } - return result; -} - -// Helper to extract text content from UIMessage parts -export function getMessageContent(message: UIMessage): string { - return message.parts - .filter((part): part is { type: "text", text: string } => part.type === "text") - .map(part => part.text) - .join(""); -} - -// Helper to extract tool invocations from UIMessage parts -export function getToolInvocations(message: UIMessage): ToolInvocationPart[] { - return message.parts.filter( - (part): part is ToolInvocationPart => part.type.startsWith("tool-") - ); -} - -// Memoized user message component -export const UserMessage = memo(function UserMessage({ content }: { content: string }) { - return ( -
    -
    -

    {content}

    -
    -
    - -
    -
    - ); -}); - -// Memoized assistant message component -export const AssistantMessage = memo(function AssistantMessage({ - content, - toolInvocations, -}: { - content: string, - toolInvocations?: ToolInvocationPart[], -}) { - const hasToolInvocations = toolInvocations && toolInvocations.length > 0; - const hasContent = content.trim().length > 0; - - return ( -
    -
    - -
    -
    - {/* Tool invocations */} - {hasToolInvocations && ( -
    - {toolInvocations.map((invocation) => ( - - ))} -
    - )} - - {/* Text content */} - {hasContent && ( -
    -
    - - {content} - -
    -
    - )} -
    -
    - ); -}); - -// Word streaming hook - handles the progressive word reveal animation -export function useWordStreaming(content: string) { - const [displayedWordCount, setDisplayedWordCount] = useState(0); - const targetWordCount = content ? countWords(content) : 0; - const previousContentRef = useRef(""); - - useEffect(() => { - if (!content) { - setDisplayedWordCount(0); - previousContentRef.current = ""; - return; - } - if (!content.startsWith(previousContentRef.current)) { - setDisplayedWordCount(0); - } - previousContentRef.current = content; - }, [content]); - - useEffect(() => { - if (targetWordCount === 0 || displayedWordCount >= targetWordCount) { - return; - } - const timeoutId = setTimeout(() => { - setDisplayedWordCount(prev => Math.min(prev + 1, targetWordCount)); - }, 15); - return () => clearTimeout(timeoutId); - }, [displayedWordCount, targetWordCount]); - - return { - displayedWordCount, - targetWordCount, - getDisplayContent: (text: string) => getFirstNWords(text, displayedWordCount), - isRevealing: displayedWordCount < targetWordCount, - }; -} - - -// Classifies raw AI provider errors into user-friendly messages. -// The raw error is captured to Sentry separately via captureError — never shown to the user. -export function getFriendlyAiErrorMessage(error: Error): string { - const causeMessage = (error as { cause?: { message?: string } }).cause?.message ?? ""; - const blob = `${error.message} ${causeMessage}`; - if (/maximum context length|context_length_exceeded|too many tokens|context length/i.test(blob)) { - return "The conversation got too long. Try starting a new chat or asking a more focused question."; - } - if (/rate limit|429|quota|too many requests/i.test(blob)) { - return "Service is busy. Please try again in a moment."; - } - if (/timeout|ECONNRESET|fetch failed|network/i.test(blob)) { - return "Request timed out. Please try again."; - } - if (/result too large|limit \d+/i.test(blob)) { - return "The query returned too much data. Try narrowing your question or requesting fewer rows."; - } - // Unclassified — this is unexpected, report it - captureError("ask-ai", error); - return "Something went wrong. Please try again."; -} diff --git a/apps/dashboard/src/components/commands/ask-ai.tsx b/apps/dashboard/src/components/commands/ask-ai.tsx index 49d5cec4f8..7e9a4cd292 100644 --- a/apps/dashboard/src/components/commands/ask-ai.tsx +++ b/apps/dashboard/src/components/commands/ask-ai.tsx @@ -1,260 +1,133 @@ -import { cn } from "@/components/ui"; +import { formatThreadMessagesForBackend, getFriendlyAiErrorMessage, sendAiStreamRequest, uiPartsToChatContent } from "@/components/assistant-ui/chat-stream"; +import { ImageAttachmentAdapter } from "@/components/assistant-ui/image-attachment-adapter"; +import { MarkdownText } from "@/components/assistant-ui/markdown-text"; +import { Thread } from "@/components/assistant-ui/thread"; +import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { useDebouncedAction } from "@/hooks/use-debounced-action"; -import { useChat, type UIMessage } from "@ai-sdk/react"; -import { PaperPlaneTiltIcon, SparkleIcon, SpinnerGapIcon } from "@phosphor-icons/react"; +import { getPublicEnvVar } from "@/lib/env"; +import { + AssistantRuntimeProvider, + useLocalRuntime, + useThreadRuntime, + type ChatModelAdapter, +} from "@assistant-ui/react"; import { useUser } from "@stackframe/stack"; -import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { readUIMessageStream } from "ai"; import { usePathname } from "next/navigation"; -import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { memo, useEffect, useMemo, useRef, useState } from "react"; import { CmdKPreviewProps } from "../cmdk-commands"; -import { - AssistantMessage, - createAskAiTransport, - getFriendlyAiErrorMessage, - getMessageContent, - getToolInvocations, - UserMessage, - useWordStreaming -} from "./ai-chat-shared"; +const RUNNING_STATUS_MESSAGES = ["Thinking..."]; -/** - * AI Chat Preview Component - * - * Displays an AI chat conversation. Sends the initial query on mount - * and supports follow-up questions. - */ export function AIChatPreview({ query, ...rest }: CmdKPreviewProps) { return ; } - const AIChatPreviewInner = memo(function AIChatPreview({ query, registerOnFocus, unregisterOnFocus, - onBlur, }: CmdKPreviewProps) { - const [followUpInput, setFollowUpInput] = useState(""); - const messagesContainerRef = useRef(null); - const followUpInputRef = useRef(null); - const lastMessageCountRef = useRef(0); - const isNearBottomRef = useRef(true); const currentUser = useUser(); const pathname = usePathname(); const projectId = pathname.startsWith("/projects/") ? pathname.split("/")[2] : undefined; - const trimmedQuery = query.trim(); - - const { - messages, - status, - sendMessage, - error: aiError, - } = useChat({ - transport: createAskAiTransport({ currentUser, projectId }), - }); + const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") + ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set"); + + const [runError, setRunError] = useState(null); + + const chatAdapter = useMemo(() => ({ + async *run({ messages, abortSignal }) { + setRunError(null); + const wireMessages = formatThreadMessagesForBackend(messages); + try { + const chunkStream = await sendAiStreamRequest( + backendBaseUrl, + currentUser ?? undefined, + { + quality: "smart", + speed: "slow", + systemPrompt: "command-center-ask-ai", + tools: ["docs", "sql-query"], + projectId, + messages: wireMessages, + }, + abortSignal, + ); + + for await (const uiMessage of readUIMessageStream({ stream: chunkStream })) { + if (abortSignal.aborted) return; + yield { content: uiPartsToChatContent(uiMessage.parts) }; + } + } catch (error) { + if (abortSignal.aborted) return; + const message = error instanceof Error + ? getFriendlyAiErrorMessage(error) + : "Failed to get response. Please try again."; + setRunError(message); + throw error; + } + }, + }), [backendBaseUrl, currentUser, projectId]); - const aiLoading = status === "submitted" || status === "streaming"; + const attachmentAdapter = useMemo(() => new ImageAttachmentAdapter(), []); - // Send initial query on mount (once) with debounce - useDebouncedAction({ - action: async () => { - await sendMessage({ text: trimmedQuery }); - }, - delayMs: 400, - skip: !trimmedQuery, + const runtime = useLocalRuntime(chatAdapter, { + adapters: { attachments: attachmentAdapter }, }); - // Word streaming for the last assistant message - const lastAssistantMessage = messages.slice(1).reverse().find((m: UIMessage) => m.role === "assistant"); - const lastAssistantContent = lastAssistantMessage ? getMessageContent(lastAssistantMessage) : ""; - const { displayedWordCount, getDisplayContent, isRevealing } = useWordStreaming(lastAssistantContent); - const isStreaming = aiLoading && lastAssistantMessage; + const assistantContentComponents = useMemo(() => ({ + Text: MarkdownText, + tools: { Fallback: ToolFallback }, + }), []); + + const containerRef = useRef(null); - // Focus handler registration useEffect(() => { const focusHandler = () => { - followUpInputRef.current?.focus(); - followUpInputRef.current?.select(); + const textarea = containerRef.current?.querySelector("textarea"); + textarea?.focus(); }; registerOnFocus(focusHandler); return () => unregisterOnFocus(focusHandler); }, [registerOnFocus, unregisterOnFocus]); - // Track if user is near the bottom of the scroll container - const handleScroll = useCallback(() => { - if (!messagesContainerRef.current) return; - const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current; - isNearBottomRef.current = scrollHeight - scrollTop - clientHeight < 100; - }, []); - - // Auto-scroll when new messages are added or when already at bottom - useEffect(() => { - if (!messagesContainerRef.current) return; - - const container = messagesContainerRef.current; - const messageCount = messages.length; - - if (messageCount > lastMessageCountRef.current) { - container.scrollTop = container.scrollHeight; - isNearBottomRef.current = true; - } else if (aiLoading && isNearBottomRef.current) { - container.scrollTop = container.scrollHeight; - } - - lastMessageCountRef.current = messageCount; - }, [messages, aiLoading]); - - // Handle follow-up questions - const handleFollowUp = useCallback(() => { - const input = followUpInput.trim(); - if (!input || aiLoading) return; - setFollowUpInput(""); - // runAsynchronously intentionally used instead of runAsynchronouslyWithAlert: - // sendMessage errors are already surfaced to the user via the aiError state below. - runAsynchronously(sendMessage({ text: input })); - requestAnimationFrame(() => { - followUpInputRef.current?.focus(); - }); - }, [followUpInput, sendMessage, aiLoading]); - - // Handle follow-up input keyboard - const handleFollowUpKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.nativeEvent.isComposing) return; - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - runAsynchronously(handleFollowUp()); - } else if (e.key === "ArrowLeft") { - const input = e.currentTarget; - if (input.selectionStart === 0 && input.selectionEnd === 0) { - e.preventDefault(); - e.stopPropagation(); - onBlur(); - } - } else if (e.key === "ArrowUp" || e.key === "ArrowDown") { - e.preventDefault(); - e.stopPropagation(); - const container = messagesContainerRef.current; - if (container) { - const scrollAmount = e.key === "ArrowUp" ? -100 : 100; - container.scrollBy({ top: scrollAmount, behavior: "smooth" }); - } - } - }, - [handleFollowUp, onBlur] - ); - - // Determine what to show in the loading state - const showLoadingIndicator = messages.length === 0 || (aiLoading && !messages.some((m: UIMessage) => m.role === "assistant" && getMessageContent(m))); - return ( -
    - {/* Messages - skip the first user message (the initial query) */} -
    - {messages.slice(1).map((message: UIMessage, index: number, arr: UIMessage[]) => { - const messageContent = getMessageContent(message); - const toolInvocations = message.role === "assistant" ? getToolInvocations(message) : []; - - // For the last assistant message, apply word-by-word streaming - const isLastAssistant = message.role === "assistant" && - index === arr.length - 1 - (arr[arr.length - 1]?.role === "user" ? 1 : 0); - const displayContent = message.role === "assistant" && isLastAssistant - ? getDisplayContent(messageContent) - : messageContent; - - // Don't render if no content to show yet AND no tool invocations - if (message.role === "assistant" && isLastAssistant && !displayContent && toolInvocations.length === 0) { - return null; - } - - if (message.role === "user") { - return ; - } - return ( - - ); - })} - - {/* Loading indicator */} - {showLoadingIndicator && ( -
    -
    - -
    -
    -
    - - Thinking... -
    -
    -
    - )} - - {/* Streaming indicator */} - {isStreaming && displayedWordCount > 0 && ( -
    - - - - - -
    - )} - - {/* Error display */} - {aiError && ( -
    + + +
    + {runError && ( +
    - {getFriendlyAiErrorMessage(aiError)} + {runError}
    )} +
    - - {/* Follow-up input */} -
    -
    - setFollowUpInput(e.target.value)} - onKeyDown={handleFollowUpKeyDown} - placeholder="Ask a follow-up question..." - className="flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground/40" - autoComplete="off" - autoCorrect="off" - spellCheck={false} - /> - -
    -

    - Enter to send -

    -
    -
    + ); }); + +function AskAiAutoSend({ query }: { query: string }) { + const threadRuntime = useThreadRuntime(); + const trimmed = query.trim(); + useDebouncedAction({ + action: async () => { + threadRuntime.append({ + role: "user", + content: [{ type: "text", text: trimmed }], + }); + }, + delayMs: 400, + skip: !trimmed, + }); + return null; +} diff --git a/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx b/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx index 6691a3bf4e..3b707cbf75 100644 --- a/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx +++ b/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx @@ -4,9 +4,9 @@ import { useAdminApp, useProjectId } from "@/app/(main)/(protected)/projects/[pr import { useRouter } from "@/components/router"; import { Button } from "@/components/ui"; import { useDebouncedAction } from "@/hooks/use-debounced-action"; +import { createUnifiedAiTransport } from "@/components/assistant-ui/chat-stream"; import { buildDashboardMessages } from "@/lib/ai-dashboard/shared-prompt"; import type { AppId } from "@/lib/apps-frontend"; -import { buildStackAuthHeaders } from "@/lib/api-headers"; import { useUpdateConfig } from "@/lib/config-update"; import { getPublicEnvVar } from "@/lib/env"; import { cn } from "@/lib/utils"; @@ -18,7 +18,6 @@ import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import { useChat, type UIMessage } from "@ai-sdk/react"; -import { convertToModelMessages, DefaultChatTransport } from "ai"; import { memo, useCallback, useMemo, useRef, useState } from "react"; import { CmdKPreviewProps } from "../../cmdk-commands"; import { DashboardSandboxHost } from "./dashboard-sandbox-host"; @@ -118,15 +117,15 @@ const CreateDashboardPreviewInner = memo(function CreateDashboardPreviewInner({ const finalizedRef = useRef(false); - const transport = useMemo(() => new DefaultChatTransport({ - api: `${browserBaseUrl}/api/latest/ai/query/stream`, - headers: () => buildStackAuthHeaders(currentUserRef.current), - prepareSendMessagesRequest: async ({ messages: uiMessages, headers }) => { - const modelMessages = await convertToModelMessages(uiMessages); - const userMessages = modelMessages.map(m => ({ - role: m.role as string, - content: m.content as unknown, - })); + const transport = useMemo(() => createUnifiedAiTransport({ + backendBaseUrl: browserBaseUrl, + currentUser: () => currentUserRef.current, + systemPrompt: "create-dashboard", + tools: ["update-dashboard"], + quality: "smart", + speed: "slow", + projectId: projectIdRef.current, + transformMessages: async (userMessages) => { const contextMessages = await buildDashboardMessages( backendBaseUrlRef.current, currentUserRef.current, @@ -134,17 +133,7 @@ const CreateDashboardPreviewInner = memo(function CreateDashboardPreviewInner({ undefined, enabledAppIdsRef.current, ); - return { - body: { - systemPrompt: "create-dashboard", - tools: ["update-dashboard"], - quality: "smart", - speed: "slow", - projectId: projectIdRef.current, - messages: [...contextMessages, ...userMessages], - }, - headers, - }; + return [...contextMessages, ...userMessages]; }, // eslint-disable-next-line react-hooks/exhaustive-deps }), [browserBaseUrl]); diff --git a/apps/dashboard/src/components/stack-companion/ai-chat-widget.tsx b/apps/dashboard/src/components/stack-companion/ai-chat-widget.tsx index 8c7e643bbc..5bc77d7ff4 100644 --- a/apps/dashboard/src/components/stack-companion/ai-chat-widget.tsx +++ b/apps/dashboard/src/components/stack-companion/ai-chat-widget.tsx @@ -1,6 +1,11 @@ 'use client'; import { cn } from "@/components/ui"; +import { formatThreadMessagesForBackend, getFriendlyAiErrorMessage, sendAiStreamRequest, uiPartsToChatContent } from "@/components/assistant-ui/chat-stream"; +import { ImageAttachmentAdapter } from "@/components/assistant-ui/image-attachment-adapter"; +import { MarkdownText } from "@/components/assistant-ui/markdown-text"; +import { Thread } from "@/components/assistant-ui/thread"; +import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { createConversation, deleteConversation, @@ -9,27 +14,55 @@ import { replaceConversationMessages, type ConversationSummary, } from "@/lib/ai-conversations"; -import { buildStackAuthHeaders } from "@/lib/api-headers"; import { getPublicEnvVar } from "@/lib/env"; -import { useChat, type UIMessage } from "@ai-sdk/react"; -import { ArrowCounterClockwiseIcon, ArrowLeftIcon, ChatCircleDotsIcon, PaperPlaneTiltIcon, PlusIcon, SparkleIcon, SpinnerGapIcon, TrashIcon } from "@phosphor-icons/react"; +import { + AssistantRuntimeProvider, + useLocalRuntime, + type ChatModelAdapter, + type ChatModelRunOptions, + type ChatModelRunResult, + type ThreadAssistantContentPart, + type ThreadMessage, + type ThreadMessageLike, +} from "@assistant-ui/react"; +import { ArrowCounterClockwiseIcon, ArrowLeftIcon, ChatCircleDotsIcon, PlusIcon, SparkleIcon, SpinnerGapIcon, TrashIcon } from "@phosphor-icons/react"; import { useUser } from "@stackframe/stack"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; -import { runAsynchronously, runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; -import { convertToModelMessages, DefaultChatTransport } from "ai"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { readUIMessageStream } from "ai"; import { usePathname } from "next/navigation"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { - AssistantMessage, - getMessageContent, - getToolInvocations, - UserMessage, - useWordStreaming, -} from "../commands/ai-chat-shared"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +type StoredPart = { + type: string, + text?: string, + image?: string, + toolCallId?: string, + toolName?: string, + input?: unknown, + output?: unknown, + args?: unknown, + argsText?: string, + result?: unknown, + state?: string, + isError?: boolean, +}; + +type StoredMessage = { + id?: string, + role: "user" | "assistant" | "system" | "tool", + content: unknown, +}; type ViewMode = | { view: 'list' } - | { view: 'chat', conversationId: string | null, initialMessages: UIMessage[] }; + | { view: 'chat', conversationId: string | null, initialMessages: ThreadMessageLike[] }; + +type ThreadLikeContentArray = Exclude; +type ThreadLikeContentPart = ThreadLikeContentArray[number]; +type ThreadLikeToolArgs = Extract extends { args?: infer A } ? NonNullable : never; + +const RUNNING_STATUS_MESSAGES = ["Thinking..."]; function formatRelativeTime(dateString: string): string { const date = new Date(dateString); @@ -45,6 +78,83 @@ function formatRelativeTime(dateString: string): string { return date.toLocaleDateString(); } +function convertStoredPartsToThreadContent(rawParts: unknown): ThreadLikeContentPart[] { + if (!Array.isArray(rawParts)) return []; + const result: ThreadLikeContentPart[] = []; + for (const candidate of rawParts as unknown[]) { + if (!candidate || typeof candidate !== "object") continue; + const raw = candidate as StoredPart; + if (typeof raw.type !== "string") continue; + + if (raw.type === "text" && typeof raw.text === "string") { + result.push({ type: "text", text: raw.text }); + continue; + } + + if (raw.type === "image" && typeof raw.image === "string") { + result.push({ type: "image", image: raw.image }); + continue; + } + + if (raw.type === "tool-call") { + const args = ((raw.args ?? raw.input ?? {}) as unknown) as ThreadLikeToolArgs; + result.push({ + type: "tool-call", + toolCallId: raw.toolCallId, + toolName: raw.toolName ?? "tool", + args, + argsText: raw.argsText ?? (typeof (raw.args ?? raw.input) === "string" + ? String(raw.args ?? raw.input) + : JSON.stringify(raw.args ?? raw.input ?? {})), + result: raw.result ?? raw.output, + isError: raw.isError ?? raw.state === "output-error", + }); + continue; + } + + if (raw.type === "dynamic-tool" || raw.type.startsWith("tool-")) { + const toolName = raw.type === "dynamic-tool" + ? (raw.toolName ?? "tool") + : raw.type.slice("tool-".length); + const rawInput = raw.input ?? raw.args ?? {}; + const args = ((typeof rawInput === "object" ? rawInput : {}) as unknown) as ThreadLikeToolArgs; + result.push({ + type: "tool-call", + toolCallId: raw.toolCallId, + toolName, + args, + argsText: typeof rawInput === "string" ? rawInput : JSON.stringify(rawInput), + result: raw.output ?? raw.result, + isError: raw.state === "output-error" || raw.isError, + }); + continue; + } + } + return result; +} + +function storedMessagesToThreadMessages(stored: readonly StoredMessage[]): ThreadMessageLike[] { + const out: ThreadMessageLike[] = []; + for (const m of stored) { + if (m.role !== "user" && m.role !== "assistant") continue; + const content = convertStoredPartsToThreadContent(m.content); + if (content.length === 0) continue; + out.push({ + id: m.id, + role: m.role, + content, + }); + } + return out; +} + +function getMessageText(message: ThreadMessage | ThreadMessageLike): string { + if (typeof message.content === "string") return message.content; + return message.content + .map(p => p.type === "text" ? p.text : "") + .join(""); +} + function ConversationList({ projectId, onSelectConversation, @@ -199,11 +309,7 @@ export function AIChatWidget() { const conversations = await listConversations(currentUser, projectId); if (conversations.length > 0) { const conv = await getConversation(currentUser, conversations[0].id); - const initialMessages: UIMessage[] = conv.messages.map((msg) => ({ - id: msg.id, - role: msg.role, - parts: msg.content as UIMessage["parts"], - })); + const initialMessages = storedMessagesToThreadMessages(conv.messages as StoredMessage[]); setViewMode({ view: 'chat', conversationId: conversations[0].id, initialMessages }); setConversationKey(prev => prev + 1); } @@ -216,11 +322,7 @@ export function AIChatWidget() { const handleSelectConversation = useCallback(async (id: string) => { const conv = await getConversation(currentUser, id); - const initialMessages: UIMessage[] = conv.messages.map((msg) => ({ - id: msg.id, - role: msg.role, - parts: msg.content as UIMessage["parts"], - })); + const initialMessages = storedMessagesToThreadMessages(conv.messages as StoredMessage[]); setConversationKey(prev => prev + 1); setViewMode({ view: 'chat', conversationId: id, initialMessages }); }, [currentUser]); @@ -285,62 +387,23 @@ function AIChatWidgetInner({ }: { projectId: string | undefined, conversationId: string | null, - initialMessages: UIMessage[], + initialMessages: ThreadMessageLike[], onConversationCreated: (id: string) => void, onBackToList: () => void, onNewChat: () => void, }) { - const [followUpInput, setFollowUpInput] = useState(""); - const messagesContainerRef = useRef(null); - const inputRef = useRef(null); - const followUpInputRef = useRef(null); - const lastMessageCountRef = useRef(0); - const isNearBottomRef = useRef(true); const currentUser = useUser(); const conversationIdRef = useRef(initialConversationId); - const prevStatusRef = useRef(""); const isSavingRef = useRef(false); - const pendingMessagesRef = useRef<{ messages: Array<{ role: string; content: unknown }>; title: string } | null>(null); - - const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_BROWSER_STACK_API_URL") ?? getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_BROWSER_STACK_API_URL is not set"); - - const hasInitialMessages = initialMessages.length > 0; - const [input, setInput] = useState(""); - const [conversationStarted, setConversationStarted] = useState(hasInitialMessages); - - const { - messages, - status, - sendMessage, - error: aiError, - } = useChat({ - messages: hasInitialMessages ? initialMessages : undefined, - transport: new DefaultChatTransport({ - api: `${backendBaseUrl}/api/latest/ai/query/stream`, - headers: () => buildStackAuthHeaders(currentUser), - prepareSendMessagesRequest: async ({ messages: uiMessages, headers }) => { - const modelMessages = await convertToModelMessages(uiMessages); - return { - body: { - systemPrompt: "command-center-ask-ai", - tools: ["docs", "sql-query"], - quality: "smart", - speed: "slow", - projectId, - messages: modelMessages.map(m => ({ - role: m.role, - content: m.content, - })), - }, - headers, - }; - }, - }), - }); + const pendingMessagesRef = useRef<{ messages: Array<{ role: string, content: unknown }>, title: string } | null>(null); + const [isRunning, setIsRunning] = useState(false); + const [runError, setRunError] = useState(null); - const aiLoading = status === "submitted" || status === "streaming"; + const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_BROWSER_STACK_API_URL") + ?? getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") + ?? throwErr("NEXT_PUBLIC_BROWSER_STACK_API_URL is not set"); - const doSave = useCallback(async (messagesToSave: Array<{ role: string; content: unknown }>, title: string) => { + const doSave = useCallback(async (messagesToSave: Array<{ role: string, content: unknown }>, title: string) => { isSavingRef.current = true; try { if (conversationIdRef.current) { @@ -364,349 +427,151 @@ function AIChatWidgetInner({ } }, [currentUser, projectId, onConversationCreated]); - // Save conversation when streaming completes - useEffect(() => { - const prevStatus = prevStatusRef.current; - prevStatusRef.current = status; - - const completedOk = (prevStatus === "streaming" || prevStatus === "submitted") && status === "ready"; - const completedWithError = (prevStatus === "streaming" || prevStatus === "submitted") && status === "error"; - - if ( - (completedOk || completedWithError) && - messages.length > 0 - ) { - // On error, only save user messages (strip any partial/failed assistant turn) - const safeMessages = completedWithError - ? messages.filter(m => m.role === "user") - : messages; - if (safeMessages.length === 0) return; - - const messagesToSave = safeMessages.map(m => ({ - role: m.role, - content: m.parts, - })); - const firstUserMessage = messages.find(m => m.role === "user"); - const title = firstUserMessage - ? getMessageContent(firstUserMessage).slice(0, 50) || "New conversation" - : "New conversation"; - - if (isSavingRef.current) { - pendingMessagesRef.current = { messages: messagesToSave, title }; - return; - } - - runAsynchronouslyWithAlert(doSave(messagesToSave, title)); + const persist = useCallback((priorMessages: readonly ThreadMessage[], finalAssistantContent: ThreadAssistantContentPart[]) => { + const allWire: Array<{ role: string, content: unknown }> = priorMessages.map(m => ({ + role: m.role, + content: m.content, + })); + if (finalAssistantContent.length > 0) { + allWire.push({ role: "assistant", content: finalAssistantContent }); } - }, [status, messages, doSave]); - // Word streaming for the last assistant message - const lastAssistantMessage = messages.slice().reverse().find((m: UIMessage) => m.role === "assistant"); - const lastAssistantContent = lastAssistantMessage ? getMessageContent(lastAssistantMessage) : ""; - const { displayedWordCount, getDisplayContent, isRevealing } = useWordStreaming(lastAssistantContent); - const isStreaming = aiLoading && lastAssistantMessage; + const firstUserMessage = priorMessages.find(m => m.role === "user"); + const title = firstUserMessage + ? getMessageText(firstUserMessage).slice(0, 50) || "New conversation" + : "New conversation"; - // Auto-focus input on mount - useEffect(() => { - if (!conversationStarted) { - inputRef.current?.focus(); - } else { - followUpInputRef.current?.focus(); + if (isSavingRef.current) { + pendingMessagesRef.current = { messages: allWire, title }; + return; } - }, [conversationStarted]); - - // Track if user is near the bottom of the scroll container - const handleScroll = useCallback(() => { - if (!messagesContainerRef.current) return; - const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current; - isNearBottomRef.current = scrollHeight - scrollTop - clientHeight < 100; - }, []); + runAsynchronouslyWithAlert(doSave(allWire, title)); + }, [doSave]); - // Auto-scroll when new messages are added or when already at bottom - useEffect(() => { - if (!messagesContainerRef.current) return; + const chatAdapter = useMemo(() => ({ + async *run({ messages, abortSignal }: ChatModelRunOptions): AsyncGenerator { + setRunError(null); + setIsRunning(true); + const wireMessages = formatThreadMessagesForBackend(messages); + let latestAssistantContent: ThreadAssistantContentPart[] = []; - const container = messagesContainerRef.current; - const messageCount = messages.length; - - if (messageCount > lastMessageCountRef.current) { - container.scrollTop = container.scrollHeight; - isNearBottomRef.current = true; - } else if (aiLoading && isNearBottomRef.current) { - container.scrollTop = container.scrollHeight; - } - - lastMessageCountRef.current = messageCount; - }, [messages, aiLoading]); - - // Handle initial question submit - const handleSubmit = useCallback(() => { - if (!input.trim() || aiLoading) return; - setConversationStarted(true); - runAsynchronously(sendMessage({ text: input.trim() })); - setInput(""); - requestAnimationFrame(() => { - followUpInputRef.current?.focus(); - }); - }, [input, aiLoading, sendMessage]); - - // Handle initial input keyboard - const handleInputKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.nativeEvent.isComposing) return; - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSubmit(); - } - }, - [handleSubmit] - ); + try { + const chunkStream = await sendAiStreamRequest( + backendBaseUrl, + currentUser ?? undefined, + { + quality: "smart", + speed: "slow", + systemPrompt: "command-center-ask-ai", + tools: ["docs", "sql-query"], + projectId, + messages: wireMessages, + }, + abortSignal, + ); - // Handle follow-up questions - const handleFollowUp = useCallback(() => { - const text = followUpInput.trim(); - if (!text || aiLoading) return; - setFollowUpInput(""); - runAsynchronously(sendMessage({ text })); - requestAnimationFrame(() => { - followUpInputRef.current?.focus(); - }); - }, [followUpInput, sendMessage, aiLoading]); - - // Handle follow-up input keyboard - const handleFollowUpKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.nativeEvent.isComposing) return; - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - handleFollowUp(); - } else if (e.key === "ArrowUp" || e.key === "ArrowDown") { - e.preventDefault(); - e.stopPropagation(); - const container = messagesContainerRef.current; - if (container) { - const scrollAmount = e.key === "ArrowUp" ? -100 : 100; - container.scrollBy({ top: scrollAmount, behavior: "smooth" }); + for await (const uiMessage of readUIMessageStream({ stream: chunkStream })) { + if (abortSignal.aborted) return; + latestAssistantContent = uiPartsToChatContent(uiMessage.parts) as ThreadAssistantContentPart[]; + yield { content: latestAssistantContent }; } + + persist(messages, latestAssistantContent); + } catch (error) { + if (abortSignal.aborted) return; + const message = error instanceof Error + ? getFriendlyAiErrorMessage(error) + : "Failed to get response. Please try again."; + setRunError(message); + persist(messages.filter(m => m.role === "user"), []); + throw error; + } finally { + setIsRunning(false); } }, - [handleFollowUp] - ); + }), [backendBaseUrl, currentUser, projectId, persist]); - // Determine what to show in the loading state - const showLoadingIndicator = conversationStarted && (messages.length === 0 || (aiLoading && !messages.some((m: UIMessage) => m.role === "assistant" && getMessageContent(m)))); + const attachmentAdapter = useMemo(() => new ImageAttachmentAdapter(), []); - // Initial state - show input - if (!conversationStarted) { - return ( -
    - {/* Back button */} -
    - -
    + const runtime = useLocalRuntime(chatAdapter, { + initialMessages, + adapters: { attachments: attachmentAdapter }, + }); -
    -
    - -
    -
    -

    Ask AI

    -

    - Get AI-powered answers about Stack Auth, your project, and analytics -

    -
    -
    + const assistantContentComponents = useMemo(() => ({ + Text: MarkdownText, + tools: { Fallback: ToolFallback }, + }), []); -
    -
    - setInput(e.target.value)} - onKeyDown={handleInputKeyDown} - aria-label="Initial prompt" - placeholder="Ask a question..." - className="flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground/40" - autoComplete="off" - autoCorrect="off" - spellCheck={false} - /> - -
    -

    - Enter to send -

    -
    -
    - ); - } - - // Conversation view return ( -
    - {/* Back button */} -
    - -
    - - {/* Messages */} -
    - {messages.map((message: UIMessage, index: number, arr: UIMessage[]) => { - const messageContent = getMessageContent(message); - const toolInvocations = message.role === "assistant" ? getToolInvocations(message) : []; - - // For the last assistant message, apply word-by-word streaming - const isLastAssistant = message.role === "assistant" && - index === arr.length - 1 - (arr[arr.length - 1]?.role === "user" ? 1 : 0); - const displayContent = message.role === "assistant" && isLastAssistant && aiLoading - ? getDisplayContent(messageContent) - : messageContent; - - // Don't render if no content to show yet AND no tool invocations - if (message.role === "assistant" && isLastAssistant && !displayContent && toolInvocations.length === 0) { - return null; - } - - if (message.role === "user") { - return ; - } - return ( - - ); - })} - - {/* Loading indicator */} - {showLoadingIndicator && ( -
    -
    - -
    -
    -
    - - Thinking... -
    -
    -
    - )} - - {/* Streaming indicator */} - {isStreaming && displayedWordCount > 0 && ( -
    - - - - - -
    - )} - - {/* Error display */} - {aiError && ( -
    - - {aiError.message || "Failed to get response. Please try again."} -
    - )} -
    - - {/* Follow-up input + new conversation button */} -
    -
    - setFollowUpInput(e.target.value)} - onKeyDown={handleFollowUpKeyDown} - aria-label="Follow-up question" - placeholder="Ask a follow-up question..." - className="flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground/40" - autoComplete="off" - autoCorrect="off" - spellCheck={false} - /> + +
    +
    -
    -
    -

    - Enter to send -

    + + {runError && ( +
    + + {runError} +
    + )} + + } + composerAttachments + attachmentAdapter={attachmentAdapter} + /> +
    +
    + ); +} + +function AskAiWelcome() { + return ( +
    +
    +
    + +
    +

    + Ask AI +

    +

    + Get AI-powered answers about Stack Auth, your project, and analytics. +

    ); } + diff --git a/apps/dashboard/src/components/vibe-coding/chat-adapters.ts b/apps/dashboard/src/components/vibe-coding/chat-adapters.ts index d05baf31e7..8810558d62 100644 --- a/apps/dashboard/src/components/vibe-coding/chat-adapters.ts +++ b/apps/dashboard/src/components/vibe-coding/chat-adapters.ts @@ -1,3 +1,4 @@ +import { formatThreadMessagesForBackend, sendAiStreamRequest, uiPartsToChatContent } from "@/components/assistant-ui/chat-stream"; import { buildDashboardMessages } from "@/lib/ai-dashboard/shared-prompt"; import { buildStackAuthHeaders, type CurrentUser } from "@/lib/api-headers"; import type { AppId } from "@/lib/apps-frontend"; @@ -11,13 +12,7 @@ import { import { StackAdminApp } from "@stackframe/stack"; import { ChatContent } from "@stackframe/stack-shared/dist/interface/admin-interface"; import type { EditableMetadata } from "@stackframe/stack-shared/dist/utils/jsx-editable-transpiler"; -import { - parseJsonEventStream, - readUIMessageStream, - uiMessageChunkSchema, - type UIMessage, - type UIMessageChunk, -} from "ai"; +import { readUIMessageStream } from "ai"; export type ToolCallContent = Extract; @@ -25,29 +20,6 @@ const isToolCall = (content: { type: string }): content is ToolCallContent => { return content.type === "tool-call"; }; -/** Maps thread messages to the backend wire format; merges `attachments` into `content`. */ -function formatThreadMessagesForBackend( - messages: readonly { role: string, content: readonly { type: string }[], attachments?: readonly { content?: readonly unknown[] }[] }[], -): Array<{ role: string, content: unknown }> { - const formatted: Array<{ role: string, content: unknown }> = []; - for (const msg of messages) { - const textContent = msg.content.filter((c) => !isToolCall(c)); - const attachmentContent: unknown[] = []; - if (msg.attachments) { - for (const attachment of msg.attachments) { - if (Array.isArray(attachment.content)) { - attachmentContent.push(...attachment.content); - } - } - } - const combined = [...textContent, ...attachmentContent]; - if (combined.length > 0) { - formatted.push({ role: msg.role, content: combined }); - } - } - return formatted; -} - /** Normalizes model JSX: strip fences, decode basic entities, fix `;` vs `,` between object props. */ function sanitizeGeneratedCode(code: string): string { let result = code.trim(); @@ -126,103 +98,6 @@ function sanitizeAiContent(content: ChatContent): ChatContent { }); } -/** - * Sends a request to the AI streaming endpoint and returns a stream of UIMessageChunks - * (as produced by the Vercel AI SDK's `streamText().toUIMessageStreamResponse()`). - */ -async function sendAiStreamRequest( - backendBaseUrl: string, - currentUser: CurrentUser | undefined, - body: { - quality: string, - speed: string, - systemPrompt: string, - tools: string[], - messages: Array<{ role: string, content: unknown }>, - projectId?: string, - }, - abortSignal?: AbortSignal, -): Promise> { - const authHeaders = await buildStackAuthHeaders(currentUser); - - const response = await fetch(`${backendBaseUrl}/api/latest/ai/query/stream`, { - method: "POST", - headers: { - "content-type": "application/json", - accept: "text/event-stream", - ...authHeaders, - }, - ...(abortSignal ? { signal: abortSignal } : {}), - body: JSON.stringify(body), - }); - - if (!response.ok || !response.body) { - throw new Error(`AI stream request failed: ${response.status} ${response.statusText}`); - } - - return parseJsonEventStream({ - stream: response.body, - schema: uiMessageChunkSchema, - }).pipeThrough( - new TransformStream< - { success: true, value: UIMessageChunk, rawValue: unknown } | { success: false, error: unknown, rawValue: unknown }, - UIMessageChunk - >({ - transform(parseResult, controller) { - if (parseResult.success) { - controller.enqueue(parseResult.value); - } - }, - }), - ); -} - -/** - * Converts a UIMessage's parts (as emitted by `readUIMessageStream`) into our - * ChatContent shape so the existing tool UI / sanitizer pipeline keeps working. - */ -function uiPartsToChatContent(parts: UIMessage["parts"]): ChatContent { - const result: ChatContent = []; - for (const part of parts) { - if (part.type === "text") { - if (part.text) { - result.push({ type: "text", text: part.text }); - } - continue; - } - - if (part.type === "dynamic-tool") { - const toolPart = part as { toolCallId: string, toolName: string, input?: unknown, output?: unknown }; - const input = toolPart.input ?? {}; - result.push({ - type: "tool-call", - toolCallId: toolPart.toolCallId, - toolName: toolPart.toolName, - args: input, - argsText: typeof input === "string" ? input : JSON.stringify(input), - result: toolPart.output ?? null, - }); - continue; - } - - if (typeof part.type === "string" && part.type.startsWith("tool-")) { - const toolName = part.type.slice("tool-".length); - const toolPart = part as { toolCallId: string, input?: unknown, output?: unknown }; - const input = toolPart.input ?? {}; - result.push({ - type: "tool-call", - toolCallId: toolPart.toolCallId, - toolName, - args: input, - argsText: typeof input === "string" ? input : JSON.stringify(input), - result: toolPart.output ?? null, - }); - continue; - } - } - return result; -} - /** * Streaming dashboard generation: yields progressively updated ChatContent as the AI * streams text and tool-call input. Each yield represents the full current state of