Skip to content
Merged
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
33 changes: 29 additions & 4 deletions packages/openui-cli/src/commands/create-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,25 @@ function shouldCopyTemplatePath(templateDir: string, src: string): boolean {
return !["node_modules", ".next", ".turbo", "dist"].includes(top);
}

function restoreDotfiles(projectDir: string) {
// Templates ship `gitignore` un-dotted: npm silently strips `.gitignore`
// files (at any depth) from published packages, so a dotted copy never
// reaches the scaffold — and freshly created apps would commit `.env`.
// Restore the real name here instead.
const plain = path.join(projectDir, "gitignore");
if (fs.existsSync(plain)) {
fs.renameSync(plain, path.join(projectDir, ".gitignore"));
}
}

function buildAppId(name: string): string {
// Stable per-scaffold identity (see writeEnv). Slugified because the name is
// free-form and APP_ID lands in .env and ?app_id= query params; the random
// suffix keeps two same-named apps in one org from colliding.
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
return `${slug}-${Math.random().toString(36).slice(2, 8)}`;
}

function rewritePackageJson(projectDir: string, name: string) {
// package.json: set the project name and de-vendor monorepo-local deps
// (workspace:* / file: / catalog:) to the published "latest". link: deps are
Expand Down Expand Up @@ -149,6 +168,7 @@ export async function runCreateApp(options: CreateAppOptions): Promise<void> {
recursive: true,
filter: (src) => shouldCopyTemplatePath(templateDir, src),
});
restoreDotfiles(targetDir);
rewritePackageJson(targetDir, name);
} catch (err) {
captureScaffoldFailed();
Expand All @@ -160,7 +180,7 @@ export async function runCreateApp(options: CreateAppOptions): Promise<void> {
ai_setup: aiSetup,
});

await writeEnv(targetDir, envResult);
await writeEnv(targetDir, envResult, template === "openui-cloud" ? buildAppId(name) : undefined);
telemetry.capture("cli_env_resolved", {
...createFunnelProps("env_written"),
template,
Expand Down Expand Up @@ -241,9 +261,14 @@ export async function runCreateApp(options: CreateAppOptions): Promise<void> {
);
}

async function writeEnv(targetDir: string, result: EnvResult): Promise<void> {
if (!result.envContent) return;
await fs.promises.writeFile(path.join(targetDir, ".env"), result.envContent);
async function writeEnv(targetDir: string, result: EnvResult, appId?: string): Promise<void> {
// APP_ID is the scaffold's stable identity: the frontend-token route sends
// it as `app_id`, so every conversation this app creates is bound to it and
// apps sharing one org API key stay isolated from each other. It must stay
// stable for the app's lifetime — regenerating it orphans existing threads.
const content = `${result.envContent ?? ""}${appId ? `APP_ID=${appId}\n` : ""}`;
if (!content) return;
await fs.promises.writeFile(path.join(targetDir, ".env"), content);
}

async function resolveChatEnv(interactive: boolean): Promise<EnvResult> {
Expand Down
12 changes: 9 additions & 3 deletions packages/openui-cli/src/templates/openui-cloud/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3300",
"dev": "next dev",
"build": "next build",
"start": "next start -p 3300",
"start": "next start",
"lint": "eslint",
"typecheck": "tsc --noEmit",
"test": "vitest run"
Expand All @@ -30,6 +30,7 @@
"lucide-react": "^0.575.0",
"mermaid": "11.15.0",
"next": "16.1.6",
"openai": "^6.22.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"recharts": "2.15.4",
Expand All @@ -49,9 +50,14 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"openai": "^6.22.0",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.1.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"sharp",
"unrs-resolver"
]
}
}
10 changes: 10 additions & 0 deletions packages/openui-cli/src/templates/openui-cloud/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Allow native build scripts so `pnpm install` never stops at (or nags about)
# approve-builds. pnpm reads this from a different place per major version, so
# the same list appears three times: package.json "pnpm" field (<=10.13),
# onlyBuiltDependencies here (10.14-10.x), allowBuilds here (>=11).
onlyBuiltDependencies:
- sharp
- unrs-resolver
allowBuilds:
sharp: true
unrs-resolver: true
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { getBillingCreditsErrorMessage } from "@/lib/billing";
import { envOr, requiredEnv } from "@/lib/env";
import { DEFAULT_MODEL, resolveRequestedModel } from "@/lib/models";
import { runFunctionToolLoop } from "@/lib/tool-loop";
import { executeGetWeather, getWeatherTool } from "@/lib/tools/get-weather";
import { artifactTool, createResponsesInstructions } from "@openuidev/thesys-server";
import OpenAI from "openai";
import type { ResponseInputItem } from "openai/resources/responses/responses";
import type {
ResponseCreateParamsNonStreaming,
ResponseInputItem,
Tool,
} from "openai/resources/responses/responses";

