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
71 changes: 63 additions & 8 deletions apps/control-plane-web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,36 +15,91 @@ the library contract.

## Run locally

Build the workspace package, then start the app:
The chat workspace uses the local `multi-model-playground` as its durable Nest backend. Copy that
app's `.env.example` to its ignored `.env.local`, replace the placeholders with fresh provider
credentials, then build the workspace and run both apps in separate terminals:

```sh
pnpm run build

pnpm --filter @nestm/ai-sdk-playground dev
```

```sh
AI_OBSERVABILITY_API_URL=http://127.0.0.1:3001 \
pnpm --filter @nestm/ai-sdk-control-plane-web dev
```

With no environment configured, `/api/snapshot` serves a realistic demo snapshot. To connect one
NestJS process, copy `.env.example` to `.env.local` and set:
Open `http://127.0.0.1:3000`. Never commit either app's local environment file. With no control-plane
environment configured, `/api/snapshot` still serves a realistic demo snapshot, while chat routes
return a safe not-connected response.

The control-plane proxy can also be configured through its `.env.local`:

```sh
AI_OBSERVABILITY_API_URL=http://127.0.0.1:3001
```

The URL is the loopback-only base origin of the Nest application; the proxy reads
`/ai-observability/v1/snapshot`. For an upstream protected by a bearer credential, set the optional
server-only `AI_OBSERVABILITY_API_BEARER_TOKEN`. Neither variable is sent to the browser.
The URL must be the loopback-only base origin of the Nest application. For an upstream protected by
a bearer credential, set the optional server-only `AI_OBSERVABILITY_API_BEARER_TOKEN`. Neither
variable is sent to the browser.

The dashboard also proxies `POST /api/compare` to the local playground. Model responses remain in
transient browser state and are not stored in the observability snapshot.

This reference app has no Sites or `chatgpt.site` hosting integration. It runs as a normal local
workspace application.

## Chat workspace

The root route selects the most recently updated conversation or creates a new OpenAI conversation
when none exists. `/c/[chatId]` provides the ChatGPT-style thread and the persistent sidebar remains
available on both chat and observability pages. From the workspace you can:

- create and switch between saved conversations;
- page through older conversations from the sidebar;
- delete idle conversations while active runs remain protected;
- choose the configured OpenAI, Claude, or Gemini model before a run;
- render streaming Markdown, reasoning, sources, files and images, tool inputs/outputs, tool errors,
and approval controls;
- keep the composer locked while an approval is pending so approval responses cannot be orphaned;
- attach up to three images, text, JSON, or PDF files totaling 256 KiB and request sourced answers
through each provider's native web-search tool;
- copy or regenerate a response and inspect message-level provider, model, timing, token, step, and
finish metadata;
- open `/observability` without cancelling a running conversation.

Each saved conversation accepts up to 200 input messages (about 100 complete turns). At that
boundary the composer becomes read-only and offers a one-click new conversation using the same
provider, while the full original transcript remains available.

Each `/c/[chatId]` runtime mounts only after its persisted messages load and is keyed by chat ID.
Submitting uses `POST /api/chats/[chatId]/stream`. Revisiting a conversation with an authoritative
active run reconnects with `GET /api/chats/[chatId]/stream`; idle chats do not open a resume request.
Route changes explicitly close only the browser subscriber. The backend's independent consumer
keeps the run alive. The explicit Stop control first calls the server cancel route for the current
run and then stops the local stream.

The browser talks only to same-origin routes. Their loopback-only Nest mirrors are:

- `GET|POST /api/chats` for listing and creating conversations;
- `GET|PATCH|DELETE /api/chats/[chatId]` for a saved conversation;
- `POST|GET /api/chats/[chatId]/stream` for starting/regenerating and resuming streams;
- `POST /api/chats/[chatId]/runs/[runId]/cancel` for explicit cancellation;
- `GET /api/providers` for the configured provider/model catalog.

`/observability` preserves the snapshot dashboard and comparison lab. `/api/snapshot` remains
read-only, while `/api/compare` applies the same-origin JSON mutation policy used by chat writes.

## Security boundary

