Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions examples/google-adk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,11 @@ Open [http://localhost:3000](http://localhost:3000) and try a starter such as
## How it works

- `src/agent.ts` defines the `get_weather` tool and a `createAgent()` builder
that appends OpenUI's generated system prompt to the agent's instruction.
that combines OpenUI's generated system prompt with weather-specific behavior.
- `src/app/api/chat/route.ts` runs the agent with a `Runner`, keys ADK sessions
by chat `threadId` (so multi-turn history is preserved), and streams the
assistant text as OpenAI chat-completion SSE chunks.
by chat `threadId` (so multi-turn history is preserved), normalizes OpenUI
actions and submitted form values, and streams the assistant text as OpenAI
chat-completion SSE chunks.
- `src/app/page.tsx` renders `<AgentInterface />`, sending `{ messages, threadId }`
to `/api/chat` and parsing the stream with `openAIAdapter()`.

Expand Down
43 changes: 27 additions & 16 deletions examples/google-adk/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ import { z } from "zod";
*/
export const getWeather = new FunctionTool({
name: "get_weather",
description: "Get the current weather for a given city.",
description:
"Get the current weather for one or more cities. Include every city the user asks about in one call.",
parameters: z.object({
city: z.string().describe('The city to get the weather for, e.g. "Tokyo".'),
cities: z
.array(z.string())
.min(1)
.describe('Every city to get the weather for, e.g. ["Tokyo", "London"].'),
}),
execute: ({ city }) => {
execute: ({ cities }) => {
// Hard-coded so the demo runs without any external weather API.
const table: Record<string, { condition: string; temperature_celsius: number }> = {
tokyo: { condition: "Sunny", temperature_celsius: 24 },
Expand All @@ -21,30 +25,37 @@ export const getWeather = new FunctionTool({
paris: { condition: "Clear", temperature_celsius: 19 },
sydney: { condition: "Sunny", temperature_celsius: 27 },
};
const key = city.toLowerCase();
const data = table[key];
if (!data) {
return { city, error: "No weather data for this city." };
}
return { city, ...data };

return {
weather: cities.map((city) => {
const data = table[city.toLowerCase()];
return data ? { city, ...data } : { city, error: "No weather data for this city." };
}),
};
},
});

/**
* Builds the weather assistant. The generated OpenUI system prompt is appended
* to the base instruction so the model replies with OpenUI Lang that the
* frontend renders as generative UI.
* Builds the weather assistant. Domain rules follow the generated OpenUI
* system prompt so the model replies with OpenUI Lang while prioritizing the
* user's actual weather request.
*/
export function createAgent(genUISystemPrompt: string) {
return new Agent({
name: "weather_assistant",
model: process.env.GEMINI_MODEL || "gemini-flash-latest",
description: "A helpful assistant that can report the weather.",
instruction:
"You are a friendly assistant. When the user asks about the weather, " +
"use the get_weather tool before answering. Help the user with any other " +
"requests too.\n\n" +
genUISystemPrompt,
genUISystemPrompt +
"\n\n## Weather assistant behavior\n" +
"These application rules override any general UI guidance above. Answer the user's latest " +
"request directly and never show an onboarding screen. Never invent plausible weather data. " +
"For every weather request, call get_weather exactly once with every city mentioned, then " +
"present only the returned data. Use a table when comparing multiple cities. If a weather " +
"request has no city, ask which city in a brief response. Do not generate a form for weather " +
"requests or greetings; only generate a form when the user explicitly asks you to build one. " +
"For a greeting, return one short TextContent saying you can check weather. Do not add follow-up " +
"suggestions unless the user asks for them. Help with other requests too.",
tools: [getWeather],
});
}
132 changes: 120 additions & 12 deletions examples/google-adk/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
import { createAgent } from "@/agent";
import { InMemorySessionService, Runner, StreamingMode } from "@google/adk";
import { readFileSync } from "fs";
import { NextRequest } from "next/server";
import { join } from "path";
import { createAgent } from "@/agent";

// @google/adk relies on Node APIs, so pin this route to the Node.js runtime.
export const runtime = "nodejs";

const APP_NAME = "openui-adk-chat";
const USER_ID = "demo-user";

const systemPrompt = readFileSync(
join(process.cwd(), "src/generated/system-prompt.txt"),
"utf-8",
);
const systemPrompt = readFileSync(join(process.cwd(), "src/generated/system-prompt.txt"), "utf-8");

// A single Runner + in-memory session store, shared across requests. Sessions
// are keyed by the chat threadId so multi-turn history is preserved for the
Expand All @@ -32,12 +29,91 @@ interface AGUIMessage {
content?: string | Array<{ type?: string; text?: string }>;
}

const CONTENT_MARKER = "]]>openui:content";
const CONTEXT_MARKER = "]]>openui:context";
const END_MARKER = "]]>openui:end";

interface SubmittedField {
path: string;
value: unknown;
}

function collectSubmittedFields(
value: unknown,
path: string[] = [],
fields: SubmittedField[] = [],
): SubmittedField[] {
if (Array.isArray(value)) {
for (const item of value) {
if (item !== null && typeof item === "object") {
collectSubmittedFields(item, path, fields);
}
}
return fields;
}

if (value === null || typeof value !== "object") return fields;

const record = value as Record<string, unknown>;
if (path.length > 0 && Object.prototype.hasOwnProperty.call(record, "value")) {
fields.push({ path: path.join("."), value: record.value });
return fields;
}

for (const [key, child] of Object.entries(record)) {
collectSubmittedFields(child, [...path, key], fields);
}
return fields;
}

function markerBody(value: string, markerIndex: number): string {
const lineEnd = value.indexOf("\n", markerIndex);
return lineEnd === -1 ? "" : value.slice(lineEnd + 1);
}

function normalizeOpenUIMessage(raw: string): string {
const contextIndex = raw.lastIndexOf(CONTEXT_MARKER);
const contentIndex = raw.lastIndexOf(CONTENT_MARKER);
const contentEnd = contextIndex === -1 ? raw.length : contextIndex;

let humanText = raw.slice(0, contentEnd);
if (contentIndex !== -1 && contentIndex < contentEnd) {
humanText = markerBody(raw.slice(0, contentEnd), contentIndex);
}
humanText = humanText
.split(/\r?\n/)
.filter((line) => !line.startsWith(END_MARKER))
.join("\n")
.trim();

if (contextIndex === -1) return humanText;

try {
const context = JSON.parse(markerBody(raw, contextIndex)) as unknown;
const fields = collectSubmittedFields(context);
if (fields.length === 0) return humanText;

const formValues = fields
.map(({ path, value }) => {
const formatted = typeof value === "string" ? value : JSON.stringify(value);
return `- ${path}: ${formatted}`;
})
.join("\n");

return `${humanText}\n\nSubmitted form values:\n${formValues}`.trim();
} catch {
// Malformed context should not hide the user-visible action label.
return humanText;
}
}

function messageText(message: AGUIMessage | undefined): string {
if (!message?.content) return "";
if (typeof message.content === "string") return message.content;
return message.content
.map((part) => (typeof part.text === "string" ? part.text : ""))
.join("");
const raw =
typeof message.content === "string"
? message.content
: message.content.map((part) => (typeof part.text === "string" ? part.text : "")).join("");
return normalizeOpenUIMessage(raw);
}

async function ensureSession(threadId: string): Promise<string> {
Expand Down Expand Up @@ -67,10 +143,24 @@ function stopChunk(id: string): string {
return `data: ${JSON.stringify(payload)}\n\n`;
}

function modelErrorProgram(code?: string): string {
let description = "Gemini could not complete this request. Check the server logs and try again.";
if (code === "429") {
description =
"The Gemini API quota has been reached. Wait for the quota window to reset, or use a billed API key or another model.";
} else if (code === "401" || code === "403") {
description = "Gemini rejected the API key. Check GEMINI_API_KEY and its project permissions.";
}

return [
"root = Card([error])",
`error = TextCallout("danger", "Gemini request failed", ${JSON.stringify(description)})`,
].join("\n");
}

export async function POST(req: NextRequest) {
try {
const { messages, threadId }: { messages: AGUIMessage[]; threadId: string } =
await req.json();
const { messages, threadId }: { messages: AGUIMessage[]; threadId: string } = await req.json();

const lastUser = [...(messages ?? [])].reverse().find((m) => m.role === "user");
const prompt = messageText(lastUser);
Expand All @@ -88,6 +178,7 @@ export async function POST(req: NextRequest) {
const readable = new ReadableStream({
async start(controller) {
let closed = false;
let sentText = false;
const close = () => {
if (closed) return;
closed = true;
Expand All @@ -107,10 +198,21 @@ export async function POST(req: NextRequest) {
for await (const event of runner.runAsync({
userId: USER_ID,
sessionId,
newMessage: { parts: [{ text: prompt }] },
newMessage: { role: "user", parts: [{ text: prompt }] },
runConfig: { streamingMode: StreamingMode.SSE },
abortSignal: req.signal,
})) {
if (event.errorCode) {
if (!sentText) {
controller.enqueue(
encoder.encode(contentChunk(responseId, modelErrorProgram(event.errorCode))),
);
sentText = true;
}
console.error(`ADK model error ${event.errorCode}:`, event.errorMessage);
break;
}

const parts = event.content?.parts ?? [];
const text = parts
.map((part) => (typeof part.text === "string" ? part.text : ""))
Expand All @@ -121,13 +223,19 @@ export async function POST(req: NextRequest) {
if (event.partial) {
sawPartial = true;
controller.enqueue(encoder.encode(contentChunk(responseId, text)));
sentText = true;
} else if (!sawPartial) {
controller.enqueue(encoder.encode(contentChunk(responseId, text)));
sentText = true;
}
}
} catch (error) {
if (!req.signal.aborted) {
console.error("ADK stream error:", error);
if (!sentText) {
controller.enqueue(encoder.encode(contentChunk(responseId, modelErrorProgram())));
sentText = true;
}
}
} finally {
close();
Expand Down
Loading