Compare commits
7 Commits
feat/more-
...
82fb3c34f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82fb3c34f7 | ||
| 896ff6f210 | |||
| 9223ae3750 | |||
| eabe49552d | |||
| 444c722708 | |||
| 64e049c976 | |||
| 74cce6edd1 |
21
.dockerignore
Normal file
@@ -0,0 +1,21 @@
|
||||
node_modules/
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
*.md
|
||||
.editorconfig
|
||||
.prettierrc
|
||||
.prettierignore
|
||||
eslint.config.mjs
|
||||
.env
|
||||
.env*
|
||||
tests/
|
||||
content/
|
||||
docs/
|
||||
web/landing/
|
||||
web/docs/
|
||||
vitest.config.ts
|
||||
*.tsbuildinfo
|
||||
.next/
|
||||
dist/
|
||||
.astro/
|
||||
65
.github/workflows/build-image.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: Build Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
tags: ["v*"]
|
||||
paths-ignore:
|
||||
- 'web/docs/**'
|
||||
- 'web/landing/**'
|
||||
- 'content/**'
|
||||
- 'README.md'
|
||||
- 'CONTRIBUTING.md'
|
||||
- '.github/workflows/deploy-docs.yml'
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths-ignore:
|
||||
- 'web/docs/**'
|
||||
- 'web/landing/**'
|
||||
- 'content/**'
|
||||
- '*.md'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}/omnia-gui
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,format=short
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: apps/gui/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
59
apps/gui/Dockerfile
Normal file
@@ -0,0 +1,59 @@
|
||||
FROM node:22-slim AS base
|
||||
ENV PNPM_HOME="/pnpm" \
|
||||
PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable && corepack prepare pnpm@11.13.0 --activate
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS build-deps
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install -qq -y --no-install-recommends \
|
||||
python3 make g++ && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY package.json tsconfig.json tsconfig.base.json ./
|
||||
COPY apps/gui/package.json apps/gui/package.json
|
||||
COPY apps/gui/tsconfig.json apps/gui/tsconfig.json
|
||||
COPY apps/gui/next.config.ts apps/gui/next.config.ts
|
||||
COPY apps/gui/postcss.config.mjs apps/gui/postcss.config.mjs
|
||||
COPY apps/gui/components.json apps/gui/components.json
|
||||
COPY packages/actor/package.json packages/actor/package.json
|
||||
COPY packages/architect/package.json packages/architect/package.json
|
||||
COPY packages/core/package.json packages/core/package.json
|
||||
COPY packages/intent/package.json packages/intent/package.json
|
||||
COPY packages/llm/package.json packages/llm/package.json
|
||||
COPY packages/memory/package.json packages/memory/package.json
|
||||
COPY packages/scenario/package.json packages/scenario/package.json
|
||||
COPY packages/spatial/package.json packages/spatial/package.json
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
FROM build-deps AS pkg-builder
|
||||
COPY packages/ ./packages/
|
||||
RUN pnpm build
|
||||
|
||||
FROM pkg-builder AS gui-builder
|
||||
COPY apps/gui/src ./apps/gui/src
|
||||
COPY apps/gui/public ./apps/gui/public
|
||||
RUN pnpm --filter @omnia/gui build
|
||||
|
||||
FROM base AS runner
|
||||
ENV NODE_ENV=production
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install -qq -y --no-install-recommends \
|
||||
python3 make g++ && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=gui-builder /app/package.json /app/pnpm-workspace.yaml ./
|
||||
COPY --from=gui-builder /app/node_modules ./node_modules
|
||||
COPY --from=gui-builder /app/packages ./packages
|
||||
COPY --from=gui-builder /app/apps/gui/package.json \
|
||||
/app/apps/gui/next.config.ts \
|
||||
/app/apps/gui/postcss.config.mjs \
|
||||
./apps/gui/
|
||||
COPY --from=gui-builder /app/apps/gui/.next ./apps/gui/.next
|
||||
COPY --from=gui-builder /app/apps/gui/public ./apps/gui/public
|
||||
|
||||
WORKDIR /app/apps/gui
|
||||
EXPOSE 3000
|
||||
CMD ["pnpm", "start"]
|
||||
BIN
apps/gui/public/claude_logo.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
apps/gui/public/deepseek_logo.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
apps/gui/public/gemini_logo.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
apps/gui/public/groq_logo.webp
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
apps/gui/public/ollama_logo.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
apps/gui/public/openai_logo.webp
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
apps/gui/public/openrouter_logo.webp
Normal file
|
After Width: | Height: | Size: 50 KiB |
@@ -38,6 +38,7 @@ export async function startSimulation(input: {
|
||||
scenario?: string;
|
||||
playEntity?: string;
|
||||
providerInstanceId?: string;
|
||||
customName?: string;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
const scenarioFile =
|
||||
@@ -52,6 +53,7 @@ export async function startSimulation(input: {
|
||||
resolved,
|
||||
input.playEntity || undefined,
|
||||
input.providerInstanceId,
|
||||
input.customName,
|
||||
);
|
||||
|
||||
if (snapshot.status === "error") {
|
||||
@@ -348,3 +350,21 @@ export async function fetchAvailableModelsForInstance(
|
||||
inst.endpointUrl,
|
||||
);
|
||||
}
|
||||
|
||||
export async function renameSimulation(
|
||||
simId: string,
|
||||
newName: string,
|
||||
): Promise<{ ok: true; snapshot: SimSnapshot } | { ok: false; error: string }> {
|
||||
try {
|
||||
const snapshot = await simulationManager.rename(simId, newName);
|
||||
if (!snapshot) {
|
||||
return { ok: false, error: "Simulation session not found" };
|
||||
}
|
||||
return { ok: true, snapshot };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : "Failed to rename simulation",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export function ConfigView() {
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto w-full relative">
|
||||
<div className="relative z-10 mx-auto max-w-[800px] px-10 py-12">
|
||||
<div className="relative z-10 mx-auto max-w-[1024px] px-10 py-12">
|
||||
<h1 className="mb-6 text-headline-lg text-primary animate-fade-in">
|
||||
Configuration
|
||||
</h1>
|
||||
|
||||
@@ -54,6 +54,16 @@ import { Empty, EmptyTitle, EmptyDescription } from "@/components/ui/empty";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RefreshCwIcon } from "lucide-react";
|
||||
|
||||
const providerLogoMap: Record<string, string> = {
|
||||
anthropic: "/claude_logo.webp",
|
||||
"google-genai": "/gemini_logo.webp",
|
||||
openrouter: "/openrouter_logo.webp",
|
||||
ollama: "/ollama_logo.webp",
|
||||
deepseek: "/deepseek_logo.webp",
|
||||
openai: "/openai_logo.webp",
|
||||
groq: "/groq_logo.webp",
|
||||
};
|
||||
|
||||
interface ProviderInstancesConfigProps {
|
||||
instances: ModelProviderInstance[];
|
||||
availableProviders: ModelProviderMeta[];
|
||||
@@ -337,7 +347,7 @@ export function ProviderInstancesConfig({
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mb-8">
|
||||
<section className="mb-8 flex min-h-[600px] flex-col">
|
||||
{error && (
|
||||
<div className="mb-4 rounded border-2 border-red-500 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
@@ -345,7 +355,7 @@ export function ProviderInstancesConfig({
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 grid min-h-[400px] grid-cols-1 gap-4 md:grid-cols-[30%_70%]",
|
||||
"mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-[30%_70%]",
|
||||
loading && "pointer-events-none opacity-60",
|
||||
"transition-opacity duration-200",
|
||||
)}
|
||||
@@ -379,14 +389,22 @@ export function ProviderInstancesConfig({
|
||||
)}
|
||||
onClick={() => setSelectedInstanceId(inst.id)}
|
||||
>
|
||||
<ItemContent>
|
||||
<ItemTitle>{inst.name}</ItemTitle>
|
||||
<ItemDescription>{inst.providerName}</ItemDescription>
|
||||
<div className="flex flex-row gap-1.5">
|
||||
{inst.isActive && <Badge>Active</Badge>}
|
||||
<Badge variant="outline">
|
||||
{inst.type === "generative" ? "gen" : "embed"}
|
||||
</Badge>
|
||||
<ItemContent className="flex-row items-center gap-3">
|
||||
{providerLogoMap[inst.providerName] && (
|
||||
<img
|
||||
src={providerLogoMap[inst.providerName]}
|
||||
alt={inst.providerName}
|
||||
className="max-h-[38px] max-w-[38px] shrink-0 rounded-sm object-contain"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<ItemTitle>{inst.name}</ItemTitle>
|
||||
<div className="flex flex-row gap-1.5">
|
||||
{inst.isActive && <Badge>Active</Badge>}
|
||||
<Badge variant="outline">
|
||||
{inst.type === "generative" ? "gen" : "embed"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
@@ -431,6 +449,7 @@ export function ProviderInstancesConfig({
|
||||
<div className="grid grid-cols-[2fr_3fr] gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Instance Type</Label>
|
||||
{/* TODO: Change this to choice card */}
|
||||
<Select
|
||||
value={editType}
|
||||
onValueChange={(v) =>
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export function HomeView() {
|
||||
const router = useRouter();
|
||||
@@ -59,6 +60,7 @@ export function HomeView() {
|
||||
const [loadingEntities, setLoadingEntities] = useState(false);
|
||||
const [selectedEntityForModal, setSelectedEntityForModal] =
|
||||
useState<string>("");
|
||||
const [customName, setCustomName] = useState<string>("");
|
||||
|
||||
const loadSavedSessions = useCallback(async () => {
|
||||
try {
|
||||
@@ -144,6 +146,7 @@ export function HomeView() {
|
||||
setScenarioForModal(scenario);
|
||||
setLoadingEntities(true);
|
||||
setSelectedEntityForModal(""); // Reset selection to Spectator
|
||||
setCustomName(scenario.name); // Set custom simulation name default
|
||||
try {
|
||||
const res = await getScenarioEntities(scenario.path);
|
||||
if (res.ok) {
|
||||
@@ -168,6 +171,7 @@ export function HomeView() {
|
||||
const result = await startSimulation({
|
||||
scenario: targetScenario.path,
|
||||
playEntity: selectedEntityForModal || undefined,
|
||||
customName: customName.trim() || undefined,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
@@ -188,7 +192,7 @@ export function HomeView() {
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto w-full relative">
|
||||
<div className="relative z-10 mx-auto max-w-[800px] px-10 py-12">
|
||||
<div className="relative z-10 mx-auto max-w-[1024px] px-10 py-12">
|
||||
<div className="animate-fade-in">
|
||||
{/* Centered Big Logo */}
|
||||
<div className="flex flex-col items-center justify-center mb-10 pt-4">
|
||||
@@ -394,6 +398,16 @@ export function HomeView() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono">
|
||||
Custom Simulation Name
|
||||
</label>
|
||||
<Input
|
||||
value={customName}
|
||||
onChange={(e) => setCustomName(e.target.value)}
|
||||
placeholder="Enter custom name..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground font-mono">
|
||||
Simulation Mode / Play as
|
||||
|
||||
218
apps/gui/src/components/play/InteractView.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { SimSnapshot } from "@/lib/simulation-types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function IntentTag({
|
||||
intent,
|
||||
isSelf,
|
||||
}: {
|
||||
intent: SimSnapshot["log"][number]["intents"][number];
|
||||
isSelf?: boolean;
|
||||
}) {
|
||||
const labels: Record<string, string> = {
|
||||
monologue: "thought",
|
||||
dialogue: "dialogue",
|
||||
action: "action",
|
||||
};
|
||||
|
||||
const label = labels[intent.type] || intent.type;
|
||||
|
||||
let outcome = "";
|
||||
if (intent.type === "action") {
|
||||
outcome = intent.isValid ? " ✅" : ` ❌ (${intent.reason})`;
|
||||
}
|
||||
|
||||
const textToDisplay =
|
||||
isSelf && intent.selfDescription
|
||||
? intent.selfDescription
|
||||
: intent.description;
|
||||
|
||||
const modifiersStr =
|
||||
intent.modifiers && intent.modifiers.length > 0 ? (
|
||||
<span className="italic opacity-80 text-muted-foreground ml-1">
|
||||
({intent.modifiers.join(", ")})
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
[{label}] “{textToDisplay}”{modifiersStr}
|
||||
{outcome}
|
||||
{intent.minutesToAdvance ? ` [+${intent.minutesToAdvance}min]` : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSimTime(isoString: string) {
|
||||
try {
|
||||
const d = new Date(isoString);
|
||||
if (isNaN(d.getTime())) return isoString;
|
||||
const yyyy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||
const min = String(d.getUTCMinutes()).padStart(2, "0");
|
||||
const ss = String(d.getUTCSeconds()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss} UTC`;
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
function LogEntryCard({
|
||||
entry,
|
||||
onShowPrompt,
|
||||
isPlayerCard,
|
||||
}: {
|
||||
entry: SimSnapshot["log"][number];
|
||||
onShowPrompt: (entry: SimSnapshot["log"][number]) => void;
|
||||
isPlayerCard: boolean;
|
||||
}) {
|
||||
const showMenu = !!(entry.rawPrompt || entry.decoderPrompt);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border p-4 shadow-[2px_2px_0_0_var(--border)]",
|
||||
isPlayerCard
|
||||
? "border-primary bg-surface-container-low"
|
||||
: "border-border/30 bg-card",
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2 border-b border-dotted border-border/20 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<strong className="text-body-md font-bold text-foreground">
|
||||
{entry.entityName}
|
||||
</strong>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Turn {entry.turn} · {formatSimTime(entry.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
{showMenu && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onShowPrompt(entry)}
|
||||
title="View Raw Prompts & Token Usage"
|
||||
>
|
||||
☰
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-body-md leading-relaxed mb-3 text-foreground/90 whitespace-pre-wrap">
|
||||
{entry.narrativeProse}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 mt-2 border-t border-dotted border-border/10 pt-2">
|
||||
{entry.intents.map((intent, i) => (
|
||||
<IntentTag key={i} intent={intent} isSelf={isPlayerCard} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InteractViewProps {
|
||||
snapshot: SimSnapshot;
|
||||
loading: boolean;
|
||||
statusText: string;
|
||||
playerInput: string;
|
||||
setPlayerInput: (value: string) => void;
|
||||
onSubmitAction: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
onShowPrompt: (entry: SimSnapshot["log"][number]) => void;
|
||||
logEndRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function InteractView({
|
||||
snapshot,
|
||||
loading,
|
||||
statusText,
|
||||
playerInput,
|
||||
setPlayerInput,
|
||||
onSubmitAction,
|
||||
onShowPrompt,
|
||||
logEndRef,
|
||||
}: InteractViewProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const playerEntity = snapshot.entities.find((e) => e.isPlayer);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Scrollable Center Viewport */}
|
||||
<main className="flex-1 overflow-y-auto px-8 py-6">
|
||||
<div className="flex flex-col gap-4 max-w-[800px] mx-auto pb-12">
|
||||
{snapshot.log.map((entry, i) => (
|
||||
<LogEntryCard
|
||||
key={i}
|
||||
entry={entry}
|
||||
onShowPrompt={onShowPrompt}
|
||||
isPlayerCard={entry.entityId === playerEntity?.id}
|
||||
/>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-sm italic text-muted-foreground p-2 font-mono">
|
||||
<Spinner />
|
||||
{statusText || "Processing..."}
|
||||
</div>
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Sticky Chat / Interaction Input Footer */}
|
||||
<footer className="sticky bottom-0 bg-background/95 backdrop-blur-xs border-t border-dotted border-border/20 px-8 py-4 z-10 shrink-0">
|
||||
<div className="max-w-[800px] mx-auto">
|
||||
{snapshot.status === "waiting_player" && snapshot.waitingEntity ? (
|
||||
<div className="border border-border/30 bg-card p-4 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<details className="mb-3">
|
||||
<summary className="cursor-pointer text-sm font-medium font-head text-primary select-none outline-none">
|
||||
<strong>Your context as {snapshot.waitingEntity.name}</strong>
|
||||
</summary>
|
||||
<pre className="text-xs whitespace-pre-wrap bg-input border border-border/20 p-2 max-h-[150px] overflow-y-auto mt-2 font-mono">
|
||||
{snapshot.waitingEntity.userContext}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<form onSubmit={onSubmitAction} className="flex flex-col gap-2">
|
||||
<Textarea
|
||||
value={playerInput}
|
||||
onChange={(e) => setPlayerInput(e.target.value)}
|
||||
placeholder="Describe what your character does, says, or thinks..."
|
||||
rows={3}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button type="submit" disabled={loading || !playerInput.trim()}>
|
||||
{loading ? "Processing..." : "Submit Action"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : snapshot.status === "done" || snapshot.status === "error" ? (
|
||||
<div className="flex justify-between items-center bg-card border border-border/30 p-4 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<span className="text-sm font-mono text-muted-foreground">
|
||||
{snapshot.status === "error"
|
||||
? "Simulation finished with an error."
|
||||
: "Simulation complete."}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
router.push("/");
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
{snapshot.status === "error"
|
||||
? "Back to Dashboard"
|
||||
: "New Simulation"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
176
apps/gui/src/components/play/ManageView.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import type { SimSnapshot } from "@/lib/simulation-types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { renameSimulation } from "@/app/actions";
|
||||
|
||||
interface ManageViewProps {
|
||||
snapshot: SimSnapshot;
|
||||
onRename: (updated: SimSnapshot) => void;
|
||||
}
|
||||
|
||||
export function ManageView({ snapshot, onRename }: ManageViewProps) {
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [editedName, setEditedName] = useState(snapshot.scenarioName);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
setEditedName(snapshot.scenarioName);
|
||||
}, [snapshot.scenarioName]);
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!editedName.trim()) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await renameSimulation(snapshot.id, editedName.trim());
|
||||
if (res.ok) {
|
||||
onRename(res.snapshot);
|
||||
setIsEditingName(false);
|
||||
} else {
|
||||
setError(res.error);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to rename simulation",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex-1 overflow-y-auto px-8 py-6">
|
||||
<div className="max-w-[800px] mx-auto space-y-6 pb-12">
|
||||
{/* Simulation Info */}
|
||||
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<h3 className="text-headline-sm text-primary mb-4 border-b border-dotted border-border/20 pb-2">
|
||||
Simulation Info
|
||||
</h3>
|
||||
{error && (
|
||||
<div className="mb-4 border border-destructive bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm font-mono">
|
||||
<div className="flex flex-col gap-1 border-b border-border/10 pb-2 md:col-span-2">
|
||||
<span className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Simulation Name
|
||||
</span>
|
||||
{isEditingName ? (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Input
|
||||
value={editedName}
|
||||
onChange={(e) => setEditedName(e.target.value)}
|
||||
className="h-8 max-w-sm font-sans"
|
||||
disabled={saving}
|
||||
/>
|
||||
<Button size="sm" onClick={handleSaveName} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsEditingName(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-foreground font-bold text-base font-head">
|
||||
{snapshot.scenarioName}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => {
|
||||
setEditedName(snapshot.scenarioName);
|
||||
setIsEditingName(true);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
|
||||
<span className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Session ID
|
||||
</span>
|
||||
<span className="text-foreground font-bold break-all">
|
||||
{snapshot.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
|
||||
<span className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Max Turns
|
||||
</span>
|
||||
<span className="text-foreground font-bold font-mono">
|
||||
{snapshot.maxTurns}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
|
||||
<span className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Turn Count
|
||||
</span>
|
||||
<span className="text-foreground font-bold font-mono">
|
||||
{snapshot.turn}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
|
||||
<span className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Entities Registered
|
||||
</span>
|
||||
<span className="text-foreground font-bold font-mono">
|
||||
{snapshot.entities.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entities Involved */}
|
||||
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<h3 className="text-headline-sm text-primary mb-4 border-b border-dotted border-border/20 pb-2">
|
||||
Entities Involved
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{snapshot.entities.map((ent) => (
|
||||
<div
|
||||
key={ent.id}
|
||||
className="border border-border/20 bg-secondary/20 p-4 shadow-[1px_1px_0_0_var(--border)] flex justify-between items-center"
|
||||
>
|
||||
<div>
|
||||
<strong className="text-sm text-foreground block font-head tracking-wide">
|
||||
{ent.name}
|
||||
</strong>
|
||||
<span className="text-xs text-muted-foreground font-mono block mt-1">
|
||||
ID: {ent.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{ent.isPlayer ? (
|
||||
<span className="bg-primary/20 text-primary border border-primary/30 px-2 py-0.5 text-xs font-mono">
|
||||
PLAYER
|
||||
</span>
|
||||
) : (
|
||||
<span className="bg-secondary/60 text-muted-foreground border border-border/20 px-2 py-0.5 text-xs font-mono">
|
||||
NPC
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
} from "@/app/actions";
|
||||
import type { SimSnapshot } from "@/lib/simulation-types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { PromptModal } from "./PromptModal";
|
||||
import { InteractView } from "./InteractView";
|
||||
import { ManageView } from "./ManageView";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import {
|
||||
@@ -22,114 +23,6 @@ import {
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
function IntentTag({
|
||||
intent,
|
||||
isSelf,
|
||||
}: {
|
||||
intent: SimSnapshot["log"][number]["intents"][number];
|
||||
isSelf?: boolean;
|
||||
}) {
|
||||
const labels: Record<string, string> = {
|
||||
monologue: "thought",
|
||||
dialogue: "dialogue",
|
||||
action: "action",
|
||||
};
|
||||
|
||||
const label = labels[intent.type] || intent.type;
|
||||
|
||||
let outcome = "";
|
||||
if (intent.type === "action") {
|
||||
outcome = intent.isValid ? " ✅" : ` ❌ (${intent.reason})`;
|
||||
}
|
||||
|
||||
const textToDisplay =
|
||||
isSelf && intent.selfDescription
|
||||
? intent.selfDescription
|
||||
: intent.description;
|
||||
|
||||
const modifiersStr =
|
||||
intent.modifiers && intent.modifiers.length > 0 ? (
|
||||
<span className="italic opacity-80 text-muted-foreground ml-1">
|
||||
({intent.modifiers.join(", ")})
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
[{label}] “{textToDisplay}”{modifiersStr}
|
||||
{outcome}
|
||||
{intent.minutesToAdvance ? ` [+${intent.minutesToAdvance}min]` : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSimTime(isoString: string) {
|
||||
try {
|
||||
const d = new Date(isoString);
|
||||
if (isNaN(d.getTime())) return isoString;
|
||||
const yyyy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||
const min = String(d.getUTCMinutes()).padStart(2, "0");
|
||||
const ss = String(d.getUTCSeconds()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss} UTC`;
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
function LogEntryCard({
|
||||
entry,
|
||||
onShowPrompt,
|
||||
isPlayerCard,
|
||||
}: {
|
||||
entry: SimSnapshot["log"][number];
|
||||
onShowPrompt: (entry: SimSnapshot["log"][number]) => void;
|
||||
isPlayerCard: boolean;
|
||||
}) {
|
||||
const showMenu = !!(entry.rawPrompt || entry.decoderPrompt);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border p-4 shadow-[2px_2px_0_0_var(--border)]",
|
||||
isPlayerCard
|
||||
? "border-primary bg-surface-container-low"
|
||||
: "border-border/30 bg-card",
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2 border-b border-dotted border-border/20 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<strong className="text-body-md font-bold text-foreground">
|
||||
{entry.entityName}
|
||||
</strong>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Turn {entry.turn} · {formatSimTime(entry.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
{showMenu && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onShowPrompt(entry)}
|
||||
title="View Raw Prompts & Token Usage"
|
||||
>
|
||||
☰
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-body-md leading-relaxed mb-3 text-foreground/90 whitespace-pre-wrap">
|
||||
{entry.narrativeProse}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 mt-2 border-t border-dotted border-border/10 pt-2">
|
||||
{entry.intents.map((intent, i) => (
|
||||
<IntentTag key={i} intent={intent} isSelf={isPlayerCard} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function MobileSidebarClose() {
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
if (!isMobile) return null;
|
||||
@@ -482,171 +375,19 @@ export function PlayView() {
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Scrollable Center Viewport */}
|
||||
<main className="flex-1 overflow-y-auto px-8 py-6">
|
||||
{activeTab === "interact" ? (
|
||||
<div className="flex flex-col gap-4 max-w-[800px] mx-auto pb-12">
|
||||
{(() => {
|
||||
const playerEntity = snapshot.entities.find(
|
||||
(e) => e.isPlayer,
|
||||
);
|
||||
return snapshot.log.map((entry, i) => (
|
||||
<LogEntryCard
|
||||
key={i}
|
||||
entry={entry}
|
||||
onShowPrompt={setSelectedEntryForModal}
|
||||
isPlayerCard={entry.entityId === playerEntity?.id}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-sm italic text-muted-foreground p-2 font-mono">
|
||||
<Spinner />
|
||||
{statusText || "Processing..."}
|
||||
</div>
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-[800px] mx-auto space-y-6 pb-12">
|
||||
{/* Simulation Info */}
|
||||
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<h3 className="text-headline-sm text-primary mb-4 border-b border-dotted border-border/20 pb-2">
|
||||
Simulation Info
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm font-mono">
|
||||
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
|
||||
<span className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Session ID
|
||||
</span>
|
||||
<span className="text-foreground font-bold break-all">
|
||||
{snapshot.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
|
||||
<span className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Max Turns
|
||||
</span>
|
||||
<span className="text-foreground font-bold">
|
||||
{snapshot.maxTurns}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
|
||||
<span className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Turn Count
|
||||
</span>
|
||||
<span className="text-foreground font-bold">
|
||||
{snapshot.turn}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-b border-border/10 pb-2">
|
||||
<span className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
Entities Registered
|
||||
</span>
|
||||
<span className="text-foreground font-bold">
|
||||
{snapshot.entities.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entities Involved */}
|
||||
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<h3 className="text-headline-sm text-primary mb-4 border-b border-dotted border-border/20 pb-2">
|
||||
Entities Involved
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{snapshot.entities.map((ent) => (
|
||||
<div
|
||||
key={ent.id}
|
||||
className="border border-border/20 bg-secondary/20 p-4 shadow-[1px_1px_0_0_var(--border)] flex justify-between items-center"
|
||||
>
|
||||
<div>
|
||||
<strong className="text-sm text-foreground block font-head tracking-wide">
|
||||
{ent.name}
|
||||
</strong>
|
||||
<span className="text-xs text-muted-foreground font-mono block mt-1">
|
||||
ID: {ent.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{ent.isPlayer ? (
|
||||
<span className="bg-primary/20 text-primary border border-primary/30 px-2 py-0.5 text-xs font-mono">
|
||||
PLAYER
|
||||
</span>
|
||||
) : (
|
||||
<span className="bg-secondary/60 text-muted-foreground border border-border/20 px-2 py-0.5 text-xs font-mono">
|
||||
NPC
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Sticky Chat / Interaction Input Footer */}
|
||||
{activeTab === "interact" && (
|
||||
<footer className="sticky bottom-0 bg-background/95 backdrop-blur-xs border-t border-dotted border-border/20 px-8 py-4 z-10 shrink-0">
|
||||
<div className="max-w-[800px] mx-auto">
|
||||
{snapshot.status === "waiting_player" &&
|
||||
snapshot.waitingEntity ? (
|
||||
<div className="border border-border/30 bg-card p-4 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<details className="mb-3">
|
||||
<summary className="cursor-pointer text-sm font-medium font-head text-primary select-none outline-none">
|
||||
<strong>
|
||||
Your context as {snapshot.waitingEntity.name}
|
||||
</strong>
|
||||
</summary>
|
||||
<pre className="text-xs whitespace-pre-wrap bg-input border border-border/20 p-2 max-h-[150px] overflow-y-auto mt-2 font-mono">
|
||||
{snapshot.waitingEntity.userContext}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmitAction}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Textarea
|
||||
value={playerInput}
|
||||
onChange={(e) => setPlayerInput(e.target.value)}
|
||||
placeholder="Describe what your character does, says, or thinks..."
|
||||
rows={3}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !playerInput.trim()}
|
||||
>
|
||||
{loading ? "Processing..." : "Submit Action"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : snapshot.status === "done" ||
|
||||
snapshot.status === "error" ? (
|
||||
<div className="flex justify-between items-center bg-card border border-border/30 p-4 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<span className="text-sm font-mono text-muted-foreground">
|
||||
{snapshot.status === "error"
|
||||
? "Simulation finished with an error."
|
||||
: "Simulation complete."}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
router.push("/");
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
{snapshot.status === "error"
|
||||
? "Back to Dashboard"
|
||||
: "New Simulation"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</footer>
|
||||
{activeTab === "interact" ? (
|
||||
<InteractView
|
||||
snapshot={snapshot}
|
||||
loading={loading}
|
||||
statusText={statusText}
|
||||
playerInput={playerInput}
|
||||
setPlayerInput={setPlayerInput}
|
||||
onSubmitAction={handleSubmitAction}
|
||||
onShowPrompt={setSelectedEntryForModal}
|
||||
logEndRef={logEndRef}
|
||||
/>
|
||||
) : (
|
||||
<ManageView snapshot={snapshot} onRename={setSnapshot} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export class SimulationManager {
|
||||
scenarioPath: string,
|
||||
playEntityName?: string,
|
||||
providerInstanceId?: string,
|
||||
customName?: string,
|
||||
): Promise<SimSnapshot> {
|
||||
// Resolve or validate the active generative provider upfront so we can
|
||||
// return a clean error snapshot before touching the filesystem.
|
||||
@@ -168,7 +169,7 @@ export class SimulationManager {
|
||||
bufferRepo,
|
||||
ledgerRepo,
|
||||
worldInstanceId,
|
||||
scenarioName: scenarioJson.name,
|
||||
scenarioName: customName || scenarioJson.name,
|
||||
scenarioDescription: scenarioJson.description || "",
|
||||
turn: 1,
|
||||
maxTurns: 20,
|
||||
@@ -291,6 +292,19 @@ export class SimulationManager {
|
||||
return session ? this.snapshot(session) : null;
|
||||
}
|
||||
|
||||
async rename(id: string, newName: string): Promise<SimSnapshot | null> {
|
||||
let session = this.sessions.get(id);
|
||||
if (!session) {
|
||||
await this.load(id);
|
||||
session = this.sessions.get(id);
|
||||
}
|
||||
if (!session) return null;
|
||||
|
||||
session.scenarioName = newName;
|
||||
saveSession(session);
|
||||
return this.snapshot(session);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulation stepping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
15
docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
omnia-gui:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/gui/Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
volumes:
|
||||
- omnia-data:/app/apps/gui/data
|
||||
|
||||
volumes:
|
||||
omnia-data:
|
||||
@@ -1,225 +0,0 @@
|
||||
> ## Documentation Index
|
||||
>
|
||||
> Fetch the complete documentation index at: https://docs.langchain.com/llms.txt
|
||||
> Use this file to discover all available pages before exploring further.
|
||||
|
||||
# OpenAI integrations
|
||||
|
||||
> Integrate with OpenAI using LangChain JavaScript.
|
||||
|
||||
LangChain integrates with OpenAI and Azure OpenAI through the `@langchain/openai` package.
|
||||
|
||||
> [OpenAI](https://en.wikipedia.org/wiki/OpenAI) is American artificial intelligence (AI) research laboratory
|
||||
> consisting of the non-profit `OpenAI Incorporated`
|
||||
> and its for-profit subsidiary corporation `OpenAI Limited Partnership`.
|
||||
> OpenAI conducts AI research with the declared intention of promoting and developing a friendly AI.
|
||||
> OpenAI systems run on an `Azure`-based supercomputing platform from `Microsoft`.
|
||||
|
||||
> The [OpenAI API](https://platform.openai.com/docs/models) is powered by a diverse set of models with different capabilities and price points.
|
||||
>
|
||||
> [ChatGPT](https://chat.openai.com) is the Artificial Intelligence (AI) chatbot developed by `OpenAI`.
|
||||
|
||||
## Installation and setup
|
||||
|
||||
- Get an OpenAI api key and set it as an environment variable (`OPENAI_API_KEY`)
|
||||
|
||||
## Chat model
|
||||
|
||||
See a [usage example](/oss/javascript/integrations/chat/openai).
|
||||
|
||||
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
```
|
||||
|
||||
## LLM
|
||||
|
||||
See a [usage example](/oss/javascript/integrations/llms/openai).
|
||||
|
||||
<Tip>
|
||||
See [this section for general instructions on installing LangChain packages](/oss/javascript/langchain/install).
|
||||
</Tip>
|
||||
|
||||
```bash npm theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
npm install @langchain/openai @langchain/core
|
||||
```
|
||||
|
||||
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
import { OpenAI } from "@langchain/openai";
|
||||
```
|
||||
|
||||
## Text embedding model
|
||||
|
||||
See a [usage example](/oss/javascript/integrations/embeddings/openai)
|
||||
|
||||
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
import { OpenAIEmbeddings } from "@langchain/openai";
|
||||
```
|
||||
|
||||
## Chain
|
||||
|
||||
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
import { OpenAIModerationChain } from "@langchain/classic/chains";
|
||||
```
|
||||
|
||||
## Middleware
|
||||
|
||||
Middleware specifically designed for OpenAI models. Learn more about [middleware](/oss/javascript/langchain/middleware/overview).
|
||||
|
||||
| Middleware | Description |
|
||||
| ----------------------------------------- | --------------------------------------------------------- |
|
||||
| [Content moderation](#content-moderation) | Moderate agent traffic using OpenAI's moderation endpoint |
|
||||
|
||||
### Content moderation
|
||||
|
||||
Moderate agent traffic (user input, model output, and tool results) using OpenAI's moderation endpoint to detect and handle unsafe content. Content moderation is useful for the following:
|
||||
|
||||
- Applications requiring content safety and compliance
|
||||
- Filtering harmful, hateful, or inappropriate content
|
||||
- Customer-facing agents that need safety guardrails
|
||||
- Meeting platform moderation requirements
|
||||
|
||||
<Info>
|
||||
Learn more about [OpenAI's moderation models](https://platform.openai.com/docs/guides/moderation) and categories.
|
||||
</Info>
|
||||
|
||||
**API reference:** [`openAIModerationMiddleware`](https://reference.langchain.com/javascript/langchain/index/openAIModerationMiddleware)
|
||||
|
||||
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
import { createAgent, openAIModerationMiddleware } from "langchain";
|
||||
|
||||
const agent = createAgent({
|
||||
model: "openai:gpt-5.5",
|
||||
tools: [searchTool, databaseTool],
|
||||
middleware: [
|
||||
openAIModerationMiddleware({
|
||||
model: "openai:gpt-5.5",
|
||||
moderationModel: "omni-moderation-latest",
|
||||
checkInput: true,
|
||||
checkOutput: true,
|
||||
exitBehavior: "end",
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Accordion title="Configuration options">
|
||||
<ParamField body="model" type="string | BaseChatModel" required>
|
||||
OpenAI model to use for moderation. Can be either a model name string (e.g., `"openai:gpt-5.5"`) or a `BaseChatModel` instance. The middleware will use this model's client to access the moderation endpoint.
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="moderationModel" type="ModerationModel" default="omni-moderation-latest">
|
||||
OpenAI moderation model to use. Options: `'omni-moderation-latest'`, `'omni-moderation-2024-09-26'`, `'text-moderation-latest'`, `'text-moderation-stable'`
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="checkInput" type="boolean" default="true">
|
||||
Whether to check user input messages before the model is called
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="checkOutput" type="boolean" default="true">
|
||||
Whether to check model output messages after the model is called
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="checkToolResults" type="boolean" default="false">
|
||||
Whether to check tool result messages before the model is called
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="exitBehavior" type="'error' | 'end' | 'replace'" default="'end'">
|
||||
How to handle violations when content is flagged. Options:
|
||||
|
||||
* `'end'` - End agent execution immediately with a violation message
|
||||
* `'error'` - Throw `OpenAIModerationError` exception
|
||||
* `'replace'` - Replace the flagged content with the violation message and continue
|
||||
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="violationMessage" type="string | undefined">
|
||||
Custom template for violation messages. Supports template variables:
|
||||
|
||||
* `{categories}` - Comma-separated list of flagged categories
|
||||
* `{category_scores}` - JSON string of category scores
|
||||
* `{original_content}` - The original flagged content
|
||||
|
||||
Default: `"I'm sorry, but I can't comply with that request. It was flagged for {categories}."`
|
||||
|
||||
</ParamField>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Full example">
|
||||
The middleware integrates OpenAI's moderation endpoint to check content at different stages:
|
||||
|
||||
**Moderation stages:**
|
||||
|
||||
- `checkInput` - User messages before model call
|
||||
- `checkOutput` - AI messages after model call
|
||||
- `checkToolResults` - Tool outputs before model call
|
||||
|
||||
**Exit behaviors:**
|
||||
|
||||
- `'end'` (default) - Stop execution with violation message
|
||||
- `'error'` - Throw exception for application handling
|
||||
- `'replace'` - Replace flagged content and continue
|
||||
|
||||
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
|
||||
import { createAgent, openAIModerationMiddleware } from "langchain";
|
||||
|
||||
// Basic moderation
|
||||
const agent = createAgent({
|
||||
model: "openai:gpt-5.5",
|
||||
tools: [searchTool, customerDataTool],
|
||||
middleware: [
|
||||
openAIModerationMiddleware({
|
||||
model: "openai:gpt-5.5",
|
||||
moderationModel: "omni-moderation-latest",
|
||||
checkInput: true,
|
||||
checkOutput: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Strict moderation with custom message
|
||||
const agentStrict = createAgent({
|
||||
model: "openai:gpt-5.5",
|
||||
tools: [searchTool, customerDataTool],
|
||||
middleware: [
|
||||
openAIModerationMiddleware({
|
||||
model: "openai:gpt-5.5",
|
||||
moderationModel: "omni-moderation-latest",
|
||||
checkInput: true,
|
||||
checkOutput: true,
|
||||
checkToolResults: true,
|
||||
exitBehavior: "error",
|
||||
violationMessage:
|
||||
"Content policy violation detected: {categories}. " +
|
||||
"Please rephrase your request.",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Moderation with replacement behavior
|
||||
const agentReplace = createAgent({
|
||||
model: "openai:gpt-5.5",
|
||||
tools: [searchTool],
|
||||
middleware: [
|
||||
openAIModerationMiddleware({
|
||||
model: "openai:gpt-5.5",
|
||||
checkInput: true,
|
||||
exitBehavior: "replace",
|
||||
violationMessage: "[Content removed due to safety policies]",
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
---
|
||||
|
||||
<div className="source-links">
|
||||
<Callout icon="terminal-2">
|
||||
[Connect these docs](/use-these-docs) to Claude, VSCode, and more via MCP for real-time answers.
|
||||
</Callout>
|
||||
|
||||
<Callout icon="edit">
|
||||
[Edit this page on GitHub](https://github.com/langchain-ai/docs/edit/main/src/oss/javascript/integrations/providers/openai.mdx) or [file an issue](https://github.com/langchain-ai/docs/issues/new/choose).
|
||||
</Callout>
|
||||
</div>
|
||||