- Keep the dashboard private with platform authentication and authorization.
- Keep the Nest snapshot route behind application-owned auth, CORS, and rate limits.
- The app validates every upstream response against schema version 1 and rejects responses over
2 MiB.
- Chat, comparison, and provider proxy routes accept only a configured loopback origin, validate
IDs and bounded request bodies, strictly validate JSON responses, and stream only
`text/event-stream` responses.
- The app validates every upstream response against its versioned schema and enforces bounded
per-endpoint request and response sizes.
- Polling occurs every five seconds while the page is visible. A failed refresh preserves the last
accepted snapshot; a lower revision is rejected only within the same process epoch.
- The current contract is process-scoped. Multi-replica aggregation belongs in a future durable
Expand Down
76 changes: 76 additions & 0 deletions apps/control-plane-web/app/api/chats/[chatId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { z } from "zod";

import { chatViewSchema, updateChatRequestSchema } from "@/lib/chat-schema";
import {
emptyUpstreamResponse,
fetchChatUpstream,
handleChatProxyError,
invalidChatRequest,
readBoundedRequestJson,
validateChatMutationRequest,
validatedJsonResponse,
} from "@/lib/chat-proxy";

export const dynamic = "force-dynamic";

interface ChatRouteContext {
readonly params: Promise<{ chatId: string }>;
}

export async function GET(request: Request, context: ChatRouteContext): Promise<Response> {
const chatId = await parsedChatId(context);
if (!chatId) return invalidChatRequest();
try {
const upstream = await fetchChatUpstream(
request,
`/playground/v1/chats/${encodeURIComponent(chatId)}`,
{ method: "GET" },
);
return await validatedJsonResponse(upstream, chatViewSchema);
} catch (error) {
if (request.signal.aborted) throw error;
return handleChatProxyError(error);
}
}

export async function PATCH(request: Request, context: ChatRouteContext): Promise<Response> {
const rejected = validateChatMutationRequest(request, { jsonBody: true });
if (rejected) return rejected;
const chatId = await parsedChatId(context);
const body = updateChatRequestSchema.safeParse(await readBoundedRequestJson(request));
if (!chatId || !body.success) return invalidChatRequest();
try {
const upstream = await fetchChatUpstream(
request,
`/playground/v1/chats/${encodeURIComponent(chatId)}`,
{ method: "PATCH", body: body.data },
);
return await validatedJsonResponse(upstream, chatViewSchema);
} catch (error) {
if (request.signal.aborted) throw error;
return handleChatProxyError(error);
}
}

export async function DELETE(request: Request, context: ChatRouteContext): Promise<Response> {
const rejected = validateChatMutationRequest(request);
if (rejected) return rejected;
const chatId = await parsedChatId(context);
if (!chatId) return invalidChatRequest();
try {
const upstream = await fetchChatUpstream(
request,
`/playground/v1/chats/${encodeURIComponent(chatId)}`,
{ method: "DELETE" },
);
return emptyUpstreamResponse(upstream);
} catch (error) {
if (request.signal.aborted) throw error;
return handleChatProxyError(error);
}
}

async function parsedChatId(context: ChatRouteContext): Promise<string | undefined> {
const parsed = z.uuid().safeParse((await context.params).chatId);
return parsed.success ? parsed.data : undefined;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { z } from "zod";

import { cancelRunResponseSchema } from "@/lib/chat-schema";
import {
fetchChatUpstream,
handleChatProxyError,
invalidChatRequest,
validateChatMutationRequest,
validatedJsonResponse,
} from "@/lib/chat-proxy";

export const dynamic = "force-dynamic";

interface CancelRouteContext {
readonly params: Promise<{ chatId: string; runId: string }>;
}

export async function POST(request: Request, context: CancelRouteContext): Promise<Response> {
const rejected = validateChatMutationRequest(request);
if (rejected) return rejected;
const params = await context.params;
const chatId = z.uuid().safeParse(params.chatId);
const runId = z.uuid().safeParse(params.runId);
if (!chatId.success || !runId.success) return invalidChatRequest();
try {
const upstream = await fetchChatUpstream(
request,
`/playground/v1/chats/${encodeURIComponent(chatId.data)}/runs/${encodeURIComponent(runId.data)}/cancel`,
{ method: "POST" },
);
return await validatedJsonResponse(upstream, cancelRunResponseSchema);
} catch (error) {
if (request.signal.aborted) throw error;
return handleChatProxyError(error);
}
}
57 changes: 57 additions & 0 deletions apps/control-plane-web/app/api/chats/[chatId]/stream/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { z } from "zod";

import { chatStreamRequestSchema } from "@/lib/chat-schema";
import {
fetchChatUpstream,
handleChatProxyError,
invalidChatRequest,
readBoundedRequestJson,
streamResponse,
validateChatMutationRequest,
} from "@/lib/chat-proxy";

export const dynamic = "force-dynamic";

interface ChatStreamRouteContext {
readonly params: Promise<{ chatId: string }>;
}

export async function POST(request: Request, context: ChatStreamRouteContext): Promise<Response> {
const rejected = validateChatMutationRequest(request, { jsonBody: true });
if (rejected) return rejected;
const chatId = await parsedChatId(context);
const body = chatStreamRequestSchema.safeParse(await readBoundedRequestJson(request));
if (!chatId || !body.success) return invalidChatRequest();
try {
const upstream = await fetchChatUpstream(
request,
`/playground/v1/chats/${encodeURIComponent(chatId)}/stream`,
{ method: "POST", body: body.data, stream: true },
);
return streamResponse(upstream);
} catch (error) {
if (request.signal.aborted) throw error;
return handleChatProxyError(error);
}
}

export async function GET(request: Request, context: ChatStreamRouteContext): Promise<Response> {
const chatId = await parsedChatId(context);
if (!chatId) return invalidChatRequest();
try {
const upstream = await fetchChatUpstream(
request,
`/playground/v1/chats/${encodeURIComponent(chatId)}/stream`,
{ method: "GET", stream: true },
);
return streamResponse(upstream);
} catch (error) {
if (request.signal.aborted) throw error;
return handleChatProxyError(error);
}
}

async function parsedChatId(context: ChatStreamRouteContext): Promise<string | undefined> {
const parsed = z.uuid().safeParse((await context.params).chatId);
return parsed.success ? parsed.data : undefined;
}
57 changes: 57 additions & 0 deletions apps/control-plane-web/app/api/chats/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { chatListSchema, chatViewSchema, createChatRequestSchema } from "@/lib/chat-schema";
import {
fetchChatUpstream,
handleChatProxyError,
invalidChatRequest,
readBoundedRequestJson,
validateChatMutationRequest,
validatedJsonResponse,
} from "@/lib/chat-proxy";
import { z } from "zod";

export const dynamic = "force-dynamic";

const listChatsQuerySchema = z
.object({
cursor: z.uuid().optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
})
.strict();

export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const raw = Object.fromEntries(url.searchParams);
const parsed = listChatsQuerySchema.safeParse(raw);
if (!parsed.success) return invalidChatRequest();
const upstreamQuery = new URLSearchParams();
if (parsed.data.cursor) upstreamQuery.set("cursor", parsed.data.cursor);
if (parsed.data.limit !== undefined) upstreamQuery.set("limit", String(parsed.data.limit));
const suffix = upstreamQuery.size === 0 ? "" : `?${upstreamQuery.toString()}`;
try {
const upstream = await fetchChatUpstream(request, `/playground/v1/chats${suffix}`, {
method: "GET",
});
return await validatedJsonResponse(upstream, chatListSchema);
} catch (error) {
if (request.signal.aborted) throw error;
return handleChatProxyError(error);
}
}

export async function POST(request: Request): Promise<Response> {
const rejected = validateChatMutationRequest(request, { jsonBody: true });
if (rejected) return rejected;
const parsed = createChatRequestSchema.safeParse(await readBoundedRequestJson(request));
if (!parsed.success) return invalidChatRequest();

try {
const upstream = await fetchChatUpstream(request, "/playground/v1/chats", {
method: "POST",
body: parsed.data,
});
return await validatedJsonResponse(upstream, chatViewSchema);
} catch (error) {
if (request.signal.aborted) throw error;
return handleChatProxyError(error);
}
}
5 changes: 5 additions & 0 deletions apps/control-plane-web/app/api/compare/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { compareRequestSchema, comparisonSchema, PROVIDER_IDS } from "@/lib/compare-schema";
import { mutationRequestViolation } from "@/lib/chat-proxy";
import { localUpstreamEndpoint } from "@/lib/local-upstream";

export const dynamic = "force-dynamic";
Expand All @@ -9,6 +10,10 @@ const MAX_REQUEST_BYTES = 4 * 1024;
const MAX_RESPONSE_BYTES = 1024 * 1024;

export async function POST(request: Request): Promise<Response> {
const violation = mutationRequestViolation(request, { jsonBody: true });
if (violation === "origin") return safeError("REQUEST_ORIGIN_FORBIDDEN", 403);
if (violation === "content-type") return safeError("REQUEST_CONTENT_TYPE_UNSUPPORTED", 415);

const configuredBaseUrl = process.env.AI_OBSERVABILITY_API_URL?.trim();
if (!configuredBaseUrl) return safeError("PLAYGROUND_NOT_CONNECTED", 503);

Expand Down
16 changes: 16 additions & 0 deletions apps/control-plane-web/app/api/providers/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { providerCatalogSchema } from "@/lib/chat-schema";
import { fetchChatUpstream, handleChatProxyError, validatedJsonResponse } from "@/lib/chat-proxy";

export const dynamic = "force-dynamic";

export async function GET(request: Request): Promise<Response> {
try {
const upstream = await fetchChatUpstream(request, "/playground/v1/providers", {
method: "GET",
});
return await validatedJsonResponse(upstream, providerCatalogSchema);
} catch (error) {
if (request.signal.aborted) throw error;
return handleChatProxyError(error);
}
}
10 changes: 10 additions & 0 deletions apps/control-plane-web/app/c/[chatId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ChatPage } from "@/components/chat-page";

export default async function ConversationPage({
params,
}: {
readonly params: Promise<{ chatId: string }>;
}) {
const { chatId } = await params;
return <ChatPage chatId={chatId} />;
}
Loading
Loading