AVA Dashboard WebUI — Full Implementation Spec
OPS-021 | kitchen.ava-project.com | Version 1.0 | 2026-03-24
Mục tiêu: 1 AI LLM đọc file này là có thể dev được chính xác từng phần — không cần đoán, không cần hỏi thêm.
TABLE OF CONTENTS
- Part 0 — Executive Summary
- Part 1 — Foundation & Infrastructure
- Part 2 — Database Layer
- Part 3 — API Layer (CF Workers)
- Part 4 — Stories CRUD
- Part 5 — Pipeline Visualization
- Part 6 — Phase Control
- Part 7 — Chat Session Integration
- Part 8 — Pipeline Config UI
- Part 9 — Deployment & DevOps
- Part 10 — Testing Strategy
Part 0 — Executive Summary
Project Overview
AVA Kitchen Dashboard là web interface cho phép Rio quản lý toàn bộ story production pipeline từ browser, thay thế static dashboard hiện tại (ava-story-engine.pages.dev) với realtime interaction.
Goals
- ✅ Quản lý stories: create, view, archive, delete
- ✅ Chat với AVA per-story (OpenClaw WebSocket session)
- ✅ Configure pipeline per-story (genre, voice, mood, thresholds)
- ✅ Approve / regress / skip phases với realtime feedback
- ✅ Xem pipeline progress realtime (WebSocket, không polling)
- ✅ Access via kitchen.ava-project.com (Cloudflare Access protected)
Non-Goals
- ❌ Multi-user / team collaboration (solo Rio only)
- ❌ Mobile app (web responsive đủ)
- ❌ Video playback trong dashboard
- ❌ Replace pipeline Python code (dashboard = UI layer only)
Architecture Diagram
Browser (kitchen.ava-project.com)
│ HTTPS + WSS
▼
Cloudflare Access ──── Zero Trust auth
│
▼
Cloudflare Workers (api.kitchen.ava-project.com)
├── REST /api/stories/*
├── WS /api/stories/:id/ws
└── Proxy /api/chat/:id/ws → OpenClaw Gateway
│ Cloudflare Tunnel (cloudflared)
▼
Mac mini M4 (RioMacMinis-Mac-mini.local)
├── OpenClaw Gateway :18795 ← chat sessions
├── Python API Server :8765 ← pipeline control (new)
└── SQLite pipeline.db ← existing DB
│
▼
orchestrator_v2/ ← existing Python pipeline
Tech Stack Decisions
| Layer | Technology | Rationale |
|---|---|---|
| Frontend | Next.js 15 + React 19 | SSR, App Router, streaming |
| Styling | Tailwind CSS v4 + shadcn/ui | Dark theme nhanh, consistent |
| State | Zustand + TanStack Query v5 | Lightweight, cache + realtime |
| WebSocket | Native WebSocket + reconnect | No deps, works với CF Workers |
| Backend | Cloudflare Workers (Hono.js) | Edge, fast, free tier đủ |
| Tunnel | cloudflared | Secure, no port exposure |
| Auth | Cloudflare Access (Zero Trust) | SSO, single user, no code needed |
| DB access | Python FastAPI :8765 on Mac mini | Thin wrapper around pipeline_db.py |
| Deploy | wrangler pages deploy | Already in toolchain |
Part 1 — Foundation & Infrastructure
Goal
Setup toàn bộ project structure, CF Pages + Workers, CF Access, Tunnel, môi trường dev.
Input
- Cloudflare account (đang active)
- Domain: ava-project.com (cần add A/CNAME records)
- Mac mini M4 tại local network
Output
- Next.js app chạy được tại localhost:3000
- CF Pages deploy tại kitchen.ava-project.com
- CF Access protect toàn bộ domain
- cloudflared tunnel kết nối Mac mini → CF
- Python API server chạy tại Mac mini :8765
Must Do
- CF Access policy: allow only rio@ava-project.com (hoặc email Rio đang dùng)
- Tunnel phải auto-restart khi Mac mini reboot (launchd plist)
- Next.js dùng App Router (không Pages Router)
- TypeScript strict mode
Haven't Do
- Không cần database hosted trên CF (dùng Mac mini local)
- Không cần CF KV hay D1 (SQLite trên Mac mini đủ)
- Không cần authentication code (CF Access handle hết)
File Tree
kitchen-dashboard/ ← Next.js project root
├── app/ ← App Router
│ ├── layout.tsx ← Root layout (sidebar + header)
│ ├── page.tsx ← Home (redirect → /stories)
│ ├── stories/
│ │ ├── page.tsx ← Stories list
│ │ ├── new/
│ │ │ └── page.tsx ← Create story modal page
│ │ └── [id]/
│ │ ├── page.tsx ← Story detail (pipeline + chat)
│ │ ├── config/
│ │ │ └── page.tsx ← Pipeline config
│ │ └── loading.tsx ← Loading skeleton
│ ├── api/ ← Next.js API routes (proxy to CF Worker)
│ │ └── [...path]/
│ │ └── route.ts ← Catch-all proxy
│ └── globals.css ← Tailwind base
├── components/
│ ├── layout/
│ │ ├── Sidebar.tsx
│ │ ├── Header.tsx
│ │ └── PageWrapper.tsx
│ ├── stories/
│ │ ├── StoryCard.tsx
│ │ ├── StoryList.tsx
│ │ ├── CreateStoryModal.tsx
│ │ └── DeleteConfirmDialog.tsx
│ ├── pipeline/
│ │ ├── PhaseProgress.tsx ← Vertical stepper
│ │ ├── GateHistory.tsx ← Gate token list
│ │ ├── SceneBoard.tsx ← Per-scene grid
│ │ ├── QualityScores.tsx ← Score charts
│ │ ├── CostTracker.tsx
│ │ └── PhaseControls.tsx ← Approve/Back/Forward buttons
│ ├── chat/
│ │ ├── ChatPanel.tsx ← Main chat container
│ │ ├── ChatMessage.tsx ← Single message (streaming)
│ │ ├── ChatInput.tsx ← Input + send
│ │ └── ToolCallDisplay.tsx ← Show tool calls inline
│ └── config/
│ ├── GenreSelect.tsx
│ ├── ArchetypeSelect.tsx
│ ├── VoiceSelector.tsx
│ ├── EmotionalCurveEditor.tsx
│ └── QualitySliders.tsx
├── lib/
│ ├── api.ts ← API client (fetch wrapper)
│ ├── ws.ts ← WebSocket client with reconnect
│ ├── stores/
│ │ ├── storyStore.ts ← Zustand: story list + selected
│ │ ├── pipelineStore.ts ← Zustand: phase/gate state
│ │ └── chatStore.ts ← Zustand: chat messages
│ ├── types.ts ← All TypeScript types
│ └── constants.ts ← GENRE_REGISTRY, etc (mirrored from Python)
├── worker/ ← Cloudflare Worker (separate deploy)
│ ├── index.ts ← Hono.js app
│ ├── routes/
│ │ ├── stories.ts
│ │ ├── gates.ts
│ │ ├── chat.ts
│ │ └── config.ts
│ ├── middleware/
│ │ └── auth.ts ← CF Access JWT validation
│ └── wrangler.toml
├── package.json
├── tailwind.config.ts
├── tsconfig.json
└── next.config.ts
package.json Dependencies
{
"dependencies": {
"next": "15.2.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"zustand": "^5.0.0",
"@tanstack/react-query": "^5.0.0",
"hono": "^4.6.0",
"tailwindcss": "^4.0.0",
"@shadcn/ui": "latest",
"lucide-react": "^0.400.0",
"react-markdown": "^9.0.0",
"recharts": "^2.12.0",
"clsx": "^2.1.0"
},
"devDependencies": {
"typescript": "^5.5.0",
"wrangler": "^4.0.0",
"vitest": "^2.0.0",
"@playwright/test": "^1.45.0",
"eslint": "^9.0.0"
}
}
Environment Variables
# .env.local (development)
NEXT_PUBLIC_API_URL=http://localhost:8787
NEXT_PUBLIC_WS_URL=ws://localhost:8787
MAC_MINI_API_URL=http://localhost:8765
OPENCLAW_GATEWAY_URL=http://localhost:18795
# .env.production (CF Pages env vars)
NEXT_PUBLIC_API_URL=https://api.kitchen.ava-project.com
NEXT_PUBLIC_WS_URL=wss://api.kitchen.ava-project.com
MAC_MINI_API_URL=http://localhost:8765 # via CF Tunnel, internal only
OPENCLAW_GATEWAY_URL=http://localhost:18795
wrangler.toml (CF Worker)
name = "ava-kitchen-api"
main = "worker/index.ts"
compatibility_date = "2026-01-01"
[vars]
MAC_MINI_API_URL = "https://mac-mini.tunnel.ava-project.com"
OPENCLAW_GATEWAY_URL = "https://openclaw.tunnel.ava-project.com"
[[routes]]
pattern = "api.kitchen.ava-project.com/*"
zone_name = "ava-project.com"
CF Access Policy
# CF Zero Trust → Access → Applications → Add
Application:
name: "AVA Kitchen Dashboard"
subdomain: "kitchen"
domain: "ava-project.com"
session_duration: "24h"
Policy:
name: "Rio only"
action: Allow
rules:
- type: email
value: "riophung00@gmail.com" # Rio's email
cloudflared Tunnel Setup (Mac mini)
# Install
brew install cloudflared
# Authenticate
cloudflared tunnel login
# Create tunnels
cloudflared tunnel create ava-mac-mini
cloudflared tunnel create ava-openclaw
# Config: ~/.cloudflared/config.yml
tunnel: <TUNNEL_ID>
credentials-file: ~/.cloudflared/<TUNNEL_ID>.json
ingress:
- hostname: mac-mini.tunnel.ava-project.com
service: http://localhost:8765
- hostname: openclaw.tunnel.ava-project.com
service: http://localhost:18795
- service: http_status:404
# Auto-start (launchd)
cloudflared service install
Python API Server (Mac mini :8765)
# /Users/riomacmini/ava-project/api_server.py
# FastAPI thin wrapper around pipeline_db.py
# Run: source .venv/bin/activate && uvicorn api_server:app --port 8765
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from orchestrator_v2.pipeline_db import PipelineDB
from pathlib import Path
app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=["*"])
db = PipelineDB(Path("orchestrator_v2/data/pipeline.db"))
@app.get("/stories")
def list_stories(): return db.get_active_stories()
@app.get("/stories/{story_id}")
def get_story(story_id: str):
s = db.get_story(story_id)
if not s: raise HTTPException(404)
return s
@app.get("/stories/{story_id}/scenes")
def get_scenes(story_id: str): return db.get_scene_progress(story_id)
@app.get("/stories/{story_id}/gates")
def get_gates(story_id: str): return db.get_gate_history(story_id)
@app.get("/stories/{story_id}/events")
def get_events(story_id: str, limit: int = 50):
return db.get_recent_events(story_id, limit)
@app.post("/stories/{story_id}/gate")
def post_gate(story_id: str, body: dict):
# body: {gate_id, result, score, approved_by, notes}
db.record_gate(story_id, **body)
return {"ok": True}
Part 2 — Database Layer
Goal
Đọc data từ SQLite pipeline.db có sẵn, thêm 2 bảng mới cho dashboard: chat_sessions và user_preferences.
Input
/Users/riomacmini/ava-project/orchestrator_v2/data/pipeline.db- Existing schema: stories, scenes, gates, events, runs
Output
- Python API server expose tất cả tables qua REST
- 2 bảng mới không conflict với pipeline code
Must Do
- KHÔNG sửa existing tables — pipeline Python code đang dùng
- chat_sessions phải store per-story, per-session history
- user_preferences store config UI state (genre filter, sort order...)
Haven't Do
- Không migrate sang PostgreSQL (SQLite đủ cho solo user)
- Không cần migrations framework (manual ALTER TABLE)
Existing Schema (Read-only for dashboard)
-- stories: pipeline state per story
CREATE TABLE stories (
story_id TEXT PRIMARY KEY,
title TEXT,
phase INTEGER DEFAULT 1,
status TEXT DEFAULT 'active',
voice_id TEXT,
scene_count INTEGER DEFAULT 0,
store_path TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- scenes: per-scene production steps
CREATE TABLE scenes (
story_id TEXT NOT NULL,
scene_num INTEGER NOT NULL,
tts_done INTEGER DEFAULT 0,
timing_done INTEGER DEFAULT 0,
overlay_done INTEGER DEFAULT 0,
portrait_done INTEGER DEFAULT 0,
render_done INTEGER DEFAULT 0,
duration_s REAL,
word_count INTEGER,
updated_at TEXT NOT NULL,
PRIMARY KEY (story_id, scene_num)
);
-- gates: gate token history
CREATE TABLE gates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
story_id TEXT NOT NULL,
gate_id TEXT NOT NULL,
result TEXT NOT NULL, -- PASS | FAIL | SKIP | PENDING_HUMAN
score REAL,
approved_by TEXT,
notes TEXT,
passed_at TEXT NOT NULL
);
-- events: pipeline event log
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
story_id TEXT NOT NULL,
event_type TEXT NOT NULL,
phase INTEGER,
scene_num INTEGER,
data TEXT, -- JSON
created_at TEXT NOT NULL
);
-- runs: pipeline run tracking
CREATE TABLE runs (
run_id TEXT PRIMARY KEY,
story_id TEXT NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT,
status TEXT DEFAULT 'running',
start_phase INTEGER,
end_phase INTEGER,
error TEXT
);
New Tables (Dashboard-only)
-- chat_sessions: OpenClaw chat history per story
CREATE TABLE IF NOT EXISTS chat_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
story_id TEXT NOT NULL,
session_key TEXT NOT NULL, -- e.g. "ava:story:STO-007"
role TEXT NOT NULL, -- "user" | "assistant"
content TEXT NOT NULL, -- message text (markdown)
tool_calls TEXT, -- JSON array of tool calls
created_at TEXT NOT NULL,
FOREIGN KEY (story_id) REFERENCES stories(story_id)
);
CREATE INDEX IF NOT EXISTS idx_chat_story ON chat_sessions(story_id, created_at);
-- user_preferences: dashboard UI state
CREATE TABLE IF NOT EXISTS user_preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL, -- JSON
updated_at TEXT NOT NULL
);
-- Default prefs: {"stories_sort": "updated_desc", "stories_filter": "active", "theme": "dark"}
API Data Access Patterns
GET /stories → db.get_active_stories()
GET /stories/:id → db.get_story() + meta JSON
GET /stories/:id/scenes → db.get_scene_progress()
GET /stories/:id/gates → db.get_gate_history()
GET /stories/:id/events → db.get_recent_events()
GET /stories/:id/runs → db.get_story_runs()
GET /stories/:id/chat → SELECT * FROM chat_sessions WHERE story_id=?
POST /stories/:id/chat → INSERT INTO chat_sessions
POST /stories/:id/gate → db.record_gate()
GET /preferences → SELECT * FROM user_preferences
PUT /preferences/:key → UPSERT user_preferences
Part 3 — API Layer (CF Workers)
Goal
CF Worker làm proxy giữa Next.js frontend và Mac mini API server + OpenClaw Gateway. Handle auth, routing, WebSocket upgrade.
Input
- Request từ browser (JWT từ CF Access trong header)
- CF Access JWT để validate user
Output
- REST responses từ Mac mini API
- WebSocket connections proxied tới Mac mini / OpenClaw
Must Do
- Validate CF Access JWT trên mọi request
- WebSocket: proxy
/api/stories/:id/ws→ Mac mini SSE/WS - WebSocket: proxy
/api/chat/:id/ws→ OpenClaw Gateway :18795 - CORS headers cho localhost:3000 (dev)
Haven't Do
- Không cần CF KV cache (data quá dynamic)
- Không cần request queuing
All Endpoints
GET /api/stories → Mac mini GET /stories
POST /api/stories → Mac mini POST /stories
GET /api/stories/:id → Mac mini GET /stories/:id
DELETE /api/stories/:id → Mac mini DELETE /stories/:id
PATCH /api/stories/:id/config → Mac mini PATCH /stories/:id/config
POST /api/stories/:id/gate → Mac mini POST /stories/:id/gate
GET /api/stories/:id/gates → Mac mini GET /stories/:id/gates
GET /api/stories/:id/events → Mac mini GET /stories/:id/events
GET /api/stories/:id/scenes → Mac mini GET /stories/:id/scenes
GET /api/stories/:id/runs → Mac mini GET /stories/:id/runs
GET /api/stories/:id/chat → Mac mini GET /stories/:id/chat
WS /api/stories/:id/ws → Mac mini WS /stories/:id/ws
WS /api/chat/:id/ws → OpenClaw Gateway WS
GET /api/config/genres → static JSON from constants
GET /api/config/archetypes → static JSON from constants
GET /api/config/voices → Mac mini GET /voices
GET /api/preferences → Mac mini GET /preferences
PUT /api/preferences/:key → Mac mini PUT /preferences/:key
Request/Response Schemas
// GET /api/stories → StoryListResponse
interface StoryListResponse {
stories: Story[];
total: number;
}
interface Story {
story_id: string; // "STO-007"
title: string;
phase: number; // 0-10
status: string; // "active" | "archived" | "completed"
voice_id: string | null;
scene_count: number;
created_at: string; // ISO 8601
updated_at: string;
// từ story_meta.json (merged)
genre?: string;
archetype?: string;
backstory_mode?: string;
thesis_mode?: string;
pipeline_version?: string;
current_gate?: string;
gate_description?: string;
}
// POST /api/stories → CreateStoryRequest
interface CreateStoryRequest {
title: string;
genre: string; // từ GENRE_REGISTRY
archetype: string; // từ ARCHETYPE_REGISTRY
scene_count?: number; // default 25
mode?: "GUIDED" | "AUTO"; // default GUIDED
source_text?: string; // optional source material
}
// POST /api/stories/:id/gate → GateActionRequest
interface GateActionRequest {
action: "approve" | "regress" | "skip" | "abort" | "retry";
gate_id?: string; // e.g. "G3"
to_phase?: number; // for regress
note?: string;
}
interface GateActionResponse {
ok: boolean;
gate_token?: string;
next_phase?: number;
error?: string;
}
// WebSocket messages
type WSMessage =
| { type: "phase_update"; story_id: string; phase: number; status: string }
| { type: "gate_pass"; story_id: string; gate_id: string; score?: number }
| { type: "scene_update"; story_id: string; scene_num: number; step: string }
| { type: "cost_update"; story_id: string; cost: number; limit: number }
| { type: "error"; story_id: string; message: string }
| { type: "ping" }
| { type: "pong" };
CF Worker Implementation (Hono.js)
// worker/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { validateCFAccessJWT } from './middleware/auth'
import stories from './routes/stories'
import gates from './routes/gates'
import chat from './routes/chat'
import config from './routes/config'
const app = new Hono()
app.use('*', cors({
origin: ['https://kitchen.ava-project.com', 'http://localhost:3000'],
}))
app.use('/api/*', validateCFAccessJWT)
app.route('/api/stories', stories)
app.route('/api/stories/:id/gate', gates)
app.route('/api/chat', chat)
app.route('/api/config', config)
export default app
// worker/middleware/auth.ts
import { MiddlewareHandler } from 'hono'
export const validateCFAccessJWT: MiddlewareHandler = async (c, next) => {
// CF Access tự inject Cf-Access-Jwt-Assertion header
const jwt = c.req.header('Cf-Access-Jwt-Assertion')
if (!jwt) return c.json({ error: 'Unauthorized' }, 401)
// CF Access đã validate trước khi request tới Worker
// Chỉ cần check header tồn tại là đủ cho solo user
await next()
}
Part 4 — Stories CRUD
Goal
UI để list, create, view, archive, delete stories. Stories list là main entry point.
Input
- GET /api/stories → array of Story objects
- User action: click card, create new, delete
Output
- Stories list page với cards
- Create modal với form fields
- Story detail navigation
Must Do
- Filter: active | archived | completed | all
- Sort: updated_at desc (default), created_at, title
- Create form validate: title (required), genre (required), archetype (required, filtered by genre)
- Delete: soft-delete (status = "archived"), không xóa DB record
Haven't Do
- Pagination (ít stories, full list đủ)
- Bulk operations
Component: StoryCard
// components/stories/StoryCard.tsx
interface StoryCardProps {
story: Story;
onClick: () => void;
}
export function StoryCard({ story, onClick }: StoryCardProps) {
const phaseLabel = PHASE_LABELS[story.phase] ?? `Phase ${story.phase}`
const progress = (story.phase / 10) * 100
const statusColor = {
active: 'border-l-blue-500',
completed: 'border-l-green-500',
archived: 'border-l-gray-500',
error: 'border-l-red-500',
}[story.status] ?? 'border-l-gray-500'
return (
<div
onClick={onClick}
className={`bg-card border border-border rounded-lg p-4 cursor-pointer
border-l-4 ${statusColor} hover:border-gold transition-colors`}
>
<div className="flex justify-between items-start">
<div>
<span className="text-xs text-muted-foreground font-mono">{story.story_id}</span>
<h3 className="font-semibold mt-1 line-clamp-2">{story.title}</h3>
</div>
<span className="text-xs bg-muted px-2 py-1 rounded uppercase">
{story.genre ?? 'default'}
</span>
</div>
<div className="mt-3">
<div className="flex justify-between text-xs text-muted-foreground mb-1">
<span>{phaseLabel}</span>
<span>{story.scene_count} scenes</span>
</div>
<div className="w-full bg-muted rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{story.gate_description && (
<p className="text-xs text-muted-foreground mt-2 line-clamp-1">
→ {story.gate_description}
</p>
)}
</div>
)
}
Component: CreateStoryModal
// components/stories/CreateStoryModal.tsx
export function CreateStoryModal({ onClose, onCreated }) {
const [form, setForm] = useState({
title: '',
genre: 'revenge',
archetype: '',
scene_count: 25,
mode: 'GUIDED',
})
// Genre change → reset archetype to genre default
const handleGenreChange = (genre: string) => {
const defaultArchetype = GENRE_REGISTRY[genre].default_archetype
setForm(f => ({ ...f, genre, archetype: defaultArchetype }))
}
// Filter archetypes compatible với genre hiện tại
const compatibleArchetypes = Object.entries(ARCHETYPE_REGISTRY)
.filter(([_, v]) => v.compatible_genres.includes(form.genre))
const handleSubmit = async () => {
const res = await fetch('/api/stories', {
method: 'POST',
body: JSON.stringify(form),
headers: { 'Content-Type': 'application/json' },
})
const story = await res.json()
onCreated(story)
}
return (
<Dialog open onClose={onClose}>
<DialogTitle>New Story</DialogTitle>
<DialogContent>
<Input label="Title" value={form.title} onChange={...} required />
<Select label="Genre" value={form.genre} onChange={handleGenreChange}
options={Object.entries(GENRE_REGISTRY).map(([k,v]) => ({value:k, label:v.label}))} />
<Select label="Archetype" value={form.archetype} onChange={...}
options={compatibleArchetypes.map(([k,v]) => ({value:k, label:v.label}))} />
<NumberInput label="Scenes" value={form.scene_count} min={15} max={40} />
<RadioGroup label="Mode" value={form.mode} options={['GUIDED','AUTO']} />
</DialogContent>
<DialogFooter>
<Button onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleSubmit}>Create Story</Button>
</DialogFooter>
</Dialog>
)
}
Zustand Store: storyStore
// lib/stores/storyStore.ts
import { create } from 'zustand'
interface StoryStore {
stories: Story[];
selectedId: string | null;
filter: 'all' | 'active' | 'archived' | 'completed';
sort: 'updated_desc' | 'created_desc' | 'title_asc';
isLoading: boolean;
fetchStories: () => Promise<void>;
selectStory: (id: string) => void;
setFilter: (f: StoryStore['filter']) => void;
setSort: (s: StoryStore['sort']) => void;
deleteStory: (id: string) => Promise<void>;
}
export const useStoryStore = create<StoryStore>((set, get) => ({
stories: [],
selectedId: null,
filter: 'active',
sort: 'updated_desc',
isLoading: false,
fetchStories: async () => {
set({ isLoading: true })
const res = await fetch('/api/stories')
const data = await res.json()
set({ stories: data.stories, isLoading: false })
},
selectStory: (id) => set({ selectedId: id }),
setFilter: (filter) => set({ filter }),
setSort: (sort) => set({ sort }),
deleteStory: async (id) => {
await fetch(`/api/stories/${id}`, { method: 'DELETE' })
set(s => ({ stories: s.stories.filter(x => x.story_id !== id) }))
},
}))
Part 5 — Pipeline Visualization
Goal
Hiển thị realtime trạng thái 11 phases, 17 gates, scene loop, quality scores.
Input
- GET /api/stories/:id → story state
- GET /api/stories/:id/gates → gate history
- GET /api/stories/:id/scenes → scene progress
- WS /api/stories/:id/ws → realtime updates
Output
- Vertical stepper 11 phases
- Gate token list với scores
- Scene grid (per-scene status)
- Quality score radar/bar chart
Must Do
- Phase icons: ✅ done, 🔄 running, ⏳ pending, ❌ failed, 🛑 human gate
- Scene loop: expandable section, show scene 1-N với step status
- Gate: show gate_id, result, score, timestamp
- WebSocket reconnect khi disconnect (max 5 retries, exponential backoff)
Component: PhaseProgress
// components/pipeline/PhaseProgress.tsx
const PHASES = [
{ num: 0, label: 'Ideation', gate: 'G0', auto: true },
{ num: 1, label: 'Research', gate: 'G1', auto: true },
{ num: 2, label: 'Bible Draft', gate: 'G2', auto: true },
{ num: 3, label: 'Bible Lock 🔒', gate: 'G3', auto: false },
{ num: 4, label: 'Outline', gate: 'G4', auto: true },
{ num: 5, label: 'Scene Loop', gate: 'G5', auto: true },
{ num: 6, label: 'Assembly', gate: 'G6', auto: true },
{ num: 7, label: 'Output', gate: 'G8', auto: false },
{ num: 8, label: 'Thumbnail & SEO', gate: 'G8.5', auto: true },
{ num: 9, label: 'Upload', gate: 'G9', auto: true },
{ num: 10, label: 'Monitor', gate: 'G10', auto: true },
]
export function PhaseProgress({ story, gates, onPhaseClick }) {
return (
<div className="space-y-1">
{PHASES.map(phase => {
const isDone = story.phase > phase.num
const isActive = story.phase === phase.num
const isPending = story.phase < phase.num
const isHuman = !phase.auto
const gateRecord = gates.find(g => g.gate_id === phase.gate)
return (
<div
key={phase.num}
onClick={() => onPhaseClick(phase.num)}
className={`flex items-center gap-3 p-2 rounded cursor-pointer
${isActive ? 'bg-blue-950 border border-blue-700' : ''}
${isDone ? 'opacity-80' : ''}
hover:bg-card2`}
>
<div className="w-8 h-8 flex items-center justify-center text-lg">
{isDone ? '✅' : isActive ? '🔄' : isHuman ? '✋' : '⏳'}
</div>
<div className="flex-1">
<div className="text-sm font-medium">{phase.label}</div>
{gateRecord && (
<div className="text-xs text-muted-foreground">
{phase.gate}: {gateRecord.result}
{gateRecord.score != null && ` (${gateRecord.score.toFixed(1)})`}
</div>
)}
</div>
</div>
)
})}
</div>
)
}
WebSocket Client
// lib/ws.ts
export class PipelineWS {
private ws: WebSocket | null = null
private retries = 0
private maxRetries = 5
private handlers: Map<string, (msg: WSMessage) => void> = new Map()
constructor(private storyId: string) {}
connect() {
const url = `${process.env.NEXT_PUBLIC_WS_URL}/api/stories/${this.storyId}/ws`
this.ws = new WebSocket(url)
this.ws.onmessage = (e) => {
const msg: WSMessage = JSON.parse(e.data)
this.handlers.get(msg.type)?.(msg)
this.handlers.get('*')?.(msg)
}
this.ws.onclose = () => {
if (this.retries < this.maxRetries) {
const delay = Math.min(1000 * 2 ** this.retries, 30000)
setTimeout(() => this.connect(), delay)
this.retries++
}
}
this.ws.onopen = () => { this.retries = 0 }
}
on(type: string, handler: (msg: WSMessage) => void) {
this.handlers.set(type, handler)
return this
}
disconnect() { this.ws?.close() }
}
Part 6 — Phase Control
Goal
Nút Approve/Back/Forward để Rio điều khiển pipeline phases.
Input
- Story current phase + status
- Rio click Approve / Back / Forward / Abort
Output
- POST /api/stories/:id/gate với action
- Realtime WebSocket broadcast update
Must Do
- Approve chỉ enable khi: phase hiện tại là human gate (G3 hoặc G8) HOẶC current status = "PENDING_HUMAN"
- Back: confirm dialog "Regress to Phase X?" — double confirm nếu phase > 7
- Forward (skip): double confirm luôn
- Abort: triple confirm
- Optimistic UI: update local state ngay, rollback nếu API fail
PhaseControls Component
// components/pipeline/PhaseControls.tsx
export function PhaseControls({ story, onAction }) {
const [confirming, setConfirming] = useState<string | null>(null)
const isHumanGate = [3, 7].includes(story.phase) || story.status === 'PENDING_HUMAN'
const canBack = story.phase > 0
const canForward = story.phase < 10
const handleAction = async (action: string, opts = {}) => {
if (action === 'approve' && !isHumanGate) return
if (action === 'regress' && confirming !== 'regress') {
setConfirming('regress'); return
}
if (action === 'skip' && confirming !== 'skip') {
setConfirming('skip'); return
}
const res = await fetch(`/api/stories/${story.story_id}/gate`, {
method: 'POST',
body: JSON.stringify({ action, ...opts }),
headers: { 'Content-Type': 'application/json' },
})
const data = await res.json()
if (data.ok) onAction(action, data)
setConfirming(null)
}
return (
<div className="flex gap-2 mt-4">
<Button
disabled={!isHumanGate}
variant="success"
onClick={() => handleAction('approve')}
>
✅ Approve
</Button>
<Button
disabled={!canBack}
variant="warning"
onClick={() => handleAction('regress', { to_phase: story.phase - 1 })}
>
{confirming === 'regress' ? 'Confirm Back?' : '◀ Back'}
</Button>
<Button
disabled={!canForward}
variant="secondary"
onClick={() => handleAction('skip', { to_phase: story.phase + 1 })}
>
{confirming === 'skip' ? 'Confirm Skip?' : '▶ Skip'}
</Button>
<Button variant="danger" onClick={() => handleAction('abort')}>
🛑 Abort
</Button>
</div>
)
}
Part 7 — Chat Session Integration
Goal
Chat realtime với AVA per-story, mỗi story có 1 OpenClaw session riêng. Streaming response, markdown render.
Input
- User gõ message → POST tới OpenClaw Gateway
- OpenClaw stream response tokens → display
Output
- Chat panel bên phải story detail page
- Streaming token display
- Chat history persistent (chat_sessions table)
Must Do
- Session key format:
ava:story:{story_id}(e.g.ava:story:STO-007) - Reconnect on WebSocket drop (same logic as pipeline WS)
- Render markdown: bold, italic, code, tables, lists
- Tool calls: hiển thị collapsible (tool name + result)
- Quick commands: /status, /approve, /cost, /preview → intercept client-side → send structured message
WebSocket Protocol
// Client → Server (send message)
interface ChatSend {
type: "message";
story_id: string;
session_key: string; // "ava:story:STO-007"
text: string;
}
// Server → Client (streaming)
interface ChatChunk {
type: "chunk";
text: string; // token
message_id: string;
}
interface ChatDone {
type: "done";
message_id: string;
full_text: string;
}
interface ChatToolCall {
type: "tool_call";
name: string;
input: Record<string, unknown>;
}
interface ChatToolResult {
type: "tool_result";
name: string;
content: string;
}
interface ChatError {
type: "error";
message: string;
}
ChatPanel Component
// components/chat/ChatPanel.tsx
export function ChatPanel({ storyId }) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const wsRef = useRef<WebSocket | null>(null)
useEffect(() => {
const ws = new WebSocket(
`${process.env.NEXT_PUBLIC_WS_URL}/api/chat/${storyId}/ws`
)
ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
if (msg.type === 'chunk') {
// Append token to last message
setMessages(prev => {
const last = prev[prev.length - 1]
if (last?.role === 'assistant' && last.id === msg.message_id) {
return [...prev.slice(0, -1), { ...last, content: last.content + msg.text }]
}
return [...prev, { id: msg.message_id, role: 'assistant', content: msg.text }]
})
} else if (msg.type === 'done') {
setStreaming(false)
}
}
wsRef.current = ws
return () => ws.close()
}, [storyId])
const send = () => {
if (!input.trim() || streaming) return
const text = input.trim()
setInput('')
setStreaming(true)
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'user', content: text }])
wsRef.current?.send(JSON.stringify({
type: 'message',
story_id: storyId,
session_key: `ava:story:${storyId}`,
text,
}))
}
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map(msg => (
<ChatMessage key={msg.id} message={msg} />
))}
</div>
<div className="p-4 border-t border-border">
<div className="flex gap-2">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && send()}
placeholder="Type message... (/status /approve /cost)"
className="flex-1 bg-card border border-border rounded-lg px-3 py-2 text-sm"
/>
<Button onClick={send} disabled={streaming}>Send</Button>
</div>
</div>
</div>
)
}
Quick Commands
// Intercept before sending to WS
const QUICK_COMMANDS: Record<string, string> = {
'/status': 'What is the current pipeline status for this story? Show phase, gate, cost, and next action.',
'/approve': 'Approve the current human gate and proceed to next phase.',
'/cost': 'Show current cost breakdown: tokens used, credits spent, and remaining budget.',
'/preview': 'Show a preview/summary of the script so far.',
}
const send = () => {
const expanded = QUICK_COMMANDS[input.trim()] ?? input.trim()
// ... send expanded text
}
Part 8 — Pipeline Config UI
Goal
Form để configure genre, archetype, voice, emotional curve, quality thresholds per-story.
Input
- GET /api/stories/:id → current config
- GET /api/config/genres → GENRE_REGISTRY
- GET /api/config/archetypes → ARCHETYPE_REGISTRY
- GET /api/config/voices → Fish Audio voice list
Output
- PATCH /api/stories/:id/config với updated config
- Config persisted trong story_meta.json + DB
Must Do
- Archetype dropdown filter by selected genre
- Voice preview: GET /api/voices/:id/preview → play 5s audio
- Emotional curve: SVG path editor, drag control points
- Quality thresholds: sliders min 0 max 10
- Save disabled nếu no changes (dirty state tracking)
- Validation: archetype must be compatible với genre
GENRE_REGISTRY (constants.ts — mirrored from Python)
// lib/constants.ts
export const GENRE_REGISTRY = {
revenge: {
label: "Family Revenge / Betrayal",
default_archetype: "betrayed_victim",
default_backstory_mode: "front_loaded",
default_thesis_mode: "narrator",
required_beats: ["betrayal", "discovery", "confrontation", "resolution"],
},
healing: {
label: "Healing / Recovery",
default_archetype: "wounded_survivor",
default_backstory_mode: "front_loaded",
default_thesis_mode: "narrator",
required_beats: ["wound", "breakdown", "turning_point", "acceptance"],
},
redemption: {
label: "Redemption / Invisible Good",
default_archetype: "invisible_giver",
default_backstory_mode: "woven",
default_thesis_mode: "environmental",
required_beats: ["selfless_act","unrewarded_stretch","unfair_opponent_rise","opponent_fall","quiet_payoff"],
},
hidden_identity: {
label: "Hidden Identity / Competence Reveal",
default_archetype: "suppressed_identity",
default_backstory_mode: "earned",
default_thesis_mode: "character_spoken",
required_beats: ["hidden_state","crisis_trigger","competence_reveal","backstory_earn","villain_layer2_exposed","identity_restored"],
},
injustice: {
label: "Injustice / Wrongful Accusation",
default_archetype: "wrongly_accused",
default_backstory_mode: "revelation",
default_thesis_mode: "protagonist_earned",
required_beats: ["accusation","isolation","ally_appears","evidence_found","true_villain_exposed","vindication"],
},
transformation: {
label: "Transformation / Underdog Rise",
default_archetype: "underdog",
default_backstory_mode: "woven",
default_thesis_mode: "environmental",
required_beats: ["low_point","catalyst","first_win","setback","mastery","proving_moment"],
},
} as const
export const ARCHETYPE_REGISTRY = {
betrayed_victim: { label: "Victim of Betrayal", compatible_genres: ["revenge","healing","default"] },
wounded_survivor: { label: "Wounded Survivor", compatible_genres: ["healing","transformation"] },
invisible_giver: { label: "Invisible Giver", compatible_genres: ["redemption"] },
suppressed_identity: { label: "Suppressed Identity", compatible_genres: ["hidden_identity","transformation"] },
wrongly_accused: { label: "Wrongly Accused", compatible_genres: ["injustice"] },
underdog: { label: "Underdog", compatible_genres: ["transformation","redemption"] },
hidden_competence: { label: "Hidden Competence", compatible_genres: ["hidden_identity"] },
} as const
export const SCRIPT_STANDARDS = {
consistency_min: 9.0,
originality_min: 8.5,
hook_min: 8.5,
cliffhanger_min: 8.0,
emotional_min: 8.5,
tts_min: 9.0,
}
export const PHASE_LABELS: Record<number, string> = {
0: 'Phase 0 — Ideation',
1: 'Phase 1 — Research',
2: 'Phase 2 — Bible Draft',
3: 'Phase 3 — Bible Lock 🔒',
4: 'Phase 4 — Outline',
5: 'Phase 5 — Scene Loop',
6: 'Phase 6 — Assembly',
7: 'Phase 7 — Output',
8: 'Phase 8 — Thumbnail & SEO',
9: 'Phase 9 — Upload',
10: 'Phase 10 — Monitor',
}
EmotionalCurveEditor Algorithm
// components/config/EmotionalCurveEditor.tsx
// SVG interactive curve editor — drag control points to reshape emotional arc
interface Point { x: number; y: number } // x: 0-1 (story progress), y: 0-1 (intensity)
export function EmotionalCurveEditor({ points, onChange }) {
const [dragging, setDragging] = useState<number | null>(null)
const svgRef = useRef<SVGSVGElement>(null)
const W = 400, H = 150, PAD = 20
// Convert data coords to SVG coords
const toSVG = (p: Point) => ({
x: PAD + p.x * (W - 2*PAD),
y: PAD + (1 - p.y) * (H - 2*PAD)
})
// Convert SVG coords to data coords
const toData = (svgX: number, svgY: number): Point => ({
x: Math.max(0, Math.min(1, (svgX - PAD) / (W - 2*PAD))),
y: Math.max(0, Math.min(1, 1 - (svgY - PAD) / (H - 2*PAD)))
})
// Build SVG path from points (catmull-rom spline)
const pathD = points.map((p, i) => {
const s = toSVG(p)
return i === 0 ? `M ${s.x} ${s.y}` : `L ${s.x} ${s.y}`
}).join(' ')
const handleMouseMove = (e: React.MouseEvent) => {
if (dragging === null) return
const rect = svgRef.current!.getBoundingClientRect()
const newPt = toData(e.clientX - rect.left, e.clientY - rect.top)
// Lock x for first/last points
if (dragging === 0) newPt.x = 0
if (dragging === points.length - 1) newPt.x = 1
onChange(points.map((p, i) => i === dragging ? newPt : p))
}
return (
<svg ref={svgRef} width={W} height={H}
onMouseMove={handleMouseMove}
onMouseUp={() => setDragging(null)}
className="bg-card border border-border rounded"
>
{/* Grid lines */}
{[0.25,0.5,0.75].map(x => (
<line key={x} x1={PAD+x*(W-2*PAD)} y1={PAD} x2={PAD+x*(W-2*PAD)} y2={H-PAD}
stroke="rgba(255,255,255,0.1)" />
))}
{/* Curve */}
<path d={pathD} fill="none" stroke="#C9A96E" strokeWidth={2} />
{/* Control points */}
{points.map((p, i) => {
const s = toSVG(p)
return (
<circle key={i} cx={s.x} cy={s.y} r={6}
fill={dragging === i ? '#C9A96E' : '#1B2838'}
stroke="#C9A96E" strokeWidth={2}
cursor="grab"
onMouseDown={() => setDragging(i)}
/>
)
})}
</svg>
)
}
Part 9 — Deployment & DevOps
Goal
Deploy Next.js frontend lên CF Pages, Workers API lên CF Edge, tunnel Mac mini.
Must Do
- CF Pages: automatic deploy từ local via wrangler
- Workers: separate deploy từ worker/ directory
- DNS: kitchen.ava-project.com → CF Pages, api.kitchen.ava-project.com → CF Workers
- cloudflared: launchd plist để auto-restart
DNS Records (add in CF dashboard)
kitchen.ava-project.com CNAME kitchen-dashboard.pages.dev
api.kitchen.ava-project.com CNAME ava-kitchen-api.workers.dev
Deploy Commands
# Frontend (CF Pages)
cd kitchen-dashboard
npx wrangler pages deploy out/ --project-name kitchen-dashboard --branch main
# Worker (CF Workers)
cd kitchen-dashboard/worker
npx wrangler deploy
# Python API server (Mac mini)
cd /Users/riomacmini/ava-project
source .venv/bin/activate
uvicorn api_server:app --host 0.0.0.0 --port 8765 --reload
# CF Tunnel
cloudflared tunnel run ava-mac-mini
launchd Plist (auto-start tunnel)
<!-- ~/Library/LaunchAgents/com.cloudflare.tunnel.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "...">
<plist version="1.0">
<dict>
<key>Label</key><string>com.cloudflare.tunnel</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/cloudflared</string>
<string>tunnel</string><string>run</string>
<string>ava-mac-mini</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardErrorPath</key><string>/tmp/cloudflared.err</string>
</dict>
</plist>
Dev Workflow
# 1. Start Mac mini Python API
cd /Users/riomacmini/ava-project
source .venv/bin/activate && uvicorn api_server:app --port 8765
# 2. Start CF Worker locally
cd kitchen-dashboard/worker && npx wrangler dev
# 3. Start Next.js
cd kitchen-dashboard && npm run dev
# 4. Open localhost:3000
Part 10 — Testing Strategy
Goal
Đảm bảo mỗi part hoạt động đúng. AI dev phải viết tests trước code (TDD).
Unit Tests (Vitest)
// tests/unit/storyStore.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { useStoryStore } from '@/lib/stores/storyStore'
describe('storyStore', () => {
it('fetchStories populates stories array', async () => {
// mock fetch
})
it('filter=active excludes archived stories', () => {})
it('deleteStory removes from list', async () => {})
})
// tests/unit/constants.test.ts
describe('GENRE_REGISTRY', () => {
it('all genres have required_beats', () => {
Object.values(GENRE_REGISTRY).forEach(g => {
expect(g.required_beats.length).toBeGreaterThan(0)
})
})
it('archetype compatible_genres match genre keys', () => {
Object.values(ARCHETYPE_REGISTRY).forEach(a => {
a.compatible_genres.forEach(g => {
expect(Object.keys(GENRE_REGISTRY)).toContain(g)
})
})
})
})
Integration Tests (Vitest + MSW)
// tests/integration/api.test.ts
// Mock CF Worker responses, test React components
describe('Stories list page', () => {
it('renders story cards for each story', async () => {})
it('filter change updates displayed stories', async () => {})
it('create story modal submits correctly', async () => {})
})
E2E Tests (Playwright)
// tests/e2e/stories.spec.ts
import { test, expect } from '@playwright/test'
test('create a new story', async ({ page }) => {
await page.goto('http://localhost:3000/stories')
await page.click('text=New Story')
await page.fill('[placeholder="Story title"]', 'Test Story E2E')
await page.selectOption('[name=genre]', 'revenge')
await page.click('text=Create Story')
await expect(page.locator('text=Test Story E2E')).toBeVisible()
})
test('approve a human gate', async ({ page }) => {
await page.goto('http://localhost:3000/stories/STO-TEST-001')
await page.click('text=✅ Approve')
await expect(page.locator('text=Phase 4')).toBeVisible()
})
Test Data Fixtures
{
"stories": [
{
"story_id": "STO-TEST-001",
"title": "Test Revenge Story",
"phase": 3,
"status": "active",
"genre": "revenge",
"archetype": "betrayed_victim",
"scene_count": 25,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
]
}
Implementation Order (Recommended)
| Part | Name | Estimated Days | Dependencies |
|---|---|---|---|
| 1 | Foundation | 1-2 | None |
| 2 | Database Layer | 0.5 | Part 1 |
| 4 | Stories CRUD | 1-2 | Parts 1-2 |
| 5 | Pipeline Viz | 1-2 | Part 4 |
| 3 | API Layer | 1 | Part 2 |
| 6 | Phase Control | 1 | Parts 3,5 |
| 7 | Chat Session | 2-3 | Parts 3,4 |
| 8 | Pipeline Config | 1 | Parts 3,4 |
| 9 | Deployment | 1 | All |
| 10 | Testing | Ongoing | Each part |
| Total | 9-13 days |
Prompt Templates (for AI dev agents)
Prompt: Part 1 (Foundation)
You are implementing Part 1 of AVA Kitchen Dashboard (OPS-021).
Goal: Setup Next.js 15 project with CF Pages deploy, CF Access auth, cloudflared tunnel.
Input: See file tree in DASHBOARD_IMPLEMENTATION_SPEC.md Part 1.
Must do: App Router, TypeScript strict, Tailwind v4, shadcn/ui dark theme.
Output: Working Next.js app at localhost:3000, deployable to CF Pages.
Read DASHBOARD_IMPLEMENTATION_SPEC.md Part 1 fully before writing any code.
Prompt: Part 4 (Stories CRUD)
You are implementing Part 4 of AVA Kitchen Dashboard (OPS-021).
Goal: Stories list page with CRUD (create, view, archive, delete).
Input: API spec from Part 3, component specs from Part 4.
Must do: StoryCard component, CreateStoryModal, Zustand storyStore.
Output: /stories page with working create and delete.
Read DASHBOARD_IMPLEMENTATION_SPEC.md Parts 2-4 fully before writing any code.
Prompt: Part 7 (Chat)
You are implementing Part 7 of AVA Kitchen Dashboard (OPS-021).
Goal: Per-story chat panel connecting to OpenClaw Gateway via WebSocket.
Input: WS protocol spec from Part 7. OpenClaw Gateway at localhost:18795.
Must do: Streaming token display, markdown render, reconnect logic, quick commands.
Output: ChatPanel component showing in right panel of Story Detail page.
Read DASHBOARD_IMPLEMENTATION_SPEC.md Part 7 fully before writing any code.
Generated: 2026-03-24 | OPS-021 | kitchen.ava-project.com Source: pipeline_db.py, dashboard_connector.py, config.py — orchestrator_v2 dev branch