From c7513c54abdde649910549cb7bd19899093416c4 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Tue, 28 Jul 2026 18:52:59 +0530 Subject: [PATCH 01/33] feat(packages): shared ModelSwitcher + credits modal, fetchLLM buildBody - react-ui: ModelSwitcher promoted to /blocks + main entry; theme-aware {light,dark} logo API (ModelLogo); v0.13.3 - react-headless: fetchLLM gains buildBody for custom request bodies (default AG-UI shape unchanged) - devtools: OpenUICreditsModal (429 -> credits notice via observability) + CreditsModal; dev-only widget Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MpABGCgS6C4HRdQSrwhPMR --- packages/devtools/package.json | 9 +- packages/devtools/src/OpenUICreditsModal.tsx | 65 +++++++ packages/devtools/src/OpenUIDevtools.tsx | 8 +- packages/devtools/src/index.ts | 1 + packages/react-headless/package.json | 5 +- .../react-headless/src/adapters/fetchLLM.ts | 60 ++++-- packages/react-ui/cp-css.js | 2 +- packages/react-ui/package.json | 12 +- .../blocks/ModelSwitcher/ModelSwitcher.tsx | 173 ++++++++++++++++++ .../src/blocks/ModelSwitcher/index.ts | 1 + .../blocks/ModelSwitcher/modelSwitcher.scss | 143 +++++++++++++++ .../src/blocks/ModelSwitcher/utils.ts | 37 ++++ packages/react-ui/src/blocks/index.ts | 4 + packages/react-ui/src/blocks/scss.d.ts | 3 + packages/react-ui/src/index.ts | 3 + packages/react-ui/tsdown.config.ts | 17 ++ pnpm-lock.yaml | 23 +-- 17 files changed, 527 insertions(+), 39 deletions(-) create mode 100644 packages/devtools/src/OpenUICreditsModal.tsx create mode 100644 packages/react-ui/src/blocks/ModelSwitcher/ModelSwitcher.tsx create mode 100644 packages/react-ui/src/blocks/ModelSwitcher/index.ts create mode 100644 packages/react-ui/src/blocks/ModelSwitcher/modelSwitcher.scss create mode 100644 packages/react-ui/src/blocks/ModelSwitcher/utils.ts create mode 100644 packages/react-ui/src/blocks/index.ts create mode 100644 packages/react-ui/src/blocks/scss.d.ts diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 2c37a24b6..8d7bf87f6 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -1,6 +1,6 @@ { "name": "@openuidev/devtools", - "version": "0.0.1", + "version": "0.0.2", "description": "Development-only UI widget for OpenUI apps: surfaces errors captured by @openuidev/observability in a floating dialog", "license": "MIT", "type": "module", @@ -41,9 +41,15 @@ }, "peerDependencies": { "@openuidev/observability": "workspace:^", + "@openuidev/react-ui": "workspace:^", "react": "catalog:", "react-dom": "catalog:" }, + "peerDependenciesMeta": { + "@openuidev/react-ui": { + "optional": true + } + }, "keywords": [ "openui", "devtools", @@ -64,6 +70,7 @@ "author": "engineering@thesys.dev", "devDependencies": { "@openuidev/observability": "workspace:^", + "@openuidev/react-ui": "workspace:^", "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", diff --git a/packages/devtools/src/OpenUICreditsModal.tsx b/packages/devtools/src/OpenUICreditsModal.tsx new file mode 100644 index 000000000..efae8b394 --- /dev/null +++ b/packages/devtools/src/OpenUICreditsModal.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { observability } from "@openuidev/observability"; +import { Button } from "@openuidev/react-ui"; +import { Modal } from "@openuidev/react-ui/Modal"; +import type { ReactNode } from "react"; +import { useEffect, useState } from "react"; + +export interface OpenUICreditsModalProps { + /** Where the action button links. */ + billingUrl?: string; + title?: string; + message?: string; + actionLabel?: string; + /** Optional leading icon for the action button (apps supply their own asset). */ + icon?: ReactNode; +} + +const DEFAULTS = { + billingUrl: "https://console.thesys.dev/billing", + title: "Add credits to keep going", + message: + "Looks like this workspace is out of OpenUI Cloud credits. Purchase credits to keep testing, then try your request again. This notice is only shown in development.", + actionLabel: "Purchase credits", +} as const; + +/** + * Drop-in credits notice: listens for 429 errors on the observability bus and + * shows the modal. Render it once (dev-only) alongside your chat surface. + */ +export function OpenUICreditsModal({ + billingUrl = DEFAULTS.billingUrl, + title = DEFAULTS.title, + message = DEFAULTS.message, + actionLabel = DEFAULTS.actionLabel, + icon, +}: OpenUICreditsModalProps) { + const [open, setOpen] = useState(false); + + useEffect(() => { + const remove = observability.listen("error", (event) => { + if ((event.detail as { status?: unknown }).status === 429) setOpen(true); + }); + + return () => { + remove(); + }; + }, []); + + return ( + +

{message}

+
+ +
+
+ ); +} diff --git a/packages/devtools/src/OpenUIDevtools.tsx b/packages/devtools/src/OpenUIDevtools.tsx index 6cd58176d..c0cd52b1f 100644 --- a/packages/devtools/src/OpenUIDevtools.tsx +++ b/packages/devtools/src/OpenUIDevtools.tsx @@ -2,7 +2,6 @@ import { observability, - type Observability, type ObservabilityErrorInfo, type ObservabilityEvent, } from "@openuidev/observability"; @@ -22,8 +21,6 @@ export interface OpenUIDevtoolsProps { 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 +36,6 @@ export function OpenUIDevtools({ maxEvents = 50, errorsOnly = true, autoOpenOnError = true, - bus = observability, }: OpenUIDevtoolsProps) { const isEnabled = enabled ?? (typeof process === "undefined" || process.env["NODE_ENV"] !== "production"); @@ -56,12 +52,12 @@ export function OpenUIDevtools({ useEffect(() => { if (!isEnabled) return; - return bus.listenAll((event) => { + return observability.listenAll((event) => { if (errorsOnly && event.level === "info") return; setEvents((prev) => [event, ...prev].slice(0, maxEvents)); if (event.level === "error" && autoOpenRef.current) setOpen(true); }); - }, [bus, isEnabled, errorsOnly, maxEvents]); + }, [isEnabled, errorsOnly, maxEvents]); // Escape steps back: stack view → list, list → closed. useEffect(() => { diff --git a/packages/devtools/src/index.ts b/packages/devtools/src/index.ts index aec6480b8..c7370089c 100644 --- a/packages/devtools/src/index.ts +++ b/packages/devtools/src/index.ts @@ -1,3 +1,4 @@ "use client"; +export { OpenUICreditsModal, type OpenUICreditsModalProps } from "./OpenUICreditsModal"; export { OpenUIDevtools, type OpenUIDevtoolsProps } from "./OpenUIDevtools"; 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..605bf5735 100644 --- a/packages/react-headless/src/adapters/fetchLLM.ts +++ b/packages/react-headless/src/adapters/fetchLLM.ts @@ -1,6 +1,8 @@ +import { observability, ObservabilityLevel, toErrorInfo } from "@openuidev/observability"; + import { identityMessageFormat, type MessageFormat } from "../types/messageFormat"; import type { StreamProtocolAdapter } from "../types/stream"; -import type { ChatLLM } from "./types"; +import type { ChatLLM, Message } from "./types"; export interface FetchLLMOptions { /** Endpoint that accepts POST'd messages and returns a streaming Response. */ @@ -13,6 +15,18 @@ export interface FetchLLMOptions { headers?: Record; /** Override fetch implementation (for tests, custom auth wrappers, etc.). */ fetch?: typeof fetch; + /** Customize the POST body. Receives the run's thread/run ids and the + * canonical messages; returns the JSON-serializable request body. Defaults + * to the AG-UI `RunAgentInput` shape. When provided, `messageFormat` is not + * applied — shape the wire format inside `buildBody`. */ + buildBody?: (params: { threadId: string; runId: string; messages: Message[] }) => unknown; +} + +// Observability level for a response's HTTP status: a rate limit surfaces as an +// error (it feeds the dev credits notice), server errors are errors, other +// client errors are warnings, and 2xx is info. +function levelForStatus(status: number): ObservabilityLevel { + return status >= 400 ? "error" : "info"; } /** @@ -20,9 +34,9 @@ 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` — an `llm:request` on + * start, then an `llm:response`/`llm:error` (level varied by status) on the + * reply, or an `llm:error` on network failure. The `runId` correlates them. */ export function fetchLLM({ url, @@ -30,26 +44,46 @@ export function fetchLLM({ messageFormat = identityMessageFormat, headers, fetch: customFetch, + buildBody, }: 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: "llm:request", requestId: runId, url }); + + const body = buildBody + ? buildBody({ threadId, runId, messages }) + : { threadId, runId, messages: messageFormat.toApi(messages), tools: [], context: [] }; return fetchImpl(url, { method: "POST", headers: { "Content-Type": "application/json", ...headers, }, - body: JSON.stringify({ - threadId, - runId: crypto.randomUUID(), - messages: wire, - tools: [], - context: [], - }), + body: JSON.stringify(body), signal, - }); + }).then( + (response) => { + observability(levelForStatus(response.status), { + kind: response.ok ? "fetchLLM:response" : "fetchLLM:error", + requestId: runId, + url, + status: response.status, + ok: response.ok, + }); + return response; + }, + (error: unknown) => { + observability.error({ + kind: "fetchLLM:error", + requestId: runId, + url, + error: toErrorInfo(error), + }); + throw error; + }, + ); }, streamProtocol: streamAdapter, }; diff --git a/packages/react-ui/cp-css.js b/packages/react-ui/cp-css.js index 321559c75..48bf77027 100644 --- a/packages/react-ui/cp-css.js +++ b/packages/react-ui/cp-css.js @@ -21,7 +21,7 @@ function fixScssImportsInJs(dir) { const stat = fs.statSync(fullPath); if (stat.isDirectory()) { fixScssImportsInJs(fullPath); - } else if (entry.endsWith(".js")) { + } else if (/\.(js|mjs|cjs)$/.test(entry)) { const content = fs.readFileSync(fullPath, "utf8"); const fixed = content.replace(/(['"])([^'"]*\.scss)\1/g, (match, quote, p) => { return `${quote}${p.replace(/\.scss$/, ".css")}${quote}`; diff --git a/packages/react-ui/package.json b/packages/react-ui/package.json index 784fe9b6a..80d612c22 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.3", "description": "Component library for Generative UI SDK", "main": "dist/index.cjs", "module": "dist/index.mjs", @@ -36,6 +36,16 @@ "./layered/styles/*": { "default": "./dist/layered/styles/*" }, + "./blocks": { + "import": { + "types": "./dist/blocks/index.d.mts", + "default": "./dist/blocks/index.mjs" + }, + "require": { + "types": "./dist/blocks/index.d.cts", + "default": "./dist/blocks/index.cjs" + } + }, "./genui-lib": { "import": { "types": "./dist/genui-lib/index.d.mts", diff --git a/packages/react-ui/src/blocks/ModelSwitcher/ModelSwitcher.tsx b/packages/react-ui/src/blocks/ModelSwitcher/ModelSwitcher.tsx new file mode 100644 index 000000000..11c02bd94 --- /dev/null +++ b/packages/react-ui/src/blocks/ModelSwitcher/ModelSwitcher.tsx @@ -0,0 +1,173 @@ +"use client"; + +import clsx from "clsx"; +import type { ReactNode } from "react"; + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, +} from "../../components/Select"; +import { useTheme, type ThemeMode } from "../../components/ThemeProvider"; + +import { groupModels, useHydrated } from "./utils"; + +import "./modelSwitcher.scss"; + +/** 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. Pass a + * `{ light, dark }` pair to have the switcher swap it by the active theme. */ + 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; +} + +/** + * A dropdown for picking an LLM, grouped by `ModelOption.group`, with optional + * per-model logo, "Recommended", and badge chips. Data-agnostic: pass your own + * `models` — the block owns no model list. A model's `logo` may be a single + * node or a `{ light, dark }` pair the switcher swaps by the active theme. + */ +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 ( + <> +