diff --git a/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx b/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx index 2a3728137..fd90a922a 100644 --- a/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx +++ b/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx @@ -5,7 +5,12 @@ import { useThread } from "@openuidev/react-headless"; import type { ActionEvent, Library } from "@openuidev/react-lang"; import { BuiltinActionType, Renderer } from "@openuidev/react-lang"; import { useCallback, useMemo } from "react"; -import { separateContentAndContext, wrapContent, wrapContext } from "../../utils/contentParser"; +import { + separateContentAndContext, + wrapContent, + wrapContentWithHeader, + wrapContext, +} from "../../utils/contentParser"; import { AssistantMessageContainer } from "../Shell"; import { BehindTheScenes, ToolCallComponent } from "../ToolCall"; import { ToolResult } from "../ToolResult"; @@ -33,8 +38,12 @@ export const GenUIAssistantMessage = ({ }, [isRunning, messages, message.id]); // Separate openui-lang code from persisted form state - const { content: openuiCode, contextString } = useMemo(() => { - if (!message.content) return { content: null, contextString: null }; + const { + content: openuiCode, + contextString, + contentHeader, + } = useMemo(() => { + if (!message.content) return { content: null, contextString: null, contentHeader: undefined }; return separateContentAndContext(message.content); }, [message.content]); @@ -71,15 +80,20 @@ export const GenUIAssistantMessage = ({ return toolCall?.function.name; }; - // Persist form state into the message content (XML-wrapped) + // Persist form state into the inline-wrapped message content. The original + // header line (which may include `libraryVersion` and telemetry tags emitted + // by muse) is reused so attrs survive the persist round-trip. const handleStateUpdate = useCallback( (state: Record) => { const code = openuiCode ?? ""; - const contextJson = JSON.stringify([state]); - const fullMessage = code + "\n" + wrapContext(contextJson); + const hasState = Object.keys(state).length > 0; + const contentPart = wrapContentWithHeader(code, contentHeader); + const fullMessage = hasState + ? contentPart + wrapContext(JSON.stringify([state])) + : contentPart; updateMessage({ ...message, content: fullMessage }); }, - [updateMessage, message, openuiCode], + [updateMessage, message, openuiCode, contentHeader], ); // Build LLM-friendly message from action + form state, then dispatch diff --git a/packages/react-ui/src/components/OpenUIChat/GenUIUserMessage.tsx b/packages/react-ui/src/components/OpenUIChat/GenUIUserMessage.tsx index 6b11b2ab2..e82cc5634 100644 --- a/packages/react-ui/src/components/OpenUIChat/GenUIUserMessage.tsx +++ b/packages/react-ui/src/components/OpenUIChat/GenUIUserMessage.tsx @@ -103,7 +103,7 @@ function FormDataAccordion({ state }: { state: Record }) { /** * Renders a user message, handling both plain text messages and - * XML-formatted messages from form submissions (which contain and tags). + * inline-formatted messages from form submissions. */ export const GenUIUserMessage = ({ message }: { message: UserMessage }) => { const rawContent = typeof message.content === "string" ? message.content : ""; diff --git a/packages/react-ui/src/utils/contentParser.ts b/packages/react-ui/src/utils/contentParser.ts index 31361e41e..18ea77cc9 100644 --- a/packages/react-ui/src/utils/contentParser.ts +++ b/packages/react-ui/src/utils/contentParser.ts @@ -1,26 +1,91 @@ -const openTag = (tag: string) => `<${tag}>`; -const closeTag = (tag: string) => ``; +const OPENUI_INLINE_SENTINEL = "]]>openui:"; +const CONTENT_MARKER = `${OPENUI_INLINE_SENTINEL}content`; +const CONTEXT_MARKER = `${OPENUI_INLINE_SENTINEL}context`; export function wrapContent(text: string): string { - return `${openTag("content")}${text}${closeTag("content")}`; + return `${CONTENT_MARKER}\n${text}`; +} + +// Round-trip the original header verbatim so its attrs (libraryVersion, etc.) +// survive form-state persistence. +export function wrapContentWithHeader(text: string, contentHeader?: string): string { + return contentHeader ? `${contentHeader}\n${text}` : wrapContent(text); } export function wrapContext(json: string): string { - return `${openTag("context")}${json}${closeTag("context")}`; + return `\n${CONTEXT_MARKER}\n${json}`; } -/** - * Separate openui-lang code from tag in a message. - * Returns { content: the message/code, contextString: raw JSON or null } - */ +// Separate openui-lang code from inline context in a message. export function separateContentAndContext(raw: string): { content: string; contextString: string | null; + contentHeader?: string; } { - const contextMatch = raw.match(/([\s\S]*)<\/context>\s*$/); + const lastContentIdx = raw.lastIndexOf(CONTENT_MARKER); + const lastContextIdx = raw.lastIndexOf(CONTEXT_MARKER); + + // No inline markers: fall back to the deprecated XML envelope so messages + // persisted by older app versions still round-trip on reload. + if (lastContentIdx === -1 && lastContextIdx === -1) { + return parseLegacyXml(raw); + } + + // Only context section + if (lastContentIdx === -1) { + return { + content: stripSectionSeparator(raw.slice(0, lastContextIdx)), + contextString: raw.slice(bodyStartIndex(raw, lastContextIdx)), + }; + } + + // Content-only response + if (lastContextIdx === -1 || lastContentIdx > lastContextIdx) { + return { + content: raw.slice(bodyStartIndex(raw, lastContentIdx)), + contextString: null, + contentHeader: contentHeader(raw, lastContentIdx), + }; + } + + // Content section followed by context section + return { + content: stripSectionSeparator(raw.slice(bodyStartIndex(raw, lastContentIdx), lastContextIdx)), + contextString: raw.slice(bodyStartIndex(raw, lastContextIdx)), + contentHeader: contentHeader(raw, lastContentIdx), + }; +} + +function contentHeader(raw: string, markerIdx: number): string { + const headerEndIdx = raw.indexOf("\n", markerIdx); + return headerEndIdx === -1 ? raw.slice(markerIdx) : raw.slice(markerIdx, headerEndIdx); +} + +function bodyStartIndex(raw: string, markerIdx: number): number { + const headerEndIdx = raw.indexOf("\n", markerIdx); + return headerEndIdx === -1 ? raw.length : headerEndIdx + 1; +} + +function stripSectionSeparator(value: string): string { + if (value.endsWith("\r\n")) { + return value.slice(0, -2); + } + if (value.endsWith("\n")) { + return value.slice(0, -1); + } + return value; +} + +/** + * @deprecated Legacy ``/`` XML envelope. Retained only to + * parse messages persisted before the inline sentinel format; new messages are + * always wrapped with {@link wrapContent}/{@link wrapContext}. + */ +function parseLegacyXml(raw: string): { content: string; contextString: string | null } { let content = raw; let contextString: string | null = null; + const contextMatch = raw.match(/([\s\S]*)<\/context>\s*$/); if (contextMatch) { contextString = contextMatch[1] ?? null; content = raw.slice(0, contextMatch.index!).trimEnd();