From cbd95bbea22cea9b644cf89e834f95b110bb269a Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Sat, 9 May 2026 13:29:58 +0800 Subject: [PATCH] Initial commit: AI-driven visual novel scaffold - Monorepo (pnpm workspace): apps/web + packages/{types,ai-client,engine} - Next.js 16 web app with three-stage AI orchestration - Three independently configurable providers: text LLM, image generator, vision model - Warm minimalist editorial UI design - One-click Vercel deploy ready Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 21 +++ .gitignore | 21 +++ LICENSE | 21 +++ README.md | 93 +++++++++++ apps/web/app/api/interact/route.ts | 32 ++++ apps/web/app/api/start/route.ts | 32 ++++ apps/web/app/globals.css | 68 ++++++++ apps/web/app/layout.tsx | 38 +++++ apps/web/app/new/page.tsx | 58 +++++++ apps/web/app/page.tsx | 159 +++++++++++++++++++ apps/web/app/play/page.tsx | 235 ++++++++++++++++++++++++++++ apps/web/components/CustomForm.tsx | 92 +++++++++++ apps/web/components/PlayCanvas.tsx | 106 +++++++++++++ apps/web/components/PresetCard.tsx | 38 +++++ apps/web/lib/config.ts | 27 ++++ apps/web/lib/presets.ts | 37 +++++ apps/web/next-env.d.ts | 4 + apps/web/next.config.ts | 14 ++ apps/web/package.json | 31 ++++ apps/web/postcss.config.mjs | 6 + apps/web/tailwind.config.ts | 57 +++++++ apps/web/tsconfig.json | 13 ++ package.json | 21 +++ packages/ai-client/package.json | 17 ++ packages/ai-client/src/chat.ts | 41 +++++ packages/ai-client/src/image.ts | 44 ++++++ packages/ai-client/src/index.ts | 4 + packages/ai-client/src/vision.ts | 46 ++++++ packages/ai-client/tsconfig.json | 7 + packages/engine/package.json | 19 +++ packages/engine/src/annotate.ts | 30 ++++ packages/engine/src/director.ts | 37 +++++ packages/engine/src/index.ts | 3 + packages/engine/src/jsonParser.ts | 27 ++++ packages/engine/src/orchestrator.ts | 71 +++++++++ packages/engine/src/prompts.ts | 115 ++++++++++++++ packages/engine/src/renderer.ts | 12 ++ packages/engine/src/vision.ts | 26 +++ packages/engine/tsconfig.json | 7 + packages/types/package.json | 14 ++ packages/types/src/index.ts | 74 +++++++++ packages/types/tsconfig.json | 7 + pnpm-workspace.yaml | 3 + tsconfig.base.json | 17 ++ vercel.json | 10 ++ 45 files changed, 1855 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 apps/web/app/api/interact/route.ts create mode 100644 apps/web/app/api/start/route.ts create mode 100644 apps/web/app/globals.css create mode 100644 apps/web/app/layout.tsx create mode 100644 apps/web/app/new/page.tsx create mode 100644 apps/web/app/page.tsx create mode 100644 apps/web/app/play/page.tsx create mode 100644 apps/web/components/CustomForm.tsx create mode 100644 apps/web/components/PlayCanvas.tsx create mode 100644 apps/web/components/PresetCard.tsx create mode 100644 apps/web/lib/config.ts create mode 100644 apps/web/lib/presets.ts create mode 100644 apps/web/next-env.d.ts create mode 100644 apps/web/next.config.ts create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.mjs create mode 100644 apps/web/tailwind.config.ts create mode 100644 apps/web/tsconfig.json create mode 100644 package.json create mode 100644 packages/ai-client/package.json create mode 100644 packages/ai-client/src/chat.ts create mode 100644 packages/ai-client/src/image.ts create mode 100644 packages/ai-client/src/index.ts create mode 100644 packages/ai-client/src/vision.ts create mode 100644 packages/ai-client/tsconfig.json create mode 100644 packages/engine/package.json create mode 100644 packages/engine/src/annotate.ts create mode 100644 packages/engine/src/director.ts create mode 100644 packages/engine/src/index.ts create mode 100644 packages/engine/src/jsonParser.ts create mode 100644 packages/engine/src/orchestrator.ts create mode 100644 packages/engine/src/prompts.ts create mode 100644 packages/engine/src/renderer.ts create mode 100644 packages/engine/src/vision.ts create mode 100644 packages/engine/tsconfig.json create mode 100644 packages/types/package.json create mode 100644 packages/types/src/index.ts create mode 100644 packages/types/tsconfig.json create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json create mode 100644 vercel.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6547b5c --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# ============================================================= +# Dada — AI Visual Novel +# Three independently configurable AI providers +# Any OpenAI-compatible endpoint works (OpenAI, Anthropic, Gemini, +# OpenRouter, DeepSeek, Ollama, ...). +# ============================================================= + +# ---- 1. Text LLM (story director) ----------------------------- +TEXT_BASE_URL=https://api.anthropic.com/v1 +TEXT_API_KEY=sk-ant-xxx +TEXT_MODEL=claude-opus-4-7 + +# ---- 2. Image generator (renders the whole UI screen) --------- +IMAGE_BASE_URL=https://api.openai.com/v1 +IMAGE_API_KEY=sk-xxx +IMAGE_MODEL=gpt-image-2 + +# ---- 3. Vision model (interprets where the user clicked) ------ +VISION_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai +VISION_API_KEY=xxx +VISION_MODEL=gemini-3-flash diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..abb94ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +node_modules +.pnpm-store +.next +dist +build +out +*.tsbuildinfo + +.env +.env.local +.env.*.local + +.vercel +.turbo + +.DS_Store +*.log +npm-debug.log* +pnpm-debug.log* + +repomix-output.xml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9d98466 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dada contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..077afca --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# Dada + +> An AI-driven visual novel where every frame — scenes, dialogue, choices — is rendered by an AI, one frame at a time. You click. It paints. The story unfolds. + +Open source, MIT. + +--- + +## How it works + +Each turn is three model calls: + +``` +[user clicks somewhere on the image] + │ + ▼ +1. Vision model interprets the click against the visible UI + │ + ▼ +2. Text LLM writes the next frame (narration, dialogue, choices) + │ + ▼ +3. Image model renders the entire next UI screen — scene, dialogue, + buttons, all of it — as one painted frame + │ + ▼ +[new image is shown; repeat] +``` + +There is no traditional UI. There is only the image. The AI chooses the layout, the colors, the typography, the buttons. Pick "stick figure on grid paper" as your style and you'll get hand-drawn UI. Pick "cyberpunk noir" and you'll get neon HUDs. Whatever fits the world. + +--- + +## One-click deploy + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/YOUR_USERNAME/dada&env=TEXT_BASE_URL,TEXT_API_KEY,TEXT_MODEL,IMAGE_BASE_URL,IMAGE_API_KEY,IMAGE_MODEL,VISION_BASE_URL,VISION_API_KEY,VISION_MODEL&envDescription=Three%20independently%20configurable%20providers.%20Any%20OpenAI-compatible%20endpoint%20works.&envLink=https://github.com/YOUR_USERNAME/dada%23environment-variables) + +After deploy, set the nine environment variables (see below) in your Vercel project. That's it. + +--- + +## Environment variables + +Three providers, all independently configurable. Any OpenAI-compatible chat / image endpoint works (OpenAI, Anthropic via OpenAI-compat proxy, Gemini, OpenRouter, DeepSeek, local Ollama, …). + +| Provider | Variables | Recommended | +|---|---|---| +| Text · story director | `TEXT_BASE_URL` `TEXT_API_KEY` `TEXT_MODEL` | `claude-opus-4-7` via Anthropic | +| Image · UI renderer | `IMAGE_BASE_URL` `IMAGE_API_KEY` `IMAGE_MODEL` | `gpt-image-2` via OpenAI | +| Vision · click reader | `VISION_BASE_URL` `VISION_API_KEY` `VISION_MODEL` | `gemini-3-flash` via Google | + +See `.env.example` for the exact shape. + +--- + +## Local development + +Requires Node 20+ and pnpm 9+. + +```bash +pnpm install +cp .env.example .env.local +# fill in the nine env vars +pnpm dev +# open http://localhost:3000 +``` + +--- + +## Project layout + +``` +dada/ +├── apps/web/ Next.js 16 app — pages + API routes +└── packages/ + ├── types/ shared TypeScript types + ├── ai-client/ unified OpenAI-compatible clients + └── engine/ three-stage AI orchestration (open core) +``` + +`packages/engine` is the open core — pure TS, no Next.js or browser dependency. Import it directly to build your own visual-novel front-end (Tauri, Electron, CLI, anywhere). + +--- + +## Cost & limits + +Each turn costs roughly **\$0.15–0.25** in API fees with the recommended model trio. A 30-turn session is **\~\$5–8**. There is no rate limiting or auth out of the box — if you make your deployment public, your bill will reflect that. Add limits before sharing widely. + +--- + +## License + +MIT. diff --git a/apps/web/app/api/interact/route.ts b/apps/web/app/api/interact/route.ts new file mode 100644 index 0000000..c33510a --- /dev/null +++ b/apps/web/app/api/interact/route.ts @@ -0,0 +1,32 @@ +import { takeTurn } from "@dada/engine"; +import type { InteractRequest } from "@dada/types"; +import { NextResponse } from "next/server"; +import { loadEngineConfig } from "@/lib/config"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +export async function POST(req: Request) { + let body: InteractRequest; + try { + body = (await req.json()) as InteractRequest; + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + if (!body.session || !body.prevImageBase64 || !body.click) { + return NextResponse.json( + { error: "session, prevImageBase64, click are required" }, + { status: 400 }, + ); + } + + try { + const config = loadEngineConfig(); + const result = await takeTurn(config, body); + return NextResponse.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/web/app/api/start/route.ts b/apps/web/app/api/start/route.ts new file mode 100644 index 0000000..1449203 --- /dev/null +++ b/apps/web/app/api/start/route.ts @@ -0,0 +1,32 @@ +import { startSession } from "@dada/engine"; +import type { StartRequest } from "@dada/types"; +import { NextResponse } from "next/server"; +import { loadEngineConfig } from "@/lib/config"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +export async function POST(req: Request) { + let body: StartRequest; + try { + body = (await req.json()) as StartRequest; + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + if (!body.worldSetting?.trim() || !body.styleGuide?.trim()) { + return NextResponse.json( + { error: "worldSetting and styleGuide are required" }, + { status: 400 }, + ); + } + + try { + const config = loadEngineConfig(); + const result = await startSession(config, body); + return NextResponse.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..5f17d2a --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,68 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + font-feature-settings: "ss01", "kern", "liga"; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + body { + background-image: + radial-gradient(rgba(133, 79, 37, 0.025) 1px, transparent 1px), + radial-gradient(rgba(133, 79, 37, 0.018) 1px, transparent 1px); + background-size: 28px 28px, 38px 38px; + background-position: 0 0, 14px 19px; + } + + ::selection { + background-color: rgb(217 122 46 / 0.28); + color: #2d1810; + } + + textarea::placeholder { + color: rgb(168 105 59 / 0.45); + } +} + +@layer utilities { + .hairline { + background-image: linear-gradient( + to right, + transparent, + rgba(45, 24, 16, 0.18) 18%, + rgba(45, 24, 16, 0.18) 82%, + transparent + ); + height: 1px; + } + + .hairline-full { + height: 1px; + background: rgba(45, 24, 16, 0.14); + } + + .num { + font-variant-numeric: tabular-nums lining-nums; + } + + .smallcaps { + text-transform: uppercase; + letter-spacing: 0.32em; + } +} + +@keyframes dada-ripple { + 0% { + width: 14px; + height: 14px; + opacity: 0.95; + } + 100% { + width: 110px; + height: 110px; + opacity: 0; + } +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..6c73574 --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Dada — AI Visual Novel", + description: + "An open-source visual novel where every frame is generated by AI.", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + + + + + {children} + + + ); +} diff --git a/apps/web/app/new/page.tsx b/apps/web/app/new/page.tsx new file mode 100644 index 0000000..75bb978 --- /dev/null +++ b/apps/web/app/new/page.tsx @@ -0,0 +1,58 @@ +import Link from "next/link"; +import { CustomForm } from "@/components/CustomForm"; + +export default function NewPage() { + return ( +
+
+ + + Dada + + + Compose · a · world + +
+ +
+
+
+

