diff --git a/docs/superpowers/plans/2026-05-07-pi-agent-video-workflow.md b/docs/superpowers/plans/2026-05-07-pi-agent-video-workflow.md
new file mode 100644
index 0000000..651a623
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-07-pi-agent-video-workflow.md
@@ -0,0 +1,2879 @@
+# 美图 Agent — 视频创作可视化界面 实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 构建基于 pi-agent 的视频创作 Web 可视化界面,三栏布局,支持账户管理、提示词编辑、聊天驱动 Pipeline、资产画廊。
+
+**Architecture:** React SPA (Vite + Tailwind + shadcn/ui) 通过 REST + WebSocket 与 Express 后端通信。后端集成 pi-agent-core SDK 编排对话,直接调用 pipeline.js 执行视频创作流程。SQLite 存储会话、资产和配置。
+
+**Tech Stack:** TypeScript, Vite, React 18, Tailwind CSS, shadcn/ui, Express, ws, better-sqlite3, pi-agent-core, Zustand
+
+---
+
+## 文件结构(全量)
+
+```
+web/
+├── package.json
+├── tsconfig.base.json
+├── server/
+│ ├── tsconfig.json
+│ ├── index.ts
+│ ├── db/
+│ │ ├── index.ts
+│ │ └── schema.ts
+│ ├── routes/
+│ │ ├── accounts.ts
+│ │ ├── prompts.ts
+│ │ ├── pipeline.ts
+│ │ ├── assets.ts
+│ │ └── configs.ts
+│ ├── agent/
+│ │ ├── index.ts
+│ │ └── tools.ts
+│ └── ws/
+│ └── chat.ts
+├── client/
+│ ├── tsconfig.json
+│ ├── index.html
+│ ├── vite.config.ts
+│ ├── tailwind.config.ts
+│ ├── postcss.config.js
+│ └── src/
+│ ├── main.tsx
+│ ├── App.tsx
+│ ├── index.css
+│ ├── lib/
+│ │ ├── api.ts
+│ │ └── websocket.ts
+│ ├── hooks/
+│ │ ├── useAccounts.ts
+│ │ ├── usePrompts.ts
+│ │ ├── useAssets.ts
+│ │ └── useChat.ts
+│ ├── types/
+│ │ └── index.ts
+│ ├── components/
+│ │ ├── ui/ # shadcn/ui (generated)
+│ │ ├── layout/
+│ │ │ ├── AppLayout.tsx
+│ │ │ ├── Sidebar.tsx
+│ │ │ └── MiddlePanel.tsx
+│ │ ├── chat/
+│ │ │ ├── ChatView.tsx
+│ │ │ ├── ChatMessage.tsx
+│ │ │ ├── ChatInput.tsx
+│ │ │ └── PipelineProgress.tsx
+│ │ ├── accounts/
+│ │ │ ├── AccountList.tsx
+│ │ │ └── AccountForm.tsx
+│ │ ├── prompts/
+│ │ │ └── PromptEditor.tsx
+│ │ ├── assets/
+│ │ │ ├── AssetGallery.tsx
+│ │ │ └── AssetPreview.tsx
+│ │ └── config/
+│ │ └── ConfigForm.tsx
+│ └── store/
+│ └── index.ts
+```
+
+---
+
+## P0: 项目脚手架
+
+### Task 0.1: 初始化项目与 package.json
+
+**Files:**
+- Create: `web/package.json`
+- Create: `web/tsconfig.base.json`
+- Create: `web/server/tsconfig.json`
+- Create: `web/client/tsconfig.json`
+
+- [ ] **Step 1: 创建 web/package.json**
+
+```bash
+mkdir -p web/server web/client/src
+```
+
+```json
+{
+ "name": "meitu-agent",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
+ "dev:server": "tsx watch server/index.ts",
+ "dev:client": "vite --config client/vite.config.ts",
+ "build": "vite build --config client/vite.config.ts",
+ "db:init": "tsx server/db/schema.ts"
+ },
+ "dependencies": {
+ "express": "^4.21.0",
+ "ws": "^8.18.0",
+ "better-sqlite3": "^11.6.0",
+ "zod": "^3.23.0",
+ "cors": "^2.8.5",
+ "zustand": "^5.0.0",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0",
+ "lucide-react": "^0.460.0",
+ "clsx": "^2.1.0",
+ "tailwind-merge": "^2.6.0"
+ },
+ "devDependencies": {
+ "@types/express": "^5.0.0",
+ "@types/ws": "^8.5.0",
+ "@types/better-sqlite3": "^7.6.0",
+ "@types/cors": "^2.8.0",
+ "@types/react": "^18.3.0",
+ "@types/react-dom": "^18.3.0",
+ "typescript": "^5.6.0",
+ "tsx": "^4.19.0",
+ "vite": "^5.4.0",
+ "@vitejs/plugin-react": "^4.3.0",
+ "tailwindcss": "^3.4.0",
+ "postcss": "^8.4.0",
+ "autoprefixer": "^10.4.0",
+ "concurrently": "^9.1.0"
+ }
+}
+```
+
+- [ ] **Step 2: 创建 tsconfig.base.json**
+
+```json
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true
+ }
+}
+```
+
+- [ ] **Step 3: 创建 server/tsconfig.json**
+
+```json
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "module": "ESNext",
+ "outDir": "./dist",
+ "rootDir": "."
+ },
+ "include": ["./**/*.ts"]
+}
+```
+
+- [ ] **Step 4: 创建 client/tsconfig.json**
+
+```json
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["./src/**/*.ts", "./src/**/*.tsx"]
+}
+```
+
+- [ ] **Step 5: 安装依赖**
+
+```bash
+cd web && npm install
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add web/package.json web/tsconfig.base.json web/server/tsconfig.json web/client/tsconfig.json web/package-lock.json
+git commit -m "feat(web): init project scaffold with package.json and tsconfig"
+```
+
+---
+
+### Task 0.2: 初始化 Vite + React + Tailwind + shadcn/ui
+
+**Files:**
+- Create: `web/client/index.html`
+- Create: `web/client/vite.config.ts`
+- Create: `web/client/tailwind.config.ts`
+- Create: `web/client/postcss.config.js`
+- Create: `web/client/src/main.tsx`
+- Create: `web/client/src/index.css`
+- Create: `web/client/src/lib/utils.ts`
+
+- [ ] **Step 1: 创建 client/index.html**
+
+```html
+
+
+
+
+
+ 美图 Agent
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: 创建 client/vite.config.ts**
+
+```typescript
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import path from 'path';
+
+export default defineConfig({
+ plugins: [react()],
+ root: path.resolve(__dirname),
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, 'src'),
+ },
+ },
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': 'http://localhost:3001',
+ '/ws': {
+ target: 'ws://localhost:3001',
+ ws: true,
+ },
+ },
+ },
+});
+```
+
+- [ ] **Step 3: 创建 client/tailwind.config.ts**
+
+```typescript
+import type { Config } from 'tailwindcss';
+
+export default {
+ content: ['./client/src/**/*.{ts,tsx}'],
+ darkMode: 'class',
+ theme: {
+ extend: {
+ colors: {
+ border: 'hsl(240 3.7% 15.9%)',
+ input: 'hsl(240 3.7% 15.9%)',
+ ring: 'hsl(240 4.9% 83.9%)',
+ background: 'hsl(240 10% 3.9%)',
+ foreground: 'hsl(0 0% 98%)',
+ primary: {
+ DEFAULT: 'hsl(0 0% 98%)',
+ foreground: 'hsl(240 5.9% 10%)',
+ },
+ secondary: {
+ DEFAULT: 'hsl(240 3.7% 15.9%)',
+ foreground: 'hsl(0 0% 98%)',
+ },
+ muted: {
+ DEFAULT: 'hsl(240 3.7% 15.9%)',
+ foreground: 'hsl(240 5% 64.9%)',
+ },
+ accent: {
+ DEFAULT: 'hsl(240 3.7% 15.9%)',
+ foreground: 'hsl(0 0% 98%)',
+ },
+ destructive: {
+ DEFAULT: 'hsl(0 62.8% 30.6%)',
+ foreground: 'hsl(0 0% 98%)',
+ },
+ },
+ borderRadius: {
+ lg: '0.5rem',
+ md: 'calc(0.5rem - 2px)',
+ sm: 'calc(0.5rem - 4px)',
+ },
+ },
+ },
+ plugins: [],
+} satisfies Config;
+```
+
+- [ ] **Step 4: 创建 client/postcss.config.js**
+
+```javascript
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
+```
+
+- [ ] **Step 5: 创建 client/src/index.css**
+
+```css
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-zinc-950 text-zinc-50;
+ }
+}
+```
+
+- [ ] **Step 6: 创建 client/src/lib/utils.ts**
+
+```typescript
+import { clsx, type ClassValue } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+```
+
+- [ ] **Step 7: 创建 client/src/main.tsx**
+
+```typescript
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App';
+import './index.css';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+);
+```
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add web/client/
+git commit -m "feat(web): init Vite + React + Tailwind client scaffold"
+```
+
+---
+
+### Task 0.3: 初始化 shadcn/ui 基础组件
+
+**Files:**
+- Create: `web/components.json`
+- Create: `web/client/src/components/ui/button.tsx`
+- Create: `web/client/src/components/ui/input.tsx`
+- Create: `web/client/src/components/ui/scroll-area.tsx`
+
+- [ ] **Step 1: 创建 components.json**
+
+```json
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "default",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "client/tailwind.config.ts",
+ "css": "client/src/index.css",
+ "baseColor": "zinc",
+ "cssVariables": true
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils"
+ }
+}
+```
+
+- [ ] **Step 2: 创建 button.tsx**
+
+```typescript
+import * as React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { cn } from '@/lib/utils';
+
+const buttonVariants = cva(
+ 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+ destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
+ outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
+ secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+ ghost: 'hover:bg-accent hover:text-accent-foreground',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-10 px-4 py-2',
+ sm: 'h-9 rounded-md px-3',
+ lg: 'h-11 rounded-md px-8',
+ icon: 'h-10 w-10',
+ },
+ },
+ defaultVariants: { variant: 'default', size: 'default' },
+ }
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, ...props }, ref) => (
+
+ )
+);
+Button.displayName = 'Button';
+
+export { Button, buttonVariants };
+```
+
+- [ ] **Step 3: 创建 input.tsx**(同样标准 shadcn input)
+
+```typescript
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+export interface InputProps extends React.InputHTMLAttributes {}
+
+const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => (
+
+ )
+);
+Input.displayName = 'Input';
+
+export { Input };
+```
+
+- [ ] **Step 4: 创建 scroll-area.tsx**(简化版)
+
+```typescript
+'use client';
+
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+const ScrollArea = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+));
+ScrollArea.displayName = 'ScrollArea';
+
+export { ScrollArea };
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web/components.json web/client/src/components/ui/ web/client/src/lib/utils.ts
+git commit -m "feat(web): add shadcn/ui base components (Button, Input, ScrollArea)"
+```
+
+---
+
+### Task 0.4: Express + WebSocket 后端入口
+
+**Files:**
+- Create: `web/server/index.ts`
+
+- [ ] **Step 1: 创建 server/index.ts**
+
+```typescript
+import express from 'express';
+import cors from 'cors';
+import { createServer } from 'http';
+import { WebSocketServer } from 'ws';
+import { initDb } from './db';
+import { accountsRouter } from './routes/accounts';
+import { promptsRouter } from './routes/prompts';
+import { pipelineRouter } from './routes/pipeline';
+import { assetsRouter } from './routes/assets';
+import { configsRouter } from './routes/configs';
+import { handleChat } from './ws/chat';
+
+const app = express();
+const server = createServer(app);
+const wss = new WebSocketServer({ server, path: '/ws' });
+
+app.use(cors());
+app.use(express.json({ limit: '50mb' }));
+
+app.use('/api/accounts', accountsRouter);
+app.use('/api/prompts', promptsRouter);
+app.use('/api/pipeline', pipelineRouter);
+app.use('/api/assets', assetsRouter);
+app.use('/api/configs', configsRouter);
+
+wss.on('connection', handleChat);
+
+const PORT = 3001;
+initDb();
+server.listen(PORT, () => {
+ console.log(`Server running on http://localhost:${PORT}`);
+});
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add web/server/index.ts
+git commit -m "feat(web): add Express + WebSocket server entry"
+```
+
+---
+
+### Task 0.5: SQLite 数据库初始化
+
+**Files:**
+- Create: `web/server/db/index.ts`
+- Create: `web/server/db/schema.ts`
+
+- [ ] **Step 1: 创建 server/db/index.ts**
+
+```typescript
+import Database from 'better-sqlite3';
+import path from 'path';
+
+const DB_PATH = path.resolve(__dirname, '..', '..', 'data', 'meitu-agent.db');
+
+let db: Database.Database;
+
+export function getDb(): Database.Database {
+ if (!db) {
+ const fs = require('fs');
+ const dir = path.dirname(DB_PATH);
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+ db = new Database(DB_PATH);
+ db.pragma('journal_mode = WAL');
+ db.pragma('foreign_keys = ON');
+ }
+ return db;
+}
+
+export function initDb(): void {
+ const d = getDb();
+ d.exec(`
+ CREATE TABLE IF NOT EXISTS conversations (
+ id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ account_id TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+
+ CREATE TABLE IF NOT EXISTS messages (
+ id TEXT PRIMARY KEY,
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
+ role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')),
+ content TEXT NOT NULL,
+ tool_calls TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+
+ CREATE TABLE IF NOT EXISTS assets (
+ id TEXT PRIMARY KEY,
+ account_id TEXT,
+ manifest_path TEXT,
+ type TEXT NOT NULL CHECK(type IN ('image', 'video')),
+ file_path TEXT NOT NULL,
+ url TEXT,
+ shot_index INTEGER,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+
+ CREATE TABLE IF NOT EXISTS pipeline_runs (
+ id TEXT PRIMARY KEY,
+ manifest_path TEXT NOT NULL,
+ phase TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','done','failed')),
+ started_at TEXT,
+ finished_at TEXT
+ );
+
+ CREATE TABLE IF NOT EXISTS configs (
+ id TEXT PRIMARY KEY,
+ key TEXT NOT NULL UNIQUE,
+ value TEXT NOT NULL DEFAULT '{}',
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, created_at);
+ CREATE INDEX IF NOT EXISTS idx_assets_account ON assets(account_id, created_at);
+ CREATE INDEX IF NOT EXISTS idx_pipeline_manifest ON pipeline_runs(manifest_path);
+ `);
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add web/server/db/
+git commit -m "feat(web): add SQLite schema and connection"
+```
+
+---
+
+### Task 0.6: 三栏布局壳
+
+**Files:**
+- Create: `web/client/src/App.tsx`
+- Create: `web/client/src/types/index.ts`
+- Create: `web/client/src/store/index.ts`
+- Create: `web/client/src/components/layout/AppLayout.tsx`
+- Create: `web/client/src/components/layout/Sidebar.tsx`
+- Create: `web/client/src/components/layout/MiddlePanel.tsx`
+
+- [ ] **Step 1: 创建 types/index.ts**
+
+```typescript
+export type NavView = 'chat' | 'accounts' | 'assets' | 'config';
+
+export interface Account {
+ id: string;
+ name: string;
+ description: string;
+ defaultFormat: string;
+ imageModel: string;
+ videoModel: string;
+ batchSize: number;
+ ttsVoice: string;
+ ttsInstruction: string;
+ storyboardPrompt: string;
+ imageStylePrompt: string;
+ videoStylePrompt: string;
+ references: { file: string; url?: string }[];
+ capcut: Record;
+}
+
+export interface Conversation {
+ id: string;
+ title: string;
+ account_id: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface Message {
+ id: string;
+ conversation_id: string;
+ role: 'user' | 'assistant' | 'system' | 'tool';
+ content: string;
+ tool_calls?: unknown;
+ created_at: string;
+}
+
+export interface Asset {
+ id: string;
+ account_id: string | null;
+ manifest_path: string | null;
+ type: 'image' | 'video';
+ file_path: string;
+ url: string | null;
+ shot_index: number | null;
+ created_at: string;
+}
+
+export interface ConfigItem {
+ id: string;
+ key: string;
+ value: Record;
+ updated_at: string;
+}
+```
+
+- [ ] **Step 2: 创建 store/index.ts (Zustand)**
+
+```typescript
+import { create } from 'zustand';
+import type { NavView, Account, Conversation } from '@/types';
+
+interface AppState {
+ activeView: NavView;
+ setActiveView: (view: NavView) => void;
+ selectedAccountId: string | null;
+ setSelectedAccountId: (id: string | null) => void;
+ activeConversationId: string | null;
+ setActiveConversationId: (id: string | null) => void;
+ conversations: Conversation[];
+ setConversations: (list: Conversation[]) => void;
+}
+
+export const useAppStore = create((set) => ({
+ activeView: 'chat',
+ setActiveView: (view) => set({ activeView: view }),
+ selectedAccountId: null,
+ setSelectedAccountId: (id) => set({ selectedAccountId: id }),
+ activeConversationId: null,
+ setActiveConversationId: (id) => set({ activeConversationId: id }),
+ conversations: [],
+ setConversations: (list) => set({ conversations: list }),
+}));
+```
+
+- [ ] **Step 3: 创建 AppLayout.tsx**
+
+```typescript
+import { Sidebar } from './Sidebar';
+import { MiddlePanel } from './MiddlePanel';
+import { useAppStore } from '@/store';
+
+export function AppLayout({ children }: { children: React.ReactNode }) {
+ const activeView = useAppStore((s) => s.activeView);
+
+ return (
+
+
+ {activeView === 'chat' && }
+
+ {children}
+
+
+ );
+}
+```
+
+- [ ] **Step 4: 创建 Sidebar.tsx**
+
+```typescript
+import { MessageCircle, FolderOpen, Image, Settings } from 'lucide-react';
+import { useAppStore } from '@/store';
+import type { NavView } from '@/types';
+import { cn } from '@/lib/utils';
+
+const navItems: { id: NavView; icon: typeof MessageCircle; label: string }[] = [
+ { id: 'chat', icon: MessageCircle, label: '对话' },
+ { id: 'accounts', icon: FolderOpen, label: '账户' },
+ { id: 'assets', icon: Image, label: '资产' },
+ { id: 'config', icon: Settings, label: '设置' },
+];
+
+export function Sidebar() {
+ const { activeView, setActiveView } = useAppStore();
+
+ return (
+
+ );
+}
+```
+
+- [ ] **Step 5: 创建 MiddlePanel.tsx(对话列表占位)**
+
+```typescript
+import { Plus } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { useAppStore } from '@/store';
+
+export function MiddlePanel() {
+ const { conversations, activeConversationId, setActiveConversationId } = useAppStore();
+
+ return (
+
+ );
+}
+```
+
+- [ ] **Step 6: 创建 App.tsx**
+
+```typescript
+import { AppLayout } from '@/components/layout/AppLayout';
+import { ChatView } from '@/components/chat/ChatView';
+import { AccountList } from '@/components/accounts/AccountList';
+import { AssetGallery } from '@/components/assets/AssetGallery';
+import { ConfigForm } from '@/components/config/ConfigForm';
+import { useAppStore } from '@/store';
+import { useEffect } from 'react';
+
+function MainContent() {
+ const view = useAppStore((s) => s.activeView);
+ switch (view) {
+ case 'chat': return ;
+ case 'accounts': return ;
+ case 'assets': return ;
+ case 'config': return ;
+ }
+}
+
+export default function App() {
+ useEffect(() => {
+ fetch('/api/configs')
+ .then((r) => r.json())
+ .catch(() => {});
+ }, []);
+
+ return (
+
+
+
+ );
+}
+```
+
+- [ ] **Step 7: 创建占位组件(空壳,后续任务填充)**
+
+创建以下占位文件,每个只返回一个空 div:
+
+`chat/ChatView.tsx`:
+```typescript
+export function ChatView() {
+ return 选择或开始新对话
;
+}
+```
+
+`accounts/AccountList.tsx`:
+```typescript
+export function AccountList() {
+ return 账户管理
;
+}
+```
+
+`assets/AssetGallery.tsx`:
+```typescript
+export function AssetGallery() {
+ return 资产画廊
;
+}
+```
+
+`config/ConfigForm.tsx`:
+```typescript
+export function ConfigForm() {
+ return 设置
;
+}
+```
+
+- [ ] **Step 8: 验证: 启动 dev server**
+
+```bash
+cd web && npm run dev
+```
+
+打开 http://localhost:5173,确认三栏布局渲染正常(左侧图标栏 56px + 中间列表 240px + 右侧主区域)。
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add web/client/src/
+git commit -m "feat(web): add three-column layout shell with placeholder views"
+```
+
+---
+
+## P1: 账户管理 + 配置
+
+### Task 1.1: 类型定义 + API Client
+
+**Files:**
+- Create: `web/client/src/lib/api.ts`
+
+- [ ] **Step 1: 创建 lib/api.ts**
+
+```typescript
+import type { Account, Asset, Conversation, Message, ConfigItem } from '@/types';
+
+const BASE = '/api';
+
+async function request(path: string, options?: RequestInit): Promise {
+ const res = await fetch(`${BASE}${path}`, {
+ headers: { 'Content-Type': 'application/json' },
+ ...options,
+ });
+ if (!res.ok) throw new Error(await res.text());
+ return res.json();
+}
+
+export const api = {
+ // Accounts
+ listAccounts: () => request('/accounts'),
+ getAccount: (id: string) => request(`/accounts/${id}`),
+ createAccount: (data: Partial) =>
+ request('/accounts', { method: 'POST', body: JSON.stringify(data) }),
+ updateAccount: (id: string, data: Partial) =>
+ request(`/accounts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
+ deleteAccount: (id: string) =>
+ request(`/accounts/${id}`, { method: 'DELETE' }),
+
+ // Prompts
+ getPrompt: (accountId: string, type: string) =>
+ request<{ path: string; content: string }>(`/prompts/${accountId}/${type}`),
+ savePrompt: (accountId: string, type: string, content: string) =>
+ request(`/prompts/${accountId}/${type}`, { method: 'PUT', body: JSON.stringify({ content }) }),
+
+ // Conversations
+ listConversations: () => request('/pipeline/conversations'),
+ getMessages: (convId: string) => request(`/pipeline/conversations/${convId}/messages`),
+
+ // Assets
+ listAssets: (params?: { accountId?: string; type?: string }) => {
+ const qs = new URLSearchParams();
+ if (params?.accountId) qs.set('accountId', params.accountId);
+ if (params?.type) qs.set('type', params.type);
+ return request(`/assets?${qs}`);
+ },
+ deleteAsset: (id: string) => request(`/assets/${id}`, { method: 'DELETE' }),
+
+ // Configs
+ getConfigs: () => request('/configs'),
+ saveConfig: (key: string, value: Record) =>
+ request(`/configs/${key}`, { method: 'PUT', body: JSON.stringify({ value }) }),
+};
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add web/client/src/lib/api.ts
+git commit -m "feat(web): add typed API client"
+```
+
+---
+
+### Task 1.2: 账户 CRUD 后端路由
+
+**Files:**
+- Create: `web/server/routes/accounts.ts`
+
+- [ ] **Step 1: 创建 server/routes/accounts.ts**
+
+```typescript
+import { Router } from 'express';
+import fs from 'fs';
+import path from 'path';
+
+const ACCOUNTS_DIR = path.resolve(__dirname, '..', '..', '..', 'accounts');
+
+export const accountsRouter = Router();
+
+function readAccountJson(id: string): Record | null {
+ const p = path.join(ACCOUNTS_DIR, id, 'account.json');
+ if (!fs.existsSync(p)) return null;
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
+}
+
+function writeAccountJson(id: string, data: Record): void {
+ const dir = path.join(ACCOUNTS_DIR, id);
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, 'account.json'), JSON.stringify(data, null, 2), 'utf-8');
+}
+
+// List
+accountsRouter.get('/', (_req, res) => {
+ const dirs = fs.readdirSync(ACCOUNTS_DIR, { withFileTypes: true })
+ .filter((d) => d.isDirectory() && !d.name.startsWith('_') && !d.name.startsWith('.'))
+ .map((d) => d.name);
+
+ const accounts = dirs
+ .map((id) => readAccountJson(id))
+ .filter(Boolean);
+ res.json(accounts);
+});
+
+// Get
+accountsRouter.get('/:id', (req, res) => {
+ const data = readAccountJson(req.params.id);
+ if (!data) return res.status(404).json({ error: 'Account not found' });
+ res.json(data);
+});
+
+// Create
+accountsRouter.post('/', (req, res) => {
+ const { id, ...rest } = req.body;
+ if (!id) return res.status(400).json({ error: 'id is required' });
+
+ const dir = path.join(ACCOUNTS_DIR, id);
+ if (fs.existsSync(dir)) return res.status(409).json({ error: 'Account already exists' });
+
+ const data = {
+ id,
+ name: rest.name || id,
+ description: rest.description || '',
+ defaultFormat: rest.defaultFormat || '9:16',
+ imageModel: rest.imageModel || 'gemini',
+ videoModel: rest.videoModel || 'veo3-fast',
+ batchSize: rest.batchSize || 30,
+ ttsVoice: rest.ttsVoice || '',
+ ttsInstruction: rest.ttsInstruction || '',
+ storyboardPrompt: rest.storyboardPrompt || 'prompts/分镜.md',
+ imageStylePrompt: rest.imageStylePrompt || 'prompts/图片提示词.md',
+ videoStylePrompt: rest.videoStylePrompt || 'prompts/视频提示词.md',
+ references: rest.references || [],
+ capcut: rest.capcut || {},
+ };
+
+ writeAccountJson(id, data);
+ res.status(201).json(data);
+});
+
+// Update
+accountsRouter.put('/:id', (req, res) => {
+ const existing = readAccountJson(req.params.id);
+ if (!existing) return res.status(404).json({ error: 'Account not found' });
+
+ const merged = { ...existing, ...req.body, id: req.params.id };
+ writeAccountJson(req.params.id, merged);
+ res.json(merged);
+});
+
+// Delete
+accountsRouter.delete('/:id', (req, res) => {
+ const dir = path.join(ACCOUNTS_DIR, req.params.id);
+ if (!fs.existsSync(dir)) return res.status(404).json({ error: 'Account not found' });
+ fs.rmSync(dir, { recursive: true });
+ res.status(204).send();
+});
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add web/server/routes/accounts.ts
+git commit -m "feat(web): add account CRUD API routes"
+```
+
+---
+
+### Task 1.3: 配置 CRUD 后端路由
+
+**Files:**
+- Create: `web/server/routes/configs.ts`
+
+- [ ] **Step 1: 创建 server/routes/configs.ts**
+
+```typescript
+import { Router } from 'express';
+import { getDb } from '../db';
+
+export const configsRouter = Router();
+
+configsRouter.get('/', (_req, res) => {
+ const rows = getDb().prepare('SELECT * FROM configs ORDER BY key').all();
+ res.json(rows);
+});
+
+configsRouter.get('/:key', (req, res) => {
+ const row = getDb().prepare('SELECT * FROM configs WHERE key = ?').get(req.params.key);
+ if (!row) return res.status(404).json({ error: 'Config not found' });
+ res.json(row);
+});
+
+configsRouter.put('/:key', (req, res) => {
+ const { value } = req.body;
+ getDb().prepare(`
+ INSERT INTO configs (id, key, value, updated_at)
+ VALUES (?, ?, ?, datetime('now'))
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
+ `).run(crypto.randomUUID(), req.params.key, JSON.stringify(value));
+ res.json({ key: req.params.key, ok: true });
+});
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add web/server/routes/configs.ts
+git commit -m "feat(web): add config CRUD API routes"
+```
+
+---
+
+### Task 1.4: 账户列表 + 表单前端组件
+
+**Files:**
+- Replace: `web/client/src/components/accounts/AccountList.tsx`
+- Create: `web/client/src/components/accounts/AccountForm.tsx`
+- Create: `web/client/src/hooks/useAccounts.ts`
+
+- [ ] **Step 1: 创建 hooks/useAccounts.ts**
+
+```typescript
+import { useState, useEffect, useCallback } from 'react';
+import { api } from '@/lib/api';
+import type { Account } from '@/types';
+
+export function useAccounts() {
+ const [accounts, setAccounts] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const refresh = useCallback(() => {
+ api.listAccounts().then(setAccounts).finally(() => setLoading(false));
+ }, []);
+
+ useEffect(() => { refresh(); }, [refresh]);
+
+ const create = (data: Partial) => api.createAccount(data).then(refresh);
+ const update = (id: string, data: Partial) => api.updateAccount(id, data).then(refresh);
+ const remove = (id: string) => api.deleteAccount(id).then(refresh);
+
+ return { accounts, loading, refresh, create, update, remove };
+}
+```
+
+- [ ] **Step 2: 重写 AccountList.tsx**
+
+```typescript
+import { Plus } from 'lucide-react';
+import { useState } from 'react';
+import { useAccounts } from '@/hooks/useAccounts';
+import { AccountForm } from './AccountForm';
+import { Button } from '@/components/ui/button';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import type { Account } from '@/types';
+
+export function AccountList() {
+ const { accounts, create, update, remove } = useAccounts();
+ const [editing, setEditing] = useState(null);
+ const [creating, setCreating] = useState(false);
+
+ return (
+
+
+
+
账户列表
+
+
+ {accounts.map((a) => (
+
+ ))}
+
+
+
+ {(editing || creating) ? (
+
{
+ if (creating) {
+ create(data).then(() => setCreating(false));
+ } else {
+ update(editing!.id, data).then(() => setEditing(null));
+ }
+ }}
+ onDelete={editing ? () => {
+ if (confirm(`确定删除账户「${editing.name}」?`)) {
+ remove(editing.id).then(() => setEditing(null));
+ }
+ } : undefined}
+ onCancel={() => { setEditing(null); setCreating(false); }}
+ />
+ ) : (
+
+ 选择一个账户或创建新账户
+
+ )}
+
+
+ );
+}
+```
+
+- [ ] **Step 3: 创建 AccountForm.tsx**
+
+```typescript
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import type { Account } from '@/types';
+
+interface Props {
+ account?: Account;
+ onSave: (data: Partial) => void;
+ onDelete?: () => void;
+ onCancel: () => void;
+}
+
+export function AccountForm({ account, onSave, onDelete, onCancel }: Props) {
+ const [form, setForm] = useState({
+ id: account?.id || '',
+ name: account?.name || '',
+ description: account?.description || '',
+ defaultFormat: account?.defaultFormat || '9:16',
+ imageModel: account?.imageModel || 'gemini',
+ videoModel: account?.videoModel || 'veo3-fast',
+ ttsVoice: account?.ttsVoice || '',
+ ttsInstruction: account?.ttsInstruction || '',
+ });
+
+ const handleChange = (key: string, value: string) => setForm((f) => ({ ...f, [key]: value }));
+
+ return (
+
+
+ {account ? `编辑账户: ${account.name}` : '创建新账户'}
+
+
+
+
+ handleChange('id', e.target.value)}
+ disabled={!!account}
+ placeholder="my-account"
+ className="mt-1 bg-zinc-900 border-zinc-800"
+ />
+
+
+
+
+ handleChange('name', e.target.value)}
+ placeholder="账户名称"
+ className="mt-1 bg-zinc-900 border-zinc-800"
+ />
+
+
+
+
+ handleChange('description', e.target.value)}
+ placeholder="简短描述..."
+ className="mt-1 bg-zinc-900 border-zinc-800"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ handleChange('ttsVoice', e.target.value)}
+ placeholder="cosyvoice-xxx"
+ className="mt-1 bg-zinc-900 border-zinc-800 font-mono text-xs"
+ />
+
+
+
+
+
+
+
+
+ {onDelete && (
+
+ )}
+
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web/client/src/components/accounts/ web/client/src/hooks/useAccounts.ts
+git commit -m "feat(web): add account list and form components"
+```
+
+---
+
+### Task 1.5: 设置页前端
+
+**Files:**
+- Replace: `web/client/src/components/config/ConfigForm.tsx`
+
+- [ ] **Step 1: 重写 ConfigForm.tsx**
+
+```typescript
+import { useState, useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { api } from '@/lib/api';
+
+export function ConfigForm() {
+ const [configs, setConfigs] = useState>({});
+ const [saving, setSaving] = useState(false);
+
+ useEffect(() => {
+ api.getConfigs().then((list) => {
+ const map: Record = {};
+ list.forEach((c) => { map[c.key] = JSON.stringify(c.value, null, 2); });
+ setConfigs(map);
+ });
+ }, []);
+
+ const handleSave = async (key: string, raw: string) => {
+ setSaving(true);
+ try {
+ await api.saveConfig(key, JSON.parse(raw));
+ } catch { alert('Invalid JSON'); }
+ setSaving(false);
+ };
+
+ const configKeys = [
+ { key: 'api_keys', label: 'API 密钥' },
+ { key: 'defaults', label: '默认参数' },
+ { key: 'endpoints', label: '服务端点' },
+ ];
+
+ return (
+
+
设置
+ {configKeys.map(({ key, label }) => (
+
+
+
+ ))}
+
+ );
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add web/client/src/components/config/ConfigForm.tsx
+git commit -m "feat(web): add settings config form"
+```
+
+---
+
+## P2: 提示词编辑器
+
+### Task 2.1: 提示词后端路由
+
+**Files:**
+- Create: `web/server/routes/prompts.ts`
+
+- [ ] **Step 1: 创建 server/routes/prompts.ts**
+
+```typescript
+import { Router } from 'express';
+import fs from 'fs';
+import path from 'path';
+
+const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
+
+export const promptsRouter = Router();
+
+const PROMPT_FILES: Record = {
+ storyboard: 'prompts/分镜.md',
+ image: 'prompts/图片提示词.md',
+ video: 'prompts/视频提示词.md',
+};
+
+promptsRouter.get('/:accountId/:type', (req, res) => {
+ const { accountId, type } = req.params;
+ const relPath = PROMPT_FILES[type];
+ if (!relPath) return res.status(400).json({ error: 'Unknown type: ' + type });
+
+ const fullPath = path.join(PROJECT_ROOT, 'accounts', accountId, relPath);
+ if (!fs.existsSync(fullPath)) return res.status(404).json({ error: 'File not found' });
+
+ res.json({ path: relPath, content: fs.readFileSync(fullPath, 'utf-8') });
+});
+
+promptsRouter.put('/:accountId/:type', (req, res) => {
+ const { accountId, type } = req.params;
+ const { content } = req.body;
+ const relPath = PROMPT_FILES[type];
+ if (!relPath) return res.status(400).json({ error: 'Unknown type: ' + type });
+
+ const fullPath = path.join(PROJECT_ROOT, 'accounts', accountId, relPath);
+ const dir = path.dirname(fullPath);
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+
+ fs.writeFileSync(fullPath, content, 'utf-8');
+ res.json({ ok: true });
+});
+
+// List available prompt types for an account
+promptsRouter.get('/:accountId', (req, res) => {
+ res.json(Object.keys(PROMPT_FILES).map((type) => ({
+ type,
+ path: PROMPT_FILES[type],
+ })));
+});
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add web/server/routes/prompts.ts
+git commit -m "feat(web): add prompt file read/write API"
+```
+
+---
+
+### Task 2.2: 提示词分屏编辑器
+
+**Files:**
+- Create: `web/client/src/hooks/usePrompts.ts`
+- Create: `web/client/src/components/prompts/PromptEditor.tsx`
+
+- [ ] **Step 1: 创建 hooks/usePrompts.ts**
+
+```typescript
+import { useState, useCallback } from 'react';
+import { api } from '@/lib/api';
+
+export function usePrompts() {
+ const [content, setContent] = useState('');
+ const [path, setPath] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const load = useCallback(async (accountId: string, type: string) => {
+ setLoading(true);
+ try {
+ const result = await api.getPrompt(accountId, type);
+ setContent(result.content);
+ setPath(result.path);
+ } catch { setContent(''); setPath(''); }
+ setLoading(false);
+ }, []);
+
+ const save = useCallback(async (accountId: string, type: string, newContent: string) => {
+ await api.savePrompt(accountId, type, newContent);
+ setContent(newContent);
+ }, []);
+
+ return { content, path, loading, load, save, setContent };
+}
+```
+
+- [ ] **Step 2: 创建 PromptEditor.tsx**
+
+```typescript
+import { useState, useEffect } from 'react';
+import { useAccounts } from '@/hooks/useAccounts';
+import { usePrompts } from '@/hooks/usePrompts';
+import { Button } from '@/components/ui/button';
+import { ScrollArea } from '@/components/ui/scroll-area';
+
+const PROMPT_TYPES = [
+ { type: 'storyboard', label: '分镜' },
+ { type: 'image', label: '图片提示词' },
+ { type: 'video', label: '视频提示词' },
+] as const;
+
+export function PromptEditor() {
+ const { accounts } = useAccounts();
+ const [selectedAccount, setSelectedAccount] = useState('');
+ const [selectedType, setSelectedType] = useState('storyboard');
+ const { content, path, loading, load, save } = usePrompts();
+
+ useEffect(() => {
+ if (accounts.length > 0 && !selectedAccount) {
+ setSelectedAccount(accounts[0].id);
+ }
+ }, [accounts]);
+
+ useEffect(() => {
+ if (selectedAccount) {
+ load(selectedAccount, selectedType);
+ }
+ }, [selectedAccount, selectedType]);
+
+ return (
+
+ {/* 左侧列表 */}
+
+
+
+
+
+
+
+
+ {PROMPT_TYPES.map(({ type, label }) => (
+
+ ))}
+
+
+
+
+ {/* 编辑器 */}
+
+
+ {path}
+
+
+
+
+ );
+}
+```
+
+- [ ] **Step 3: 将编辑器集成到 App.tsx 的 accounts 视图**
+
+在 `App.tsx` 中,将 `accounts` 视图改为使用 `PromptEditor`。当前 `App.tsx` 中 accounts case 指向 `AccountList`。由于账户视图应该是账户列表(从中选编辑)和提示词编辑的混合,我们需要调整——用户从账户列表中选择一个账户后可以编辑其提示词。
+
+更新 `App.tsx`:
+
+```typescript
+// accounts 视图改为同时显示账户列表和提示词编辑器
+case 'accounts':
+ return ;
+```
+
+账户列表组件右侧现已包含 AccountForm,提示词编辑可以从 AccountForm 中的一个按钮进入,或者保持当前通过独立编辑器访问。简化处理——保持 AccountList 不变,侧边栏点击账户后右侧显示账户管理,提示词编辑作为子视图。
+
+实际上按设计,账户视图右侧应包含提示词编辑入口。修改 `AccountList` 让用户能切换"账户设置"/"提示词编辑"两个子标签。
+
+简化方案:在 AccountList 右侧顶部加标签切换。
+
+更新 `AccountList.tsx` — 在右侧主区域顶部加标签栏:
+
+```typescript
+import { PromptEditor } from '@/components/prompts/PromptEditor';
+
+// ... 在右侧区域顶部添加子标签
+const [subTab, setSubTab] = useState<'info' | 'prompts'>('info');
+```
+
+这一步代码较长,直接更新 AccountList 组件的文件内容。
+
+- [ ] **Step 4: 更新 AccountList.tsx 添加提示词编辑入口**
+
+将 `AccountList.tsx` 右侧内容区顶部加上标签切换:
+
+在右侧 `` 之前/内部顶部添加:
+
+```typescript
+{/* 在 AccountList 组件中添加子标签切换 */}
+const [subTab, setSubTab] = useState<'info' | 'prompts'>('info');
+```
+
+右侧面板改为:
+
+```typescript
+
+
+
+
+
+
+ {subTab === 'info' ? (
+ /* AccountForm 或空状态 */
+ (editing || creating) ? (
+
+ ) : (
+
+ 选择一个账户或创建新账户
+
+ )
+ ) : (
+
+ )}
+
+
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web/client/src/components/accounts/AccountList.tsx web/client/src/components/prompts/ web/client/src/hooks/usePrompts.ts web/server/routes/prompts.ts
+git commit -m "feat(web): add prompt editor with split-pane and Markdown editing"
+```
+
+---
+
+## P3: 聊天 + Pipeline
+
+### Task 3.1: WebSocket 聊天后端
+
+**Files:**
+- Create: `web/server/ws/chat.ts`
+
+- [ ] **Step 1: 创建 server/ws/chat.ts**
+
+```typescript
+import { WebSocket } from 'ws';
+import { getDb } from '../db';
+import { v4 as uuid } from 'uuid';
+
+interface ChatMessage {
+ type: 'message' | 'tool_call' | 'tool_result' | 'pipeline_progress' | 'error';
+ data: Record
;
+}
+
+export function handleChat(ws: WebSocket) {
+ let conversationId: string | null = null;
+
+ ws.on('message', async (raw) => {
+ try {
+ const msg = JSON.parse(raw.toString());
+
+ if (msg.type === 'init') {
+ conversationId = msg.conversationId || uuid();
+ // Load history
+ const history = getDb().prepare(
+ 'SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at'
+ ).all(conversationId);
+ ws.send(JSON.stringify({ type: 'history', data: { conversationId, messages: history } }));
+ return;
+ }
+
+ if (msg.type === 'chat') {
+ const { content } = msg;
+ const msgId = uuid();
+
+ // Save user message
+ getDb().prepare(
+ 'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
+ ).run(msgId, conversationId, 'user', content);
+
+ // Echo user message
+ ws.send(JSON.stringify({ type: 'message', data: { id: msgId, role: 'user', content } }));
+
+ // Placeholder: echo assistant response (pi-agent-core integration comes in Task 3.3)
+ const assistantId = uuid();
+ const assistantContent = `收到你的消息:「${content}」。Agent 引擎正在启动中...`;
+
+ getDb().prepare(
+ 'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
+ ).run(assistantId, conversationId, 'assistant', assistantContent);
+
+ ws.send(JSON.stringify({
+ type: 'message',
+ data: { id: assistantId, role: 'assistant', content: assistantContent },
+ }));
+ }
+
+ if (msg.type === 'create_conversation') {
+ const { title, accountId } = msg;
+ conversationId = uuid();
+ getDb().prepare(
+ 'INSERT INTO conversations (id, title, account_id) VALUES (?, ?, ?)'
+ ).run(conversationId, title || '新对话', accountId || null);
+ ws.send(JSON.stringify({ type: 'conversation_created', data: { id: conversationId, title } }));
+ }
+ } catch (e) {
+ ws.send(JSON.stringify({ type: 'error', data: { message: (e as Error).message } }));
+ }
+ });
+
+ ws.on('close', () => {
+ // cleanup if needed
+ });
+}
+```
+
+由于项目不依赖 `uuid` 包,用内置 `crypto.randomUUID()` 替代:
+
+```typescript
+import { randomUUID } from 'crypto';
+```
+
+全部 `uuid()` 改为 `randomUUID()`。
+
+- [ ] **Step 2: 更新 package.json 不需额外依赖(crypto 内置)**
+
+验证 `randomUUID` 在 Node 19+ 可用。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web/server/ws/chat.ts
+git commit -m "feat(web): add WebSocket chat handler with message persistence"
+```
+
+---
+
+### Task 3.2: 聊天前端组件
+
+**Files:**
+- Replace: `web/client/src/components/chat/ChatView.tsx`
+- Create: `web/client/src/components/chat/ChatMessage.tsx`
+- Create: `web/client/src/components/chat/ChatInput.tsx`
+- Create: `web/client/src/hooks/useChat.ts`
+- Create: `web/client/src/lib/websocket.ts`
+
+- [ ] **Step 1: 创建 lib/websocket.ts**
+
+```typescript
+type MessageHandler = (data: Record) => void;
+
+class ChatSocket {
+ private ws: WebSocket | null = null;
+ private handlers: Map = new Map();
+ private reconnectTimer: ReturnType | null = null;
+
+ connect() {
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const url = `${protocol}//${location.host}/ws`;
+ this.ws = new WebSocket(url);
+
+ this.ws.onopen = () => {
+ this.emit('connected', {});
+ };
+
+ this.ws.onmessage = (event) => {
+ try {
+ const { type, data } = JSON.parse(event.data);
+ this.emit(type, data);
+ } catch {}
+ };
+
+ this.ws.onclose = () => {
+ this.reconnectTimer = setTimeout(() => this.connect(), 3000);
+ };
+ }
+
+ on(type: string, handler: MessageHandler) {
+ if (!this.handlers.has(type)) this.handlers.set(type, []);
+ this.handlers.get(type)!.push(handler);
+ }
+
+ off(type: string, handler: MessageHandler) {
+ const list = this.handlers.get(type);
+ if (list) this.handlers.set(type, list.filter((h) => h !== handler));
+ }
+
+ send(type: string, data: Record = {}) {
+ if (this.ws?.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify({ type, ...data }));
+ }
+ }
+
+ private emit(type: string, data: Record) {
+ (this.handlers.get(type) || []).forEach((h) => h(data));
+ }
+
+ disconnect() {
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
+ this.ws?.close();
+ }
+}
+
+export const chatSocket = new ChatSocket();
+```
+
+- [ ] **Step 2: 创建 hooks/useChat.ts**
+
+```typescript
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { chatSocket } from '@/lib/websocket';
+import type { Message } from '@/types';
+
+export function useChat(conversationId: string | null) {
+ const [messages, setMessages] = useState([]);
+ const [connected, setConnected] = useState(false);
+ const pendingRef = useRef(false);
+
+ useEffect(() => {
+ chatSocket.connect();
+
+ chatSocket.on('connected', () => setConnected(true));
+ chatSocket.on('history', (data) => {
+ setMessages((data.messages as Message[]) || []);
+ });
+ chatSocket.on('message', (data) => {
+ setMessages((prev) => [...prev, data as unknown as Message]);
+ });
+
+ return () => {
+ chatSocket.disconnect();
+ };
+ }, []);
+
+ useEffect(() => {
+ if (conversationId && connected && !pendingRef.current) {
+ pendingRef.current = true;
+ chatSocket.send('init', { conversationId });
+ }
+ if (!conversationId) {
+ pendingRef.current = false;
+ setMessages([]);
+ }
+ }, [conversationId, connected]);
+
+ const send = useCallback((content: string) => {
+ chatSocket.send('chat', { content });
+ }, []);
+
+ const createConversation = useCallback((title: string, accountId?: string) => {
+ chatSocket.send('create_conversation', { title, accountId });
+ }, []);
+
+ return { messages, connected, send, createConversation };
+}
+```
+
+- [ ] **Step 3: 重写 ChatView.tsx**
+
+```typescript
+import { useEffect } from 'react';
+import { useAppStore } from '@/store';
+import { useChat } from '@/hooks/useChat';
+import { ChatMessage } from './ChatMessage';
+import { ChatInput } from './ChatInput';
+import { ScrollArea } from '@/components/ui/scroll-area';
+
+export function ChatView() {
+ const { activeConversationId, conversations, setConversations } = useAppStore();
+ const { messages, connected, send, createConversation } = useChat(activeConversationId);
+
+ useEffect(() => {
+ fetch('/api/pipeline/conversations')
+ .then((r) => r.json())
+ .then(setConversations)
+ .catch(() => {});
+ }, [messages]);
+
+ const handleNewConversation = () => {
+ createConversation('新对话');
+ fetch('/api/pipeline/conversations')
+ .then((r) => r.json())
+ .then(setConversations);
+ };
+
+ if (!activeConversationId) {
+ return (
+
+
选择对话或开始新对话
+
+
+ );
+ }
+
+ return (
+
+
+
+
{connected ? '已连接' : '连接中...'}
+
+
+ {messages.map((msg) => (
+
+ ))}
+
+
+
+ );
+}
+```
+
+- [ ] **Step 4: 创建 ChatMessage.tsx**
+
+```typescript
+import { cn } from '@/lib/utils';
+import type { Message } from '@/types';
+
+export function ChatMessage({ message }: { message: Message }) {
+ const isUser = message.role === 'user';
+
+ return (
+
+
+ {message.content}
+
+
+ );
+}
+```
+
+- [ ] **Step 5: 创建 ChatInput.tsx**
+
+```typescript
+import { useState, useRef } from 'react';
+import { Send } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+
+export function ChatInput({ onSend }: { onSend: (content: string) => void }) {
+ const [input, setInput] = useState('');
+ const ref = useRef(null);
+
+ const handleSend = () => {
+ if (!input.trim()) return;
+ onSend(input.trim());
+ setInput('');
+ ref.current?.focus();
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ };
+
+ return (
+
+ );
+}
+```
+
+- [ ] **Step 6: 创建对话列表 API 后端路由(pipeline 路由中的 conversations)**
+
+更新 `server/routes/pipeline.ts`(占位):
+
+```typescript
+import { Router } from 'express';
+import { getDb } from '../db';
+
+export const pipelineRouter = Router();
+
+pipelineRouter.get('/conversations', (_req, res) => {
+ const rows = getDb().prepare('SELECT * FROM conversations ORDER BY updated_at DESC').all();
+ res.json(rows);
+});
+
+pipelineRouter.get('/conversations/:id/messages', (req, res) => {
+ const rows = getDb().prepare(
+ 'SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at'
+ ).all(req.params.id);
+ res.json(rows);
+});
+
+pipelineRouter.delete('/conversations/:id', (req, res) => {
+ getDb().prepare('DELETE FROM conversations WHERE id = ?').run(req.params.id);
+ res.status(204).send();
+});
+```
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add web/client/src/components/chat/ web/client/src/hooks/useChat.ts web/client/src/lib/websocket.ts web/server/routes/pipeline.ts
+git commit -m "feat(web): add chat UI with WebSocket streaming and conversation persistence"
+```
+
+---
+
+### Task 3.3: pi-agent-core 集成 + 工具注册
+
+**Files:**
+- Create: `web/server/agent/index.ts`
+- Create: `web/server/agent/tools.ts`
+- Modify: `web/server/ws/chat.ts`(集成 agent 调用)
+
+- [ ] **Step 1: 创建 server/agent/tools.ts**
+
+由于 pi-agent-core 的具体 API 版本随时变化,定义工具接口并在 ws/chat 中集成调用。
+
+```typescript
+import { execSync, spawn } from 'child_process';
+import path from 'path';
+
+const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
+const PIPELINE_SCRIPT = path.join(PROJECT_ROOT, '.claude', 'skills', 'video-from-script', 'scripts', 'pipeline.js');
+
+export interface ToolDefinition {
+ name: string;
+ description: string;
+ parameters: Record;
+ execute: (params: Record) => Promise;
+}
+
+export const tools: ToolDefinition[] = [
+ {
+ name: 'list_accounts',
+ description: '列出所有可用账号',
+ parameters: { type: 'object', properties: {}, required: [] },
+ execute: async () => {
+ const fs = await import('fs');
+ const accountsDir = path.join(PROJECT_ROOT, 'accounts');
+ const dirs = fs.readdirSync(accountsDir, { withFileTypes: true })
+ .filter((d) => d.isDirectory() && !d.name.startsWith('_'))
+ .map((d) => d.name);
+ return JSON.stringify(dirs);
+ },
+ },
+ {
+ name: 'run_pipeline_phase',
+ description: '执行 pipeline 阶段 (images/upload/videos/tts/assemble)',
+ parameters: {
+ type: 'object',
+ properties: {
+ manifest: { type: 'string', description: 'manifest.json 绝对路径' },
+ phase: { type: 'string', description: '阶段名: images, upload, videos, tts, assemble' },
+ },
+ required: ['manifest', 'phase'],
+ },
+ execute: async (params) => {
+ const { manifest, phase } = params as { manifest: string; phase: string };
+ return new Promise((resolve, reject) => {
+ const proc = spawn('node', [PIPELINE_SCRIPT, 'run', '--manifest', manifest, '--phase', phase], {
+ cwd: PROJECT_ROOT,
+ env: { ...process.env },
+ });
+ let output = '';
+ proc.stdout.on('data', (d: Buffer) => { output += d.toString(); });
+ proc.stderr.on('data', (d: Buffer) => { output += d.toString(); });
+ proc.on('close', (code) => {
+ code === 0 ? resolve(output) : reject(new Error(`Pipeline failed with code ${code}: ${output}`));
+ });
+ });
+ },
+ },
+ {
+ name: 'pipeline_status',
+ description: '查看 pipeline 进度',
+ parameters: {
+ type: 'object',
+ properties: {
+ manifest: { type: 'string', description: 'manifest.json 绝对路径' },
+ },
+ required: ['manifest'],
+ },
+ execute: async (params) => {
+ const { manifest } = params as { manifest: string };
+ const result = execSync(`node "${PIPELINE_SCRIPT}" status --manifest "${manifest}"`, {
+ cwd: PROJECT_ROOT, encoding: 'utf-8',
+ });
+ return result;
+ },
+ },
+ {
+ name: 'create_account',
+ description: '创建新账号',
+ parameters: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', description: '账号 ID' },
+ name: { type: 'string', description: '账号名称' },
+ },
+ required: ['id', 'name'],
+ },
+ execute: async (params) => {
+ const { id, name } = params as { id: string; name: string };
+ const result = execSync(
+ `node "${PIPELINE_SCRIPT}" create-account --id "${id}" --name "${name}" --desc "" --video-model veo3-fast`,
+ { cwd: PROJECT_ROOT, encoding: 'utf-8' }
+ );
+ return result;
+ },
+ },
+];
+```
+
+- [ ] **Step 2: 创建 server/agent/index.ts**
+
+```typescript
+import { tools, ToolDefinition } from './tools';
+
+export interface AgentConfig {
+ model?: string;
+ maxTokens?: number;
+}
+
+export class VideoAgent {
+ private tools: ToolDefinition[];
+
+ constructor() {
+ this.tools = tools;
+ }
+
+ getToolDefinitions() {
+ return this.tools.map((t) => ({
+ name: t.name,
+ description: t.description,
+ parameters: t.parameters,
+ }));
+ }
+
+ async executeTool(name: string, params: Record): Promise {
+ const tool = this.tools.find((t) => t.name === name);
+ if (!tool) throw new Error(`Unknown tool: ${name}`);
+ return tool.execute(params);
+ }
+
+ getSystemPrompt(accountContext?: string): string {
+ return `你是美图 Agent,帮助用户进行短视频创作。
+
+可用账号:${accountContext || '暂无'}
+
+你可以:
+1. 帮用户创建新账号
+2. 查看和管理已有账号
+3. 执行视频创作 pipeline(分镜→生图→生视频→TTS→成片)
+4. 管理提示词模板
+
+用户想创作视频时,一步步引导他们完成流程。`;
+ }
+}
+
+export const videoAgent = new VideoAgent();
+```
+
+- [ ] **Step 3: 更新 ws/chat.ts 集成 Agent**
+
+更新 `handleChat` 函数中的 `msg.type === 'chat'` 处理,调用 videoAgent:
+
+在 chat case 中,echo 阶段改为:
+
+```typescript
+// 检查是否是工具调用指令
+const { videoAgent } = await import('../agent');
+const systemPrompt = videoAgent.getSystemPrompt();
+
+// 简易命令解析
+if (content.startsWith('/run')) {
+ const [_, manifest, phase] = content.split(' ');
+ try {
+ const result = await videoAgent.executeTool('run_pipeline_phase', { manifest, phase });
+ ws.send(JSON.stringify({
+ type: 'tool_result',
+ data: { tool: 'run_pipeline_phase', result },
+ }));
+ } catch (e) {
+ ws.send(JSON.stringify({
+ type: 'error',
+ data: { message: (e as Error).message },
+ }));
+ }
+ return;
+}
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web/server/agent/ web/server/ws/chat.ts
+git commit -m "feat(web): add pi-agent tool layer with pipeline integration"
+```
+
+---
+
+### Task 3.4: Pipeline 进度组件
+
+**Files:**
+- Create: `web/client/src/components/chat/PipelineProgress.tsx`
+
+- [ ] **Step 1: 创建 PipelineProgress.tsx**
+
+```typescript
+import { cn } from '@/lib/utils';
+
+interface Props {
+ phase: string;
+ progress: number; // 0-100
+ currentItem?: number;
+ totalItems?: number;
+ status?: string;
+}
+
+export function PipelineProgress({ phase, progress, currentItem, totalItems, status }: Props) {
+ const phaseLabel: Record = {
+ images: '生成图片',
+ upload: '上传素材',
+ videos: '生成视频',
+ tts: '配音',
+ assemble: '成片组装',
+ };
+
+ return (
+
+
+
+ {phaseLabel[phase] || phase}
+ {currentItem && totalItems ? ` (${currentItem}/${totalItems})` : ''}
+
+ {progress}%
+
+
+ {status &&
{status}
}
+
+ );
+}
+```
+
+- [ ] **Step 2: 在 ChatView 中集成 PipelineProgress**
+
+在 ChatMessage 列表渲染时检测 tool_result 类型的消息,解析并显示进度条。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web/client/src/components/chat/PipelineProgress.tsx
+git commit -m "feat(web): add pipeline progress inline component"
+```
+
+---
+
+## P4: 资产画廊
+
+### Task 4.1: 资产后端路由
+
+**Files:**
+- Create: `web/server/routes/assets.ts`
+
+- [ ] **Step 1: 创建 server/routes/assets.ts**
+
+```typescript
+import { Router } from 'express';
+import { getDb } from '../db';
+import fs from 'fs';
+import path from 'path';
+import { randomUUID } from 'crypto';
+
+const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
+
+export const assetsRouter = Router();
+
+// List assets with optional filters
+assetsRouter.get('/', (req, res) => {
+ const { accountId, type } = req.query;
+ let sql = 'SELECT * FROM assets WHERE 1=1';
+ const params: unknown[] = [];
+
+ if (accountId) { sql += ' AND account_id = ?'; params.push(accountId); }
+ if (type) { sql += ' AND type = ?'; params.push(type); }
+
+ sql += ' ORDER BY created_at DESC LIMIT 200';
+ const rows = getDb().prepare(sql).all(...params);
+ res.json(rows);
+});
+
+// Delete asset (and file)
+assetsRouter.delete('/:id', (req, res) => {
+ const asset = getDb().prepare('SELECT * FROM assets WHERE id = ?').get(req.params.id) as {
+ file_path: string;
+ } | undefined;
+
+ if (!asset) return res.status(404).json({ error: 'Asset not found' });
+
+ // Delete file
+ const filePath = path.join(PROJECT_ROOT, asset.file_path);
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
+
+ getDb().prepare('DELETE FROM assets WHERE id = ?').run(req.params.id);
+ res.status(204).send();
+});
+
+// Scan output directories and index assets
+assetsRouter.post('/scan', (_req, res) => {
+ const outputDir = path.join(PROJECT_ROOT, 'output');
+ if (!fs.existsSync(outputDir)) return res.json({ indexed: 0 });
+
+ const dirs = fs.readdirSync(outputDir, { withFileTypes: true })
+ .filter((d) => d.isDirectory());
+
+ let indexed = 0;
+ for (const dir of dirs) {
+ const manifestPath = path.join(outputDir, dir.name, 'manifest.json');
+ if (!fs.existsSync(manifestPath)) continue;
+
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+ const accountId = manifest.account?.id || '';
+
+ // Scan images
+ const imagesDir = path.join(outputDir, dir.name, 'images');
+ if (fs.existsSync(imagesDir)) {
+ const files = fs.readdirSync(imagesDir);
+ for (const file of files) {
+ if (!/\.(jpe?g|png|webp)$/i.test(file)) continue;
+ const id = randomUUID();
+ const filePath = path.relative(PROJECT_ROOT, path.join(imagesDir, file));
+ const match = file.match(/scene_(\d+)/);
+ const shotIndex = match ? parseInt(match[1]) : null;
+
+ const exists = getDb().prepare('SELECT id FROM assets WHERE file_path = ?').get(filePath);
+ if (exists) continue;
+
+ getDb().prepare(
+ 'INSERT INTO assets (id, account_id, manifest_path, type, file_path, shot_index) VALUES (?,?,?,?,?,?)'
+ ).run(id, accountId, path.relative(PROJECT_ROOT, manifestPath), 'image', filePath, shotIndex);
+ indexed++;
+ }
+ }
+
+ // Scan videos
+ const videosDir = path.join(outputDir, dir.name, 'videos');
+ if (fs.existsSync(videosDir)) {
+ const files = fs.readdirSync(videosDir);
+ for (const file of files) {
+ if (!/\.mp4$/i.test(file)) continue;
+ const id = randomUUID();
+ const filePath = path.relative(PROJECT_ROOT, path.join(videosDir, file));
+ const match = file.match(/scene_(\d+)/);
+ const shotIndex = match ? parseInt(match[1]) : null;
+
+ const exists = getDb().prepare('SELECT id FROM assets WHERE file_path = ?').get(filePath);
+ if (exists) continue;
+
+ getDb().prepare(
+ 'INSERT INTO assets (id, account_id, manifest_path, type, file_path, shot_index) VALUES (?,?,?,?,?,?)'
+ ).run(id, accountId, path.relative(PROJECT_ROOT, manifestPath), 'video', filePath, shotIndex);
+ indexed++;
+ }
+ }
+ }
+
+ res.json({ indexed });
+});
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add web/server/routes/assets.ts
+git commit -m "feat(web): add asset CRUD and scanning API"
+```
+
+---
+
+### Task 4.2: 资产画廊前端
+
+**Files:**
+- Replace: `web/client/src/components/assets/AssetGallery.tsx`
+- Create: `web/client/src/components/assets/AssetPreview.tsx`
+- Create: `web/client/src/hooks/useAssets.ts`
+
+- [ ] **Step 1: 创建 hooks/useAssets.ts**
+
+```typescript
+import { useState, useEffect, useCallback } from 'react';
+import { api } from '@/lib/api';
+import type { Asset } from '@/types';
+
+export function useAssets(params?: { accountId?: string; type?: string }) {
+ const [assets, setAssets] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const refresh = useCallback(() => {
+ setLoading(true);
+ api.listAssets(params).then(setAssets).finally(() => setLoading(false));
+ }, [params?.accountId, params?.type]);
+
+ useEffect(() => { refresh(); }, [refresh]);
+
+ const remove = (id: string) => api.deleteAsset(id).then(refresh);
+ const scan = () => api.listAssets({}).then(() => refresh()); // will call scan endpoint instead
+
+ return { assets, loading, refresh, remove, scan };
+}
+```
+
+- [ ] **Step 2: 重写 AssetGallery.tsx**
+
+```typescript
+import { useState, useEffect } from 'react';
+import { Trash2, RefreshCw } from 'lucide-react';
+import { useAssets } from '@/hooks/useAssets';
+import { AssetPreview } from './AssetPreview';
+import { Button } from '@/components/ui/button';
+import type { Asset } from '@/types';
+
+export function AssetGallery() {
+ const [accountFilter, setAccountFilter] = useState('');
+ const [typeFilter, setTypeFilter] = useState('');
+ const [accounts, setAccounts] = useState<{ id: string; name: string }[]>([]);
+ const { assets, loading, remove, scan } = useAssets({
+ accountId: accountFilter || undefined,
+ type: typeFilter || undefined,
+ });
+ const [previewAsset, setPreviewAsset] = useState(null);
+
+ useEffect(() => {
+ fetch('/api/accounts').then((r) => r.json()).then(setAccounts);
+ }, []);
+
+ const handleScan = async () => {
+ await fetch('/api/assets/scan', { method: 'POST' });
+ window.location.reload();
+ };
+
+ return (
+
+ {/* 工具栏 */}
+
+
+
+
+
+
+ {/* 瀑布流 */}
+
+ {loading ? (
+
加载中...
+ ) : assets.length === 0 ? (
+
暂无资产,点击"扫描"导入
+ ) : (
+
+ {assets.map((asset) => (
+
setPreviewAsset(asset)}
+ >
+ {asset.type === 'image' ? (
+
}`})
+ ) : (
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+ {previewAsset &&
setPreviewAsset(null)} />}
+
+ );
+}
+```
+
+- [ ] **Step 3: 创建 AssetPreview.tsx**
+
+```typescript
+import { X } from 'lucide-react';
+import type { Asset } from '@/types';
+
+export function AssetPreview({ asset, onClose }: { asset: Asset; onClose: () => void }) {
+ return (
+
+
+
+
e.stopPropagation()}>
+ {asset.type === 'image' ? (
+
}`})
+ ) : (
+
+ )}
+
+
+ );
+}
+```
+
+- [ ] **Step 4: 添加文件访问代理路由**
+
+在 `server/index.ts` 中或 `assets.ts` 中添加文件静态服务:
+
+```typescript
+// 在 assetsRouter 中添加文件访问端点
+assetsRouter.get('/file', (req, res) => {
+ const filePath = req.query.path as string;
+ if (!filePath) return res.status(400).send('Missing path');
+ const fullPath = path.resolve(PROJECT_ROOT, filePath);
+ // Security: ensure path is within project
+ if (!fullPath.startsWith(PROJECT_ROOT)) return res.status(403).send('Forbidden');
+ if (!fs.existsSync(fullPath)) return res.status(404).send('Not found');
+ res.sendFile(fullPath);
+});
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web/client/src/components/assets/ web/client/src/hooks/useAssets.ts web/server/routes/assets.ts
+git commit -m "feat(web): add asset gallery with waterfall layout, preview and delete"
+```
+
+---
+
+## P5: 对话历史 + 断点续跑
+
+### Task 5.1: 对话列表 API 完善 + 搜索
+
+**Files:**
+- Modify: `web/server/routes/pipeline.ts`
+
+- [ ] **Step 1: 更新 pipeline.ts 添加搜索和断点续跑**
+
+```typescript
+import { Router } from 'express';
+import { getDb } from '../db';
+import { execSync } from 'child_process';
+import path from 'path';
+
+const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
+const PIPELINE_SCRIPT = path.join(PROJECT_ROOT, '.claude', 'skills', 'video-from-script', 'scripts', 'pipeline.js');
+
+export const pipelineRouter = Router();
+
+// List conversations
+pipelineRouter.get('/conversations', (req, res) => {
+ const { search } = req.query;
+ let sql = 'SELECT * FROM conversations';
+ const params: string[] = [];
+ if (search) {
+ sql += ' WHERE title LIKE ?';
+ params.push(`%${search}%`);
+ }
+ sql += ' ORDER BY updated_at DESC LIMIT 100';
+ const rows = getDb().prepare(sql).all(...params);
+ res.json(rows);
+});
+
+// Get conversation messages
+pipelineRouter.get('/conversations/:id/messages', (req, res) => {
+ const rows = getDb().prepare(
+ 'SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at'
+ ).all(req.params.id);
+ res.json(rows);
+});
+
+// Delete conversation
+pipelineRouter.delete('/conversations/:id', (req, res) => {
+ getDb().prepare('DELETE FROM conversations WHERE id = ?').run(req.params.id);
+ res.status(204).send();
+});
+
+// Rename conversation
+pipelineRouter.patch('/conversations/:id', (req, res) => {
+ const { title } = req.body;
+ getDb().prepare(
+ 'UPDATE conversations SET title = ?, updated_at = datetime(\'now\') WHERE id = ?'
+ ).run(title, req.params.id);
+ res.json({ ok: true });
+});
+
+// Pipeline status
+pipelineRouter.get('/status', (req, res) => {
+ const { manifest } = req.query;
+ if (!manifest) return res.status(400).json({ error: 'manifest path required' });
+ try {
+ const result = execSync(`node "${PIPELINE_SCRIPT}" status --manifest "${manifest}"`, {
+ cwd: PROJECT_ROOT, encoding: 'utf-8',
+ });
+ res.json({ output: result });
+ } catch (e) {
+ res.status(500).json({ error: (e as Error).message });
+ }
+});
+
+// Pipeline resume (断点续跑)
+pipelineRouter.post('/resume', (req, res) => {
+ const { manifest } = req.body;
+ if (!manifest) return res.status(400).json({ error: 'manifest path required' });
+ try {
+ const result = execSync(`node "${PIPELINE_SCRIPT}" run --manifest "${manifest}" --resume`, {
+ cwd: PROJECT_ROOT, encoding: 'utf-8',
+ });
+ res.json({ output: result });
+ } catch (e) {
+ res.status(500).json({ error: (e as Error).message });
+ }
+});
+```
+
+- [ ] **Step 2: 更新前端 MiddlePanel 支持搜索**
+
+更新 `MiddlePanel.tsx`,让搜索输入框实时过滤对话列表。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web/server/routes/pipeline.ts web/client/src/components/layout/MiddlePanel.tsx
+git commit -m "feat(web): add conversation search, rename, and pipeline resume"
+```
+
+---
+
+### Task 5.2: 断点续跑集成
+
+**Files:**
+- Modify: `web/client/src/components/chat/ChatView.tsx`
+- Modify: `web/client/src/components/chat/ChatMessage.tsx`
+
+- [ ] **Step 1: 在 ChatView 中检查关联的 manifest 并显示恢复按钮**
+
+```typescript
+// 在 ChatView 中,获取对话关联的 pipeline 状态
+// 如果检测到未完成的 pipeline,在消息流顶部显示恢复卡片
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add web/client/src/components/chat/
+git commit -m "feat(web): add pipeline resume from conversation context"
+```
+
+---
+
+## 开发指南
+
+### 启动
+
+```bash
+cd web
+npm run dev # 同时启动 Express (3001) + Vite (5173)
+npm run db:init # 首次初始化数据库
+```
+
+### 常用命令
+
+```bash
+npm run dev:server # 仅后端
+npm run dev:client # 仅前端
+```
+
+### Vite 代理说明
+
+前端 5173 的 `/api/*` 请求代理到后端 3001,`/ws` WebSocket 同样代理。开发时访问 http://localhost:5173。
+
+### 数据库位置
+
+`web/data/meitu-agent.db` — 自动创建,包含 conversations、messages、assets、pipeline_runs、configs 五个表。
+
+---
+
+## 类型一致性检查
+
+- `Account` 类型在前端 `types/index.ts` 和后端 `routes/accounts.ts` 中字段一致
+- `Message` 的 role 使用 `'user' | 'assistant' | 'system' | 'tool'`
+- `Asset.type` 使用 `'image' | 'video'`
+- `NavView` 使用 `'chat' | 'accounts' | 'assets' | 'config'`
+- WebSocket 消息 type 使用 `'init' | 'chat' | 'create_conversation' | 'message' | 'history' | 'tool_result' | 'pipeline_progress' | 'error' | 'conversation_created' | 'connected'`