/**
* Generation plane: browser → THIS route → OpenUI Cloud.
Expand All @@ -13,9 +19,11 @@ import type { ResponseInputItem } from "openai/resources/responses/responses";
* stream straight to the browser, where `openAIResponsesAdapter` parses it
* (including the custom `response.artifact_call.delta` events).
*
* The artifact tool runs **server-side** inside OpenUI Cloud, so this route is a
* pure pipe: there is no client-side tool loop. Reads/edits go browser → /v1/*
* with the fct_ token (see /api/frontend-token + the storage adapter).
* Cloud's built-in tools (artifacts / web_search / image_search / MCP) run
* server-side inside OpenUI Cloud. App-owned `type: "function"` tools run HERE
* via `runFunctionToolLoop` — `get_weather` ships as the reference example.
* Reads/edits go browser → /v1/* with the fct_ token (see /api/frontend-token
* + the storage adapter).
*/
export async function POST(req: Request) {
const { threadId, input, model: requestedModel } = (await req.json()) as {
Expand All @@ -42,29 +50,43 @@ export async function POST(req: Request) {
apiKey: requiredEnv("THESYS_API_KEY"), // sent as Authorization: Bearer …
});

// App-owned function tools, executed in THIS route by runFunctionToolLoop.
// The loop runs ONLY the names declared here — Cloud-internal function_call
// items (thesys_*) pass through untouched. Add your own tools the same way.
const functionTools = {
[getWeatherTool.name]: executeGetWeather,
};

const createParams: ResponseCreateParamsNonStreaming = {
model: resolveRequestedModel(requestedModel, envOr("OPENUI_MODEL", DEFAULT_MODEL)),
conversation: threadId, // store:true persists to the conversation
input,
store: true,
tools: [
// artifact/image_search are Cloud extensions of the Responses tools
// union — cast those entries only; the rest stays type-checked.
artifactTool({ artifacts: ["slides", "report"] }) as unknown as Tool,
{
type: "web_search",
},
{ type: "image_search" } as unknown as Tool,
getWeatherTool,
// Remote MCP servers run server-side inside OpenUI Cloud — no client
// loop needed. Uncomment to let the model answer questions about any
// public GitHub repo via DeepWiki (no auth required):
// {
// type: "mcp",
// server_label: "deepwiki",
// server_url: "https://mcp.deepwiki.com/mcp",
// },
],
instructions: createResponsesInstructions(),
};

let stream: AsyncIterable<Record<string, unknown>>;
try {
const model = resolveRequestedModel(requestedModel, envOr("OPENUI_MODEL", DEFAULT_MODEL));

stream = (await client.responses.create(
{
model,
conversation: threadId, // store:true persists to the conversation
input,
stream: true,
store: true,
tools: [
artifactTool({ artifacts: ["slides", "report"] }),
{
type: "web_search",
},
{
type: "image_search",
},
],
instructions: createResponsesInstructions(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
{ ...createParams, stream: true },
{ signal: req.signal }, // propagate browser aborts (stop button / tab close)
)) as unknown as AsyncIterable<Record<string, unknown>>;
} catch (err) {
Expand All @@ -79,29 +101,49 @@ export async function POST(req: Request) {
);
}

if (e.status === 401 || e.status === 403) {
return Response.json(
{
error: {
code: "invalid_api_key",
message:
"OpenUI Cloud rejected THESYS_API_KEY. Check the key in .env against the Thesys console → API keys.",
},
},
{ status: e.status },
);
}

return Response.json(
{ error: e.error ?? { message: e.message ?? "upstream error" } },
{ status: e.status ?? 502 },
);
}

// Re-emit each SDK event as SSE for the browser adapter.
// Re-emit each SDK event as SSE for the browser adapter, executing declared
// function tools between model turns.
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const enqueue = (event: Record<string, unknown>) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
};
try {
for await (const event of stream) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
}
await runFunctionToolLoop({
client,
createParams,
firstStream: stream,
tools: functionTools,
enqueue,
signal: req.signal,
});
} catch (err) {
const message = isRateLimitError(err)
? getBillingCreditsErrorMessage()
: err instanceof Error
? err.message
: String(err);
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: "error", message })}\n\n`),
);
enqueue({ type: "error", message });
} finally {
controller.close();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,31 @@
import { envOr, requiredEnv } from "@/lib/env";

/**
* Mints the short-lived fct_ token the browser uses for the storage plane
* (conversations / artifacts). The token BINDS the scope: with an fct_ token,
* conversation create/list are locked to its `user_id` and `app_id` — the
* browser cannot widen them.
*
* `APP_ID` (written to .env at scaffold time) keeps this app's threads
* isolated from other apps sharing the same org API key. Keep it stable —
* changing it orphans the app's existing conversations.
*
* DEMO_USER_ID is single-user demo identity. For real multi-user support,
* derive `user_id` from your server-side auth session here (never from the
* request body) — see the OpenUI skill's cloud-integration reference.
*/
export async function POST() {
const appId = process.env.APP_ID;
const upstream = await fetch(`https://api.thesys.dev/v1/frontend-tokens`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${requiredEnv("THESYS_API_KEY")}`,
},
body: JSON.stringify({ user_id: envOr("DEMO_USER_ID", "demo-user") }),
body: JSON.stringify({
user_id: envOr("DEMO_USER_ID", "demo-user"),
...(appId ? { app_id: appId } : {}),
}),
});

if (!upstream.ok) {
Expand Down
Loading
Loading