diff --git a/docs/content/docs/agent/reference/adapters-and-formats.mdx b/docs/content/docs/agent/reference/adapters-and-formats.mdx index a1e47f4cb..a20d8caa1 100644 --- a/docs/content/docs/agent/reference/adapters-and-formats.mdx +++ b/docs/content/docs/agent/reference/adapters-and-formats.mdx @@ -90,6 +90,7 @@ function fetchLLM(options: { messageFormat?: MessageFormat; headers?: Record; fetch?: typeof fetch; + body?: Record; }): ChatLLM; ``` @@ -100,6 +101,7 @@ function fetchLLM(options: { | `messageFormat` | `MessageFormat` | No | Converts outgoing `messages` to your provider's shape. Defaults to `identityMessageFormat`. | | `headers` | `Record` | No | Extra headers merged into every request (e.g. a session token). **Browser-visible.** | | `fetch` | `typeof fetch` | No | Override `fetch` (auth wrappers, instrumentation, tests). | +| `body` | `Record` | No | Extra fields merged into the request body (e.g. `model`) | **Request body.** `fetchLLM` POSTs JSON `{ threadId, messages }`, where `messages` is run through `messageFormat.toApi`, and threads the `AbortSignal` from the UI: @@ -108,12 +110,19 @@ function fetchLLM(options: { fetch(url, { method: "POST", headers: { "Content-Type": "application/json", ...headers }, - body: JSON.stringify({ threadId, messages: messageFormat.toApi(messages) }), + body: JSON.stringify({ + ...body, + tools: [], + context: [], + threadId, + runId, + messages: messageFormat.toApi(messages), + }), signal, // wired to the UI's cancel/stop control }); ``` -Your route therefore always receives `{ threadId, messages }` and must return a streaming `Response`. `fetchLLM` runs that response through `streamAdapter`. +Your route receives this shape (destructure just the fields you need, e.g. `{ messages }`) and must return a streaming `Response`. `fetchLLM` runs that response through `streamAdapter`. ```tsx import { fetchLLM, openAIReadableStreamAdapter, openAIMessageFormat } from "@openuidev/react-ui"; diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 2c37a24b6..76f10e4ce 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -1,7 +1,7 @@ { "name": "@openuidev/devtools", - "version": "0.0.1", - "description": "Development-only UI widget for OpenUI apps: surfaces errors captured by @openuidev/observability in a floating dialog", + "version": "0.0.2", + "description": "Development-only UI widget for OpenUI apps: surfaces errors captured by @openuidev/observability", "license": "MIT", "type": "module", "main": "dist/index.cjs", @@ -71,5 +71,8 @@ "react-dom": "catalog:", "jsdom": "catalog:", "vitest": "^4.1.0" + }, + "dependencies": { + "lucide-react": "^0.575.0" } } diff --git a/packages/devtools/src/OpenUIDevtools.test.ts b/packages/devtools/src/OpenUIDevtools.test.ts index a71648eb0..5d790d02a 100644 --- a/packages/devtools/src/OpenUIDevtools.test.ts +++ b/packages/devtools/src/OpenUIDevtools.test.ts @@ -41,8 +41,7 @@ function click(el: Element): void { function buttonByText(text: string): HTMLButtonElement | undefined { return [...container.querySelectorAll("button")].find((b) => b.textContent === text) as - | HTMLButtonElement - | undefined; + HTMLButtonElement | undefined; } describe("OpenUIDevtools", () => { diff --git a/packages/devtools/src/OpenUIDevtools.tsx b/packages/devtools/src/OpenUIDevtools.tsx index 6cd58176d..c69410ced 100644 --- a/packages/devtools/src/OpenUIDevtools.tsx +++ b/packages/devtools/src/OpenUIDevtools.tsx @@ -2,10 +2,10 @@ import { observability, - type Observability, type ObservabilityErrorInfo, type ObservabilityEvent, } from "@openuidev/observability"; +import { ArrowLeft, Check, Copy, CreditCard, WrapText, X } from "lucide-react"; import { useEffect, useRef, useState, type CSSProperties } from "react"; import { ShiroLogo } from "./ShiroLogo"; @@ -18,12 +18,11 @@ export interface OpenUIDevtoolsProps { position?: DevtoolsPosition; /** How many events to keep; oldest are dropped first. */ maxEvents?: number; - /** Capture only error/warning events (default) or every event. */ + /** Initial state of the drawer's "errors only" capture filter: only + * error/warning events (default) or every event. */ errorsOnly?: boolean; /** Initial state of the drawer's "auto-open on error" checkbox. Defaults to true. */ autoOpenOnError?: boolean; - /** Observability instance to listen to. Defaults to the shared singleton. */ - bus?: Observability; } /** @@ -39,7 +38,6 @@ export function OpenUIDevtools({ maxEvents = 50, errorsOnly = true, autoOpenOnError = true, - bus = observability, }: OpenUIDevtoolsProps) { const isEnabled = enabled ?? (typeof process === "undefined" || process.env["NODE_ENV"] !== "production"); @@ -49,19 +47,22 @@ export function OpenUIDevtools({ const [wrapStack, setWrapStack] = useState(false); const [copied, setCopied] = useState(false); const [autoOpen, setAutoOpen] = useState(autoOpenOnError); + const [onlyErrors, setOnlyErrors] = useState(errorsOnly); - // Read the live checkbox value inside the (stable) subscription without re-subscribing. + // Read the live checkbox values inside the (stable) subscription without re-subscribing. const autoOpenRef = useRef(autoOpen); autoOpenRef.current = autoOpen; + const onlyErrorsRef = useRef(onlyErrors); + onlyErrorsRef.current = onlyErrors; useEffect(() => { if (!isEnabled) return; - return bus.listenAll((event) => { - if (errorsOnly && event.level === "info") return; + return observability.listenAll((event) => { + if (onlyErrorsRef.current && event.level === "info") return; setEvents((prev) => [event, ...prev].slice(0, maxEvents)); if (event.level === "error" && autoOpenRef.current) setOpen(true); }); - }, [bus, isEnabled, errorsOnly, maxEvents]); + }, [isEnabled, maxEvents]); // Escape steps back: stack view → list, list → closed. useEffect(() => { @@ -136,7 +137,7 @@ export function OpenUIDevtools({ onClick={() => setSelected(null)} aria-label="Back to event list" > - ← + ) : null} @@ -154,9 +155,11 @@ export function OpenUIDevtools({ onClick={() => setWrapStack((prev) => !prev)} aria-pressed={wrapStack} > + Wrap @@ -170,7 +173,7 @@ export function OpenUIDevtools({ onClick={() => setOpen(false)} aria-label="Close OpenUI devtools" > - ✕ + @@ -188,31 +191,65 @@ export function OpenUIDevtools({ ) : ( <> - +
+ + +
{events.length === 0 ? (
No events captured yet.
) : ( events.map((event, index) => { + const key = `${event.timestamp}-${index}`; + if (isCreditsExhausted(event)) return ; + const error = getErrorInfo(event); + const detail = asRecord(event.detail); + const kind = asString(detail["kind"]); + const status = + typeof detail["status"] === "number" ? String(detail["status"]) : undefined; + const message = error?.message ?? asString(detail["message"]); return ( -
+
- - {event.level} - +
+ + {event.level} + + {kind ? ( + + {kind} + + ) : null} + {status ? ( + + {status} + + ) : null} +
{new Date(event.timestamp).toLocaleTimeString()}
-
{summarize(event)}
+ {message ? ( +
{message}
+ ) : kind ? null : ( +
{summarize(event)}
+ )} {error?.stack ? ( +
+
+ ); +} + function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } @@ -274,11 +341,10 @@ function summarize(event: ObservabilityEvent): string { return "(no detail)"; } } - const badgeByLevel: Record = { - error: { background: "#7f1d1d", color: "#fecaca" }, - warning: { background: "#78350f", color: "#fde68a" }, - info: { background: "#1e3a5f", color: "#bfdbfe" }, + error: { background: "#fef2f2", color: "#b91c1c", borderColor: "#fecaca" }, + warning: { background: "#fffbeb", color: "#b45309", borderColor: "#fde68a" }, + info: { background: "#eff6ff", color: "#1d4ed8", borderColor: "#bfdbfe" }, }; const positionStyles: Record = { @@ -288,6 +354,9 @@ const positionStyles: Record = { "bottom-right": { bottom: 16, right: 16 }, }; +// Mirrors react-ui's look (Inter, hairline borders, soft shadows) without +// depending on it — values, not tokens. +const FONT = '"Inter", system-ui, sans-serif'; const MONO = "ui-monospace, SFMono-Regular, Menlo, monospace"; const styles = { @@ -304,19 +373,24 @@ const styles = { alignItems: "center", justifyContent: "center", borderRadius: "50%", - border: "1px solid #3f3f46", + borderWidth: 1, + borderStyle: "solid", + borderColor: "rgba(0, 0, 0, 0.08)", background: "#18181b", color: "#fff", cursor: "pointer", + boxShadow: "0 2px 8px rgba(0, 0, 0, 0.16)", + transition: "transform 150ms ease, box-shadow 150ms ease", }, toggleError: { - background: "#7f1d1d", - borderColor: "#dc2626", + background: "#b91c1c", + borderColor: "#fecaca", }, toggleCount: { position: "absolute", top: -6, right: -6, + boxSizing: "border-box", minWidth: 16, height: 16, display: "flex", @@ -324,15 +398,16 @@ const styles = { justifyContent: "center", borderRadius: 999, background: "#dc2626", + border: "2px solid #fff", color: "#fff", - fontSize: 10, + fontSize: 9, fontWeight: 700, - padding: "0 4px", + padding: "0 3px", }, backdrop: { position: "fixed", inset: 0, - background: "rgba(0, 0, 0, 0.5)", + background: "rgba(24, 24, 27, 0.4)", // Max 32-bit signed int — the open drawer sits above everything, including the toggle. zIndex: 2147483647, opacity: 0, @@ -348,20 +423,23 @@ const styles = { }, drawer: { position: "fixed", - top: 0, - right: 0, - bottom: 0, - width: "min(420px, 100vw)", + top: 12, + right: 12, + bottom: 12, + boxSizing: "border-box", + width: "min(420px, calc(100vw - 24px))", display: "flex", flexDirection: "column", - borderLeft: "1px solid #3f3f46", - background: "#18181b", - color: "#fafafa", - fontFamily: "system-ui, -apple-system, sans-serif", - fontSize: 12, - boxShadow: "-8px 0 32px rgba(0, 0, 0, 0.5)", - transform: "translateX(100%)", - transition: "transform 200ms ease", + border: "1px solid #e4e4e7", + borderRadius: 16, + background: "#ffffff", + color: "#18181b", + fontFamily: FONT, + fontSize: 13, + boxShadow: "0 16px 48px rgba(24, 24, 27, 0.18)", + transform: "translateX(calc(100% + 12px))", + transition: "transform 220ms cubic-bezier(0.32, 0.72, 0, 1)", + overflow: "hidden", }, drawerOpen: { transform: "translateX(0)", @@ -371,9 +449,10 @@ const styles = { justifyContent: "space-between", alignItems: "center", gap: 8, - padding: "10px 12px", - borderBottom: "1px solid #3f3f46", + padding: "12px 16px", + borderBottom: "1px solid #f4f4f5", fontWeight: 600, + fontSize: 14, }, headerLeft: { display: "flex", @@ -389,58 +468,119 @@ const styles = { headerActions: { display: "flex", alignItems: "center", - gap: 8, + gap: 6, flexShrink: 0, }, textButton: { - border: "1px solid #3f3f46", - borderRadius: 4, - background: "transparent", - color: "#a1a1aa", + display: "inline-flex", + alignItems: "center", + gap: 4, + border: "1px solid #e4e4e7", + borderRadius: 8, + background: "#ffffff", + color: "#3f3f46", cursor: "pointer", - fontSize: 11, - padding: "2px 8px", + fontFamily: FONT, + fontSize: 12, + fontWeight: 500, + padding: "4px 10px", }, textButtonActive: { - background: "#3f3f46", - color: "#fafafa", + background: "#18181b", + borderColor: "#18181b", + color: "#ffffff", }, iconButton: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 26, + height: 26, border: "none", + borderRadius: 8, background: "transparent", - color: "#a1a1aa", + color: "#71717a", cursor: "pointer", - fontSize: 16, - lineHeight: 1, - padding: "0 2px", + padding: 0, + }, + controlsRow: { + display: "flex", + alignItems: "center", + gap: 16, + padding: "10px 16px", + borderBottom: "1px solid #f4f4f5", }, - autoOpenRow: { + checkboxLabel: { display: "flex", alignItems: "center", gap: 6, - padding: "8px 12px", - borderBottom: "1px solid #27272a", - color: "#a1a1aa", + color: "#52525b", + fontSize: 12, cursor: "pointer", + accentColor: "#18181b", }, list: { overflowY: "auto", - padding: 8, + padding: 12, display: "flex", flexDirection: "column", - gap: 8, + gap: 10, }, empty: { color: "#a1a1aa", - padding: 8, + padding: "32px 0", + textAlign: "center", }, row: { - border: "1px solid #27272a", - borderRadius: 6, - padding: 8, + border: "1px solid #e4e4e7", + borderRadius: 12, + padding: 12, display: "flex", flexDirection: "column", - gap: 4, + gap: 6, + background: "#ffffff", + boxShadow: "0 1px 2px rgba(24, 24, 27, 0.04)", + }, + rowCredits: { + border: "1px solid #fde68a", + background: "linear-gradient(135deg, #fffbeb 0%, #fff7ed 100%)", + }, + badgeCredits: { + background: "#fef3c7", + color: "#92400e", + borderColor: "#fde68a", + }, + creditsNote: { + display: "flex", + flexDirection: "column", + gap: 6, + }, + creditsTitle: { + fontSize: 13, + fontWeight: 600, + color: "#18181b", + }, + creditsMessage: { + margin: 0, + fontSize: 12, + lineHeight: 1.55, + color: "#52525b", + }, + action: { + alignSelf: "flex-start", + display: "inline-flex", + alignItems: "center", + gap: 6, + border: "none", + borderRadius: 8, + background: "#18181b", + color: "#ffffff", + padding: "6px 12px", + fontFamily: FONT, + fontSize: 12, + fontWeight: 500, + cursor: "pointer", + marginTop: 2, }, rowHeader: { display: "flex", @@ -448,37 +588,62 @@ const styles = { alignItems: "center", gap: 8, }, + badgeGroup: { + display: "flex", + alignItems: "center", + flexWrap: "wrap", + gap: 6, + minWidth: 0, + }, + badgeNeutral: { + background: "#f4f4f5", + color: "#52525b", + borderColor: "#e4e4e7", + fontFamily: MONO, + }, badge: { - borderRadius: 4, - padding: "1px 6px", + display: "inline-flex", + alignItems: "center", + borderRadius: 999, + borderWidth: 1, + borderStyle: "solid", + borderColor: "transparent", + padding: "1px 8px", fontSize: 11, - fontFamily: MONO, + fontWeight: 500, + fontFamily: FONT, }, time: { - color: "#71717a", + color: "#a1a1aa", fontSize: 11, }, summary: { wordBreak: "break-word", + color: "#3f3f46", + fontSize: 12, + lineHeight: 1.5, }, stackButton: { alignSelf: "flex-start", border: "none", background: "transparent", - color: "#a1a1aa", + color: "#52525b", cursor: "pointer", - fontSize: 11, + fontFamily: FONT, + fontSize: 12, + fontWeight: 500, padding: 0, textDecoration: "underline", + textUnderlineOffset: 2, }, stackBody: { flex: 1, overflow: "auto", padding: "8px 0", - background: "#09090b", + background: "#fafafa", fontFamily: MONO, fontSize: 11, - color: "#fca5a5", + color: "#3f3f46", }, stackLine: { display: "flex", @@ -489,10 +654,10 @@ const styles = { flexShrink: 0, width: 32, textAlign: "right", - color: "#52525b", + color: "#a1a1aa", userSelect: "none", padding: "0 4px", - borderRight: "1px solid #27272a", + borderRight: "1px solid #e4e4e7", }, lineText: { whiteSpace: "pre", diff --git a/packages/lang-core/src/parser/prompt.ts b/packages/lang-core/src/parser/prompt.ts index 69c9c8065..42cd20c90 100644 --- a/packages/lang-core/src/parser/prompt.ts +++ b/packages/lang-core/src/parser/prompt.ts @@ -454,8 +454,7 @@ function renderToolSignature(tool: ToolSpec): string { let args = ""; if (tool.inputSchema) { const props = (tool.inputSchema as any).properties as - | Record> - | undefined; + Record> | undefined; const required = ((tool.inputSchema as any).required as string[]) ?? []; if (props && Object.keys(props).length > 0) { args = Object.entries(props) diff --git a/packages/lang-core/src/parser/types.ts b/packages/lang-core/src/parser/types.ts index ec1d82677..573331c31 100644 --- a/packages/lang-core/src/parser/types.ts +++ b/packages/lang-core/src/parser/types.ts @@ -70,11 +70,7 @@ export function isElementNode(value: unknown): value is ElementNode { * Validation error codes for schema-related issues. */ export type ValidationErrorCode = - | "missing-required" - | "null-required" - | "unknown-component" - | "inline-reserved" - | "excess-args"; + "missing-required" | "null-required" | "unknown-component" | "inline-reserved" | "excess-args"; /** * A prop validation error. Components with missing required props are diff --git a/packages/react-headless/package.json b/packages/react-headless/package.json index c03d70c1d..fa5eb69a9 100644 --- a/packages/react-headless/package.json +++ b/packages/react-headless/package.json @@ -1,6 +1,6 @@ { "name": "@openuidev/react-headless", - "version": "0.9.3", + "version": "0.9.4", "description": "Headless React primitives for AI chat — state management, streaming adapters for OpenAI and AG-UI, message format converters, and thread management for OpenUI generative UI apps", "license": "MIT", "type": "module", @@ -77,6 +77,7 @@ "url": "https://github.com/thesysdev/openui/issues" }, "dependencies": { - "@ag-ui/core": "^0.0.53" + "@ag-ui/core": "^0.0.53", + "@openuidev/observability": "workspace:^" } } diff --git a/packages/react-headless/src/adapters/fetchLLM.ts b/packages/react-headless/src/adapters/fetchLLM.ts index 29929f172..c8b0d61df 100644 --- a/packages/react-headless/src/adapters/fetchLLM.ts +++ b/packages/react-headless/src/adapters/fetchLLM.ts @@ -1,5 +1,7 @@ +import { observability, ObservabilityLevel, toErrorInfo } from "@openuidev/observability"; import { identityMessageFormat, type MessageFormat } from "../types/messageFormat"; import type { StreamProtocolAdapter } from "../types/stream"; +import { getResponseErrorMessage } from "./httpError"; import type { ChatLLM } from "./types"; export interface FetchLLMOptions { @@ -13,6 +15,12 @@ export interface FetchLLMOptions { headers?: Record; /** Override fetch implementation (for tests, custom auth wrappers, etc.). */ fetch?: typeof fetch; + /** Extra fields merged into the request body (e.g. `model`) */ + body?: Record; +} + +function levelForStatus(status: number): ObservabilityLevel { + return status >= 400 ? "error" : "info"; } /** @@ -20,9 +28,7 @@ export interface FetchLLMOptions { * (`{ threadId, runId, messages, tools, context }`, messages in the chosen wire * format) to `url` and returns the streaming `Response` for downstream processing. * - * The fields the {@link ChatLLM} `send` contract doesn't carry are defaulted - * here so the body satisfies a spec-compliant AG-UI agent: a fresh `runId` is - * generated per send, and `tools`/`context` default to `[]` (override via options). + * Every send is reported to `@openuidev/observability` — The `runId` correlates them. */ export function fetchLLM({ url, @@ -30,11 +36,14 @@ export function fetchLLM({ messageFormat = identityMessageFormat, headers, fetch: customFetch, + body, }: FetchLLMOptions): ChatLLM { const fetchImpl = customFetch ?? globalThis.fetch.bind(globalThis); return { send: ({ threadId, messages, signal }) => { - const wire = messageFormat.toApi(messages); + const runId = crypto.randomUUID(); + observability.info({ kind: "fetchLLM:request", requestId: runId, url, threadId }); + return fetchImpl(url, { method: "POST", headers: { @@ -42,14 +51,38 @@ export function fetchLLM({ ...headers, }, body: JSON.stringify({ - threadId, - runId: crypto.randomUUID(), - messages: wire, + ...body, tools: [], context: [], + threadId, + runId, + messages: messageFormat.toApi(messages), }), signal, - }); + }).then( + async (response) => { + observability(levelForStatus(response.status), { + kind: response.ok ? "fetchLLM:response" : "fetchLLM:error", + requestId: runId, + url, + status: response.status, + ok: response.ok, + threadId, + ...(response.ok ? null : { message: await getResponseErrorMessage(response) }), + }); + return response; + }, + (error: unknown) => { + observability.error({ + kind: "fetchLLM:error", + requestId: runId, + url, + error: toErrorInfo(error), + threadId, + }); + throw error; + }, + ); }, streamProtocol: streamAdapter, }; diff --git a/packages/react-headless/src/adapters/httpError.ts b/packages/react-headless/src/adapters/httpError.ts new file mode 100644 index 000000000..39f45d736 --- /dev/null +++ b/packages/react-headless/src/adapters/httpError.ts @@ -0,0 +1,22 @@ +export async function getResponseErrorMessage(response: Response): Promise { + try { + const data = (await response.clone().json()) as { + error?: { message?: unknown } | string; + message?: unknown; + }; + const message = + typeof data.error === "string" + ? data.error + : typeof data.error?.message === "string" + ? data.error.message + : typeof data.message === "string" + ? data.message + : null; + + if (message?.trim()) return message; + } catch { + // Fall through to the status-based message. + } + + return `Request failed: ${response.status} ${response.statusText}`.trim(); +} diff --git a/packages/react-headless/src/adapters/index.ts b/packages/react-headless/src/adapters/index.ts index 3e45d59be..6adc03280 100644 --- a/packages/react-headless/src/adapters/index.ts +++ b/packages/react-headless/src/adapters/index.ts @@ -11,6 +11,7 @@ export type { export { fetchLLM } from "./fetchLLM"; export type { FetchLLMOptions } from "./fetchLLM"; +export { getResponseErrorMessage } from "./httpError"; export { restStorage } from "./restStorage"; export type { RestStorageOptions } from "./restStorage"; diff --git a/packages/react-headless/src/index.ts b/packages/react-headless/src/index.ts index 6bd05b30a..6866ab82c 100644 --- a/packages/react-headless/src/index.ts +++ b/packages/react-headless/src/index.ts @@ -38,7 +38,7 @@ export { export { processStreamedMessage } from "./stream/processStreamedMessage"; // ── Adapter interfaces + factories ── -export { fetchLLM, restStorage } from "./adapters"; +export { fetchLLM, getResponseErrorMessage, restStorage } from "./adapters"; export type { Artifact, ArtifactCategory, diff --git a/packages/react-headless/src/store/ChatProvider.tsx b/packages/react-headless/src/store/ChatProvider.tsx index 76f708a26..b002e15a0 100644 --- a/packages/react-headless/src/store/ChatProvider.tsx +++ b/packages/react-headless/src/store/ChatProvider.tsx @@ -24,7 +24,9 @@ export const ChatProvider: FC = ({ artifactCategories, }) => { const [resolvedStorage] = useState(() => storage ?? createDefaultInMemoryStorage()); - const [chatStore] = useState(() => createChatStore({ storage: resolvedStorage, llm })); + const config = useRef({ storage: resolvedStorage, llm }); + config.current.llm = llm; + const [chatStore] = useState(() => createChatStore(config)); const [detailedViewStore] = useState(() => createDetailedViewStore()); const [threadContextStore] = useState(() => createThreadContextStore()); const [artifactRendererRegistry] = useState(() => diff --git a/packages/react-headless/src/store/__tests__/__helpers/makeStore.ts b/packages/react-headless/src/store/__tests__/__helpers/makeStore.ts index 4f08e1e86..01f8e6f77 100644 --- a/packages/react-headless/src/store/__tests__/__helpers/makeStore.ts +++ b/packages/react-headless/src/store/__tests__/__helpers/makeStore.ts @@ -35,5 +35,5 @@ export function makeStore(overrides: MakeStoreOverrides = {}) { streamProtocol: streamProtocol ?? { parse: async function* () {} }, }; - return createChatStore({ storage, llm }); + return createChatStore({ current: { storage, llm } }); } diff --git a/packages/react-headless/src/store/createChatStore.ts b/packages/react-headless/src/store/createChatStore.ts index ef26cfa0c..582e05cc6 100644 --- a/packages/react-headless/src/store/createChatStore.ts +++ b/packages/react-headless/src/store/createChatStore.ts @@ -1,5 +1,6 @@ import { createStore } from "zustand"; import { subscribeWithSelector } from "zustand/middleware"; +import { getResponseErrorMessage } from "../adapters/httpError"; import type { ChatLLM, ChatStorage } from "../adapters/types"; import { processStreamedMessage } from "../stream/processStreamedMessage"; import type { ChatStore, Message, Thread, UserMessage } from "./types"; @@ -14,8 +15,10 @@ const mergeThreadList = (existing: Thread[], incoming: Thread[]): Thread[] => (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), ); -export const createChatStore = (config: CreateChatStoreConfig) => { - const { storage, llm } = config; +// Takes the ref itself and reads `current` at call time, so the caller +// (ChatProvider) can refresh `llm` by mutating or replacing `current`. +export const createChatStore = (configRef: React.RefObject) => { + const { storage } = configRef.current; const { thread: threadStorage } = storage; const store = createStore()( @@ -89,6 +92,8 @@ export const createChatStore = (config: CreateChatStoreConfig) => { }, selectThread: (threadId: string) => { + // Re-selecting the active thread is a no-op — don't wipe and refetch. + if (get().selectedThreadId === threadId) return; get().cancelMessage(); set({ selectedThreadId: threadId, @@ -167,14 +172,14 @@ export const createChatStore = (config: CreateChatStoreConfig) => { set({ selectedThreadId: threadId }); } - const response = await llm.send({ + const response = await configRef.current.llm.send({ threadId, messages: get().messages, signal: abortController.signal, }); if (response instanceof Response && !response.ok) { - throw new Error(`Request failed: ${response.status} ${response.statusText}`); + throw new Error(await getResponseErrorMessage(response)); } await processStreamedMessage({ @@ -199,7 +204,7 @@ export const createChatStore = (config: CreateChatStoreConfig) => { next.delete(id); return { executingToolCallIds: next }; }), - adapter: llm.streamProtocol, + adapter: configRef.current.llm.streamProtocol, }); } catch (e) { if (!abortController.signal.aborted) { diff --git a/packages/react-headless/src/stream/adapters/langgraph.ts b/packages/react-headless/src/stream/adapters/langgraph.ts index 60749204a..3e3f91fdc 100644 --- a/packages/react-headless/src/stream/adapters/langgraph.ts +++ b/packages/react-headless/src/stream/adapters/langgraph.ts @@ -113,8 +113,7 @@ export const langGraphAdapter = (options?: LangGraphAdapterOptions): StreamProto // Payload is a tuple: [message_chunk, metadata] // or just a message object depending on the stream version. const tuple = parsed as - | [LangGraphAIMessage, LangGraphMessageMetadata] - | LangGraphAIMessage; + [LangGraphAIMessage, LangGraphMessageMetadata] | LangGraphAIMessage; const msg: LangGraphAIMessage = Array.isArray(tuple) ? tuple[0] : tuple; diff --git a/packages/react-lang/src/Renderer.tsx b/packages/react-lang/src/Renderer.tsx index 0c06a8c93..6b113a424 100644 --- a/packages/react-lang/src/Renderer.tsx +++ b/packages/react-lang/src/Renderer.tsx @@ -40,9 +40,7 @@ export interface RendererProps { * - MCP client: any object with `callTool({ name, arguments })` (e.g. from @modelcontextprotocol/sdk) */ toolProvider?: - | Record) => Promise> - | McpClientLike - | null; + Record) => Promise> | McpClientLike | null; /** Custom loading indicator shown while queries are fetching. Defaults to a spinner. */ queryLoader?: React.ReactNode; /** diff --git a/packages/react-lang/src/library.ts b/packages/react-lang/src/library.ts index cdb6dc183..a98d4faea 100644 --- a/packages/react-lang/src/library.ts +++ b/packages/react-lang/src/library.ts @@ -21,8 +21,10 @@ export type { // ─── React-specific types ─────────────────────────────────────────────────── -export interface ComponentRenderProps

> - extends CoreRenderProps {} +export interface ComponentRenderProps

> extends CoreRenderProps< + P, + ReactNode +> {} export type ComponentRenderer

> = React.FC>; diff --git a/packages/react-ui/package.json b/packages/react-ui/package.json index 784fe9b6a..0a15d6838 100644 --- a/packages/react-ui/package.json +++ b/packages/react-ui/package.json @@ -2,7 +2,7 @@ "type": "module", "name": "@openuidev/react-ui", "license": "MIT", - "version": "0.13.1", + "version": "0.13.2", "description": "Component library for Generative UI SDK", "main": "dist/index.cjs", "module": "dist/index.mjs", diff --git a/packages/react-ui/src/components/Accordion/Accordion.tsx b/packages/react-ui/src/components/Accordion/Accordion.tsx index 023e91d4e..b14351cd5 100644 --- a/packages/react-ui/src/components/Accordion/Accordion.tsx +++ b/packages/react-ui/src/components/Accordion/Accordion.tsx @@ -6,8 +6,7 @@ import React, { forwardRef } from "react"; type AccordionVariant = "clear" | "card" | "sunk"; export type AccordionProps = ( - | AccordionPrimitive.AccordionSingleProps - | AccordionPrimitive.AccordionMultipleProps + AccordionPrimitive.AccordionSingleProps | AccordionPrimitive.AccordionMultipleProps ) & { variant?: AccordionVariant; }; @@ -30,8 +29,9 @@ export const Accordion = forwardRef< /> )); -export interface AccordionItemProps - extends React.ComponentPropsWithoutRef { +export interface AccordionItemProps extends React.ComponentPropsWithoutRef< + typeof AccordionPrimitive.Item +> { className?: string; style?: React.CSSProperties; value: string; @@ -50,8 +50,9 @@ export const AccordionItem = forwardRef< /> )); -export interface AccordionTriggerProps - extends React.ComponentPropsWithoutRef { +export interface AccordionTriggerProps extends React.ComponentPropsWithoutRef< + typeof AccordionPrimitive.Trigger +> { className?: string; style?: React.CSSProperties; icon?: React.ReactNode; @@ -77,8 +78,9 @@ export const AccordionTrigger = forwardRef< )); -export interface AccordionContentProps - extends React.ComponentPropsWithoutRef { +export interface AccordionContentProps extends React.ComponentPropsWithoutRef< + typeof AccordionPrimitive.Content +> { className?: string; style?: React.CSSProperties; children?: React.ReactNode; diff --git a/packages/react-ui/src/components/AgentInterface/welcomeScreen.scss b/packages/react-ui/src/components/AgentInterface/welcomeScreen.scss index a999b6709..777b92299 100644 --- a/packages/react-ui/src/components/AgentInterface/welcomeScreen.scss +++ b/packages/react-ui/src/components/AgentInterface/welcomeScreen.scss @@ -179,8 +179,8 @@ $center-align-spacing: calc(32px + cssUtils.$space-s); // Use :not() to exclude the one inside the welcome screen .openui-agent-thread-container:has(.openui-agent-welcome-screen__desktop-starters) { .openui-agent-conversation-starter:not( - .openui-agent-welcome-screen__desktop-starters .openui-agent-conversation-starter - ) { + .openui-agent-welcome-screen__desktop-starters .openui-agent-conversation-starter + ) { display: none; } } diff --git a/packages/react-ui/src/components/Buttons/Buttons.tsx b/packages/react-ui/src/components/Buttons/Buttons.tsx index 70e75bf12..c0c365242 100644 --- a/packages/react-ui/src/components/Buttons/Buttons.tsx +++ b/packages/react-ui/src/components/Buttons/Buttons.tsx @@ -8,8 +8,7 @@ type ButtonsVariant = "vertical" | "horizontal"; export interface ButtonsProps extends HTMLAttributes { variant?: ButtonsVariant; children: - | ReactElement - | ReactElement[]; + ReactElement | ReactElement[]; className?: string; style?: CSSProperties; } diff --git a/packages/react-ui/src/components/CardHeader/CardHeader.tsx b/packages/react-ui/src/components/CardHeader/CardHeader.tsx index 1ed66c60a..593786420 100644 --- a/packages/react-ui/src/components/CardHeader/CardHeader.tsx +++ b/packages/react-ui/src/components/CardHeader/CardHeader.tsx @@ -8,8 +8,7 @@ export interface CardHeaderProps extends Omit - | ReactElement[]; + ReactElement | ReactElement[]; className?: string; styles?: CSSProperties; } diff --git a/packages/react-ui/src/components/IconButton/IconButton.tsx b/packages/react-ui/src/components/IconButton/IconButton.tsx index b1a07d2ce..3481cd82d 100644 --- a/packages/react-ui/src/components/IconButton/IconButton.tsx +++ b/packages/react-ui/src/components/IconButton/IconButton.tsx @@ -4,12 +4,7 @@ import { ButtonHTMLAttributes, forwardRef, ReactNode } from "react"; type IconButtonVariant = "primary" | "secondary" | "tertiary"; type IconButtonSize = - | "3-extra-small" - | "2-extra-small" - | "extra-small" - | "small" - | "medium" - | "large"; + "3-extra-small" | "2-extra-small" | "extra-small" | "small" | "medium" | "large"; type IconButtonShape = "square" | "circle"; type IconButtonAppearance = "normal" | "destructive"; diff --git a/packages/react-ui/src/components/ModelSwitcher/ModelSwitcher.tsx b/packages/react-ui/src/components/ModelSwitcher/ModelSwitcher.tsx new file mode 100644 index 000000000..a01bee170 --- /dev/null +++ b/packages/react-ui/src/components/ModelSwitcher/ModelSwitcher.tsx @@ -0,0 +1,166 @@ +"use client"; + +import clsx from "clsx"; +import type { ReactNode } from "react"; + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, +} from "../Select"; +import { Tag } from "../Tag"; +import { useTheme, type ThemeMode } from "../ThemeProvider"; + +import { groupModels, useHydrated } from "./utils"; + +/** A single logo node, or a light/dark pair the switcher picks from by theme. */ +export type ModelLogo = ReactNode | { light: ReactNode; dark: ReactNode }; + +export interface ModelOption { + /** Unique id — the value the switcher reports through `onValueChange`. */ + id: string; + /** Display name. */ + name: string; + /** Optional section header this model is grouped under (e.g. a provider or "Free"). */ + group?: string; + /** Optional chip label (e.g. "Free"). */ + badge?: string; + /** Marks the model with a "Recommended" chip. */ + recommended?: boolean; + /** Optional leading logo/icon — apps supply their own asset */ + logo?: ModelLogo; +} + +export interface ModelSwitcherProps { + /** The models to choose from. Grouped by `group` in first-seen order. */ + models: ModelOption[]; + /** The selected model id. */ + value: string; + /** Called with the newly selected model id. */ + onValueChange: (id: string) => void; +} + +export function ModelSwitcher({ models, value, onValueChange }: ModelSwitcherProps) { + const hydrated = useHydrated(); + const { mode } = useTheme(); + const selected = models.find((model) => model.id === value) ?? models[0]; + const groups = groupModels(models); + + return ( +

+ +
+ ); +} + +// Resolve a model's logo for the active theme: a `{ light, dark }` pair yields +// the variant for `mode`; a plain node renders as-is. +function resolveLogo(logo: ModelLogo | undefined, mode: ThemeMode): ReactNode { + if (logo && typeof logo === "object" && "light" in logo && "dark" in logo) { + return mode === "dark" ? logo.dark : logo.light; + } + return (logo ?? null) as ReactNode; +} + +function TriggerContent({ + option, + fallback, + mode, +}: { + option: ModelOption | undefined; + fallback: string; + mode: ThemeMode; +}) { + const logo = option ? resolveLogo(option.logo, mode) : null; + return ( + <> + {logo ? {logo} : null} + {option?.name ?? fallback} + {option ? : null} + + ); +} + +// Neutral skeleton shown until the client reads the persisted model, so a +// refresh doesn't flash a fallback name. +function TriggerSkeleton() { + return ( + <> +