diff --git a/docs/content/docs/agent/reference/agentinterface-props.mdx b/docs/content/docs/agent/reference/agentinterface-props.mdx index 2b2135a5c..ba647924e 100644 --- a/docs/content/docs/agent/reference/agentinterface-props.mdx +++ b/docs/content/docs/agent/reference/agentinterface-props.mdx @@ -23,6 +23,7 @@ The only required prop is `llm`. Everything else is optional and falls back to a | `agentName` | `string` | No | none | | `starters` | `ConversationStarterProps[]` | No | none | | `starterVariant` | `"short" \| "long"` | No | — | +| `getThreadMenuActions` | `GetThreadMenuActions` | No | none | | `path` | `string` | No | — (uncontrolled) | | `defaultPath` | `string` | No | thread view (`undefined`) | | `onNavigate` | `(next: string \| undefined) => void` | No | — (uncontrolled) | @@ -122,6 +123,31 @@ Set `disableThemeProvider` to `true` when `AgentInterface` is mounted inside an These feed the default `SidebarHeader` and `MobileHeader`. To go further, replace those slots. See [Sidebar](/docs/agent/customize/sidebar). +## Thread menu actions + +Use `getThreadMenuActions` to add context-aware links or callbacks to each thread's overflow menu. The callback receives `{ id, title, isSelected, isRunning }`, where `isRunning` is true only for the selected thread while its response is streaming. + +```tsx + + isRunning + ? [] + : [ + { + id: "inspect-session", + label: "Inspect session", + href: `/sessions/${encodeURIComponent(id)}`, + target: "_blank", + rel: "noopener noreferrer", + }, + ] + } +/>; +``` + +Each action must have a stable `id` and `label`, plus either `href` for a real anchor or `onSelect` for an in-app callback. An optional `icon` renders before the label. + ## Starters Conversation starters are the suggested prompts shown on the welcome screen and in the composer. diff --git a/docs/content/docs/api-reference/react-ui.mdx b/docs/content/docs/api-reference/react-ui.mdx index 4add5cee5..15b3eee33 100644 --- a/docs/content/docs/api-reference/react-ui.mdx +++ b/docs/content/docs/api-reference/react-ui.mdx @@ -89,6 +89,7 @@ const llm: ChatLLM = { - `labels?: AgentInterfaceLabels` - `starters?: ConversationStarterProps[]` - `starterVariant?: ConversationStarterVariant` + - `getThreadMenuActions?: GetThreadMenuActions` — adds link or callback actions to each thread menu - `scrollVariant?: ScrollVariant` - `scrollOnLoad?: boolean` - Theme wrapper props: diff --git a/examples/README.md b/examples/README.md index 3753242aa..ee9922b01 100644 --- a/examples/README.md +++ b/examples/README.md @@ -22,6 +22,7 @@ Each example has one primary home based on the integration seam it is intended t | Example | Demonstrates | | ------------------------------------------------- | ----------------------------------------------------------------------- | +| [Agno](./agent-frameworks/agno) | An AgentOS agent streamed into OpenUI with shared session persistence | | [Google ADK](./agent-frameworks/google-adk) | A Google ADK TypeScript agent streaming OpenUI Lang to a Next.js client | | [LangChain](./agent-frameworks/langchain) | LangGraph/DeepAgents integration through the OpenUI LangChain adapter | | [Mastra](./agent-frameworks/mastra) | A Mastra agent connected to OpenUI through AG-UI | diff --git a/examples/agent-frameworks/agno/.gitignore b/examples/agent-frameworks/agno/.gitignore new file mode 100644 index 000000000..fb4c2cfb7 --- /dev/null +++ b/examples/agent-frameworks/agno/.gitignore @@ -0,0 +1,8 @@ +node_modules +.venv +__pycache__ +dist +.env +.env.local +src/generated +tmp diff --git a/examples/agent-frameworks/agno/README.md b/examples/agent-frameworks/agno/README.md new file mode 100644 index 000000000..ebf0507d4 --- /dev/null +++ b/examples/agent-frameworks/agno/README.md @@ -0,0 +1,116 @@ +# Agno × OpenUI + +This example demonstrates the complementary boundary: + +```text +AgentOS: agents · teams · tools · memory · knowledge · sessions · auth · execution + │ + AG-UI + │ +OpenUI: component contract · streaming parser · renderer · interactions · chat UI +``` + +The browser uses `@openuidev/agno` for both channels expected by +`AgentInterface`: + +- `createAgnoLLM()` streams an AgentOS AG-UI run and marks it as an OpenUI client. +- `agnoStorage()` stores the sidebar and message history in AgentOS sessions. +- `agnoAGUIAdapter()` incrementally unwraps fenced assistant OpenUI Lang while + AgentOS retains the same payload as readable Markdown source. +- `agnoOpenUIPromptRenderer` renders the true paused `prompt_openui` HITL call. + +## Run without a model key + +The Vite development server includes a deterministic AgentOS-compatible +harness. It exercises session CRUD, Agno's empty tool-parent envelope, a backend +tool result, multi-delta assistant-text streaming, follow-ups, history reload, +and a true paused/resumed `prompt_openui` form. + +```bash +pnpm dev +``` + +Open `http://127.0.0.1:4173` and try both starters. + +## Run with AgentOS + +Configure the required model credential outside the repository, then: + +```bash +python3 -m venv .venv +source .venv/bin/activate +python -m pip install -r requirements.txt +pnpm generate:prompt +python server.py +``` + +In another terminal, point the Vite proxy at AgentOS: + +```bash +AGNO_API_URL=http://127.0.0.1:7777 pnpm dev +``` + +The React application does not change. + +The **Open in AgentOS** action defaults to the hosted AgentOS session page. To +point it at another AgentOS web interface, provide an absolute URL template with +the `{session_id}` placeholder: + +```bash +VITE_AGENT_OS_SESSION_URL_TEMPLATE='https://os.agno.com/sessions/{session_id}' pnpm dev +``` + +This configures the operational web-interface link, not the AgentOS backend API. +The example adds the current session-list query parameters after replacing the +placeholder. + +The proxy is development plumbing only: the browser calls same-origin `/agui` +and `/sessions`, while Vite forwards those paths to port 7777 and avoids local +CORS configuration. A production app can use its normal reverse proxy or pass +an already same-origin AgentOS endpoint. + +The Python server is deliberately ordinary Agno code: it owns the model, tool, +database, history, and AG-UI interface. The component library and all rendering +remain in the OpenUI frontend. + +The same agent remains usable from native AgentOS chat. OpenUI requests carry a +transient `openui_client` context dependency and receive the generated component +prompt. Requests without that marker are instructed to answer in normal +text/Markdown and not call the UI tool. + +Complete visual answers are one fenced assistant-text payload. AgentOS stores +and shows the exact OpenUI Lang inside a Markdown code block. AG-UI streams that +text as `TEXT_MESSAGE_CONTENT` events; `@openuidev/agno` removes only the fence +and OpenUI renders the inner language as it arrives. The same normalization is +applied when the session is reloaded. + +`prompt_openui` is used only when a form or choice must pause execution. The +first request persists the pending run, the OpenUI form submission is sent back +as a tool result, and AgentOS resumes the same run and `session_id`. Current +AgentOS sends the completed prompt tool arguments as one event, so the form +itself appears after the tool call closes; the resumed assistant answer streams +normally. + +## Key files + +- `server.py` configures the Agno agent, tools, session database, and AG-UI + interface. +- `src/App.tsx` connects Agent Interface to AgentOS streaming and storage. +- `src/library.ts` defines the OpenUI component library used by the model. +- `src/mock-agentos.ts` provides the credential-free local verification + harness. + +## Extend the example + +Add backend capabilities as ordinary Agno tools in `server.py`. Add or replace +frontend components in `src/library.ts`, then regenerate the prompt before +running the real AgentOS server. Production applications can also replace the +development proxy and hosted session URL template with their own endpoints. + +## Verify + +From this directory, run the credential-free verification contract: + +```bash +pnpm verify +``` diff --git a/examples/agent-frameworks/agno/index.html b/examples/agent-frameworks/agno/index.html new file mode 100644 index 000000000..a3d028dd5 --- /dev/null +++ b/examples/agent-frameworks/agno/index.html @@ -0,0 +1,13 @@ + + + + + + + Agno × OpenUI + + +
+ + + diff --git a/examples/agent-frameworks/agno/package.json b/examples/agent-frameworks/agno/package.json new file mode 100644 index 000000000..b9cd4d468 --- /dev/null +++ b/examples/agent-frameworks/agno/package.json @@ -0,0 +1,33 @@ +{ + "name": "@openuidev/example-agno", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "generate:prompt": "pnpm --filter @openuidev/cli build && pnpm exec openui generate src/library.ts --prompt-options promptOptions --out src/generated/system-prompt.txt", + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "verify": "pnpm typecheck && pnpm build" + }, + "dependencies": { + "@openuidev/agno": "workspace:*", + "@openuidev/react-headless": "workspace:*", + "@openuidev/react-lang": "workspace:*", + "@openuidev/react-ui": "workspace:*", + "lucide-react": "^0.562.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zod": "^4.0.0", + "zustand": "catalog:" + }, + "devDependencies": { + "@openuidev/cli": "workspace:*", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "typescript": "catalog:", + "vite": "^6.0.0" + } +} diff --git a/examples/agent-frameworks/agno/requirements.txt b/examples/agent-frameworks/agno/requirements.txt new file mode 100644 index 000000000..e8874d3e0 --- /dev/null +++ b/examples/agent-frameworks/agno/requirements.txt @@ -0,0 +1 @@ +agno[agui,openai,os,sqlite]>=2.9.0 diff --git a/examples/agent-frameworks/agno/server.py b/examples/agent-frameworks/agno/server.py new file mode 100644 index 000000000..82e55a99a --- /dev/null +++ b/examples/agent-frameworks/agno/server.py @@ -0,0 +1,91 @@ +"""Serve an Agno Agent through AgentOS while OpenUI owns the browser UI.""" + +from os import getenv +from pathlib import Path + +from agno.agent import Agent +from agno.db.sqlite import SqliteDb +from agno.models.openai import OpenAIResponses +from agno.os import AgentOS +from agno.os.interfaces.agui import AGUI +from agno.tools import tool + +EXAMPLE_ROOT = Path(__file__).resolve().parent +OPENUI_PROMPT_PATH = EXAMPLE_ROOT / "src" / "generated" / "system-prompt.txt" + +if not OPENUI_PROMPT_PATH.is_file(): + raise RuntimeError( + "OpenUI system prompt is missing. Run `pnpm generate:prompt` in " + "examples/agent-frameworks/agno." + ) + + +@tool +def get_quarterly_revenue() -> dict: + """Return quarterly revenue in thousands of US dollars.""" + return { + "currency": "USD", + "unit": "thousands", + "quarters": [ + {"quarter": "Q1", "revenue": 120}, + {"quarter": "Q2", "revenue": 180}, + {"quarter": "Q3", "revenue": 150}, + {"quarter": "Q4", "revenue": 240}, + ], + } + + +@tool(external_execution=True, external_execution_silent=True) +def prompt_openui(ui: str, fallback_markdown: str) -> str: + """Render an OpenUI form or choice and wait for the user to submit it.""" + return fallback_markdown + + +def agent_instructions(run_context=None) -> list[str]: + """Stream rich OpenUI to the AG-UI client and Markdown elsewhere.""" + dependencies = getattr(run_context, "dependencies", None) or {} + if dependencies.get("openui_client") is True: + return [ + "Use get_quarterly_revenue for stored revenue questions.", + OPENUI_PROMPT_PATH.read_text(encoding="utf-8"), + ( + "For every complete visual answer, stream the OpenUI Lang as the assistant text " + "inside exactly one Markdown code fence labeled openui. The first bytes must be " + "```openui followed by a newline, and the final bytes must be a newline followed " + "by ```. Put root first inside the fence. Do not add prose before or after it. " + "This wrapper rule intentionally overrides any earlier instruction that forbids " + "Markdown fences. Use prompt_openui only when the user " + "must submit a form or choice before the run can continue. For prompt_openui, set " + "ui to raw OpenUI Lang without fences and fallback_markdown to a concise Markdown " + "description for AgentOS. After the prompt resumes, return the next complete visual " + "answer as fenced assistant text." + ), + ] + + return [ + "Use get_quarterly_revenue for stored revenue questions.", + "Respond in normal text or Markdown. Do not call prompt_openui.", + ] + + +agent = Agent( + id="openui-assistant", + name="Agno × OpenUI Assistant", + model=OpenAIResponses(id=getenv("OPENAI_MODEL", "gpt-5.5")), + db=SqliteDb(id="agno-openui", db_file="tmp/agno_openui.db"), + tools=[get_quarterly_revenue, prompt_openui], + instructions=agent_instructions, + add_history_to_context=True, + num_history_runs=10, +) + +agent_os = AgentOS( + id="agno-openui-os", + description="AgentOS owns the agent runtime; OpenUI owns the user interface.", + agents=[agent], + interfaces=[AGUI(agent=agent)], +) +app = agent_os.get_app() + +if __name__ == "__main__": + agent_os.serve(app=app) diff --git a/examples/agent-frameworks/agno/src/App.tsx b/examples/agent-frameworks/agno/src/App.tsx new file mode 100644 index 000000000..b74bdcf96 --- /dev/null +++ b/examples/agent-frameworks/agno/src/App.tsx @@ -0,0 +1,85 @@ +import { agnoOpenUIPromptRenderer, agnoStorage, createAgnoLLM } from "@openuidev/agno"; +import { AgentInterface } from "@openuidev/react-ui"; +import { ExternalLinkIcon } from "lucide-react"; +import { library } from "./library"; + +declare const __AGNO_BACKEND_MODE__: "real" | "mock"; + +const isRealAgentOS = __AGNO_BACKEND_MODE__ === "real"; +const DEMO_USER_ID = isRealAgentOS ? "openui-live-demo" : "openui-demo-user"; +const AGENT_ID = "openui-assistant"; +const DEFAULT_AGENT_OS_SESSION_URL_TEMPLATE = "https://os.agno.com/sessions/{session_id}"; +const AGENT_OS_SESSION_URL_TEMPLATE = + import.meta.env.VITE_AGENT_OS_SESSION_URL_TEMPLATE ?? DEFAULT_AGENT_OS_SESSION_URL_TEMPLATE; + +const llm = createAgnoLLM({ + url: "/agui", + forwardedProps: { user_id: DEMO_USER_ID }, + context: [{ description: "openui_client", value: "true" }], +}); + +const storage = agnoStorage({ + baseUrl: "", + entityType: "agent", + entityId: AGENT_ID, + userId: DEMO_USER_ID, +}); + +const starters = [ + { + displayText: "Use an Agno tool", + prompt: "Use the stored quarterly revenue and show it as a chart with two useful follow-ups.", + }, + { + displayText: "Collect structured input", + prompt: "Create a validated project estimate form with project name, team size, and notes.", + }, +]; + +const agentOSSessionUrl = (sessionId: string) => { + const url = new URL( + AGENT_OS_SESSION_URL_TEMPLATE.replace("{session_id}", encodeURIComponent(sessionId)), + ); + url.searchParams.set("sort_by", "updated_at_desc"); + url.searchParams.set("type", "all"); + url.searchParams.set("page", "1"); + url.searchParams.set("limit", "25"); + return url.toString(); +}; + +export default function App() { + return ( + + !isRealAgentOS || isRunning + ? [] + : [ + { + id: "open-in-agentos", + label: "Open in AgentOS", + icon: , + href: agentOSSessionUrl(id), + target: "_blank", + rel: "noopener noreferrer", + }, + ] + } + starterVariant="short" + starters={starters} + > + + + ); +} diff --git a/examples/agent-frameworks/agno/src/library.ts b/examples/agent-frameworks/agno/src/library.ts new file mode 100644 index 000000000..8780b3465 --- /dev/null +++ b/examples/agent-frameworks/agno/src/library.ts @@ -0,0 +1,12 @@ +import { openuiChatLibrary, openuiChatPromptOptions } from "@openuidev/react-ui/genui-lib"; + +export const library = openuiChatLibrary; + +export const promptOptions = { + ...openuiChatPromptOptions, + additionalRules: [ + ...(openuiChatPromptOptions.additionalRules ?? []), + "Use FollowUpBlock at the end of the Card when two useful next actions would help the user.", + "For a submit form, mark requested fields required and use one primary Button whose Action contains @ToAssistant.", + ], +}; diff --git a/examples/agent-frameworks/agno/src/main.tsx b/examples/agent-frameworks/agno/src/main.tsx new file mode 100644 index 000000000..0b184b6e7 --- /dev/null +++ b/examples/agent-frameworks/agno/src/main.tsx @@ -0,0 +1,11 @@ +import "@openuidev/react-ui/layered/styles/index.css"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/examples/agent-frameworks/agno/src/mock-agentos.ts b/examples/agent-frameworks/agno/src/mock-agentos.ts new file mode 100644 index 000000000..71399a511 --- /dev/null +++ b/examples/agent-frameworks/agno/src/mock-agentos.ts @@ -0,0 +1,334 @@ +const chartResponse = `root = Card([title, chart, followups]) +title = TextContent("Quarterly revenue from an Agno tool", "large-heavy") +chart = BarChart(labels, [revenue], "grouped") +labels = ["Q1", "Q2", "Q3", "Q4"] +revenue = Series("Revenue ($K)", [120, 180, 150, 240]) +followups = FollowUpBlock([fu1, fu2]) +fu1 = FollowUpItem("Compare the first and second half") +fu2 = FollowUpItem("Show quarter-over-quarter growth")`; + +const formResponse = `root = Card([header, form]) +header = CardHeader("Project Estimate", "AgentOS will receive the submitted values as the next turn") +form = Form("project_estimate", buttons, [nameField, sizeField, notesField]) +nameField = FormControl("Project Name", Input("project_name", "Enter project name", "text", { required: true })) +sizeField = FormControl("Team Size", Input("team_size", "Enter team size", "number", { required: true, numeric: true, min: 1 })) +notesField = FormControl("Notes", TextArea("notes", "Add notes", 4, { required: true })) +buttons = Buttons([submit]) +submit = Button("Submit to AgentOS", Action([@ToAssistant("Submit project estimate form")]), "primary")`; + +const formFallback = `## Project estimate + +Open this session in the OpenUI client to submit the project name, team size, and notes form.`; + +function submissionResponse(toolResult: string): string { + let projectName = "Project"; + let teamSize = "—"; + let notes = "—"; + + try { + const payload = JSON.parse(toolResult) as { formState?: Record }; + const state = payload.formState; + const form = (state as Record | undefined)?.["project_estimate"] as + Record | undefined; + projectName = String(form?.["project_name"]?.value ?? projectName); + teamSize = String(form?.["team_size"]?.value ?? teamSize); + notes = String(form?.["notes"]?.value ?? notes); + } catch { + // The acknowledgement still renders when a custom client omits OpenUI context. + } + + return `root = Card([header, project, team, note, status]) +header = CardHeader("Project estimate received", "The structured values completed the round trip through the AgentOS boundary") +project = TextContent(${JSON.stringify(`Project: ${projectName}`)}) +team = TextContent(${JSON.stringify(`Team size: ${teamSize}`)}) +note = TextContent(${JSON.stringify(`Notes: ${notes}`)}) +status = Callout("success", "Handled by AgentOS", "OpenUI collected and rendered the data; AgentOS now owns the next action.")`; +} + +function fenceOpenUI(openui: string): string { + return `\`\`\`openui\n${openui}\n\`\`\``; +} + +function textEvents(messageId: string, content: string): Array> { + const chunks = content.match(/[\s\S]{1,24}/g) ?? []; + return [ + { type: "TEXT_MESSAGE_START", messageId, role: "assistant" }, + ...chunks.map((delta) => ({ type: "TEXT_MESSAGE_CONTENT", messageId, delta })), + { type: "TEXT_MESSAGE_END", messageId }, + ]; +} + +interface MockSession { + session_id: string; + session_name: string; + created_at: string; + updated_at: string; + chat_history: Array>; +} + +const sessions = new Map(); +const pendingPrompts = new Map(); + +function readBody(request: import("node:http").IncomingMessage): Promise> { + return new Promise((resolve, reject) => { + let body = ""; + request.setEncoding("utf8"); + request.on("data", (chunk) => { + body += chunk; + }); + request.on("end", () => { + try { + resolve(body ? JSON.parse(body) : {}); + } catch (error) { + reject(error); + } + }); + request.on("error", reject); + }); +} + +function sendJson(response: import("node:http").ServerResponse, value: unknown, status = 200) { + response.statusCode = status; + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify(value)); +} + +function sessionFromBody(body: Record): MockSession { + const now = new Date().toISOString(); + return { + session_id: crypto.randomUUID(), + session_name: + typeof body["session_name"] === "string" ? body["session_name"] : "New conversation", + created_at: now, + updated_at: now, + chat_history: [], + }; +} + +async function writeEvents( + response: import("node:http").ServerResponse, + events: Array>, +) { + for (const event of events) { + response.write(`data: ${JSON.stringify(event)}\n\n`); + await new Promise((resolve) => setTimeout(resolve, 55)); + } + response.end(); +} + +async function handleAgui( + request: import("node:http").IncomingMessage, + response: import("node:http").ServerResponse, +) { + const body = await readBody(request); + const threadId = typeof body["threadId"] === "string" ? body["threadId"] : crypto.randomUUID(); + const runId = typeof body["runId"] === "string" ? body["runId"] : crypto.randomUUID(); + const messages = Array.isArray(body["messages"]) ? body["messages"] : []; + const lastMessage = messages.at(-1) as Record | undefined; + const lastUserMessage = [...messages] + .reverse() + .find( + (message): message is Record => + typeof message === "object" && message !== null && message["role"] === "user", + ); + const prompt = typeof lastUserMessage?.["content"] === "string" ? lastUserMessage["content"] : ""; + const pendingPrompt = pendingPrompts.get(threadId); + const isSubmission = + lastMessage?.["role"] === "tool" && + typeof lastMessage["toolCallId"] === "string" && + lastMessage["toolCallId"] === pendingPrompt?.toolCallId; + const submission = + isSubmission && typeof lastMessage?.["content"] === "string" ? lastMessage["content"] : ""; + const isForm = !isSubmission && /form|estimate|structured input/i.test(prompt); + const isChart = !isSubmission && !isForm; + const openui = isSubmission + ? submissionResponse(submission) + : isForm + ? formResponse + : chartResponse; + const now = new Date().toISOString(); + const session = + sessions.get(threadId) ?? + ({ + session_id: threadId, + session_name: prompt.slice(0, 80) || "New conversation", + created_at: now, + updated_at: now, + chat_history: [], + } satisfies MockSession); + + if (isSubmission && pendingPrompt) { + session.chat_history.push({ + role: "tool", + tool_call_id: pendingPrompt.toolCallId, + content: submission, + }); + pendingPrompts.delete(threadId); + } else if (prompt) { + session.chat_history.push({ role: "user", content: prompt }); + } + session.updated_at = now; + sessions.set(threadId, session); + + response.statusCode = 200; + response.setHeader("Content-Type", "text/event-stream"); + response.setHeader("Cache-Control", "no-cache, no-transform"); + + const events: Array> = [ + { type: "RUN_STARTED", threadId, runId }, + { type: "STATE_SNAPSHOT", snapshot: { source: "AgentOS" } }, + ]; + + const parentMessageId = crypto.randomUUID(); + events.push( + { type: "TEXT_MESSAGE_START", messageId: parentMessageId, role: "assistant" }, + { type: "TEXT_MESSAGE_END", messageId: parentMessageId }, + ); + + if (isForm) { + const toolCallId = crypto.randomUUID(); + pendingPrompts.set(threadId, { toolCallId }); + events.push( + { + type: "TOOL_CALL_START", + toolCallId, + toolCallName: "prompt_openui", + parentMessageId, + }, + { + type: "TOOL_CALL_ARGS", + toolCallId, + delta: JSON.stringify({ ui: openui, fallback_markdown: formFallback }), + }, + { type: "TOOL_CALL_END", toolCallId }, + ); + session.chat_history.push({ + role: "assistant", + content: "", + tool_calls: [ + { + id: toolCallId, + name: "prompt_openui", + args: { ui: openui, fallback_markdown: formFallback }, + }, + ], + }); + } else { + if (isChart) { + const toolCallId = crypto.randomUUID(); + const revenue = { quarters: [120, 180, 150, 240], unit: "USD thousands" }; + events.push( + { + type: "TOOL_CALL_START", + toolCallId, + toolCallName: "get_quarterly_revenue", + parentMessageId, + }, + { type: "TOOL_CALL_ARGS", toolCallId, delta: "{}" }, + { type: "TOOL_CALL_END", toolCallId }, + { + type: "TOOL_CALL_RESULT", + toolCallId, + messageId: toolCallId, + role: "tool", + content: JSON.stringify(revenue), + }, + ); + session.chat_history.push({ + role: "assistant", + content: "", + tool_calls: [{ id: toolCallId, name: "get_quarterly_revenue", args: {} }], + }); + session.chat_history.push({ role: "tool", tool_call_id: toolCallId, content: revenue }); + } + + const answerMessageId = crypto.randomUUID(); + const fencedOpenUI = fenceOpenUI(openui); + events.push(...textEvents(answerMessageId, fencedOpenUI)); + session.chat_history.push({ role: "assistant", content: fencedOpenUI }); + } + + events.push( + { type: "STATE_SNAPSHOT", snapshot: { source: "AgentOS", completed: true } }, + { type: "RUN_FINISHED", threadId, runId }, + ); + + await writeEvents(response, events); +} + +export function mockAgentOSPlugin() { + return { + name: "mock-agentos", + configureServer(server: { middlewares: { use: (handler: Function) => void } }) { + server.middlewares.use( + async ( + request: import("node:http").IncomingMessage, + response: import("node:http").ServerResponse, + next: () => void, + ) => { + const url = new URL(request.url ?? "/", "http://localhost"); + + if (url.pathname === "/agui" && request.method === "POST") { + await handleAgui(request, response); + return; + } + + if (url.pathname === "/status") { + sendJson(response, { status: "ok", interface: "AG-UI", source: "mock AgentOS" }); + return; + } + + if (url.pathname === "/sessions" && request.method === "GET") { + sendJson(response, { + data: [...sessions.values()].sort((a, b) => b.updated_at.localeCompare(a.updated_at)), + meta: { page: 1, limit: 20, total_pages: 1, total_count: sessions.size }, + }); + return; + } + + if (url.pathname === "/sessions" && request.method === "POST") { + const session = sessionFromBody(await readBody(request)); + sessions.set(session.session_id, session); + sendJson(response, session, 201); + return; + } + + const renameMatch = url.pathname.match(/^\/sessions\/([^/]+)\/rename$/); + if (renameMatch && request.method === "POST") { + const session = sessions.get(decodeURIComponent(renameMatch[1]!)); + if (!session) { + sendJson(response, { detail: "Session not found" }, 404); + return; + } + const body = await readBody(request); + if (typeof body["session_name"] === "string") + session.session_name = body["session_name"]; + session.updated_at = new Date().toISOString(); + sendJson(response, session); + return; + } + + const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/); + if (sessionMatch) { + const sessionId = decodeURIComponent(sessionMatch[1]!); + const session = sessions.get(sessionId); + if (!session) { + sendJson(response, { detail: "Session not found" }, 404); + return; + } + if (request.method === "DELETE") { + sessions.delete(sessionId); + response.statusCode = 204; + response.end(); + return; + } + sendJson(response, session); + return; + } + + next(); + }, + ); + }, + }; +} diff --git a/examples/agent-frameworks/agno/src/styles.css b/examples/agent-frameworks/agno/src/styles.css new file mode 100644 index 000000000..44498807a --- /dev/null +++ b/examples/agent-frameworks/agno/src/styles.css @@ -0,0 +1,33 @@ +:root { + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#root { + width: 100%; + height: 100%; + margin: 0; +} + +body { + min-width: 320px; + overflow: hidden; +} + +#root > * { + height: 100%; +} diff --git a/examples/agent-frameworks/agno/tsconfig.json b/examples/agent-frameworks/agno/tsconfig.json new file mode 100644 index 000000000..763312313 --- /dev/null +++ b/examples/agent-frameworks/agno/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["vite/client", "node"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/examples/agent-frameworks/agno/vite.config.ts b/examples/agent-frameworks/agno/vite.config.ts new file mode 100644 index 000000000..074a58450 --- /dev/null +++ b/examples/agent-frameworks/agno/vite.config.ts @@ -0,0 +1,66 @@ +import { fileURLToPath } from "node:url"; +import { mockAgentOSPlugin } from "./src/mock-agentos"; + +const fromRoot = (path: string) => fileURLToPath(new URL(`../../../${path}`, import.meta.url)); +const agnoTarget = process.env["AGNO_API_URL"]; + +export default { + define: { + __AGNO_BACKEND_MODE__: JSON.stringify(agnoTarget ? "real" : "mock"), + "process.env.NODE_ENV": JSON.stringify(process.env["NODE_ENV"] ?? "development"), + }, + resolve: { + alias: [ + { + find: /^@openuidev\/agno$/, + replacement: fromRoot("packages/agno/src/index.ts"), + }, + { + find: /^@openuidev\/react-headless$/, + replacement: fromRoot("packages/react-headless/dist/index.mjs"), + }, + { + find: /^@openuidev\/react-ui$/, + replacement: fromRoot("packages/react-ui/dist/index.mjs"), + }, + { + find: /^@openuidev\/react-ui\/genui-lib$/, + replacement: fromRoot("packages/react-ui/dist/genui-lib/index.mjs"), + }, + { + find: /^@openuidev\/react-ui\/layered\/styles\/index.css$/, + replacement: fromRoot("packages/react-ui/dist/layered/styles/index.css"), + }, + { + find: /^react$/, + replacement: fromRoot("packages/react-ui/node_modules/react/index.js"), + }, + { + find: /^react\/jsx-runtime$/, + replacement: fromRoot("packages/react-ui/node_modules/react/jsx-runtime.js"), + }, + { + find: /^react\/jsx-dev-runtime$/, + replacement: fromRoot("packages/react-ui/node_modules/react/jsx-dev-runtime.js"), + }, + { + find: /^react-dom\/client$/, + replacement: fromRoot("packages/react-ui/node_modules/react-dom/client.js"), + }, + ], + }, + plugins: agnoTarget ? [] : [mockAgentOSPlugin()], + server: { + host: "127.0.0.1", + port: 4173, + ...(agnoTarget + ? { + proxy: { + "/agui": agnoTarget, + "/sessions": agnoTarget, + "/status": agnoTarget, + }, + } + : {}), + }, +}; diff --git a/packages/agno/README.md b/packages/agno/README.md index 20e3dd286..f6a3cfcb6 100644 --- a/packages/agno/README.md +++ b/packages/agno/README.md @@ -140,8 +140,8 @@ instruction function. The example marks AG-UI requests with the transient prompt, fenced output rule, and HITL tool rule; native AgentOS runs receive ordinary Markdown rules and do not call the UI tool. -The local `examples/agno-chat` workspace contains a runnable client, a real -AgentOS server, and a deterministic no-key development harness. +The local `examples/agent-frameworks/agno` workspace contains a runnable client, +a real AgentOS server, and a deterministic no-key development harness. ## Current scope diff --git a/packages/react-ui/src/components/AgentInterface/AgentInterface.tsx b/packages/react-ui/src/components/AgentInterface/AgentInterface.tsx index 07101e968..bb6988ee5 100644 --- a/packages/react-ui/src/components/AgentInterface/AgentInterface.tsx +++ b/packages/react-ui/src/components/AgentInterface/AgentInterface.tsx @@ -53,7 +53,7 @@ import { SidebarContainer, SidebarContent, SidebarHeader, SidebarSeparator } fro import { SidebarItem } from "./SidebarItem"; import { SidebarSlot } from "./SidebarSlot"; import { MessageLoading, Messages, ScrollArea, ThreadContainer, ThreadHeader } from "./Thread"; -import { ThreadList } from "./ThreadList"; +import { ThreadList, type GetThreadMenuActions } from "./ThreadList"; import { WelcomeGlow } from "./WelcomeGlow"; import { WelcomeScreen } from "./WelcomeScreen"; import { Workspace } from "./Workspace"; @@ -96,6 +96,8 @@ export interface AgentInterfaceProps extends Omit scrollVariant?: ScrollVariant; /** When false, the thread does not auto-scroll on load / conversation switch (auto-scroll only while generating). Default true. */ scrollOnLoad?: boolean; + /** Additional actions rendered in each thread's overflow menu. */ + getThreadMenuActions?: GetThreadMenuActions; children?: ReactNode; } @@ -198,6 +200,7 @@ export const AgentInterface: AgentInterfaceComponent = ((props: AgentInterfacePr onNavigate, scrollVariant, scrollOnLoad, + getThreadMenuActions, children, } = props; @@ -254,6 +257,7 @@ export const AgentInterface: AgentInterfaceComponent = ((props: AgentInterfacePr resolvedUserMessage={resolvedUserMessage} scrollVariant={scrollVariant} scrollOnLoad={scrollOnLoad} + getThreadMenuActions={getThreadMenuActions} /> @@ -271,6 +275,7 @@ interface AgentInterfaceBodyProps { resolvedUserMessage: UserMessageComponent | undefined; scrollVariant?: ScrollVariant; scrollOnLoad?: boolean; + getThreadMenuActions?: GetThreadMenuActions; } const ArtifactViewMobileHeader = ({ @@ -376,6 +381,7 @@ const AgentInterfaceBody = ({ resolvedUserMessage, scrollVariant, scrollOnLoad, + getThreadMenuActions, }: AgentInterfaceBodyProps) => { const { path } = useNav(); @@ -402,7 +408,7 @@ const AgentInterfaceBody = ({ - + )} diff --git a/packages/react-ui/src/components/AgentInterface/README.md b/packages/react-ui/src/components/AgentInterface/README.md index 2bad376c9..0bff185d6 100644 --- a/packages/react-ui/src/components/AgentInterface/README.md +++ b/packages/react-ui/src/components/AgentInterface/README.md @@ -178,6 +178,7 @@ Defined as `AgentInterfaceProps` in `AgentInterface.tsx`. It `extends Omit void` | — | Presence selects controlled mode | @@ -199,16 +200,17 @@ and avatar). Resolution order, per message kind: ### Named exports (from `index.ts`) -| Export | Kind | Purpose | -| ------------------------------------------------- | ---------------- | ------------------------------------------ | -| `AgentInterface` | component | The compound root | -| `AgentInterfaceProps`, `AgentInterfaceComponents` | types | Root props / override map | -| `SidebarItem`, `SidebarItemProps` | component + type | Standalone nav row | -| `ArtifactNav`, `ArtifactNavProps` | component + type | Artifact-category nav | -| `useNav`, `NavContextValue` | hook + type | Read/drive navigation from inside the tree | -| `RouteProps` | type | `` props | -| `WorkspaceProps` | type | `` props | -| `artifactListPath`, `artifactViewPath` | functions | Build reserved `artifacts/…` paths | +| Export | Kind | Purpose | +| --------------------------------------------------------------- | ---------------- | ------------------------------------------ | +| `AgentInterface` | component | The compound root | +| `AgentInterfaceProps`, `AgentInterfaceComponents` | types | Root props / override map | +| `SidebarItem`, `SidebarItemProps` | component + type | Standalone nav row | +| `ArtifactNav`, `ArtifactNavProps` | component + type | Artifact-category nav | +| `useNav`, `NavContextValue` | hook + type | Read/drive navigation from inside the tree | +| `RouteProps` | type | `` props | +| `WorkspaceProps` | type | `` props | +| `GetThreadMenuActions`, `ThreadMenuAction`, `ThreadMenuContext` | types | Custom thread overflow-menu actions | +| `artifactListPath`, `artifactViewPath` | functions | Build reserved `artifacts/…` paths | --- diff --git a/packages/react-ui/src/components/AgentInterface/ThreadList.tsx b/packages/react-ui/src/components/AgentInterface/ThreadList.tsx index 22c0bfef3..ab4647c88 100644 --- a/packages/react-ui/src/components/AgentInterface/ThreadList.tsx +++ b/packages/react-ui/src/components/AgentInterface/ThreadList.tsx @@ -1,8 +1,8 @@ -import { useThreadList } from "@openuidev/react-headless"; +import { useThread, useThreadList } from "@openuidev/react-headless"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import clsx from "clsx"; import { EllipsisIcon, Trash2Icon } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useLayoutContext } from "../../context/LayoutContext"; import { Button } from "../Button"; import { IconButton } from "../IconButton"; @@ -12,6 +12,46 @@ import { useAgentInterfaceStore } from "./_shared/store"; const THREAD_SKELETON_WIDTHS = ["78%", "62%", "86%", "70%"]; +export interface ThreadMenuContext { + id: string; + title: string; + isSelected: boolean; + /** True only when this thread is the selected thread and its response is still running. */ + isRunning: boolean; +} + +interface ThreadMenuActionBase { + id: string; + label: ReactNode; + icon?: ReactNode; +} + +export type ThreadMenuAction = ThreadMenuActionBase & + ( + | { + href: string; + target?: string; + rel?: string; + onSelect?: never; + } + | { + href?: never; + target?: never; + rel?: never; + onSelect: () => void; + } + ); + +export type GetThreadMenuActions = ( + thread: ThreadMenuContext, +) => readonly ThreadMenuAction[] | undefined; + +export interface ThreadListProps { + className?: string; + /** Additional actions rendered before Delete in every thread's overflow menu. */ + getThreadMenuActions?: GetThreadMenuActions; +} + const ThreadListSkeleton = () => (
{ const selectThread = useThreadList((s) => s.selectThread); const deleteThread = useThreadList((s) => s.deleteThread); const selectedThreadId = useThreadList((s) => s.selectedThreadId); + const selectedThreadIsRunning = useThread((s) => s.isRunning); const { isSidebarOpen, setIsSidebarOpen } = useAgentInterfaceStore((state) => ({ isSidebarOpen: state.isSidebarOpen, setIsSidebarOpen: state.setIsSidebarOpen, @@ -53,13 +96,21 @@ export const ThreadButton = ({ const { layout } = useLayoutContext(); const nav = useOptionalNav(); const [isActionsOpen, setIsActionsOpen] = useState(false); + const isSelected = selectedThreadId === id; + const customActions = + getThreadMenuActions?.({ + id, + title, + isSelected, + isRunning: isSelected && selectedThreadIsRunning, + }) ?? []; return (
+ {customActions.map((action) => + action.href ? ( + + + {action.icon} + {action.label} + + + ) : ( + + + + ), + )} + {customActions.length > 0 && ( + + )} { @@ -120,7 +200,7 @@ export const ThreadButton = ({ ); }; -export const ThreadList = ({ className }: { className?: string }) => { +export const ThreadList = ({ className, getThreadMenuActions }: ThreadListProps) => { const threads = useThreadList((s) => s.threads); const isLoadingThreads = useThreadList((s) => s.isLoadingThreads); const loadThreads = useThreadList((s) => s.loadThreads); @@ -187,7 +267,12 @@ export const ThreadList = ({ className }: { className?: string }) => {
{threads.length > 0 &&
Threads
} {threads.map((thread) => ( - + ))}
)} diff --git a/packages/react-ui/src/components/AgentInterface/index.ts b/packages/react-ui/src/components/AgentInterface/index.ts index 73eab332a..82908beed 100644 --- a/packages/react-ui/src/components/AgentInterface/index.ts +++ b/packages/react-ui/src/components/AgentInterface/index.ts @@ -9,5 +9,11 @@ export type { ArtifactNavProps } from "./ArtifactNav"; export type { RouteProps } from "./Route"; export { SidebarItem } from "./SidebarItem"; export type { SidebarItemProps } from "./SidebarItem"; +export type { + GetThreadMenuActions, + ThreadListProps, + ThreadMenuAction, + ThreadMenuContext, +} from "./ThreadList"; export { WelcomeGlow } from "./WelcomeGlow"; export type { WorkspaceProps } from "./Workspace"; diff --git a/packages/react-ui/src/components/AgentInterface/threadlist.scss b/packages/react-ui/src/components/AgentInterface/threadlist.scss index 6e2c259e3..9fbbabac4 100644 --- a/packages/react-ui/src/components/AgentInterface/threadlist.scss +++ b/packages/react-ui/src/components/AgentInterface/threadlist.scss @@ -235,5 +235,12 @@ $thread-list-mask-height: cssUtils.$space-xl; .openui-agent-thread-button-dropdown-menu-item { outline: none; + text-decoration: none; width: 100%; } + +.openui-agent-thread-button-dropdown-menu-separator { + height: 1px; + margin: cssUtils.$space-2xs 0; + background-color: cssUtils.$border-default; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb88a6810..c4a3270c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -261,6 +261,55 @@ importers: specifier: ^4.1.18 version: 4.3.3 + examples/agent-frameworks/agno: + dependencies: + '@openuidev/agno': + specifier: workspace:* + version: link:../../../packages/agno + '@openuidev/react-headless': + specifier: workspace:* + version: link:../../../packages/react-headless + '@openuidev/react-lang': + specifier: workspace:* + version: link:../../../packages/react-lang + '@openuidev/react-ui': + specifier: workspace:* + version: link:../../../packages/react-ui + lucide-react: + specifier: ^0.562.0 + version: 0.562.0(react@19.2.4) + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + zod: + specifier: ^4.0.0 + version: 4.4.3 + zustand: + specifier: 'catalog:' + version: 4.5.7(@types/react@19.2.17)(react@19.2.4) + devDependencies: + '@openuidev/cli': + specifier: workspace:* + version: link:../../../packages/openui-cli + '@types/node': + specifier: 'catalog:' + version: 22.20.1 + '@types/react': + specifier: 'catalog:' + version: 19.2.17 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.17) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) + examples/agent-frameworks/google-adk: dependencies: '@google/adk':