← AVA Docs Hub

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

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

Non-Goals

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

Output

Must Do

Haven't Do

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_sessionsuser_preferences.

Input

Output

Must Do

Haven't Do

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

Output

Must Do

Haven't Do

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

Output

Must Do

Haven't Do

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

Output

Must Do

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

Output

Must Do

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

Output

Must Do

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

Output

Must Do

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

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"
    }
  ]
}

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