diff --git a/examples/next/app/blogs/[blog]/page.val.ts b/examples/next/app/blogs/[blog]/page.val.ts index f9544a619..69cd041b1 100644 --- a/examples/next/app/blogs/[blog]/page.val.ts +++ b/examples/next/app/blogs/[blog]/page.val.ts @@ -3,8 +3,11 @@ import authorsVal from "../../../content/authors.val"; import { linkSchema } from "../../../components/link.val"; const blogSchema = s.object({ - title: s.string().maxLength(3), + title: s.string(), content: s.richtext({ + style: { + bold: true, + }, inline: { a: s.route(), }, diff --git a/examples/next/val.config.ts b/examples/next/val.config.ts index cfde56f86..068807b4a 100644 --- a/examples/next/val.config.ts +++ b/examples/next/val.config.ts @@ -7,7 +7,7 @@ const { s, c, val, config, nextAppRouter, externalPageRouter } = initVal({ ai: { chat: { experimental: { - enable: false, + enable: true, }, suggestions: [ "Summarize", diff --git a/packages/shared/src/internal/sessionStorage.ts b/packages/shared/src/internal/sessionStorage.ts index f7aa2f798..7861f9d79 100644 --- a/packages/shared/src/internal/sessionStorage.ts +++ b/packages/shared/src/internal/sessionStorage.ts @@ -1,2 +1,3 @@ export const VAL_CONFIG_SESSION_STORAGE_KEY = "val-config"; export const VAL_THEME_SESSION_STORAGE_KEY = "val-theme"; +export const VAL_AI_SESSION_STORAGE_KEY = "val-ai-session"; diff --git a/packages/ui/spa/components/AIChat.stories.tsx b/packages/ui/spa/components/AIChat.stories.tsx index b12c2be7a..bd92c8d5f 100644 --- a/packages/ui/spa/components/AIChat.stories.tsx +++ b/packages/ui/spa/components/AIChat.stories.tsx @@ -27,8 +27,8 @@ type Story = StoryObj; export const Empty: Story = { args: { - onSendMessage: (text: string) => { - console.log("Send:", text); + onSendMessage: (content) => { + console.log("Send:", content); return true; }, }, @@ -42,8 +42,8 @@ export const CustomSuggestions: Story = { "Fix validation errors", "Generate a summary", ], - onSendMessage: (text: string) => { - console.log("Send:", text); + onSendMessage: (content) => { + console.log("Send:", content); return true; }, }, @@ -92,8 +92,8 @@ const conversationMessages: ChatMessage[] = [ export const WithConversation: Story = { args: { initialMessages: conversationMessages, - onSendMessage: (text: string) => { - console.log("Send:", text); + onSendMessage: (content) => { + console.log("Send:", content); return true; }, }, @@ -191,8 +191,8 @@ export const Error: Story = { error: "Connection lost — the server closed the WebSocket unexpectedly", }, ], - onSendMessage: (text: string) => { - console.log("Retry send:", text); + onSendMessage: (content) => { + console.log("Retry send:", content); return true; }, }, @@ -216,8 +216,8 @@ export const ErrorAfterPartialResponse: Story = { error: "Stream interrupted — request timed out after 30s", }, ], - onSendMessage: (text: string) => { - console.log("Retry send:", text); + onSendMessage: (content) => { + console.log("Retry send:", content); return true; }, }, @@ -287,8 +287,8 @@ export const LongMarkdown: Story = { status: "complete", }, ], - onSendMessage: (text: string) => { - console.log("Send:", text); + onSendMessage: (content) => { + console.log("Send:", content); return true; }, }, diff --git a/packages/ui/spa/components/AIChat.tsx b/packages/ui/spa/components/AIChat.tsx index b5d302c2c..3549e0941 100644 --- a/packages/ui/spa/components/AIChat.tsx +++ b/packages/ui/spa/components/AIChat.tsx @@ -5,6 +5,7 @@ import React, { useCallback, useImperativeHandle, forwardRef, + type RefObject, } from "react"; import ReactMarkdown from "react-markdown"; import { ScrollArea } from "./designSystem/scroll-area"; @@ -14,7 +15,6 @@ import { Send, RotateCcw, Sparkles, - Check, Loader2, LogIn, Search, @@ -38,9 +38,21 @@ import type { AISession } from "../hooks/useAIWebSocket"; import type { AIContentBlock, AIMessageContent } from "./ValProvider"; import { ToolName } from "../utils/toolNames"; import { useValConfig } from "./ValFieldProvider"; +import { useValPortal } from "./ValPortalProvider"; import { DEFAULT_APP_HOST } from "@valbuild/core"; import { urlOf } from "@valbuild/shared/internal"; import { CopyableCodeBlock } from "./designSystem/CopyableCodeBlock"; +import { AIChatEditor } from "./AIChatEditor"; +import type { + ChatBlockNode, + ChatDocument, + ChatEditorRef, + ChatInlineNode, +} from "./AIChatEditor"; +import { + chatDocumentToPlainText, + collectImageKeysFromDoc, +} from "./AIChatEditor"; // --------------------------------------------------------------------------- // Types @@ -72,6 +84,12 @@ export type ChatMessage = { errorCode?: string; toolActivities?: ToolActivity[]; attachments?: ChatMessageAttachment[]; + /** + * For user messages composed in the rich editor, the original document so + * the bubble can render inline images / field refs without re-parsing HTML + * (which would lose `previewUrl`s for image nodes). + */ + userDoc?: ChatDocument; }; type AttachedFile = { @@ -115,11 +133,13 @@ export type AIChatHandle = { export type AIChatProps = { /** Called when the user submits a message (via input or suggestion chip). Returns true if sent successfully. */ onSendMessage?: ( - text: string, + content: string | ChatDocument, attachments?: ChatMessageAttachment[], ) => boolean; /** Called to upload a file to the current AI session. Returns the server key. */ onUploadFile?: (file: File) => Promise<{ key: string }>; + /** Shared ref to the inner rich text editor (used by Field.tsx to insert field references). */ + chatEditorRef?: RefObject; /** Called when the user clicks "New Chat" to start a fresh session */ onNewSession?: () => void; /** Prompt suggestion chips shown on the empty state */ @@ -134,14 +154,16 @@ export type AIChatProps = { mode: "http" | "fs" | "unknown"; /** List of past sessions (fetched on demand) */ sessions?: AISession[]; - /** The currently active session ID */ - currentSessionId?: string; + /** The currently active session ID; null when the session is unborn (no message sent yet). */ + currentSessionId?: string | null; /** Called to load a previous session */ onLoadSession?: (sessionId: string) => void; /** Called to trigger a sessions fetch */ onFetchSessions?: () => void; /** Called to rename a session */ onSetSessionName?: (sessionId: string, name: string) => void; + /** True while a previous session's messages are being fetched from the server. */ + isLoadingSession?: boolean; /** * @internal – seed messages for Storybook / testing only. * Not part of the public API. @@ -181,6 +203,244 @@ function getTextContent(content: AIMessageContent): string { .join("\n\n"); } +// HTML-esque tag set that the rich chat editor produces (see +// chatDocumentToHtmlText). Restored sessions arrive as strings containing +// these tags, so we re-render them as formatted React instead of literal text. +const USER_HTML_TAG_RE = + /<\/?(?:p|h[1-3]|blockquote|ul|ol|li|strong|em|del|code|br|img|field)\b/i; + +function renderHtmlChildren(nodes: ArrayLike): React.ReactNode[] { + return Array.from(nodes).map((n, i) => renderHtmlNode(n, i)); +} + +function renderHtmlNode(node: ChildNode, key: number): React.ReactNode { + if (node.nodeType === 3 /* TEXT_NODE */) return node.nodeValue ?? ""; + if (node.nodeType !== 1 /* ELEMENT_NODE */) return null; + const el = node as Element; + const children = renderHtmlChildren(el.childNodes); + switch (el.tagName.toLowerCase()) { + case "p": + return ( +

+ {children} +

+ ); + case "h1": + return ( +

+ {children} +

+ ); + case "h2": + return ( +

+ {children} +

+ ); + case "h3": + return ( +

+ {children} +

+ ); + case "blockquote": + return ( +
+ {children} +
+ ); + case "ul": + return ( +
    + {children} +
+ ); + case "ol": + return ( +
    + {children} +
+ ); + case "li": + return
  • {children}
  • ; + case "strong": + case "b": + return {children}; + case "em": + case "i": + return {children}; + case "del": + case "s": + return {children}; + case "code": + return ( + + {children} + + ); + case "br": + return
    ; + case "img": { + const alt = el.getAttribute("alt"); + return ( + + [{alt ? `image: ${alt}` : "image"}] + + ); + } + case "field": { + const path = el.getAttribute("path") ?? ""; + return ( + + @{path} + + ); + } + default: + return <>{children}; + } +} + +function renderUserMessageText(text: string): React.ReactNode { + if (!USER_HTML_TAG_RE.test(text)) { + return

    {text}

    ; + } + const doc = new DOMParser().parseFromString( + `${text}`, + "text/html", + ); + return <>{renderHtmlChildren(doc.body.childNodes)}; +} + +// Render a ChatDocument from the rich editor directly to React. Used for +// freshly-sent user messages so inline images keep their preview blob URLs +// (re-parsing HTML would strip the previewUrl off image nodes). +function ChatDocumentRenderer({ + doc, +}: { + doc: ChatDocument; +}): React.ReactElement { + return <>{doc.map((block, i) => renderChatBlock(block, i))}; +} + +function renderChatBlock(block: ChatBlockNode, key: number): React.ReactNode { + switch (block.tag) { + case "p": + return ( +

    + {block.children.map((c, i) => renderChatInline(c, i))} +

    + ); + case "h1": + return ( +

    + {block.children.map((c, i) => renderChatInline(c, i))} +

    + ); + case "h2": + return ( +

    + {block.children.map((c, i) => renderChatInline(c, i))} +

    + ); + case "h3": + return ( +

    + {block.children.map((c, i) => renderChatInline(c, i))} +

    + ); + case "blockquote": + return ( +
    + {block.children.map((c, i) => renderChatBlock(c, i))} +
    + ); + case "ul": + return ( +
      + {block.children.map((item, i) => ( +
    • + {item.children.map((c, j) => renderChatBlock(c, j))} +
    • + ))} +
    + ); + case "ol": + return ( +
      + {block.children.map((item, i) => ( +
    1. + {item.children.map((c, j) => renderChatBlock(c, j))} +
    2. + ))} +
    + ); + } +} + +function renderChatInline(node: ChatInlineNode, key: number): React.ReactNode { + if (typeof node === "string") return node; + if (node.tag === "br") return
    ; + if (node.tag === "span") { + let el: React.ReactNode = node.children[0]; + for (const style of node.styles) { + if (style === "bold") el = {el}; + else if (style === "italic") el = {el}; + else if (style === "line-through") el = {el}; + else if (style === "code") + el = ( + + {el} + + ); + } + return {el}; + } + if (node.tag === "img") { + if (node.previewUrl) { + return ( + {node.alt + ); + } + return ( + + [{node.alt ? `image: ${node.alt}` : "image"}] + + ); + } + if (node.tag === "field_ref") { + return ( + + @{node.path} + + ); + } + return null; +} + function getImageUrls(content: AIMessageContent): string[] { if (typeof content === "string") { return []; @@ -212,7 +472,9 @@ export const AIChat = forwardRef(function AIChat( onLoadSession, onFetchSessions, onSetSessionName, + isLoadingSession, initialMessages, + chatEditorRef: chatEditorRefProp, }, ref, ) { @@ -222,10 +484,13 @@ export const AIChat = forwardRef(function AIChat( const [currentMessage, setCurrentMessage] = useState( null, ); - const [inputValue, setInputValue] = useState(""); + const [isEditorEmpty, setIsEditorEmpty] = useState(true); + const [hasPendingInlineImage, setHasPendingInlineImage] = useState(false); + const [isAwaitingAssistant, setIsAwaitingAssistant] = useState(false); const [attachedFiles, setAttachedFiles] = useState([]); const fileInputRef = useRef(null); - const textareaRef = useRef(null); + const internalEditorRef = useRef(null); + const editorRef = chatEditorRefProp ?? internalEditorRef; const bottomRef = useRef(null); const [showSessions, setShowSessions] = useState(false); const [renamingSessionId, setRenamingSessionId] = useState( @@ -233,6 +498,7 @@ export const AIChat = forwardRef(function AIChat( ); const [renameValue, setRenameValue] = useState(""); const config = useValConfig(); + const portalContainer = useValPortal(); const effectiveSuggestions = config?.ai?.chat?.suggestions ?? suggestions; const emptyTitle = config?.ai?.chat?.title; const emptyDescription = config?.ai?.chat?.description; @@ -249,6 +515,12 @@ export const AIChat = forwardRef(function AIChat( }); }, [messages]); + // Once the assistant message actually starts streaming, drop the + // "thinking" placeholder so the StreamingCursor takes over. + useEffect(() => { + if (currentMessage) setIsAwaitingAssistant(false); + }, [currentMessage]); + // 2-minute timeout for in-progress assistant messages useEffect(() => { if (!currentMessage) return; @@ -446,9 +718,33 @@ export const AIChat = forwardRef(function AIChat( }, []); const handleSend = useCallback( - (text?: string) => { - const content = (text ?? inputValue).trim(); - if (!content || isStreaming) return; + (suggestion?: string) => { + if (isStreaming) return; + + let outgoing: string | ChatDocument; + let displayText: string; + let outgoingDoc: ChatDocument | undefined; + if (suggestion !== undefined) { + const trimmed = suggestion.trim(); + if (!trimmed) return; + outgoing = trimmed; + displayText = trimmed; + } else { + const editor = editorRef.current; + if (!editor || editor.isEmpty()) return; + const doc = editor.getDocument(); + if ( + collectImageKeysFromDoc(doc).some((k) => k.startsWith("pending:")) + ) { + // Image still uploading — Send button should already be disabled, + // but bail out defensively so a stale pending key never reaches the + // server (it would 404 when the AI tries to use it). + return; + } + outgoing = doc; + outgoingDoc = doc; + displayText = chatDocumentToPlainText(doc); + } const doneAttachments: ChatMessageAttachment[] = attachedFiles .filter( @@ -462,7 +758,6 @@ export const AIChat = forwardRef(function AIChat( previewUrl: f.previewUrl, })); - // Revoke object URLs for files we're sending (they'll be in the message) attachedFiles.forEach((f) => { if (f.previewUrl && f.status !== "done") URL.revokeObjectURL(f.previewUrl); @@ -473,16 +768,21 @@ export const AIChat = forwardRef(function AIChat( const userMsg: ChatMessage = { id: msgId, role: "user", - content, + content: displayText, status: "complete", attachments: doneAttachments.length > 0 ? doneAttachments : undefined, + userDoc: outgoingDoc, }; setCompletedMessages((prev) => [...prev, userMsg]); - setInputValue(""); + + if (suggestion === undefined) { + editorRef.current?.clear(); + setIsEditorEmpty(true); + } const sent = onSendMessage ? onSendMessage( - content, + outgoing, doneAttachments.length > 0 ? doneAttachments : undefined, ) : true; @@ -494,12 +794,13 @@ export const AIChat = forwardRef(function AIChat( : m, ), ); + } else { + setIsAwaitingAssistant(true); } - // Refocus textarea after send - requestAnimationFrame(() => textareaRef.current?.focus()); + requestAnimationFrame(() => editorRef.current?.focus()); }, - [inputValue, isStreaming, attachedFiles, onSendMessage], + [isStreaming, attachedFiles, onSendMessage, editorRef], ); const handleRetry = useCallback( @@ -515,9 +816,10 @@ export const AIChat = forwardRef(function AIChat( : m, ), ); - const retryText = getTextContent(errorMsg.content); + const retryPayload: string | ChatDocument = + errorMsg.userDoc ?? getTextContent(errorMsg.content); const sent = onSendMessage - ? onSendMessage(retryText, errorMsg.attachments) + ? onSendMessage(retryPayload, errorMsg.attachments) : true; if (!sent) { setCompletedMessages((prev) => @@ -527,6 +829,8 @@ export const AIChat = forwardRef(function AIChat( : m, ), ); + } else { + setIsAwaitingAssistant(true); } return; } @@ -543,24 +847,15 @@ export const AIChat = forwardRef(function AIChat( // Remove the errored assistant message setCompletedMessages((prev) => prev.filter((m) => m.id !== errorMsgId)); - onSendMessage?.( - getTextContent(prevUserMsg.content), - prevUserMsg.attachments, - ); + const retryPayload: string | ChatDocument = + prevUserMsg.userDoc ?? getTextContent(prevUserMsg.content); + const sent = + onSendMessage?.(retryPayload, prevUserMsg.attachments) ?? true; + if (sent) setIsAwaitingAssistant(true); }, [messages, onSendMessage], ); - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - }, - [handleSend], - ); - // ---- Render ---- return ( @@ -720,9 +1015,14 @@ export const AIChat = forwardRef(function AIChat( {/* Message list */} -
    +
    {authError ? ( + ) : isLoadingSession && isEmpty ? ( +
    + + Loading conversation… +
    ) : isEmpty ? ( (function AIChat( )) )} + {isAwaitingAssistant && !currentMessage && }
    @@ -793,65 +1094,66 @@ export const AIChat = forwardRef(function AIChat( accept="image/*" /> )} -
    - {onUploadFile && ( +
    + handleSend()} + onUploadAiImage={onUploadFile} + getPortalContainer={() => portalContainer} + onChange={(doc) => { + const empty = + doc.length === 0 || + (doc.length === 1 && + doc[0].tag === "p" && + doc[0].children.length === 0); + setIsEditorEmpty(empty); + const keys = collectImageKeysFromDoc(doc); + setHasPendingInlineImage( + keys.some((k) => k.startsWith("pending:")), + ); + }} + className={cn( + "max-h-[18rem] overflow-y-auto px-3 pt-3 pb-1", + "text-fg-primary text-base", + )} + /> +
    + {onUploadFile && ( + + )} - )} -
    -