+ Ⅳ · Untitled +

+

+ Write +
+ two +
+ paragraphs. +

+
+

+ The first sketches the world your story unfolds in. The second + describes how the world should look — its medium, its mood, its + grain. +

+

+ Both fields accept any language. Specificity rewards specificity. +

+
+
+ +
+
+
+ +
+
+
+ MIT · MMXXVI + Ⅰ · Ⅳ +
+
+
+ ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 0000000..f282fd4 --- /dev/null +++ b/apps/web/app/page.tsx @@ -0,0 +1,159 @@ +import Link from "next/link"; +import { PRESETS } from "@/lib/presets"; +import { PresetCard } from "@/components/PresetCard"; + +const ORDINALS = ["Ⅰ", "Ⅱ", "Ⅲ", "Ⅳ"]; + +export default function LandingPage() { + return ( +
+
+
+ + Dada + + + + Frame · by · Frame + +
+ +
+ +
+
+
+

+ An open-source experiment · MMXXVI +

+

+ Every{" "} + frame +
+ is painted on +
+ demand. +

+

+ Dada is a visual novel where the entire interface — scene, + dialogue, choices — is rendered by an AI, one frame at a time. You + click. It paints. The story unfolds. +

+
+ + +
+
+ +
+
+
+ +
+
+

+ Four Doors +

+

+ Choose a world · or compose your own +

+
+ +
+ {PRESETS.map((p, i) => ( + + ))} + + +
+ + {ORDINALS[3]} + +
+

+ Untitled +

+

+ Bring your own world. Describe it in your own words. +

+
+ + COMPOSE + + +
+ +
+
+ +
+
+

+ Colophon · I +

+

+ A small open-source experiment in generative narrative. Self-host on + Vercel in a single click. +

+
+
+

+ Colophon · II +

+
    +
  • Story · large language model
  • +
  • Image · generative renderer
  • +
  • Click · vision interpreter
  • +
+
+
+

+ Colophon · III +

+

+ All three are configured separately — bring any OpenAI-compatible + endpoint. +

+
+
+ +
+
+
+ MIT · MMXXVI + Ⅰ · Ⅰ +
+
+
+ ); +} diff --git a/apps/web/app/play/page.tsx b/apps/web/app/play/page.tsx new file mode 100644 index 0000000..8104812 --- /dev/null +++ b/apps/web/app/play/page.tsx @@ -0,0 +1,235 @@ +"use client"; + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense, useEffect, useRef, useState } from "react"; +import { PlayCanvas, type Phase } from "@/components/PlayCanvas"; +import { PRESETS } from "@/lib/presets"; +import type { + ClickIntent, + InteractResponse, + Session, + StartResponse, + StoryFrame, +} from "@dada/types"; + +function PlayInner() { + const router = useRouter(); + const params = useSearchParams(); + + const [phase, setPhase] = useState("loading-first"); + const [session, setSession] = useState(null); + const [imageBase64, setImageBase64] = useState(null); + const [frame, setFrame] = useState(null); + const [intent, setIntent] = useState(null); + const [pendingClick, setPendingClick] = useState<{ + x: number; + y: number; + } | null>(null); + const [turnNum, setTurnNum] = useState(0); + const [error, setError] = useState(null); + const startedRef = useRef(false); + + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + + let payload: { worldSetting: string; styleGuide: string } | null = null; + const presetId = params.get("preset"); + + if (presetId) { + const p = PRESETS.find((x) => x.id === presetId); + if (p) { + payload = { worldSetting: p.worldSetting, styleGuide: p.styleGuide }; + } + } else if (params.get("custom") === "1") { + const stored = sessionStorage.getItem("dada:custom"); + if (stored) { + try { + payload = JSON.parse(stored); + } catch { + payload = null; + } + } + } + + if (!payload) { + router.replace("/"); + return; + } + + const finalPayload = payload; + + fetch("/api/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(finalPayload), + }) + .then(async (r) => { + if (!r.ok) { + const j = (await r.json().catch(() => ({}))) as { error?: string }; + throw new Error(j.error ?? r.statusText); + } + return r.json() as Promise; + }) + .then((data) => { + setSession({ + id: data.sessionId, + createdAt: Date.now(), + worldSetting: finalPayload.worldSetting, + styleGuide: finalPayload.styleGuide, + history: [{ frame: data.frame }], + }); + setFrame(data.frame); + setImageBase64(data.imageBase64); + setPhase("ready"); + setTurnNum(1); + }) + .catch((e) => setError(String(e))); + }, [params, router]); + + async function handleClick(click: { x: number; y: number }) { + if (phase !== "ready" || !session || !imageBase64) return; + setPhase("interacting"); + setPendingClick(click); + setIntent(null); + + try { + const res = await fetch("/api/interact", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + session, + prevImageBase64: imageBase64, + click, + }), + }); + if (!res.ok) { + const j = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(j.error ?? res.statusText); + } + const data = (await res.json()) as InteractResponse; + + const updatedHistory = [ + ...data.session.history, + { frame: data.frame }, + ]; + setSession({ ...data.session, history: updatedHistory }); + setFrame(data.frame); + setImageBase64(data.imageBase64); + setIntent(data.intent); + setPendingClick(null); + setTurnNum((t) => t + 1); + setPhase("ready"); + } catch (e) { + setError(String(e)); + setPendingClick(null); + setPhase("ready"); + } + } + + if (error) { + return ( +
+
+

+ An · error · occurred +

+

+ {error} +

+ + + Return + +
+
+ ); + } + + return ( +
+
+ + + Dada + +
+ Frame · {String(turnNum).padStart(3, "0")} + · + + {session?.id.slice(2, 14) ?? "—"} + +
+
+ +
+ + +
+ {phase === "loading-first" && ( +

+ Summoning · the · first · frame +

+ )} + {phase === "interacting" && ( +
+

+ AI · is · painting · the · next · moment +

+

+ this usually takes 12–20 seconds +

+
+ )} + {phase === "ready" && intent?.targetLabel && ( +

+ + Last · move · + + {intent.targetLabel} +

+ )} + {phase === "ready" && !intent && turnNum > 0 && ( +

+ Click · anywhere · to · respond +

+ )} +
+
+ +
+
+ Ⅰ · Ⅰ +
+
+
+ ); +} + +export default function PlayPage() { + return ( + + + Loading + +
+ } + > + + + ); +} diff --git a/apps/web/components/CustomForm.tsx b/apps/web/components/CustomForm.tsx new file mode 100644 index 0000000..19a38d9 --- /dev/null +++ b/apps/web/components/CustomForm.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +export function CustomForm() { + const router = useRouter(); + const [worldSetting, setWorldSetting] = useState(""); + const [styleGuide, setStyleGuide] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const canSubmit = + worldSetting.trim().length > 10 && + styleGuide.trim().length > 5 && + !submitting; + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!canSubmit) return; + setSubmitting(true); + sessionStorage.setItem( + "dada:custom", + JSON.stringify({ worldSetting, styleGuide }), + ); + router.push("/play?custom=1"); + } + + return ( +
+
+ +