Compare commits

..
6 Commits
Author SHA1 Message Date
fengxiangdebaozi cb2edeadb4 文档修改 2026-05-13 16:04:05 +08:00
董宏凯 309c864096 代码修改 2026-05-13 14:14:58 +08:00
董宏凯 7a5753b3ed daima1tijioa 2026-05-13 14:14:32 +08:00
董宏凯 3e8b9cf2d9 代码修改 2026-05-13 14:13:41 +08:00
董宏凯 d23d871878 代码提交 2026-05-13 14:09:55 +08:00
吉鑫升 3f2aefdcd8 Initial commit 2026-05-13 13:06:31 +08:00
2378 changed files with 86279 additions and 556673 deletions

No files matched your search

-12
View File
@@ -1,12 +0,0 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "md-viewer-frontend",
"runtimeExecutable": "npx",
"runtimeArgs": ["ng", "serve", "--port", "4200"],
"port": 4200,
"cwd": "F:\\angular\\md-viewer\\frontend"
}
]
}
-11
View File
@@ -1,11 +0,0 @@
{
"permissions": {
"allow": [
"PowerShell(uv --version)",
"PowerShell(netstat -ano)",
"PowerShell(Get-NetTCPConnection -LocalPort 8001 -ErrorAction SilentlyContinue)",
"PowerShell(Get-Process -Id 20560 -ErrorAction SilentlyContinue | Select-Object Id,ProcessName,Path,StartTime; try { \\(Invoke-WebRequest -Uri \"http://127.0.0.1:8001/health\" -UseBasicParsing -TimeoutSec 5\\).Content } catch { \"health check failed: $_\" })",
"Bash(curl -s -X POST -H \"Content-Type: application/json\" -d '{\"username\":\"demo\"}' http://localhost:8001/api/v1/auth/login/username)"
]
}
}
-1
View File
@@ -1 +0,0 @@
remote connectivity test - 2026-05-13
+56 -103
View File
@@ -1,103 +1,56 @@
# Workspace/editor # Workspace/editor
.vscode/ .vscode/
frontend-web/.vscode/ frontend-web/.vscode/
# Claude Code local-only settings (per-user permissions, not shared) # Frontend local/runtime artifacts
.claude/settings.local.json frontend-web/.env
frontend-web/node_modules/
# Local runtime logs and pid files frontend-web/tsconfig.tsbuildinfo
.runtime/ frontend-web/vite-dev.log
logs/ frontend-web/vite-dev.err.log
offline-backend-20260512/backend/logs/
# Frontend local packages/archives/docs (non-source)
# Frontend local/runtime artifacts frontend-web/pnpm-lock.yaml
frontend-web/.env frontend-web/README.md
frontend-web/.env.local frontend-web/doc.md
frontend-web/.env.*.local frontend-web/src.zip
frontend-web/node_modules/ frontend-web/src9.zip
frontend-web/.pnpm-store/ frontend-web/src-v1.zip
frontend-web/tsconfig.tsbuildinfo frontend-web/src-*.rar
frontend-web/vite-dev.log
frontend-web/vite-dev.err.log # Frontend build artifacts
frontend-web/dist-scheduled-check/
# Frontend local packages/archives/docs (non-source)
frontend-web/pnpm-lock.yaml # Offline backend bundles and local deployment artifacts
frontend-web/README.md offline-backend-20260505.zip
frontend-web/doc.md offline-backend-20260505/output/
frontend-web/src.zip offline-backend-20260505/bundle/.env
frontend-web/src9.zip offline-backend-20260505/bundle/.env.example
frontend-web/src-v1.zip offline-backend-20260505/bundle/DEPLOY_BACKEND_OFFLINE_ZH.md
frontend-web/src-*.rar offline-backend-20260505/bundle/SHA256SUMS
frontend-web/dist offline-backend-20260505/bundle/config.yaml
# Frontend build artifacts offline-backend-20260505/bundle/config.yaml.example
frontend-web/dist/ offline-backend-20260505/bundle/data/
frontend-web/dist-scheduled-check/ offline-backend-20260505/bundle/deerflow-backend-offline-image-20260505.tar
offline-backend-20260505/bundle/extensions_config.json
# Offline backend bundles and local deployment artifacts offline-backend-20260505/bundle/frontend-vite.zip
offline-backend-20260505.zip offline-backend-20260505/bundle/skills/
offline-backend-20260505/output/ offline-backend-20260505/bundle/start_backend.0509.sh
offline-backend-20260505/bundle/.env offline-backend-20260505/bundle/start_backend.sh
offline-backend-20260505/bundle/.env.example offline-backend-20260505/bundle/stop_backend.sh
offline-backend-20260505/bundle/DEPLOY_BACKEND_OFFLINE_ZH.md
offline-backend-20260505/bundle/SHA256SUMS # offline-backend-20260512 runtime artifacts (large files, local config, data)
offline-backend-20260505/bundle/config.yaml offline-backend-20260512/.env
offline-backend-20260505/bundle/config.yaml.example offline-backend-20260512/.env.example
offline-backend-20260505/bundle/data/ offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
offline-backend-20260505/bundle/deerflow-backend-offline-image-20260505.tar offline-backend-20260512/SHA256SUMS
offline-backend-20260505/bundle/extensions_config.json offline-backend-20260512/config.yaml
offline-backend-20260505/bundle/frontend-vite.zip offline-backend-20260512/config.yaml.example
offline-backend-20260505/bundle/skills/ offline-backend-20260512/data/
offline-backend-20260505/bundle/start_backend.0509.sh offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
offline-backend-20260505/bundle/start_backend.sh offline-backend-20260512/extensions_config.json
offline-backend-20260505/bundle/stop_backend.sh offline-backend-20260512/skills/
offline-backend-20260512/wheelhouse/
# offline-backend-20260512 runtime artifacts (large files, local config, data) offline-backend-20260512/start_backend.sh
offline-backend-20260512/.env.example offline-backend-20260512/stop_backend.sh
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md offline-backend-20260512/check_backend.sh
offline-backend-20260512/SHA256SUMS
offline-backend-20260512/config.yaml.example
offline-backend-20260512/data/
offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
offline-backend-20260512/extensions_config.json
offline-backend-20260512/skills/
offline-backend-20260512/wheelhouse/
offline-backend-20260512/backend/.deer-flow/data/*.db
offline-backend-20260512/backend/.deer-flow/data/*.db-*
offline-backend-20260512/backend/.deer-flow/*
offline-backend-20260512/start_backend.sh
offline-backend-20260512/stop_backend.sh
offline-backend-20260512/check_backend.sh
hermes-agent-main/
skills/*
offline-backend-20260512/backend/.deer-flow/*
# Per-user skill_evolution overrides — runtime data written by the API
offline-backend-20260512/backend/skill_evolution.json
skills/
hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
deploy
.history
# Repo-root offline dependency pack (local only)
/dist/
# Frontend offline pack working directories
frontend-web/.offline-pack-build/
frontend-web/.offline-extract-test/
.codegraph
codegraph.mdc
docs/aidev-docs
docs/backups
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
!frontend-web/src/components/workspace/skills/
# uv lockfile (not tracked per project preference)
offline-backend-20260512/backend/uv.lock
+10
View File
@@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/deerflow-server-master.iml" filepath="$PROJECT_DIR$/.idea/deerflow-server-master.iml" />
</modules>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" />
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+648 -71
View File
@@ -4,92 +4,669 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
This is a customized offline deployment of **DeerFlow** — a LangGraph-based AI super agent platform. The repo contains two sub-projects: DeerFlow is a full-stack AI agent system combining:
- **Frontend**: React/Vite web interface for interactive agent conversations
- **Backend**: LangGraph-based agent runtime with sandbox execution, persistent memory, and subagent delegation
- `offline-backend-20260512/backend/` — Python backend (FastAPI Gateway + LangGraph agent runtime) This is a monorepo with frontend and backend components running in parallel.
- `frontend-web/` — Vite + React 19 frontend (TypeScript, pnpm)
Managed together via shell scripts in `scripts/`. ---
## Commands
### Full Application (from repo root)
```bash
./scripts/start-all.sh # Start backend + frontend as background daemons
./scripts/stop-all.sh # Stop both
./scripts/restart-all.sh # Restart both
./scripts/status.sh # Check running status
```
Logs and PID files are written to `.runtime/logs/` and `.runtime/pids/`.
### Backend Only (from `offline-backend-20260512/backend/`)
```bash
make install # Install Python dependencies (uv)
make dev # Run Gateway API with hot-reload on port 8001
make gateway # Run Gateway API without reload
make test # Run all tests: PYTHONPATH=. uv run pytest tests/ -v
make lint # Lint with ruff
make format # Format with ruff
```
Run a single test file:
```bash
PYTHONPATH=. uv run pytest tests/test_<feature>.py -v
```
### Frontend Only (from `frontend-web/`)
```bash
pnpm install # Install dependencies
pnpm dev # Start Vite dev server on port 5174
pnpm build # Production build
pnpm typecheck # TypeScript type checking (tsc --noEmit)
```
## Service Ports
| Service | Port | Notes |
|---------|------|-------|
| Backend (Gateway API) | 8001 | FastAPI + LangGraph runtime |
| Frontend (Vite) | 5174 | React SPA |
Frontend connects to backend via env vars `VITE_BACKEND_BASE_URL` and `VITE_LANGGRAPH_BASE_URL` (injected by `scripts/start-frontend.sh`).
## Architecture ## Architecture
### Backend ```
┌────────────────────────────────────────────────────────┐
│ DeerFlow Application │
├────────────────────────────────────────────────────────┤
│ Nginx (Port 2026) │
│ (Unified reverse proxy) │
└─────────────────┬──────────────────┬──────────────────┘
│ │
/api/langgraph/* (Gateway) / (Frontend + /api/*)
│ │
┌──────▼──────────┐ ┌─────────▼────────────┐
│ Gateway API │ │ Frontend Web UI │
│ (Port 8001) │ │ (Port 3000) │
│ │ │ │
│ - LangGraph │ │ - React 19 + Vite │
│ - FastAPI │ │ - TypeScript │
│ - Tools │ │ - Tailwind CSS │
│ - Memory │ │ │
└─────────────────┘ └──────────────────────┘
│
┌───────▼──────────────────┐
│ Agent Runtime (LangGraph)│
│ ┌──────────────────────┐ │
│ │ Lead Agent │ │
│ │ ┌────────────────┐ │ │
│ │ │ Middlewares │ │ │
│ │ │ (10 total) │ │ │
│ │ └────────────────┘ │ │
│ │ ┌────────────────┐ │ │
│ │ │ Tools │ │ │
│ │ │ - Sandbox │ │ │
│ │ │ - MCP │ │ │
│ │ │ - Built-in │ │ │
│ │ │ - Subagents │ │ │
│ │ └────────────────┘ │ │
│ └──────────────────────┘ │
└───────────────────────────┘
```
The backend (`offline-backend-20260512/backend/`) is split into two layers: **Request Routing**:
- `/api/langgraph/*` → Gateway (LangGraph runtime)
- `/api/*` (other) → Gateway (FastAPI REST endpoints)
- `/` → Frontend (React web interface)
- **`packages/harness/deerflow/`** — publishable agent framework (`deerflow.*` imports): LangGraph lead agent, middleware chain, sandbox, tools, MCP, memory, models, skills. **Never imports from `app.*`.** ---
- **`app/`** — application layer (`app.*` imports): FastAPI Gateway routers, IM channel integrations (Feishu, Slack, Telegram, DingTalk).
The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph entry point. It wraps a middleware chain (~18 middlewares assembled in strict order) around LLM calls, handling: thread isolation, sandbox lifecycle, uploads, memory, plan mode, vision, subagent delegation, and loop detection. ## Project Structure
**Middleware order matters** — middlewares are appended in a fixed sequence; `ClarificationMiddleware` must always be last. See `offline-backend-20260512/backend/CLAUDE.md` for the full ordered list. ```
deerflow-server-master/
├── CLAUDE.md # This file (root guidance)
├── .gitignore # Git ignore rules
│
├── frontend-web/ # React/Vite web interface
│ ├── package.json # Frontend dependencies
│ ├── package-lock.json
│ ├── vite.config.ts # Vite bundler config
│ ├── tsconfig.json # TypeScript config
│ ├── tailwind.config.ts # Tailwind CSS config
│ ├── postcss.config.js # PostCSS config
│ ├── index.html # HTML entry point
│ └── src/
│ ├── App.tsx # Root component
│ ├── main.tsx # React DOM mount
│ ├── components/
│ │ ├── ai-elements/ # Agent UI components (message, artifact, plan, etc.)
│ │ ├── landing/ # Landing page components
│ │ ├── ui/ # Base UI components (shadcn/ui styled)
│ │ ├── workspace/ # Main workspace interface
│ │ ├── query-client-provider.tsx
│ │ └── theme-provider.tsx
│ ├── hooks/ # Custom React hooks
│ ├── lib/ # Utilities (HTTP client, formatting)
│ ├── pages/ # Route pages
│ └── styles/ # Global CSS
│
└── offline-backend-20260512/backend/ # Python FastAPI + LangGraph backend
├── CLAUDE.md # Detailed backend guidance
├── README.md # Backend documentation
├── Makefile # Backend commands
├── pyproject.toml # Python dependencies + workspace
├── langgraph.json # LangGraph Studio configuration
│
├── packages/harness/ # deerflow-harness: Publishable agent framework
│ ├── pyproject.toml
│ └── deerflow/
│ ├── agents/ # LangGraph agent system
│ ├── sandbox/ # Code execution isolation
│ ├── subagents/ # Task delegation system
│ ├── tools/ # Tool ecosystem
│ ├── models/ # LLM model factory
│ ├── mcp/ # Model Context Protocol integration
│ ├── skills/ # Skills discovery & loading
│ ├── config/ # Configuration system
│ ├── community/ # Community tools (Tavily, Jina, Firecrawl)
│ ├── reflection/ # Dynamic module loading
│ └── client.py # Embedded DeerFlowClient
│
├── app/ # Unpublished application code
│ ├── gateway/ # FastAPI Gateway API
│ │ ├── app.py # FastAPI application
│ │ └── routers/ # REST endpoints
│ └── channels/ # IM integrations (Feishu, Slack, Telegram, DingTalk)
│
├── tests/ # Comprehensive test suite
├── docs/ # Detailed documentation
└── .deer-flow/ # Runtime directory (created at startup)
├── agents/ # Custom agent definitions
├── users/ # Per-user data
│ └── {user_id}/
│ ├── memory.json # Memory system
│ ├── threads/ # Per-thread data
│ └── agents/ # Per-agent memory
└── threads/ # Thread-specific workspace (legacy)
```
**Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI). ---
### Frontend ## Setup & Installation
The frontend (`frontend-web/`) is a React 19 + Vite SPA: ### Prerequisites
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx` - Node.js 18+ and pnpm
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks) - Python 3.12+
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` - uv package manager
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - API keys for LLM providers (OpenAI, Anthropic, etc.)
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js) ### Quick Start (Full Application)
```bash
cd offline-backend-20260512/backend
# Install all dependencies
make install
# Create config from template
cp config.example.yaml config.yaml
# Set your API keys
export OPENAI_API_KEY="your-key-here"
# ... other API keys as needed
# Start all services
make dev
```
Access at: **http://localhost:2026**
---
## Development Commands
### Full Application (from backend directory)
```bash
make install # Install frontend + backend dependencies
make dev # Start Gateway + Frontend + Nginx (port 2026)
make gateway # Run Gateway API only (port 8001)
make test # Run all tests
make lint # Lint code
make format # Format code with ruff
```
### Frontend Only (from frontend-web directory)
```bash
pnpm install # Install frontend dependencies
pnpm dev # Start Vite dev server (port 3000)
pnpm build # Build for production
pnpm preview # Preview production build
pnpm typecheck # TypeScript type checking
```
### Backend Only (from backend directory)
```bash
make install # Install dependencies
make dev # Run Gateway API with auto-reload (port 8001)
make gateway # Run Gateway API (no reload)
make test # Run tests
make lint # Check code style
make format # Auto-format code
```
---
## Architecture Highlights
### Frontend (`frontend-web/`)
- **Framework**: React 19 with TypeScript
- **Bundler**: Vite for fast dev/build
- **Styling**: Tailwind CSS + shadcn/ui components
- **State Management**: React Query (TanStack Query) + local state
- **Routing**: React Router v7
- **Key Libraries**:
- `@xyflow/react` - Node/edge visualization for agent workflows
- `codemirror` - Code editing with language support
- `sonner` - Toast notifications
- `motion` - Animation library
- `katex` - Math rendering
- `remark-gfm` + `rehype` - Markdown processing
**Main Components**:
- `components/ai-elements/` - Agent-specific UI (message, artifact, plan, canvas, etc.)
- `components/ui/` - Reusable shadcn/ui-style components
- `components/workspace/` - Main workspace interface
- `components/landing/` - Public landing page
### Backend (`offline-backend-20260512/backend/`)
The backend is split into two layers:
1. **Harness** (`packages/harness/deerflow/`)
- Publishable agent framework package
- Contains: agents, sandbox, tools, models, MCP, skills, config
- Import prefix: `from deerflow.*`
- Can be published as `deerflow-harness` PyPI package
2. **App** (`app/`)
- Unpublished application code
- Contains: FastAPI Gateway API, IM channel integrations
- Import prefix: `from app.*`
- **Boundary rule**: App imports Harness, but Harness never imports App
**Key Systems**:
- **Lead Agent** - Single LangGraph agent with 10 middleware components
- **Sandbox** - Per-thread isolated execution (local filesystem or Docker)
- **Subagents** - Background task delegation with 3-concurrent limit
- **Memory** - LLM-powered persistent context across conversations
- **Tools** - Sandbox (bash, read/write), MCP, community (web search), built-in (clarification, image view)
- **Skills** - Domain-specific workflows injected via system prompt
- **MCP** - Model Context Protocol server integration
**For detailed backend architecture, see `offline-backend-20260512/backend/CLAUDE.md`**
---
## Configuration
### Main Configuration (`config.yaml`)
Place in project root (same level as this file). See `offline-backend-20260512/backend/config.example.yaml` for full template.
Key sections:
- `models` - LLM configurations (OpenAI, Anthropic, etc.) with thinking/vision flags
- `tools` - Tool definitions with module paths
- `sandbox` - Execution environment provider
- `skills` - Skills directory paths
- `memory` - Memory system (enabled, storage, debounce)
- `title` - Auto-title generation
- `summarization` - Context summarization
- `subagents` - Task delegation (enabled/disabled)
Config values starting with `$` are resolved as environment variables (e.g., `$OPENAI_API_KEY`).
### Extensions Configuration (`extensions_config.json`)
Place in project root. Configures MCP servers and skill states:
```json
{
"mcpServers": {
"github": {
"enabled": true,
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {"GITHUB_TOKEN": "$GITHUB_TOKEN"}
}
},
"skills": {
"pdf-processing": {"enabled": true}
}
}
```
### Environment Variables
```bash
# LLM API Keys
export OPENAI_API_KEY="sk-..."
export ANTHROPIC_API_KEY="sk-ant-..."
# Tool API Keys
export TAVILY_API_KEY="tvly-..."
export GITHUB_TOKEN="ghp_..."
# Configuration paths
export DEER_FLOW_CONFIG_PATH="./config.yaml"
export DEER_FLOW_EXTENSIONS_CONFIG_PATH="./extensions_config.json"
# Tracing (optional)
export LANGSMITH_TRACING=true
export LANGSMITH_API_KEY="lsv2_pt_..."
export LANGSMITH_PROJECT="myproject"
```
---
## Important Development Guidelines
### Documentation Updates
**CRITICAL**: Always update relevant documentation after code changes:
- Update `offline-backend-20260512/backend/README.md` for user-facing changes
- Update `offline-backend-20260512/backend/CLAUDE.md` for internal changes
- Keep documentation synchronized with code
### Testing
**Test-Driven Development (TDD) is mandatory** for backend:
```bash
cd offline-backend-20260512/backend
# Run all tests
make test
# Run specific test
PYTHONPATH=. uv run pytest tests/test_<feature>.py -v
```
Frontend testing is lighter-weight but should be done for critical components.
### Code Style
- **Backend**: `ruff` (line length: 240, Python 3.12+, double quotes)
- **Frontend**: TypeScript with eslint/prettier defaults
### Architecture Boundaries
The **Harness ↔ App boundary** (enforced by CI tests):
```python
# ✅ Allowed (App → Harness)
from deerflow.agents import make_lead_agent
# ❌ Forbidden (Harness → App)
from app.gateway.routers import ... # This will fail CI tests
```
---
## Key Features
### 1. Sandbox Execution
Per-thread isolated code execution with virtual path mapping:
- **Local**: Filesystem-based execution (default)
- **Docker**: Aio sandbox provider for complete isolation
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace`
- **Paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
### 2. Persistent Memory
LLM-powered context retention across conversations:
- Automatic extraction of facts and preferences
- Structured storage with confidence scoring
- Debounced updates to minimize LLM calls
- Injected into agent system prompt for context awareness
See `offline-backend-20260512/backend/docs/memory-improvements.md` for details.
### 3. Subagent Delegation
Background task execution with concurrent limits:
- Built-in agents: `general-purpose` (full toolset) and `bash` (commands)
- Max 3 concurrent subagents per turn
- 15-minute timeout per task
- Real-time status updates via SSE
### 4. Tool Ecosystem
Modular tool system supporting multiple providers:
- **Sandbox tools**: Code execution in isolated environments
- **Built-in tools**: `ask_clarification`, `view_image`, `present_files`
- **MCP tools**: Any Model Context Protocol server (stdio, SSE, HTTP)
- **Community tools**: Tavily (web search), Jina (web fetch), Firecrawl (scraping)
- **Skills**: Domain-specific workflows loaded from SKILL.md files
### 5. Model Context Protocol (MCP)
Integrate external tools and services:
- **Transports**: stdio, SSE, HTTP (with OAuth support)
- **Configuration**: `extensions_config.json` for server setup
- **Runtime updates**: Changes detected and reloaded automatically
### 6. Plan Mode
TodoList middleware for complex multi-step tasks:
- Controlled via runtime config: `is_plan_mode = True`
- Provides `write_todos` tool for task tracking
- Real-time task status updates
See `offline-backend-20260512/backend/docs/plan_mode_usage.md`.
### 7. Vision Support
For models with `supports_vision: true`:
- `ViewImageMiddleware` processes images automatically
- `view_image_tool` added to agent toolset
- Images converted to base64 and injected into state
### 8. File Upload
Multi-file upload with automatic document conversion:
- Supports: PDF, PPT, Excel, Word documents
- Auto-conversion via `markitdown`
- Endpoint: `POST /api/threads/{thread_id}/uploads`
- Files stored in thread-isolated directories
See `offline-backend-20260512/backend/docs/FILE_UPLOAD.md`.
### 9. IM Channel Integration
External messaging platform bridges:
- **Supported**: Feishu, Slack, Telegram, DingTalk
- **Architecture**: Channels use `langgraph-sdk` HTTP client
- **Streaming**: Feishu updates single card in place; Slack/Telegram use `wait()` response
- See `offline-backend-20260512/backend/app/channels/` for implementation
### 10. Context Summarization
Automatic conversation compression when approaching token limits:
- Configurable trigger types: tokens, messages, or token fraction
- Keeps recent messages while summarizing older ones
- See `offline-backend-20260512/backend/docs/summarization.md`.
---
## Middleware Chain (Backend)
The agent runtime executes 18 middleware components in strict order:
1. **ThreadDataMiddleware** - Per-thread isolated directories
2. **UploadsMiddleware** - Inject uploaded files
3. **SandboxMiddleware** - Acquire sandbox environment
4. **DanglingToolCallMiddleware** - Handle incomplete tool responses
5. **LLMErrorHandlingMiddleware** - Normalize provider errors
6. **GuardrailMiddleware** - Tool call authorization (optional)
7. **SandboxAuditMiddleware** - Security audit logging
8. **ToolErrorHandlingMiddleware** - Convert exceptions to error messages
9. **SummarizationMiddleware** - Context reduction (optional)
10. **TodoListMiddleware** - Task tracking (optional, plan mode)
11. **TokenUsageMiddleware** - Token tracking (optional)
12. **TitleMiddleware** - Auto-generate conversation titles
13. **MemoryMiddleware** - Queue for async memory updates
14. **ViewImageMiddleware** - Inject vision image data (conditional)
15. **DeferredToolFilterMiddleware** - Hide deferred tools (optional)
16. **SubagentLimitMiddleware** - Enforce concurrent subagent limit (optional)
17. **LoopDetectionMiddleware** - Detect and break tool-call loops
18. **ClarificationMiddleware** - Interrupt on clarification requests (must be last)
See `offline-backend-20260512/backend/CLAUDE.md` for detailed middleware descriptions.
---
## Common Workflows
### Adding a New Tool
1. Define tool in `packages/harness/deerflow/tools/` or `packages/harness/deerflow/community/`
2. Add to `config.yaml` `tools` section with module path
3. Write unit tests in `backend/tests/`
4. Update `CLAUDE.md` with tool description
5. Test via `make test`
### Creating a Custom Agent
1. Add agent metadata to database (`agents` table)
2. Create agent directory: `.deer-flow/agents/{agent_id}/SOUL.md`
3. Define agent behavior via SOUL.md frontmatter
4. Access via Gateway API: `GET /api/agents/{id}`
### Configuring MCP Servers
1. Add server to `extensions_config.json`:
```json
{
"mcpServers": {
"my-server": {
"enabled": true,
"type": "stdio",
"command": "npx",
"args": ["-y", "my-mcp-package"]
}
}
}
```
2. Restart Gateway API - MCP tools auto-loaded via lazy initialization
3. Tools available immediately in agent toolset
### Testing in Development
```bash
# Backend unit tests
cd offline-backend-20260512/backend
make test
# Run specific test
PYTHONPATH=. uv run pytest tests/test_memory_updater.py -v
# Frontend development
cd frontend-web
pnpm dev # Access at http://localhost:3000
```
---
## Port Mapping
| Service | Port | Purpose |
|---------|------|---------|
| **Frontend** | 3000 | React development server |
| **Gateway API** | 8001 | FastAPI REST endpoints + LangGraph runtime |
| **LangGraph Server** | 2024 | Raw LangGraph protocol (used internally) |
| **Nginx** | 2026 | Unified reverse proxy (public entry point) |
| **Provisioner** | 8002 | Kubernetes sandbox provisioner (optional, Docker dev only) |
---
## Important Files & References
### Backend Architecture
- `offline-backend-20260512/backend/CLAUDE.md` - Detailed backend guidance
- `offline-backend-20260512/backend/README.md` - User-facing backend documentation
- `offline-backend-20260512/backend/docs/ARCHITECTURE.md` - Deep-dive architecture
- `offline-backend-20260512/backend/docs/API.md` - Gateway API reference
- `offline-backend-20260512/backend/docs/CONFIGURATION.md` - Config schema
### Frontend Components
- `frontend-web/src/components/ai-elements/` - Agent UI components
- `frontend-web/src/components/ui/` - Reusable UI components
- `frontend-web/src/components/workspace/` - Main workspace
### Configuration ### Configuration
- `config.example.yaml` - Backend configuration template
- `extensions_config.json` - MCP servers and skills
- `offline-backend-20260512/backend/langgraph.json` - LangGraph Studio config
Backend config lives in `offline-backend-20260512/backend/config.yaml`. Values starting with `$` resolve as environment variables (e.g., `$OPENAI_API_KEY`). MCP servers and skills are configured in `extensions_config.json` in the same directory. ### Testing
- `offline-backend-20260512/backend/tests/` - Test suite
- Regression tests: `test_docker_sandbox_mode_detection.py`, `test_provisioner_kubeconfig.py`, `test_harness_boundary.py`
## Detailed Backend Reference ---
The backend has its own comprehensive documentation at: ## Troubleshooting
- `offline-backend-20260512/backend/CLAUDE.md` — architecture, middleware chain, all API routes, config schema, sandbox/memory/subagent systems
- `offline-backend-20260512/backend/README.md` — setup guide, technology stack ### Port Already in Use
If a port is already in use, kill the process:
```bash
# Find process on port (e.g., 8001)
lsof -i :8001
# Kill process
kill -9 <PID>
```
### Configuration Issues
1. Verify `config.yaml` exists in project root
2. Run: `make config-upgrade` to auto-merge missing fields
3. Check `config_version` in `config.yaml` matches `config.example.yaml`
### Memory System Issues
Check memory state:
```bash
# List memory files
ls -la offline-backend-20260512/backend/.deer-flow/users/
# Reload memory
curl -X POST http://localhost:8001/api/memory/reload
```
### Test Failures
Ensure the test dependency boundary is not violated:
```bash
# Run boundary test specifically
cd offline-backend-20260512/backend
PYTHONPATH=. uv run pytest tests/test_harness_boundary.py -v
```
---
## Technology Stack
### Frontend
- **React** 19 - UI framework
- **TypeScript** - Type safety
- **Vite** - Fast bundler
- **Tailwind CSS** - Utility-first styling
- **React Router** - Client routing
- **TanStack Query** - Data fetching
- **XYFlow** - Graph visualization
- **CodeMirror** - Code editor
- **Shiki** - Syntax highlighting
- **Remark/Rehype** - Markdown processing
### Backend
- **LangGraph** 1.0.6+ - Agent orchestration framework
- **LangChain** 1.2.3+ - LLM abstractions and tools
- **FastAPI** 0.115.0+ - REST API framework
- **Uvicorn** - ASGI server
- **Python 3.12+** - Programming language
- **uv** - Fast Python package manager
- **Ruff** - Linting and formatting
- **Pytest** - Testing framework
- **langchain-mcp-adapters** - Model Context Protocol support
- **markitdown** - Document conversion
- **Tavily/Jina/Firecrawl** - Web tools
---
## Contributing
1. Create a feature branch from `master`
2. Make changes and write tests
3. Run linting and tests:
```bash
make lint
make test
```
4. Update relevant CLAUDE.md and README.md files
5. Create a pull request
### CI/CD
GitHub Actions runs:
- Backend unit tests (including harness boundary test)
- Linting checks
- Type checking
All must pass before merging to `master`.
---
## Support
For issues and feature requests:
- Check the detailed docs in `offline-backend-20260512/backend/docs/`
- Review existing tests for usage examples
- Ask Claude Code for help with `/help` command
---
## License & Attribution
See LICENSE file in project root.
For more details on specific systems, see:
- Backend: `offline-backend-20260512/backend/CLAUDE.md`
- Backend architecture: `offline-backend-20260512/backend/docs/ARCHITECTURE.md`
- Frontend: Components in `frontend-web/src/components/`
@@ -1,139 +0,0 @@
# Iframe Chat Page Implementation Plan
> **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:** Add a standalone `/page/workspace/chats_iframe/*` chat page that reuses the existing thread system and history, while showing only `你好` and no input box before the first user message.
**Architecture:** Keep the existing workspace shell and chat runtime intact, then add a new route pair and a copied page component dedicated to the iframe path. The new page keeps the original chat behavior once a thread already has messages, but it owns its empty-state rendering and URL transition to `/page/workspace/chats_iframe/:thread_id`.
**Tech Stack:** React, TypeScript, React Router, Next navigation shim, existing LangGraph thread hooks
---
### Task 1: Add the iframe chat routes
**Files:**
- Modify: `frontend-web/src/pages/WorkspaceRoutes.tsx`
- [ ] **Step 1: Add `chats_iframe/new` and `chats_iframe/:thread_id` routes that render the new page inside the existing workspace layout and chat runtime**
```tsx
<Route
path="chats_iframe/new"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="chats_iframe/:thread_id"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
```
- [ ] **Step 2: Ensure the new route imports compile cleanly**
Run: `rg -n "IframeChatPage|chats_iframe" frontend-web/src/pages/WorkspaceRoutes.tsx`
Expected: `WorkspaceRoutes.tsx` contains one import for `IframeChatPage` and both `chats_iframe` route entries.
### Task 2: Add a standalone iframe chat page
**Files:**
- Create: `frontend-web/src/pages/IframeChatPage.tsx`
- Reference: `frontend-web/src/pages/ChatPage.tsx`
- [ ] **Step 1: Copy the current `ChatPage` implementation into `IframeChatPage` and preserve the existing chat runtime integration**
```tsx
export default function IframeChatPage() {
const routeBase = useRouteBase();
const router = useRouter();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
useThreadChat();
// keep the rest of the existing chat hooks and handlers aligned with ChatPage
}
```
- [ ] **Step 2: Change the thread creation URL replacement to stay on the iframe route family**
```tsx
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#${routeBase}/chats_iframe/${createdThreadId}`,
);
},
```
- [ ] **Step 3: Replace the default landing state with a simple `你好` view and do not render the input box while the iframe page is still a brand-new untouched chat**
```tsx
{isLandingState && mounted ? (
<div className="flex min-h-[240px] items-center justify-center">
<div className="text-2xl font-medium">你好</div>
</div>
) : null}
{!isLandingState &&
(mounted ? (
<div className="w-full -translate-y-4">
<InputBox
className="w-full"
isNewThread={false}
threadId={threadId}
autoFocus={false}
status={thread.error ? "error" : thread.isLoading ? "streaming" : "ready"}
context={settings.context}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || isUploading}
onContextChange={(context) => setSettings("context", context)}
promptPrefix={promptPrefixProp}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
</div>
) : (
<div aria-hidden="true" className="bg-background/5 h-32 w-full -translate-y-4 rounded-3xl border" />
))}
```
- [ ] **Step 4: Keep message rendering, thread title, export, token usage, todos, artifact triggers, and all post-first-message behavior identical to the original page**
Run: `rg -n "MessageList|ThreadTitle|ArtifactTrigger|TokenUsageIndicator|TodoList" frontend-web/src/pages/IframeChatPage.tsx`
Expected: The new page still renders the same core chat primitives used by `ChatPage`.
### Task 3: Verify the new page compiles
**Files:**
- Test: `frontend-web/src/pages/IframeChatPage.tsx`
- Test: `frontend-web/src/pages/WorkspaceRoutes.tsx`
- [ ] **Step 1: Run the frontend build**
Run: `cmd /c npm run build`
Expected: Build succeeds without TypeScript or bundling errors related to `IframeChatPage` or `chats_iframe`.
- [ ] **Step 2: Spot-check the changed routes in the built source**
Run: `rg -n "chats_iframe" frontend-web/src/pages`
Expected: Matches appear in `WorkspaceRoutes.tsx` and `IframeChatPage.tsx`.
- [ ] **Step 3: Commit after verification**
```bash
git add docs/superpowers/plans/2026-06-01-iframe-chat-page.md frontend-web/src/pages/WorkspaceRoutes.tsx frontend-web/src/pages/IframeChatPage.tsx
git commit -m "feat: add iframe chat page routes"
```
+3 -42
View File
@@ -1,43 +1,4 @@
# 本地开发(pnpm dev)— 与原先 .env 保持一致 # 本地开发:直连 Gateway(与 offline-backend-20260512/backend 默认端口一致)
# 个人覆盖请新建 .env.development.local(已 gitignore,优先级更高) # 修改端口时请同步改这里,并重启 `npm run dev`
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001/
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# CMZS后端接口
VITE_API_BASE_URL=https://ch1.b.uat.4.cn/zncm
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -1,29 +0,0 @@
# 复制为 .env.development.local,仅本机生效,不提交 Git
# 直连本机 Gateway(需后端 CORS_ORIGINS 包含 http://127.0.0.1:5173 等)
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
+5 -41
View File
@@ -1,43 +1,7 @@
# 环境变量说明(Vite 仅暴露以 VITE_ 开头的变量) # 复制为 .env 或 .env.development 后按需修改(Vite 仅暴露以 VITE_ 开头的变量)
# #
# ┌─────────────────┬──────────────────────────────────────────────────┐ # Gateway 根地址(REST:`/api/models`、`/api/threads/...` 等)
# │ 命令 │ 生效文件 │ VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
# ├─────────────────┼──────────────────────────────────────────────────┤
# │ pnpm dev │ .env.development + .env.development.local │
# │ pnpm build │ .env.production + .env.production.local │
# │ pnpm build:network │ .env.network + .env.network.local(内网地址) │
# │ scripts/start-* │ 脚本注入的环境变量会覆盖上述文件(见下方说明) │
# └─────────────────┴──────────────────────────────────────────────────┘
# #
# 推荐: # LangGraph SDK 的 apiUrl:须指向带 `/api` 的前缀,以便请求落在 `/api/threads`、`/api/assistants` 等路由上
# - 日常本地开发:改 .env.development(已提交,团队默认 UAT 地址) VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# - 本机连 127.0.0.1:8001:复制 .env.development.local.example → .env.development.local
# - 内网打包部署:改 .env.network 后执行 pnpm build:network(或改 .env.production 后 pnpm build)
#
# 若浏览器直连后端报 CORS,请在 Gateway 配置 CORS_ORIGINS,包含前端源站,例如:
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174
# --- 开发环境(见 .env.development)---
# VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
# VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
# VITE_STUDIO_ENABLED=true # 仅控制“工作室”入口显示,列表内容仍依赖 notebook 接口数据
# --- 生产打包(见 .env.production)---
# 部署前修改 .env.production 中的地址,再执行 pnpm build
# MaxKey 鉴权根地址(无界 onlineTicket 换 token)
# VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
# VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
# VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
# VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
# VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
# 可选
# VITE_CANVAS_LANGGRAPH_BASE_URL=
# VITE_STATIC_WEBSITE_ONLY=
# VITE_GITHUB_OAUTH_TOKEN=
-43
View File
@@ -1,43 +0,0 @@
# 本地开发(pnpm dev)— 与原先 .env 保持一致
# 个人覆盖请新建 .env.development.local(已 gitignore,优先级更高)
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# CMZS后端接口
VITE_API_BASE_URL=https://ch1.b.uat.4.cn/zncm
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
# VITE_CANVAS_LANGGRAPH_BASE_URL=
-35
View File
@@ -1,35 +0,0 @@
# 生产打包(pnpm build)— 部署前请改成实际内网/生产地址
# 打包后变量会写入 dist,运行时不可再改
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolweb.b.uat.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -1,8 +0,0 @@
# 复制为 .env.production.local,打包前临时覆盖生产地址(不提交 Git)
# VITE_BACKEND_BASE_URL=https://your-intranet-host/deerflow
# VITE_LANGGRAPH_BASE_URL=https://your-intranet-host/deerflow/api
# VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# VITE_AIPI_BACKENP_URL=https://your-notebook-backend-host
# VITE_AIPI_FRONTEND_URL=https://your-notebook-frontend-host
# VITE_STUDIO_ENABLED=true
-35
View File
@@ -1,35 +0,0 @@
# SIT 环境打包(pnpm build:sit / npm run build:sit)— 接口地址为 UAT 的 sit 版本
# 打包后变量会写入 dist,运行时不可再改
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.sit.4.cn/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.sit.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.sit.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.sit.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.sit.4.cn/web
VITE_AIPI_B2ACTOR_URL=https://ty.b.sit.4.cn
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.sit.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.sit.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.sit.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.sit.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.sit.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.sit.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -1,228 +0,0 @@
# AI 写作 · 右侧文本编辑器(滑词 / 段落菜单)
> 说明 AI 写作页、开放画布中文稿右侧编辑器的实现方式:**编辑器本体基于 Tiptap v3**;选中文本浮层(「滑词」)与段落悬停按钮为**自研 React 组件**,未使用 `@tiptap/extension-bubble-menu` 官方 BubbleMenu。
---
## 一、功能概览
| 交互 | 用户感知 | 组件 | 触发方式 |
|------|----------|------|----------|
| **滑词** | 拖选一段文字后,选区上方出现工具条(AI 改写、加粗、斜体、下划线、H1、复制) | `WritingBubbleMenu` | Tiptap `selectionUpdate` + 浏览器 `window.getSelection()` |
| **段落菜单** | 鼠标移入某段/标题/引用块时,段落左侧出现 ✨ 按钮,点击可 AI 改写整段 | `WritingParagraphMenu` | 编辑器 DOM `mousemove` + ProseMirror `posAtCoords` |
| **AI 改写面板** | 点击「AI 改写」后弹出润色/扩写等操作与预览 | `WritingAiMenu` / `WritingRewritePreview` | 由 `TextArtifactRenderer` + `useWritingRewrite` 串联 |
画布线程页(`/page/canvas/:thread_id`)与 AI 写作页(`/page/workspace/ai-writing`)右侧草稿**共用同一套** `TextArtifactRenderer` 与菜单组件。
---
## 二、在 AI 写作中的挂载路径
```mermaid
flowchart TB
AIWritingPage --> CanvasResizableLayout
CanvasResizableLayout --> AIWritingDraftPanel
AIWritingDraftPanel --> DraftStreamingPreview
AIWritingDraftPanel --> TextArtifactRenderer
DraftStreamingPreview["DraftStreamingPreview<br/>(流式只读 Markdown)"]
TextArtifactRenderer --> WritingBubbleMenu
TextArtifactRenderer --> WritingParagraphMenu
TextArtifactRenderer --> TiptapEditor
TextArtifactRenderer --> WritingAiMenu
```
| 阶段 | 右侧展示 | 滑词 / 段落菜单 |
|------|----------|-----------------|
| 草稿流式生成中(`writing_draft` / `revising` 且有 section 流) | `DraftStreamingPreview`(ReactMarkdown 只读) | **不可用** |
| 成稿可编辑(`isDraftEditable === true`) | `TextArtifactRenderer` + Tiptap | **可用** |
| 流式结束但尚未 editable | `TextArtifactRenderer` 且 `isStreaming={true}` | **不可用**(`editable: false`) |
相关状态由 [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) 的 `isDraftStreaming`、`isDraftEditable` 控制;注释中「禁用编辑/润色/滑词」即指生成完成前不进入可编辑渲染器。
入口文件:
- [AIWritingDraftPanel.tsx](../src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx) — 决定流式预览 vs `TextArtifactRenderer`
- [TextArtifactRenderer.tsx](../src/open-canvas/components/artifact/TextArtifactRenderer.tsx) — 挂载编辑器与两套浮层菜单
---
## 三、技术栈:编辑器 vs 浮层
### 3.1 Tiptap 编辑器(有使用)
| 包 | 用途 |
|----|------|
| `@tiptap/react` | `useEditor`、`EditorContent` |
| `@tiptap/starter-kit` | 段落、标题、加粗、斜体、下划线等 |
| `@tiptap/extension-placeholder` | 空文档占位符 |
| `tiptap-markdown` | Markdown ↔ 文档互转 |
| 自研 `AiHighlightExtension` | AI 改写前高亮选区(ProseMirror Decoration) |
扩展注册见 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts);实例创建见 [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts)。
### 3.2 浮层菜单(自研,非 Tiptap 官方插件)
| 包 | 是否用于滑词/段落菜单 |
|----|----------------------|
| `@tiptap/extension-bubble-menu` | **未使用**(`package.json` 中有依赖,代码未 import) |
| `@tiptap/react` 的 `<BubbleMenu>` | **未使用** |
两套菜单均采用:
1. 监听 Tiptap / DOM 事件更新位置与选区;
2. `createPortal(..., document.body)` 挂到 `document.body`,避免被右侧滚动容器 `overflow` 裁切;
3. `position: fixed` + `getBoundingClientRect()` 定位。
命名上的 `WritingBubbleMenu` 仅表示「气泡式选区工具条」,实现上与 Tiptap 官方 Bubble Menu 扩展无关。
---
## 四、核心文件索引
| 文件 | 职责 |
|------|------|
| [TiptapEditor.tsx](../src/open-canvas/editor/TiptapEditor.tsx) | `EditorContent` 渲染容器 |
| [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) | StarterKit、Placeholder、Markdown、AiHighlight |
| [AiHighlightExtension.ts](../src/open-canvas/editor/AiHighlightExtension.ts) | `setAiHighlight` / `clearAiHighlight` 命令 |
| [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts) | 创建 editor、同步外部 markdown、editable |
| [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) | **滑词** 选区浮层 |
| [WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx) | **段落** 左侧 ✨ 按钮 |
| [WritingAiMenu.tsx](../src/open-canvas/components/artifact/WritingAiMenu.tsx) | AI 改写操作菜单 |
| [WritingRewritePreview.tsx](../src/open-canvas/components/artifact/WritingRewritePreview.tsx) | 改写结果预览 |
| [useWritingRewrite.ts](../src/open-canvas/hooks/useWritingRewrite.ts) | 调用润色 API、写回编辑器 |
| [apply-rewrite.ts](../src/open-canvas/utils/apply-rewrite.ts) | 将改写结果应用到 ProseMirror 选区 |
| [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) | `writingSelection` 全局状态 |
| [selection.ts](../src/open-canvas/types/selection.ts) | `WritingSelection` 类型定义 |
---
## 五、滑词:`WritingBubbleMenu`
### 5.1 行为
- 用户在编辑器内拖选非空文本时显示浮层;选区为空或位于 `codeBlock` 内时隐藏。
- 浮层按钮:`AI 改写`、`B` / `I` / `U`、`H1`、复制。
- 按钮 `onMouseDown` 使用 `e.preventDefault()`,避免点击时编辑器失焦导致选区丢失。
### 5.2 实现要点
```text
editor.on("selectionUpdate")
→ 读取 editor.state.selection (from, to, empty)
→ window.getSelection() + Range.getBoundingClientRect() 算屏幕坐标
→ setWritingSelection({ from, to, text }) 写入 CanvasUIContext
→ createPortal 渲染工具条,useLayoutEffect 做贴边 / 上方空间不足时翻到选区下方
```
关键代码位置:[WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 中 `useEffect` 监听 `selectionUpdate` 与 `blur`。
点击「AI 改写」时:
1. `editor.commands.setAiHighlight(from, to)` 高亮选区;
2. 调用父组件传入的 `onAiRewrite()`,由 `TextArtifactRenderer` 打开 `WritingAiMenu`。
---
## 六、段落菜单:`WritingParagraphMenu`
### 6.1 行为
- 鼠标在编辑器内移动时,检测光标下的**块级节点**;
- 仅对 `paragraph`、`heading`、`blockquote` 且文本非空时,在块左侧显示 ✨ 按钮;
- 鼠标离开编辑器或移出可改写块后,延迟约 **380ms** 隐藏(`HIDE_DELAY`),并在按钮与正文之间留有 hover 桥接区,避免移向按钮时闪烁消失。
### 6.2 实现要点
```text
editor.view.dom mousemove
→ editor.view.posAtCoords({ left, top })
→ doc.resolve(pos).before(depth) 定位块起点
→ editor.view.nodeDOM(nodeStart) 取 DOM 矩形
→ createPortal 固定定位按钮在块左侧 (BTN_SIZE=24, BTN_GAP=6)
```
点击 ✨ 时:
1. `setTextSelection` 选中整段 ProseMirror 范围(`nodeFrom` ~ `nodeTo`);
2. `setAiHighlight` + `setWritingSelection`;
3. 触发与滑词相同的 `onAiRewrite()` 流程。
关键代码位置:[WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx)。
---
## 七、AI 改写数据流
```mermaid
sequenceDiagram
participant User
participant Menu as BubbleMenu / ParagraphMenu
participant Ctx as CanvasUIContext
participant Renderer as TextArtifactRenderer
participant API as rewrite API
User->>Menu: 选中文本或点段落 ✨
Menu->>Ctx: setWritingSelection
Menu->>Renderer: onAiRewrite()
Renderer->>Renderer: phase = menu, WritingAiMenu
User->>Renderer: 选择润色/扩写等
Renderer->>API: useWritingRewrite.rewrite
API-->>Renderer: 流式结果
Renderer->>Renderer: WritingRewritePreview
User->>Renderer: 接受改写
Renderer->>Renderer: applyReplace / applyInsert
```
- 选区信息类型:`WritingSelection`(`from`、`to`、`text`),见 [selection.ts](../src/open-canvas/types/selection.ts)。
- 与画布聊天侧选区 `CanvasTextSelection` 分离;后者用于快捷操作栏等,不用于 Tiptap 改写。
---
## 八、与顶部工具栏、目录栏的关系
同一 `TextArtifactRenderer` 编辑模式下还包含:
| 组件 | 作用 |
|------|------|
| [WritingToolbar.tsx](../src/open-canvas/components/artifact/WritingToolbar.tsx) | 顶部固定格式栏、导出等 |
| [DraftOutlineSidebar.tsx](../src/open-canvas/components/artifact/DraftOutlineSidebar.tsx) | 左侧标题目录,点击滚动到对应 heading |
滑词 / 段落菜单与工具栏互不替代:工具栏面向全局格式;浮层面向**当前选区或当前段落**的快捷操作与 AI 改写入口。
---
## 九、开发与改造建议
### 9.1 若要改用官方 Bubble Menu
1. 在 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) 或组件层引入 `@tiptap/react` 的 `BubbleMenu`(或注册 `@tiptap/extension-bubble-menu`);
2. 将 [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 内手写 `selectionUpdate` + Portal 逻辑替换为官方组件的 `shouldShow` / `tippyOptions`(v3 API 以官方文档为准);
3. **段落左侧按钮**仍建议保留自研 `WritingParagraphMenu`(官方 Bubble Menu 只覆盖选区,不覆盖 hover 段落场景)。
### 9.2 新增浮层按钮
- **滑词**:在 `WritingBubbleMenu` 的 `BubbleButton` 列表中增加,使用 `editor.chain().focus()....run()`。
- **段落**:在 `WritingParagraphMenu` 的 `handleClick` 或拆出多按钮布局;注意 `REWRITABLE_TYPES` 与 hover 延迟逻辑。
### 9.3 流式阶段禁止交互
不要在 `DraftStreamingPreview` 中挂载 Tiptap 菜单:流式阶段 token 高频更新,挂载 `useWritingEditor` 易引发更新风暴(见 [DraftStreamingPreview.tsx](../src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx) 文件头注释)。应等 `isDraftEditable` 后再走 `TextArtifactRenderer`。
---
## 十、相关文档
- [前端开发.md](./前端开发.md) §25.4 — Canvas / AI 写作路由与组件总表
- [ai-writing-用户自带大纲-测试用例.md](./ai-writing-用户自带大纲-测试用例.md) — AI 写作业务流程测试(含右侧草稿层级校验)
---
## 十一、依赖版本(摘录)
以 [package.json](../package.json) 为准,编辑器相关主要为:
- `@tiptap/core`、`@tiptap/react`、`@tiptap/starter-kit`:`^3.23.4`
- `@tiptap/extension-bubble-menu`:已安装但未接入滑词实现
- `tiptap-markdown`:Markdown 扩展
升级 Tiptap 大版本前,请对照官方迁移指南,并重点回归:`WritingBubbleMenu` 选区坐标、`WritingParagraphMenu` 的 `posAtCoords`、以及 `AiHighlightExtension` 与 StarterKit 扩展名冲突(勿重复注册 `underline`)。
@@ -1,274 +0,0 @@
# AI 写作 ·「用户自带大纲」手动测试用例
> 功能:用户在 AI 写作表单粘贴已有大纲后,系统**完全按用户大纲生成正文**,兼容一/二/三级目录。
> 入口页面:`http://localhost:5173/#/page/workspace/ai-writing`(或 `2026` 端口经 nginx)
---
## 一、测试目标
验证三件事:
1. **识别**——用户粘贴的各种格式大纲,能被正确解析成 章节(##) → 小节(###) → 子要点(####) 三级结构。
2. **保真**——生成的大纲标题、层级**与用户粘贴的完全一致**,不增、不删、不改写。
3. **兼容**——只有一级 / 一+二级 / 一+二+三级 三种深度都能正确生成对应层级的正文。
---
## 二、前置条件
| 项 | 要求 |
|----|------|
| 服务 | 后端(Gateway 8001)+ 前端(Vite 5174/5173)均已启动,可正常发起一次普通 AI 写作 |
| 模型 | 写作模型已在表单可选、能正常输出(先用不带大纲的普通流程跑通一次作为基线) |
| 账号 | 已登录(或无鉴权模式) |
| 入口 | 进入 `/page/workspace/ai-writing`,能看到「写作配置选项」表单 |
---
## 三、通用操作步骤
除非用例另有说明,每条用例都按以下步骤执行:
1. 进入 AI 写作页面,点开**「已有大纲(可选)」** 旁的「我已有大纲,直接粘贴 ▾」。
2. 在「写作意图」填一句话(如:`写一篇关于新能源汽车出口的分析文章`)。
3. 把用例里的**「输入大纲」**整段粘贴进「已有大纲」输入框。
4. 文章类型 / 目标字数 / 素材来源保持默认即可。
5. 点击「开始 AI 写作」。
6. 等待流程走到 **PAUSE-2「大纲确认」**,对照**「预期大纲结构」**核对左侧/卡片里的大纲。
7. 点「确认」继续,等正文生成完,对照**「预期正文层级」**核对右侧草稿的标题层级。
> 关键校验点都在第 6 步(大纲确认卡)。正文层级(第 7 步)作为二次确认。
---
## 四、测试用例
### TC-01 Markdown 三级 + 文章标题
**输入大纲:**
```
# 新能源汽车出口为何高速增长
## 引言
### 出口数据创新高
#### 同比增幅
#### 全球排名变化
### 市场背景
## 增长动因
### 成本优势
```
**预期大纲结构:**
- 文章标题 = `新能源汽车出口为何高速增长`(首个孤立 `#` 被识别为标题,不作为章节)
- 章节1 `引言`
- 小节 `出口数据创新高` → 子要点:`同比增幅`、`全球排名变化`
- 小节 `市场背景`(无子要点)
- 章节2 `增长动因`
- 小节 `成本优势`
**预期正文层级:** `#` 文章标题 → `##` 引言/增长动因 → `###` 小节 → `####` 同比增幅/全球排名变化,各级都有正文。
---
### TC-02 中文编号三级(一、/(一)/ 1.)
**输入大纲:**
```
一、引言
(一)背景
(二)意义
二、现状分析
(一)市场数据
1. 国内市场
2. 海外市场
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `背景`、`意义`
- 章节2 `现状分析` → 小节 `市场数据` → 子要点 `国内市场`、`海外市场`
- 标题留空 → 用「写作意图」兜底(卡片标题约等于意图前 30 字)
**校验重点:** `一、` > `(一)` > `1.` 的层级顺序正确(符合中文公文惯例)。
---
### TC-03 阿拉伯小数点编号(1. / 1.1 / 1.1.1)
**输入大纲:**
```
1. 引言
1.1 研究背景
1.2 研究意义
2. 现状
2.1 行业概况
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `研究背景`、`研究意义`
- 章节2 `现状` → 小节 `行业概况`
---
### TC-04 缩进 bullet 表示层级
**输入大纲:**
```
- 引言
- 背景
- 意义
- 现状
- 数据
- 细节展开
```
**预期大纲结构:**
- 章节1 `引言` → 小节 `背景`、`意义`
- 章节2 `现状` → 小节 `数据` → 子要点 `细节展开`
**校验重点:** 缩进深度正确映射成层级。
---
### TC-05 只有一级目录(平铺 Markdown)
**输入大纲:**
```
## 引言
## 现状
## 对策
## 结论
```
**预期大纲结构:** 4 个章节 `引言/现状/对策/结论`,**均无小节**。
**预期正文层级:** 每个章节 `##` 标题下是一整段连贯正文(不出现 `###`)。
**校验重点:** 一级目录场景不报错、不被强行拆出多余小节。
---
### TC-06 只有一级目录(中文编号)
**输入大纲:**
```
一、引言
二、现状
三、结论
```
**预期大纲结构:** 3 个章节 `引言/现状/结论`,无小节。与 TC-05 等价,验证另一种一级写法。
---
### TC-07 纯文本平铺(无任何标记)
**输入大纲:**
```
引言
现状
对策
总结
```
**预期大纲结构:** 4 个章节 `引言/现状/对策/总结`(每行一个短标题 → 一级章节)。
**校验重点:** 无编号无缩进的短标题列表也能识别为一级大纲。
---
### TC-08 格式混乱 / 散文化(走 LLM 兜底)
**输入大纲:**
```
我想写一篇分析今年AI大模型发展趋势的文章,重点关注国内企业的布局。
希望先讲背景,再讲现状,最后给出展望。
```
**预期行为:**
- 确定性解析判定为「散文」→ 自动走 **LLM 归一兜底**,整理成一份结构化大纲。
- 大纲确认卡能正常显示一份大纲(不报错、不卡死)。
- 大纲内容应贴近原意(背景 / 现状 / 展望之类),**但不要求逐字一致**(这一条是兜底鲁棒性,不是保真用例)。
**校验重点:** 流程不崩、能产出可用大纲。
---
### TC-09 保真校验(标题原样保留)
**输入大纲:**
```
一、A股市场2024年回顾与展望
(一)宏观经济(GDP/CPI)影响
(二)流动性与利率
二、行业轮动主线
```
**预期大纲结构:** 标题文本**逐字保留**,仅编号被剥离:
- 章节1 `A股市场2024年回顾与展望`
- 小节 `宏观经济(GDP/CPI)影响`、`流动性与利率`
- 章节2 `行业轮动主线`
**校验重点:** 标题里的中文括号、英文缩写、年份等**不被改写**。
---
### TC-10 大纲确认卡里再编辑 / 重新规划
**前置:** 先用 TC-02 的大纲跑到「大纲确认」卡。
**操作:**
1. 在确认卡里**手动改一个小节标题**(如把 `背景` 改成 `行业背景`)→ 点「确认」。
2. 或点「重新规划」并填修改要求(如 `给"现状分析"再加一个小节`)。
**预期行为:**
- 手动改后确认:正文按改后的大纲生成。
- 重新规划:基于**用户这份大纲**做编辑式调整(在原结构上加/改),而不是从零另起一份无关大纲。
**校验重点:** 自带大纲 → 确认 → 编辑 这条回路正常,不会丢掉用户原始结构。
---
### TC-11 回归:不填大纲(默认流程不受影响)
**操作:** 「已有大纲」**留空**,只填写作意图,正常发起。
**预期行为:** 与功能上线前完全一致——系统自动检索素材、**自动规划大纲**(大纲由 AI 生成,非用户提供)。
**校验重点:** 增量功能不污染老流程。
---
### TC-12 素材关联(仍走检索)
**前置:** 素材来源选「知识库」,用 TC-02 的大纲发起,等检索出素材并确认。
**预期行为:**
- 走到大纲时,用户的章节会被**关联上检索到的素材**(正文里能体现素材中的事实/数据)。
- 关联**只填素材、不改大纲标题与结构**。
**校验重点:** 「自带大纲」与「检索素材」能共存——大纲是用户的,正文有素材支撑。
---
## 五、缺陷记录模板
| 用例 | 输入大纲(摘要) | 预期 | 实际 | 是否通过 | 备注 |
|------|------------------|------|------|----------|------|
| TC-01 | Markdown 三级 | 标题+2章节+子要点 | | ☐ | |
| TC-02 | 一、/(一)/1. | 中文三级 | | ☐ | |
| TC-03 | 1./1.1/1.1.1 | 阿拉伯三级 | | ☐ | |
| TC-04 | 缩进 bullet | 缩进三级 | | ☐ | |
| TC-05 | 平铺 ## | 4 章节无小节 | | ☐ | |
| TC-06 | 平铺 一、 | 3 章节无小节 | | ☐ | |
| TC-07 | 纯文本平铺 | 4 章节 | | ☐ | |
| TC-08 | 散文 | LLM 兜底产出大纲 | | ☐ | |
| TC-09 | 带括号/缩写标题 | 标题逐字保留 | | ☐ | |
| TC-10 | 确认卡编辑/重规划 | 在原大纲上调整 | | ☐ | |
| TC-11 | 留空 | 走自动规划 | | ☐ | |
| TC-12 | 检索+自带大纲 | 素材关联不改结构 | | ☐ | |
---
## 六、备注
- TC-01~07、09 的解析逻辑已有**自动化单测**覆盖(`backend/tests/test_ai_writing_outline_parser.py`,19 个用例),本文档侧重**端到端真机表现**(流式展示、确认卡渲染、正文层级、素材关联)。
- 若某条解析结果与预期不符,先看大纲确认卡里的**结构**是否正确(定位是解析问题还是正文生成问题)。
- 相关后端实现:`packages/harness/deerflow/agents/ai_writing/outline_parser.py`(解析)、`nodes/writer_outline.py`(自带大纲分支 + 素材关联)。
-436
View File
@@ -1,436 +0,0 @@
# 工作台问答 · iframe 嵌入开发说明
本文档说明如何将 DeerFlow **通用工作台问答**(与 `/page/workspace/chats/*` 同一套 LangGraph 流式能力)以**普通 iframe** 形式嵌入第三方页面,并在**父页面无法读取子 frame 内 `thread_id`** 的前提下,轮询「本次问答是否已结束」。
后端公开 API 实现见:`offline-backend-20260512/backend/app/gateway/routers/public_embed.py`
数据表:`embed_sessions`(迁移 `20260602_01`)
---
## 0. 速读
| 角色 | 要做的事 |
|------|----------|
| **父页面** | 自己生成 `sessionId`(UUID),拼进 iframe `src`;用 `sessionId` 调公开 API 轮询 `finished` |
| **iframe(DeerFlow)** | 打开 `/embed/chats/...`,自动登录、自动发问、登记 `sessionId → thread_id` |
| **不需要** | 无界(wujie)、父页面拿 `thread_id`、DeerFlow 登录态 Cookie |
**完成条件**:`GET .../sessions/{sessionId}/status` 返回 `finished === true`(且无进行中的 run,非澄清中断)。
---
## 1. 背景与约束
### 1.1 业务目标
- 第三方系统用 **iframe** 嵌 DeerFlow 问答 UI。
- 传入:**问题文案**、**用户名**(走现有 `login/username`)、**主题色**。
- 展示:**消息列表** + **沙箱/产物侧栏**(与主聊天相同的 `ChatBox`);**隐藏**页面左侧栏、Workspace 侧栏、底部输入框。
- 进入后 **自动发送** 首条用户消息并走流式问答。
### 1.2 为何需要 `sessionId`
iframe 与父页面通常 **跨域**,父页面 **不能** 读取子应用 URL 里的 `thread_id`(hash 路由在子 frame 内)。
因此约定:
1. **父页面**在打开 iframe 前生成全局唯一的 `sessionId`;
2. **子页面**在创建/确定 `thread_id` 后,调用后端 **登记接口** 写入 `session_id → thread_id`;
3. **父页面**只凭 `sessionId` 查询状态,必要时响应里会带上 `thread_id`(可选使用)。
---
## 2. 端到端流程
```mermaid
sequenceDiagram
participant Parent as 父页面
participant Iframe as DeerFlow iframe
participant API as Gateway /api/public/embed
Parent->>Parent: sessionId = crypto.randomUUID()
Parent->>Iframe: iframe src 含 sessionId、message、username、theme
Iframe->>Iframe: loginByUsername + 自动 sendMessage
Iframe->>API: POST /sessions { session_id, thread_id }
loop 每 1–3s
Parent->>API: GET /sessions/{sessionId}/status
API-->>Parent: registered, finished, thread_id
end
```
---
## 3. 前端路由与页面
### 3.1 路由(独立于 `/page/*`)
在 `App.tsx` 注册,**不经过** `PageRoutes` / `PageSidebar`,因此无外层导航与 Workspace 侧栏。
| 路由 | 组件 | 说明 |
|------|------|------|
| `/embed/chats/:thread_id` | `EmbedChatPage`(外包 `ChatRuntime`) | `thread_id` 可为 `new` 或已有 UUID |
示例(HashRouter,注意 `#`):
```
http://localhost:5173/#/embed/chats/new?sessionId=...&message=...&username=guest&theme=red
```
开发环境端口以本地 Vite 为准(如 5173 / 5174)。
### 3.2 URL 查询参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `sessionId` 或 `session_id` | **是** | 父页面生成的关联 id,8–128 位,仅字母数字 `_` `-` |
| `message` / `question` / `text` | 建议 | 首条用户问题;有则进入后自动发送 |
| `username` / `userName` | 否 | 默认 `guest`,对应 `POST /api/v1/auth/login/username` |
| `theme` | 否 | `dark-blue` → 深色;`red` 等 → 浅色(与 `LoginPage` 一致) |
缺少 `sessionId` 时,嵌入页会提示错误,不会发起登记。
### 3.3 相关源码
| 路径 | 职责 |
|------|------|
| [src/pages/EmbedChatPage.tsx](../src/pages/EmbedChatPage.tsx) | 嵌入页:登录、自动发问、登记 session、仅消息列表 + ChatBox |
| [src/core/embed/params.ts](../src/core/embed/params.ts) | 从 URL 解析嵌入参数 |
| [src/core/embed/status-api.ts](../src/core/embed/status-api.ts) | 登记 / 轮询 API 封装 |
| [src/App.tsx](../src/App.tsx) | 路由注册 |
### 3.4 UI 行为说明
- **隐藏**:`PageSidebar`、`WorkspaceSidebar`、`InputBox`、顶栏标题栏等(相对完整 `ChatPage`)。
- **保留**:`MessageList`、流式指示、澄清卡片(若 agent 触发 `ask_clarification`)、右侧 **产物/沙箱**(`ChatBox` 内 60/40 分栏逻辑与主聊天一致)。
- **线程 id**:新建会话时客户端先分配 UUID,`onStart` 后 `history.replaceState` 更新 hash 为 `/embed/chats/{真实id}`(与主聊天 `ChatPage` 相同手法,避免整页重载)。
---
## 4. 公开 HTTP API(无需鉴权)
前缀:`/api/public/embed/`(走 Gateway,与 `VITE_BACKEND_BASE_URL` 一致,默认 `http://47.88.25.99:7001/`)。
CSRF / Auth 中间件对 `/api/public/` 放行;请求 **不需要** Cookie。
### 4.1 登记映射(iframe 内自动调用)
```http
POST /api/public/embed/sessions
Content-Type: application/json
{
"session_id": "parent-generated-uuid",
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b"
}
```
**响应:**
```json
{
"session_id": "parent-generated-uuid",
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
"registered": true
}
```
同一 `session_id` 再次 POST 会 **更新** `thread_id`(例如 `onStart` 后 id 变化)。
### 4.2 轮询状态(父页面使用)
```http
GET /api/public/embed/sessions/{session_id}/status
```
**尚未登记**(iframe 仍在加载或未 POST):
```json
{
"session_id": "parent-generated-uuid",
"registered": false,
"thread_id": null,
"finished": false,
"thread_status": "pending",
"has_active_run": false,
"latest_run_status": null
}
```
**已登记且问答进行中:**
```json
{
"session_id": "parent-generated-uuid",
"registered": true,
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
"finished": false,
"thread_status": "idle",
"has_active_run": true,
"latest_run_status": "running"
}
```
**已登记且已结束:**
```json
{
"session_id": "parent-generated-uuid",
"registered": true,
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
"finished": true,
"thread_status": "idle",
"has_active_run": false,
"latest_run_status": "success"
}
```
#### 字段说明
| 字段 | 含义 |
|------|------|
| `registered` | 是否已收到 iframe 的 POST 登记 |
| `finished` | **父页面应以此为准**:无 inflight run,且线程非 running/busy,且非 `interrupted`(澄清等待用户) |
| `thread_status` | 从 checkpoint 推导:`idle` / `interrupted` / `error` 等 |
| `has_active_run` | 是否存在 pending/running 的 LangGraph run |
| `latest_run_status` | 该线程最近一次 run 的状态:`success` / `error` / … |
| `thread_id` | 解析出的 DeerFlow 线程 id(父页面可选用,非必须) |
### 4.3 获取生成结果(父页面使用)
问答结束后(建议先确认 `finished === true`),拉取本次模型输出:
```http
GET /api/public/embed/sessions/{session_id}/result
```
**尚未登记:**
```json
{
"session_id": "parent-generated-uuid",
"registered": false,
"thread_id": null,
"finished": false,
"thread_status": "pending",
"has_active_run": false,
"latest_run_status": null,
"messages": [],
"generated_text": "",
"generated_files": []
}
```
**已登记且已有内容:**
```json
{
"session_id": "parent-generated-uuid",
"registered": true,
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
"finished": true,
"thread_status": "idle",
"has_active_run": false,
"latest_run_status": "success",
"messages": [
{ "type": "human", "content": "请总结要点", "id": "..." },
{ "type": "ai", "content": "根据分析,要点如下:...", "id": "..." }
],
"generated_text": "根据分析,要点如下:...",
"generated_files": [
"/mnt/user-data/outputs/summary.md",
"/mnt/user-data/outputs/chart.png"
]
}
```
#### 结果字段说明
| 字段 | 含义 |
|------|------|
| `messages` | 完整消息列表(与 checkpoint 中序列化结构一致,含 human / ai / tool 等) |
| `generated_text` | 所有 `ai` / `assistant` 消息正文按顺序拼接(段落间空一行) |
| `generated_files` | 本次会话产物的虚拟路径列表(与 UI 产物栏 `artifacts` 一致) |
未结束时也可调用,会返回当前已有内容;`finished` 仍为 false 表示可能还会继续生成。
### 4.4 按 thread_id 查询(可选)
调试或父页面已持有 thread_id 时:
```http
GET /api/public/embed/threads/{thread_id}/status
GET /api/public/embed/threads/{thread_id}/result
```
状态接口响应不含 `messages` / `generated_text` / `generated_files`;结果接口字段与 §4.3 相同(无 `session_id` / `registered`)。
### 4.5 前端封装
```ts
import {
getEmbedSessionResult,
getEmbedSessionStatus,
registerEmbedSession,
} from "@/core/embed/status-api";
// 父页面(或你们自己的脚本)
const status = await getEmbedSessionStatus(sessionId);
if (status.registered && status.finished) {
const result = await getEmbedSessionResult(sessionId);
console.log(result.generated_text, result.generated_files);
}
// 仅 iframe 内需要
await registerEmbedSession(sessionId, threadId);
```
`fetch` 使用 `credentials: "omit"`,不携带 DeerFlow 会话。
---
## 5. 父页面集成示例
### 5.1 拼接 iframe
```html
<iframe
id="deerflow-embed"
title="DeerFlow 问答"
style="width:100%;height:600px;border:0"
></iframe>
```
```javascript
const sessionId = crypto.randomUUID();
const base = "http://localhost:5173"; // 换成你们部署的前端 origin
const params = new URLSearchParams({
sessionId,
message: "请根据附件总结要点",
username: "guest",
theme: "red",
});
document.getElementById("deerflow-embed").src =
`${base}/#/embed/chats/new?${params.toString()}`;
```
### 5.2 轮询直到结束
```javascript
const API = "http://47.88.25.99:7001/"; // VITE_BACKEND_BASE_URL
async function pollUntilFinished(sessionId) {
const res = await fetch(
`${API}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/status`,
{ credentials: "omit" },
);
if (!res.ok) throw new Error(`status HTTP ${res.status}`);
const data = await res.json();
if (!data.registered) return { done: false, reason: "pending" };
if (data.finished) return { done: true, data };
return { done: false, reason: "running" };
}
const timer = setInterval(async () => {
try {
const poll = await pollUntilFinished(sessionId);
if (poll.done) {
clearInterval(timer);
const output = await fetch(
`${API}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/result`,
{ credentials: "omit" },
).then((r) => r.json());
console.log("问答已结束", output.generated_text, output.generated_files);
}
} catch (e) {
console.error(e);
}
}, 2000);
```
### 5.3 跨域注意
父页面从浏览器 `fetch` Gateway(如 `http://47.88.25.99:7001/`)时,**Origin 必须是后端 CORS 白名单里的完整地址**(含协议、主机、端口),与 API 地址是否写 `localhost` / `127.0.0.1` 无关。
**本地开发**(前端默认 `5174`)请在 Gateway 环境变量中配置:
```bash
# offline-backend-20260512/.env 或启动脚本注入
GATEWAY_CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174,http://localhost:5173,http://127.0.0.1:5173
```
修改后**重启 Gateway**(`make dev` / `make gateway`)。若父页面部署在其它域名,把该域名的 origin 一并加入逗号分隔列表。
临时调试(勿用于公网生产):
```bash
GATEWAY_CORS_ALLOW_ALL=1
```
常见报错:`No 'Access-Control-Allow-Origin' header` → 当前页面的 origin 未出现在 `GATEWAY_CORS_ORIGINS` 中。
### 5.4 其它部署注意
---
## 6. 部署与数据库
1. **跑迁移**(SQL 持久化后端):
```bash
cd offline-backend-20260512/backend
# 按项目惯例执行 alembic upgrade head
```
表:`embed_sessions(session_id PK, thread_id, created_at, updated_at)`。
2. **重启 Gateway**,确认路由已挂载:`public_embed.router`(`app/gateway/app.py`)。
3. **前端构建** 后,将嵌入地址配给第三方;`VITE_BACKEND_BASE_URL` 指向可达的 Gateway。
无 SQL 时开发环境会退化为 **内存** `MemoryEmbedSessionStore`(进程重启后映射丢失);生产请使用持久化库。
---
## 7. 安全说明
- `sessionId` 与 `thread_id` 均视为 **不透明能力令牌**:知晓即可查询状态(公开 API **无登录**)。
- 建议:父系统自行生成不可猜测的 UUID;敏感场景在贵司 API 网关再加鉴权或 IP 限制。
- 登记接口为 **POST upsert**,不校验「session 是否属于某租户」——若需多租户隔离,请在业务层约定 `sessionId` 命名空间或增加网关校验。
---
## 8. 与主聊天 `/page/workspace/chats` 的差异
| 项目 | 主聊天 | iframe 嵌入 |
|------|--------|-------------|
| 路由 | `/page/workspace/chats/:id` | `/embed/chats/:id` |
| 布局 | 完整 Workspace + 输入框 | 仅消息区 + 产物栏 |
| 登录 | 常规登录流 | URL `username` 自动 `login/username` |
| 首条消息 | 用户输入 | URL `message` 自动发送 |
| 完成态查询 | 无公开 API | `sessionId` + `/api/public/embed/sessions/.../status` |
能力上仍走同一 `useThreadStream` + `lead_agent` 流式链路,沙箱与产物行为与主聊天一致。
---
## 9. 常见问题
**Q:`registered` 一直为 false?**
A:检查 iframe URL 是否带 `sessionId`;子页是否登录成功;Network 里是否有 `POST /api/public/embed/sessions` 且 200。
**Q:`finished` 一直 false?**
A:可能仍在流式生成(`has_active_run: true`),或 agent 进入澄清(`thread_status: interrupted`)。嵌入模式无底部输入框,澄清需产品侧另行处理或避免触发澄清类工具。
**Q:父页面能否不用轮询?**
A:当前标准方案为公开 GET 轮询。若同源可考虑 `postMessage` 扩展(未默认实现)。
**Q:能否继续用已有 thread_id 嵌入?**
A:可以:`#/embed/chats/{thread_id}?sessionId=...`,可选再带 `message` 追加一轮;登记会在已知 id 上 upsert。
---
## 10. 变更记录
| 日期 | 说明 |
|------|------|
| 2026-06-02 | 初版:iframe 嵌入路由、`sessionId` 映射表、公开登记与状态 API |
| 2026-06-02 | 新增 `GET /sessions/{session_id}/result`:返回 messages、generated_text、generated_files |
-975
View File
@@ -1,975 +0,0 @@
# DeerFlow 前端开发文档
## 目录
1. [整体目录结构](#1-整体目录结构)
2. [路由结构](#2-路由结构)
3. [核心数据流与状态管理](#3-核心数据流与状态管理)
4. [主要页面组件](#4-主要页面组件)
5. [核心业务组件](#5-核心业务组件)
6. [核心 Hooks 与工具函数](#6-核心-hooks-与工具函数)
7. [API 调用层](#7-api-调用层)
8. [国际化机制](#8-国际化机制)
9. [主题与样式体系](#9-主题与样式体系)
10. [关键业务流程](#10-关键业务流程)
11. [项目配置与依赖](#11-项目配置与依赖)
12. [常见开发模式](#12-常见开发模式)
---
## 1. 整体目录结构
```
frontend-web/src/
├── App.tsx # 应用入口,顶层路由 + Provider
├── env.ts # 环境变量类型定义
├── pages/ # 路由级页面组件
│ ├── PageRoutes.tsx # /page/* 根路由容器(含外部系统预留位)
│ ├── WorkspaceRoutes.tsx # /page/workspace/* 工作区路由
│ ├── LoginPage.tsx # 登录页
│ ├── LandingPage.tsx # 营销落地页(强制 dark 主题)
│ ├── ChatsPage.tsx # 对话列表
│ ├── ChatPage.tsx # 单条对话(核心页面)
│ ├── AgentsPage.tsx # 智能体库
│ ├── AgentChatPage.tsx # 与智能体对话
│ ├── NewAgentPage.tsx # 创建智能体(两步流程)
│ ├── ScheduledTasksPage.tsx # 定时任务管理
│ ├── ScheduledTaskRunDetailPage.tsx # 定时任务执行详情
│ ├── ScheduledChatPage.tsx # 定时任务对话跳转页
│ └── NotFoundPage.tsx # 404 页面
├── components/ # 业务组件库
│ ├── ui/ # shadcn/ui 基础组件(Button、Card、Dialog 等 40+)
│ ├── ai-elements/ # AI 交互可视化组件
│ │ ├── prompt-input/ # 输入框 Context + 子组件系统
│ │ ├── artifact.tsx # 制品展示
│ │ ├── canvas.tsx
│ │ ├── conversation.tsx # 虚拟滚动对话容器
│ │ ├── message.tsx
│ │ ├── model-selector.tsx
│ │ ├── code-block.tsx
│ │ └── shimmer.tsx
│ ├── workspace/ # 工作区业务组件(详见第 5 节)
│ └── landing/ # 落地页组件
├── core/ # 核心业务逻辑层(非 UI)
│ ├── api/ # API 客户端(LangGraph SDK + Fetch)
│ ├── auth/ # 认证管理
│ ├── config/ # 环境配置 & Base URL
│ ├── i18n/ # 国际化(上下文、翻译文件、语言检测)
│ ├── threads/ # 对话线程管理(最核心)
│ ├── agents/ # 智能体 CRUD
│ ├── models/ # 模型列表
│ ├── settings/ # 本地设置(LocalStorage 发布订阅)
│ ├── uploads/ # 文件上传
│ ├── artifacts/ # 制品管理
│ ├── memory/ # 用户记忆
│ ├── skills/ # 技能管理
│ ├── scheduled-tasks/ # 定时任务
│ ├── messages/ # 消息处理工具函数
│ ├── tasks/ # 子任务 Context
│ ├── notification/ # 系统通知
│ └── utils/ # 通用工具(文件下载、日期格式化等)
└── hooks/ # 全局自定义 hooks
```
---
## 2. 路由结构
> 项目使用 **HashRouter**,所有路由访问形如 `http://host/#/xxx`
### 2.1 App.tsx 顶层路由
| 路径 | 组件 | 说明 |
|------|------|------|
| `/` | Navigate | 重定向到 `/login` |
| `/login` | LoginPage | 登录(自动登录,无表单) |
| `/login/:username` | LoginPage | 带用户名登录 |
| `/landing` | LandingPage | 营销落地页(固定 dark 主题) |
| `/page/*` | PageRoutes | 应用内容根容器 |
| `*` | NotFoundPage | 404 |
### 2.2 PageRoutes.tsx(`/page/*`)
```
/page/
├── index → Navigate to "workspace"
├── workspace/* → WorkspaceRoutes
└── [TODO 预留] → 外部系统路由挂载位置
```
### 2.3 WorkspaceRoutes.tsx(`/page/workspace/*`)
| 路径 | 页面组件 | 布局包裹 |
|------|---------|--------|
| `index` | Navigate → `chats/new` | - |
| `chats` | ChatsPage | WorkspaceLayout |
| `chats/:thread_id` | ChatPage | WorkspaceLayout + ChatRuntime |
| `agents` | AgentsPage | WorkspaceLayout |
| `agents/new` | NewAgentPage | WorkspaceLayout |
| `agents/:agent_id/chats` | Navigate → `/page/workspace/agents` | - |
| `agents/:agent_id/chats/:thread_id` | AgentChatPage | WorkspaceLayout + ChatRuntime |
| `scheduled` | Navigate → `.../scheduled/tasks` | - |
| `scheduled/tasks` | ScheduledTasksPage | WorkspaceLayout |
| `scheduled/runs/:run_id` | ScheduledTaskRunDetailPage | WorkspaceLayout |
### 2.4 布局 Provider 层级结构
```
WorkspaceLayout
├── SidebarProvider(侧边栏展开/收起状态)
│ ├── WorkspaceSidebar
│ │ ├── WorkspaceHeader(Logo、新建按钮)
│ │ ├── WorkspaceNavChatList(Chats / Agents / Scheduled 导航)
│ │ ├── RecentChatList(最近会话列表)
│ │ └── WorkspaceNavMenu(Settings / About / GitHub)
│ ├── SidebarInset(主内容区)
│ │ └── [Page Content]
│ ├── CommandPalette(快捷命令面板)
│ └── Toaster(全局通知)
ChatRuntime(仅 ChatPage / AgentChatPage 使用)
├── SubtasksProvider(子任务执行状态)
├── ArtifactsProvider(制品选中/打开状态)
└── PromptInputProvider(输入框内容/附件状态)
```
---
## 3. 核心数据流与状态管理
### 3.1 全局 Provider 架构
```
App
└── HashRouter
└── AppProviders(useLocation 驱动主题判断)
├── NextThemesProvider
│ └── attribute="class",/landing 强制 forcedTheme="dark"
├── I18nProvider
│ └── initialLocale = detectLocale()(从 cookie 或浏览器语言)
└── QueryClientProvider(TanStack React Query 全局缓存)
```
### 3.2 全局 Context 列表
| Context | 文件 | 职责 | 消费 Hook |
|---------|------|------|-----------|
| `I18nContext` | `core/i18n/context.tsx` | 当前语言 + 翻译对象 | `useI18n()` |
| `SubtaskContext` | `core/tasks/context.tsx` | 实时子任务执行状态 | `useSubtaskContext()` |
| `ArtifactsContext` | `components/workspace/artifacts/context.tsx` | 制品选中/面板开关 | `useArtifacts()` |
| `PromptInputContext` | `components/ai-elements/prompt-input/` | 输入框内容、附件列表 | `usePromptInputController()` |
| `ThreadContext` | `components/workspace/messages/context.tsx` | 当前 thread 对象下传 | 直接读 context.thread |
### 3.3 LocalStorage 数据
**操作层:** `core/settings/store.ts`(发布-订阅,`useSyncExternalStore` 驱动响应式)
| Key | 内容 |
|-----|------|
| `deerflow.auth` | `{ access_token, token_type, user_id, email }` |
| `deerflow.local-settings` | `{ notification, context: { model_name, mode, reasoning_effort } }` |
| `deerflow.thread-model.{threadId}` | 每条线程的模型覆盖 |
### 3.4 SessionStorage 数据
| Key | 内容 |
|-----|------|
| `deerflow.run-id.{threadId}` | WebSocket run_id,用于断线重连 |
### 3.5 React Query 缓存 Key
| Query Key | 数据 |
|-----------|------|
| `["threads"]` | 线程列表 |
| `["thread", threadId]` | 单条线程 |
| `["agents"]` | 智能体列表 |
| `["models"]` | 模型列表 |
| `["skills"]` | 技能列表 |
| `["memory"]` | 用户记忆 |
| `["scheduled-tasks"]` | 定时任务列表 |
| `["scheduled-runs", taskId]` | 任务执行记录 |
---
## 4. 主要页面组件
### 4.1 ChatPage.tsx — 单条对话页面
**职责:** 管理单条聊天线程的完整交互,包括消息流、输入、工具调用展示、制品等。
**关键状态:**
```typescript
const [threadId, setThreadId] = useState(routeThreadId || uuid());
const [isNewThread, setIsNewThread] = useState(!routeThreadId || routeThreadId === "new");
const [hasStartedConversation, setHasStartedConversation] = useState(false);
const [showFollowups, setShowFollowups] = useState(false);
const [settings, setSettings] = useThreadSettings(threadId);
```
**核心 Hook:**
```typescript
const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
context: settings.context,
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
// 不刷新页面,直接更新 URL hash
history.replaceState(null, "", `...#/page/workspace/chats/${createdThreadId}`);
},
onFinish: (state) => {
// 后台通知
showNotification(state.title, { body: lastMessageText });
}
});
```
**主要子组件:**
| 组件 | 职责 |
|------|------|
| `<MessageList>` | 消息渲染(含工具调用、子任务、制品) |
| `<Welcome>` | 新对话欢迎屏 |
| `<InputBox>` | 输入框(模式选择、附件、发送) |
| `<ThreadTitle>` | 线程标题(自动生成后同步更新) |
| `<ExportTrigger>` | 导出对话按钮 |
| `<ArtifactTrigger>` | 制品面板开关 |
| `<TokenUsageIndicator>` | Token 用量展示(可选) |
| `<TodoList>` | 任务清单 |
| `<Suggestions>` | 追问建议 |
**数据流:**
```
用户输入 (InputBox)
↓ handleSubmit(message: PromptInputMessage)
↓ sendMessage(threadId, message)
↓ [内部] 上传文件 → 提交到 LangGraph API
↓ WebSocket 流返回消息
↓ thread.messages 更新 → MessageList 重渲染
```
---
### 4.2 AgentChatPage.tsx — 与智能体对话
与 ChatPage 结构基本一致,差异点:
- 从路由获取 `agent_id`,传入 `useThreadStream({ assistantId: agent_id })`
- Header 展示智能体名称 Badge
- "新建对话"跳转到 `/page/workspace/agents/${agent_id}/chats/new`
- URL 更新为 `#/page/workspace/agents/${agent_id}/chats/${threadId}`
---
### 4.3 NewAgentPage.tsx — 创建智能体(两步流程)
**Step 1:name(命名 + 选技能)**
```typescript
// 自动预选前 3 个启用的技能
useEffect(() => {
if (!isSkillSelectionTouched) {
setSelectedSkills(enabledSkills.slice(0, 3).map(s => s.name));
}
}, [enabledSkills]);
// 提交:验证名称 → 创建智能体
await checkAgentName(name); // 检查重名
await createAgent(request); // 创建,返回 agent.id
setStep("chat"); // 进入 Step 2
```
**Step 2:chat(初始化对话)**
```typescript
// 发送 bootstrap 消息触发智能体初始化
sendMessage(threadId, bootstrapMessage, { agent_id: agentId });
// 监听 setup_agent 工具完成事件
onToolEnd: (event) => {
if (event.name === "setup_agent") {
// 带重试轮询加载 agent 最新信息
retryLoadAgent(agentId);
}
}
```
---
### 4.4 ChatsPage.tsx — 对话列表
```typescript
const { data: threads } = useThreads({ limit: 50, sortBy: "updated_at" });
// 客户端过滤:排除定时任务线程 + 搜索关键词
const filteredThreads = useMemo(() =>
threads?.filter(t =>
t.metadata?.thread_type !== "scheduler" &&
titleOfThread(t).toLowerCase().includes(search)
), [threads, search]
);
```
---
### 4.5 LoginPage.tsx — 登录
```typescript
// 自动登录,无需用户操作
useEffect(() => {
loginByUsername(username || "guest")
.then(auth => {
setStoredAuth(auth);
router.replace("/page/workspace/chats/new");
})
.catch(err => setStatus("error"));
}, [username]);
```
---
### 4.6 ScheduledTasksPage.tsx — 定时任务
**功能:** 创建/编辑/发布/暂停/恢复定时任务,查看执行历史。
**Cron 表达式构建:**
```typescript
type ScheduleFreq = "hourly" | "daily" | "weekly" | "monthly";
function buildCron(config): string {
// hourly → "15 * * * *"
// daily → "0 9 * * *"
// weekly → "0 9 * * 1"
// monthly → "0 9 1 * *"
}
```
**核心操作:**
```typescript
const actions = useScheduledTaskActions();
actions.create.mutateAsync(payload);
actions.publish.mutateAsync(taskId);
actions.pause.mutateAsync(taskId);
actions.trigger.mutateAsync(taskId); // 立即执行一次
actions.subscribe.mutateAsync(params); // 订阅推送
```
---
## 5. 核心业务组件
### 5.1 消息系统(`components/workspace/messages/`)
| 文件 | 职责 |
|------|------|
| `message-list.tsx` | 消息列表容器,调用 `groupMessages` 分组后渲染 |
| `message-list-item.tsx` | 单条消息渲染(含 role 判断) |
| `message-group.tsx` | 消息组(human / assistant / processing 等) |
| `markdown-content.tsx` | Markdown 渲染(rehype 系列插件) |
| `subtask-card.tsx` | 子任务状态卡片 |
| `message-token-usage.tsx` | 单条消息 Token 用量 |
| `skeleton.tsx` | 流式加载骨架屏 |
| `context.tsx` | ThreadContext,向下传递 thread 对象 |
**消息分组类型(`groupMessages`):**
```typescript
type MessageGroup =
| "human" // 用户输入
| "assistant:processing" // 推理/工具调用中
| "assistant" // 最终响应
| "assistant:clarification" // 澄清请求
| "assistant:present-files" // 文件呈现
| "assistant:subagent"; // 子智能体消息
```
---
### 5.2 输入框(`components/workspace/input-box.tsx`)
**子组件层级:**
```
InputBox
└── PromptInput(Context Provider)
├── PromptInputBody
│ ├── PromptInputTextarea(文本输入)
│ ├── PromptInputAttachments(附件列表)
│ └── PromptInputTools(工具栏:附件上传、模式选择)
├── PromptInputFooter
│ ├── PromptInputSubmit(发送/停止按钮)
│ └── Suggestions(追问建议列表)
└── ModelSelector(模型下拉选择)
```
**四种会话模式:**
| Mode | 说明 |
|------|------|
| `flash` | 快速模式,无推理步骤 |
| `thinking` | 推理模式,开启深度思考 |
| `pro` | 专业模式,多模态 + 规划 |
| `ultra` | 超级模式,启用子智能体 |
**推理努力等级(mode ≠ flash 时可选):** `minimal` / `low` / `medium` / `high`
---
### 5.3 侧边栏(`components/workspace/workspace-sidebar.tsx`)
```
WorkspaceSidebar
├── SidebarHeader → WorkspaceHeader
│ ├── Logo(可点击,跳转 /page/workspace/chats/new)
│ └── 新建对话按钮
├── SidebarContent
│ ├── WorkspaceNavChatList
│ │ ├── 新建对话(/page/workspace/chats/new)
│ │ ├── 所有对话(/page/workspace/chats)
│ │ ├── 智能体(/page/workspace/agents)
│ │ └── 任务管理(/page/workspace/scheduled/tasks)
│ └── RecentChatList(最近会话,含搜索)
└── SidebarFooter → WorkspaceNavMenu
├── Settings(打开设置 Dialog)
├── About
└── GitHub
```
---
### 5.4 制品管理(`components/workspace/artifacts/`)
```typescript
interface ArtifactsContextType {
artifacts: string[]; // artifact ID 列表
selectedArtifact: string | null;
autoSelect: boolean; // 是否自动选中最新制品
open: boolean; // 面板是否展开
autoOpen: boolean;
select(artifact: string, autoSelect?: boolean): void;
deselect(): void;
setOpen(open: boolean): void;
}
```
**文件:**
| 文件 | 职责 |
|------|------|
| `context.tsx` | ArtifactsContext + Provider |
| `artifact-trigger.tsx` | 面板展开/收起按钮 |
| `artifact-file-list.tsx` | 制品文件列表 |
| `artifact-file-detail.tsx` | 单个制品详情(含代码编辑器) |
---
### 5.5 设置面板(`components/workspace/settings/`)
| 页面文件 | 内容 |
|---------|------|
| `appearance-settings-page.tsx` | 主题切换(light/dark/system)、语言切换 |
| `memory-settings-page.tsx` | 用户记忆(事实列表、总结管理) |
| `notification-settings-page.tsx` | 桌面通知开关 |
| `skill-settings-page.tsx` | 技能启用/禁用 |
| `tool-settings-page.tsx` | 工具配置 |
| `about-settings-page.tsx` | 版本号、仓库链接 |
---
### 5.6 命令面板(`components/workspace/command-palette.tsx`)
通过 `Cmd/Ctrl + K` 触发,提供快速导航和操作入口。
```typescript
const handleNewChat = useCallback(() => {
router.push("/page/workspace/chats/new");
setOpen(false);
}, [router]);
```
---
## 6. 核心 Hooks 与工具函数
### 6.1 useThreadStream(最核心 Hook)
**位置:** `core/threads/hooks.ts`
管理 LangGraph WebSocket 连接、消息流、乐观更新。
**签名:**
```typescript
const [thread, sendMessage, isUploading] = useThreadStream({
threadId?: string, // 有值则连接现有线程,无值表示新建
context: LocalSettings["context"], // 模型、模式等
isMock?: boolean,
assistantId?: string, // 默认 "lead_agent"
onStart?: (threadId: string) => void,
onFinish?: (state: AgentThreadState) => void,
onToolEnd?: (event: ToolEndEvent) => void,
});
```
**thread 对象关键属性:**
```typescript
thread: {
messages: Message[], // 含乐观消息的完整列表
isLoading: boolean, // 流是否进行中
isThreadLoading: boolean, // 初始线程数据是否加载中
values: AgentThreadState, // 线程最终状态
error?: Error,
stop: async () => void, // 终止当前流
}
```
**sendMessage 签名:**
```typescript
sendMessage(
threadId: string | undefined,
message: PromptInputMessage,
extraContext?: Partial<AgentThreadContext>,
options?: { isMock?: boolean }
): Promise<void>
```
**内部机制:**
- **乐观更新:** 用户发送后立即本地展示消息,不等服务端响应
- **文件上传:** 自动调用 `uploadFiles()` 后再提交
- **run_id 持久化:** sessionStorage 保存,支持页面刷新后断线重连
- **消息同步:** 流结束后通过 `queryClient.invalidateQueries(["threads"])` 刷新列表
---
### 6.2 useThreadSettings
**位置:** `core/settings/hooks.ts`
```typescript
const [settings, setSettings] = useThreadSettings(threadId);
// settings 结构
{
notification: { enabled: boolean },
context: {
model_name?: string,
mode: "flash" | "thinking" | "pro" | "ultra",
reasoning_effort?: "minimal" | "low" | "medium" | "high"
}
}
// 修改(合并更新)
setSettings("context", { mode: "pro" });
setSettings("notification", { enabled: true });
```
**工作原理:** `useSyncExternalStore` 监听 localStorage 变化,跨标签页同步。
---
### 6.3 useI18n
**位置:** `core/i18n/hooks.ts`
```typescript
const { t, locale, changeLocale } = useI18n();
t.sidebar.newChat // "New Chat"
t.agents.createPageTitle // "Create Agent"
t.toolCalls.moreSteps(3) // "Show 3 more steps"
changeLocale("zh-CN"); // 切换语言,自动存 cookie
```
---
### 6.4 useScheduledTaskActions
**位置:** `core/scheduled-tasks/hooks.ts`
```typescript
const actions = useScheduledTaskActions();
// 所有返回值均为 TanStack Query Mutation
actions.create // 创建定时任务
actions.update // 更新任务配置
actions.remove // 删除任务
actions.removeRun // 删除执行记录
actions.pause // 暂停
actions.resume // 恢复
actions.trigger // 立即触发一次
actions.publish // 发布(激活)
actions.unpublish // 取消发布
actions.subscribe // 订阅推送
actions.updateSubscription
actions.unsubscribe
```
---
### 6.5 工具函数
**消息处理(`core/messages/utils.ts`):**
```typescript
groupMessages(messages, mapper) // 按类型分组消息列表
extractTextFromMessage(message) // 提取纯文本
extractContentFromMessage(message) // 提取 Markdown/图像内容
extractReasoningContentFromMessage(msg) // 提取推理过程
hasToolCalls(message) // 是否含工具调用
hasPresentFiles(message) // 是否包含文件呈现
extractPresentFilesFromMessage(message) // 获取文件列表
isClarificationToolMessage(message) // 是否为澄清请求
```
**线程路由(`core/threads/utils.ts`):**
```typescript
pathOfThread(thread, context?)
// → "/page/workspace/agents/{agentRef}/chats/{threadId}"
// 或 "/page/workspace/chats/{threadId}"
titleOfThread(thread)
// → thread.values?.title ?? "Untitled"
textOfMessage(message)
// → 消息文本内容(string 或 content 数组中第一个 text)
```
**其他工具:**
```typescript
// core/utils/files.tsx
downloadFile(blob: Blob, filename: string)
// core/utils/datetime.ts
formatTimeAgo(dateString: string) // → "2 小时前" / "3 days ago"
```
---
## 7. API 调用层
### 7.1 API 客户端架构
**两层设计:**
**第一层:LangGraph SDK Client**(`core/api/api-client.ts`)
```typescript
const client = new LangGraphClient({
apiUrl: getLangGraphBaseURL(isMock),
onRequest(url, init) {
const headers = new Headers(init.headers ?? {});
headers.set("Authorization", getAuthorizationHeaderValue());
return { ...init, headers };
}
});
export function getAPIClient(isMock?: boolean): LangGraphClient
```
**第二层:Fetch Client**(`core/api/fetch-client.ts`)
```typescript
function apiFetch(input: RequestInfo, init?: RequestInit): Promise<Response> {
return fetch(input, {
...init,
headers: withAuthHeaders(init?.headers)
});
}
```
### 7.2 主要 API 模块
| 模块 | 文件 | 关键函数 |
|------|------|---------|
| **Auth** | `core/auth/api.ts` | `loginByUsername(username)` → `LoginByUsernameResponse` |
| **Agents** | `core/agents/api.ts` | `createAgent()`, `updateAgent()`, `listAgents()`, `deleteAgent()`, `checkAgentName()` |
| **Models** | `core/models/api.ts` | `loadModels()` → `Model[]` |
| **Threads** | (via LangGraph SDK) | `useThreadStream()`, `useThreads()`, `useDeleteThread()`, `useRenameThread()` |
| **Skills** | `core/skills/api.ts` | `loadSkills()`, `enableSkill(name, enabled)` |
| **Uploads** | `core/uploads/api.ts` | `uploadFiles(threadId, files[])`, `listUploadedFiles()`, `deleteUploadedFile()` |
| **Memory** | `core/memory/api.ts` | `loadMemory()`, `createMemoryFact()`, `updateMemoryFact()`, `deleteMemoryFact()` |
| **Scheduled Tasks** | `core/scheduled-tasks/api.ts` | CRUD、publish、pause、resume、trigger、subscribe |
### 7.3 Base URL 配置
**文件:** `core/config/index.ts`
```typescript
export function getBackendBaseURL(): string {
// env.NEXT_PUBLIC_BACKEND_BASE_URL
// 默认:""(当前域名根路径)
}
export function getLangGraphBaseURL(isMock?: boolean): string {
// mock 模式:${origin}/mock/api
// 正常:${origin}/api/langgraph
// 或读取 env.NEXT_PUBLIC_LANGGRAPH_BASE_URL
}
```
### 7.4 认证流程
```
LoginPage 调用 loginByUsername(username)
↓ POST /api/auth/login
↓ 返回 { access_token, token_type, user_id, email }
↓ setStoredAuth() 存入 localStorage["deerflow.auth"]
↓
后续所有 API 请求
↓ getAuthorizationHeaderValue()
↓ → "Bearer {access_token}"
↓ 自动添加到 Authorization header
```
---
## 8. 国际化机制
### 8.1 文件结构
```
core/i18n/
├── context.tsx # I18nProvider(包裹 App)
├── hooks.ts # useI18n()
├── locale.ts # detectLocale(),Locale = "en-US" | "zh-CN"
├── cookies.ts # cookie 读写(持久化语言选择)
├── translations.ts # 翻译映射表入口
└── locales/
├── types.ts # Translations 接口定义(所有翻译 key 的类型)
├── en-US.ts # 英文翻译
└── zh-CN.ts # 简体中文翻译
```
### 8.2 Translations 接口分类
```typescript
interface Translations {
locale: { localName: string } // 语言自身名称
common: { ... } // 通用词汇(确认、取消等)
welcome: { ... } // 欢迎屏
inputBox: { ... } // 输入框(模式、占位符)
sidebar: { ... } // 侧边栏导航
agents: { ... } // 智能体相关
chats: { ... } // 对话相关
workspace: { ... } // 工作区通用
settings: { ... } // 设置面板
toolCalls: { ... } // 工具调用展示
uploads: { ... } // 文件上传
subtasks: { ... } // 子任务
tokenUsage: { ... } // Token 用量
shortcuts: { ... } // 快捷键说明
}
```
### 8.3 语言检测优先级
1. 读取 cookie `deerflow.locale`
2. 读取浏览器 `navigator.language`
3. 回退到 `en-US`
---
## 9. 主题与样式体系
### 9.1 主题系统
**库:** `next-themes`
```typescript
// App.tsx
<NextThemesProvider
attribute="class" // 通过 <html class="dark"> 应用主题
enableSystem // 跟随系统偏好
disableTransitionOnChange // 切换时禁用过渡动画
forcedTheme={pathname === "/landing" ? "dark" : undefined}
>
```
**可选主题值:** `light` / `dark` / `system`
### 9.2 样式方案
**基础:** Tailwind CSS
**组件库:** shadcn/ui(`components/ui/` 目录,40+ 基础组件)
**主要 CSS 自定义属性:**
```css
--container-width-sm /* 小容器宽度 */
--container-width-md /* 中等容器宽度(对话主区域) */
--primary /* 主色 */
--background /* 页面背景 */
--foreground /* 主文本色 */
--muted-foreground /* 次要文本色 */
--border /* 边框色 */
--sidebar-foreground /* 侧边栏文本 */
```
### 9.3 样式使用规范
- 工具类优先(Tailwind)
- 动态样式用 `cn()` 工具函数合并(基于 `clsx` + `tailwind-merge`)
- 主题感知样式使用 CSS 变量(如 `text-muted-foreground`)
---
## 10. 关键业务流程
### 10.1 新建对话流程
```
1. 点击"新建对话" → navigate("/page/workspace/chats/new")
2. ChatPage 挂载,threadId = uuid(),isNewThread = true
3. 用户输入消息,点击发送
4. sendMessage(undefined, message) 调用
5. useThreadStream 内部:
a. 上传文件(若有)
b. 创建 LangGraph 线程,后端返回 threadId
c. onStart(threadId) 回调:
- setIsNewThread(false)
- history.replaceState 更新 URL hash
6. WebSocket 流开始接收 AI 消息
7. MessageList 实时渲染消息
8. 流结束,onFinish 回调,可能触发桌面通知
```
### 10.2 创建智能体流程
```
Step 1(name 步骤)
↓ 用户输入名称
↓ checkAgentName(name) → 验证是否重名
↓ createAgent({ name, skills: selectedSkills })
↓ 后端返回 agent.id,setStep("chat")
Step 2(chat 步骤)
↓ 生成临时 threadId
↓ useThreadStream({ assistantId: agent.id })
↓ 发送 bootstrap 消息(触发智能体自我设置)
↓ onToolEnd 监听:event.name === "setup_agent"
↓ 指数退避重试加载 agent 信息,确认已保存
↓ 显示"Agent Created"完成界面
↓ 用户可点击"开始对话"或"返回列表"
```
### 10.3 文件上传流程
```
1. 用户在 InputBox 选择/粘贴文件
2. PromptInputAttachments 展示预览列表
3. 用户点击发送
4. sendMessage 内部调用 uploadFiles(threadId, files)
5. POST /api/threads/{threadId}/upload
6. 后端返回 { files: [{ filename, size, virtual_path }] }
7. 构建 Message additional_kwargs.files
8. 完整 Message 提交到 LangGraph
```
---
## 11. 项目配置与依赖
### 11.1 主要依赖
| 包 | 版本 | 用途 |
|----|------|------|
| `react` | ^18 | 核心框架 |
| `react-router-dom` | ^6 | 客户端路由(HashRouter) |
| `@tanstack/react-query` | - | 服务端状态缓存 |
| `@langchain/langgraph-sdk` | - | LangGraph WebSocket 连接 |
| `next-themes` | - | 主题管理 |
| `sonner` | - | Toast 通知 |
| `lucide-react` | - | 图标库 |
| `tailwindcss` | - | CSS 框架 |
| `rehype` 系列 | - | Markdown 渲染 |
| `uuid` | - | 生成临时 thread ID |
### 11.2 环境变量
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `NEXT_PUBLIC_BACKEND_BASE_URL` | 后端接口基础 URL | `""` (当前域名) |
| `NEXT_PUBLIC_LANGGRAPH_BASE_URL` | LangGraph API URL | `/api/langgraph` |
| `NEXT_PUBLIC_STATIC_WEBSITE_ONLY` | 纯静态演示模式 | `"false"` |
---
## 12. 常见开发模式
### 12.1 添加新工作区页面
```
1. 创建 src/pages/MyPage.tsx
2. 在 WorkspaceRoutes.tsx 中添加路由:
<Route path="my-page" element={<WorkspaceLayout><MyPage /></WorkspaceLayout>} />
3. 在 workspace-nav-chat-list.tsx 中添加导航入口(如需要)
4. 更新相关的跳转路径
```
### 12.2 添加新 API 接口
```
1. 在 core/xxx/api.ts 中定义 API 函数(使用 apiFetch 或 getAPIClient())
2. 在 core/xxx/hooks.ts 中封装 useQuery / useMutation
3. 在页面或组件中调用 hook
```
### 12.3 添加新 Context
```
1. 创建 core/xxx/context.tsx:定义 Context + Provider + 默认值
2. 创建消费 hook(如 useXxx())
3. 在合适的组件树根部插入 Provider(WorkspaceRoutes 或 ChatRuntime)
```
### 12.4 添加国际化文本
```
1. 在 core/i18n/locales/types.ts 的 Translations 接口中添加字段
2. 在 core/i18n/locales/en-US.ts 添加英文内容
3. 在 core/i18n/locales/zh-CN.ts 添加中文内容
4. 组件中通过 useI18n() 访问:const { t } = useI18n(); t.xxx.yyy
```
### 12.5 引入外部系统路由
在 `src/pages/PageRoutes.tsx` 中找到 TODO 注释位置,填入:
```typescript
import AnotherSystemRoutes from "外部系统路径";
// 在 Routes 中添加:
<Route path="other/*" element={<AnotherSystemRoutes />} />
```
如外部系统有独立 Provider 依赖,在此文件或 App.tsx 中包裹。
---
## 附:关键文件速查
| 需求 | 关键文件 |
|------|---------|
| 修改路由结构 | `App.tsx`, `PageRoutes.tsx`, `WorkspaceRoutes.tsx` |
| 修改侧边栏导航 | `components/workspace/workspace-nav-chat-list.tsx` |
| 修改对话逻辑 | `pages/ChatPage.tsx`, `core/threads/hooks.ts` |
| 修改输入框 | `components/workspace/input-box.tsx`, `components/ai-elements/prompt-input/` |
| 修改消息渲染 | `components/workspace/messages/` |
| 添加 API 接口 | `core/xxx/api.ts` + `core/xxx/hooks.ts` |
| 修改全局设置 | `core/settings/store.ts`, `core/settings/hooks.ts` |
| 修改翻译文本 | `core/i18n/locales/en-US.ts`, `core/i18n/locales/zh-CN.ts` |
| 修改主题 | `App.tsx` (NextThemesProvider) |
| 修改认证逻辑 | `core/auth/api.ts`, `pages/LoginPage.tsx` |
| 定时任务 | `pages/ScheduledTasksPage.tsx`, `core/scheduled-tasks/` |
| 智能体管理 | `pages/NewAgentPage.tsx`, `core/agents/` |
-265
View File
@@ -1,265 +0,0 @@
# 多智能体协调 API 接口文档
后端路由:`offline-backend-20260512/backend/app/gateway/routers/multi_agent.py`
路由前缀:`/api/multi-agent`
## 概览
提供一个"总控 + 多个子智能体"的协调编排接口。
- **总控(leader)** 智能体:通过 `agent_orchestration` 技能给子智能体派活
- **子智能体(special)**:执行被派到的具体任务,完成后把交付内容广播给其他线程
调用方按下面顺序使用:
1. 调 `POST /api/multi-agent/init` 一次性创建协调智能体 + 给每个子智能体建 `thread`,拿到 `thread_ids` 映射
2. 客户端把 `thread_ids` 保管好,后续每次调 `POST /api/multi-agent/run/stream` 时回传
3. 先调用 `agent_type="leader"` 拿派活清单,再针对清单里每个子智能体调用 `agent_type="special"`
## 1. 初始化多智能体集群
### `POST /api/multi-agent/init`
创建协调智能体并为列表中的每个子智能体建立独立的 `thread`,同时把"职责说明"广播到所有子智能体线程作为初始上下文。
#### 请求体
```json
{
"agent_names": ["taiwan-intelligence-gathering-agent", "us-intelligence-gathering-agent"],
"main_agent_name": "main-controller",
"model": "deepseek-chat"
}
```
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `agent_names` | `string[]` | 是 | 已有子智能体的 id 列表(kebab-case,区分大小写不敏感,自动转小写) |
| `main_agent_name` | `string` | 否 | 要创建的协调智能体名,默认 `main-agent`。重名时会复用已存在的同名智能体 |
| `model` | `string` | 否 | 协调智能体使用的模型名。不传则用后端默认模型 |
#### 响应(200)
```json
{
"status": "success",
"main_agent_name": "main-controller",
"thread_ids": {
"main-controller": "thread-uuid-xxx",
"taiwan-intelligence-gathering-agent": "thread-uuid-yyy",
"us-intelligence-gathering-agent": "thread-uuid-zzz"
}
}
```
`thread_ids` 是一个 `{agent_name: thread_id}` 字典。**前端必须把这个对象完整保管下来**,后续调用 stream 接口需要原样回传。
#### 错误
| 状态码 | 触发条件 |
|---|---|
| `400` | `agent_names` 为空 |
| `401` | 未携带 `Authorization` 头 |
| `500` | 协调智能体创建失败且不是"已存在"错误 |
---
## 2. 流式调度
### `POST /api/multi-agent/run/stream`
通过 SSE(Server-Sent Events)流式驱动一个 leader 或 special 智能体跑一轮对话。
#### 请求体
```json
{
"agent_type": "leader",
"d_agent_thread_id": {
"main-controller": "thread-uuid-xxx",
"taiwan-intelligence-gathering-agent": "thread-uuid-yyy"
},
"agent_name": "main-controller",
"new_message": "请分析特朗普的支持率",
"skill_stop_names": [],
"model": "deepseek-chat"
}
```
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `agent_type` | `"leader"` \| `"special"` | 否 | 默认 `leader`。`leader` 调用协调智能体,`special` 调用子智能体 |
| `d_agent_thread_id` | `object` | 是 | 由 `/init` 接口返回的 `thread_ids` 映射,原样回传 |
| `agent_name` | `string` | 是 | 本轮要驱动的智能体名(必须存在于 `d_agent_thread_id` 中) |
| `new_message` | `string` | 是 | 这轮发给智能体的用户消息 |
| `skill_stop_names` | `string[]` | 否 | 触发后中断执行的技能名列表(默认空) |
| `model` | `string` | 否 | 本轮 LLM 模型名,默认 `deepseek-chat` |
#### 响应
`Content-Type: text/event-stream` 的 SSE 长连接,按到达顺序产出多种数据:
##### a. 透传上游事件
原样转发底层 `POST /api/threads/{thread_id}/runs/stream` 的事件帧(LangGraph 协议),包含 `event:`、`data:` 等行。前端按 LangGraph SDK 既有方式渲染即可。
##### b. 最后一帧:调度状态
流结束前会额外产出一帧 `data:` 行,结构因 `agent_type` 而异:
**`agent_type == "leader"`:**
```
data: {"status": [["us-intelligence-gathering-agent", "收集美国民众对特朗普的看法"], ["taiwan-intelligence-gathering-agent", "..."]]}
```
`status` 是一个 `[agent_name, task_text]` 二元组数组,代表协调智能体本轮派出去的任务清单。**前端拿到后应当对每个元素再调用一次本接口(`agent_type="special"`)来执行子智能体**。如果数组为空,说明 leader 觉得已有信息足够,不再派活,可以直接展示 leader 的最终文本回答。
**`agent_type == "special"`:**
```
data: {"status": "子智能体xxx完成yyy工作"}
```
代表子智能体完成本轮工作并已把交付内容广播给其他线程。
#### 副作用(重要)
- **leader 调用**:自动把"总控智能体收到用户消息: …"广播到所有非自己的线程;派活后再把"总控智能体给 X 派活, 内容为: …"广播到所有非派活双方的线程
- **special 调用**:完成后把"子智能体 X 完成 Y 工作, 交付内容为: …"广播到所有非自己的线程
这意味着每个线程的对话历史里会逐步累积出全局上下文,无需前端再做合并。
#### 错误
| 状态码 | 触发条件 |
|---|---|
| `400` | `agent_name` 缺失或未在 `d_agent_thread_id` 中 |
| `401` | 未携带 `Authorization` 头 |
| `502` | 上游 `runs/stream` 返回非 200 / SSE 解析失败 |
---
## 3. 前端调用流程示例
```ts
// 1. 拿 token
const loginResp = await fetch("/api/v1/auth/login/username", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "yourname" }),
});
const { access_token } = await loginResp.json();
const authHeaders = {
Authorization: `Bearer ${access_token}`,
"Content-Type": "application/json",
};
// 2. 初始化
const initResp = await fetch("/api/multi-agent/init", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
agent_names: [
"taiwan-intelligence-gathering-agent",
"us-intelligence-gathering-agent",
],
main_agent_name: "main-controller",
model: "deepseek-chat",
}),
});
const { thread_ids } = await initResp.json();
// 3. 流式调用 leader
async function streamLeader(message: string, model = "deepseek-chat") {
const resp = await fetch("/api/multi-agent/run/stream", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
agent_type: "leader",
d_agent_thread_id: thread_ids,
agent_name: "main-controller",
new_message: message,
skill_stop_names: [],
model,
}),
});
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
let dispatched: [string, string][] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data:")) continue;
try {
const payload = JSON.parse(line.slice(5).trim());
if (Array.isArray(payload.status)) {
dispatched = payload.status;
}
// 其余事件按 LangGraph SDK 协议自行渲染
} catch {
/* 非 JSON 帧,可忽略或交给现有渲染器 */
}
}
}
return dispatched;
}
// 4. 对派活清单逐个调用 special
async function streamSpecial(agentName: string, task: string, model = "deepseek-chat") {
return fetch("/api/multi-agent/run/stream", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
agent_type: "special",
d_agent_thread_id: thread_ids,
agent_name: agentName,
new_message: task,
skill_stop_names: [],
model,
}),
});
// 同样按 SSE 解析
}
// 5. 编排:先 leader 派活 → 依次执行 special → 再让 leader 汇总
const dispatched = await streamLeader("请分析特朗普的支持率");
for (const [agentName, task] of dispatched) {
await streamSpecial(agentName, task);
}
const next = await streamLeader("根据上面收集到的信息出最终结论");
// 当 leader 返回 status: [] 时,意味着汇报完毕,可结束循环
```
---
## 4. 注意事项
- **`model` 字段的取值**:必须是后端 `config.yaml` 中 `models[]` 已配置的模型名(如 `deepseek-chat`、`glm-4`、`gpt-4o` 等)。传错会导致上游 502
- **`thread_ids` 必须每次回传**:后端不存储这个映射,是无状态接口
- **协调智能体必须装有 `agent_orchestration` 技能**:`/init` 创建时会自动指定 `skills=["agent_orchestration"]`。后端 `skills/` 目录里必须存在该技能定义,否则 leader 无法派活
- **同名协调智能体冲突**:`main_agent_name` 重名时后端不会报错("already exists" 走兼容分支),会复用既有智能体——所以同一个用户多次 init 用同一个名字是安全的,但 SOUL 不会被覆盖
- **SSE 断连**:本接口底层 `on_disconnect: "continue"`,断连后上游 run 会继续跑到结束(不会取消)
- **并发广播**:广播消息使用 `asyncio.gather` 并发更新所有线程 state,性能上不会随子智能体数线性变慢
---
## 5. 相关接口(底层依赖)
本接口内部转发以下 Gateway 原生接口,了解细节可查:
| 接口 | 路由 | 用途 |
|---|---|---|
| 创建 thread | `POST /api/threads` | 为每个智能体建立独立会话 |
| 读 thread state | `GET /api/threads/{id}/state` | 广播前取当前消息列表 |
| 写 thread state | `POST /api/threads/{id}/state` | 把广播消息追加到 messages |
| 取 agent 详情 | `GET /api/agents/{id}` | init 时读取子智能体的 name/description |
| 建 agent | `POST /api/agents` | init 时创建协调智能体 |
| 流式 run | `POST /api/threads/{id}/runs/stream` | leader/special 的实际执行 |
File diff suppressed because it is too large. Load diff
@@ -1,514 +0,0 @@
# 多智能体规划 · 后台挂起执行 + 进度可视化 —— 分步实现方案
> 目标:在「多智能体规划」第二步增加**后台挂起**能力——把当前任务挂到后台,自动一路跑到
> 最后一步(Step3 结果绘制完成)为止;历史记录下拉框里进行中的任务显示加载动画;点击进行中
> 的任务弹出一个**大弹窗**,里面是**横向的智能体派活链路流程图**(节点 = 各席位智能体,图标 +
> 名称,不同颜色,正在发言的节点和它的连接线带加载动画);弹窗里有「查看」按钮,点击进入正在
> 进行中的任务实时观察。
---
## 0. 现状与架构事实(动手前必读)
研究自现有代码,决定了整套方案的形态。**关键结论:当前编排循环跑在前端,离开页面即中断。**
### 0.1 前端现状
| 事实 | 位置 |
|------|------|
| Step2 编排循环(leader → 顺序派 special → leader … 直到 `dispatched===[]` 共识 / `MAX_CYCLES=8`)**完全由前端 hook 驱动** | `src/roundtable-planning/hooks/useStep2Orchestration.ts:992-1204`(recommend)、`1228-1439`(chain) |
| 循环由 `currentStep===2` 的 `useEffect` 启停;离开 Step2 的 cleanup 会 `orchestrationCancelRef.current=true` + `abortAllInFlight()` 中断 | `useStep2Orchestration.ts:1468-1541`(尤其 cleanup `1534-1537`、`1471-1477`) |
| 共识判定:`leaderRes.dispatched.length===0` → `setHasConsensus(true)` | `useStep2Orchestration.ts:1091-1099` |
| 进入 Step3:手动点按钮,前置 `hasConsensus===true` | `Step2Panel.tsx:370-381`、`RoundtablePlanningPage.tsx:586-595` |
| Step3「结果绘制」:前端流式调 `roundtable-report` 智能体,写出 `outputs/方案总览.html`,`phase→"done"` 即完成 | `src/roundtable-planning/hooks/useStep3Report.ts:36,51,165,560-596,636-652` |
| 草稿持久化:`/api/roundtable-drafts`,含 step1/step2/step3 三段快照 + `furthestStep(1|2|3)`,**无显式 status 字段** | `src/roundtable-planning/api/drafts.ts:25-110,112,184-225` |
| 历史下拉 UI(触发按钮 + 列表 + 重命名/删除) | `RoundtablePlanningPage.tsx:771-892` |
| 自动保存触发点(Step1 回复完 / Step2 每轮气泡收尾 / Step3 报告保存 / 步骤前进) | `useDraftPersistence.ts:248-316` |
| 加载草稿恢复:`handleLoadDraft` → `getDraft` → `cancelAllStreams` → hydrate → `setCurrentStep(furthestStep)` | `useDraftPersistence.ts:308-368` |
### 0.2 后端现状
| 事实 | 位置 |
|------|------|
| `/api/multi-agent/init`:为各席位并行建 thread + 单例总控 `roundtable-coordinator`,SSE 返回 `seat_ready`/`coordinator_ready`/`init_done(thread_ids)` | `app/gateway/routers/multi_agent.py:647-823` |
| `/api/multi-agent/run/stream`:驱动**单轮** leader 或 special 的 LLM 执行,末尾 JSON 帧 `status`(派活列表 / `[]` 共识 / `clarification` / `error`) | `multi_agent.py:1076-1142` |
| `/api/multi-agent/report/init`:为 Step3 报告建 thread | `multi_agent.py`(report/init) |
| **后端不存在编排循环**——leader 派活由前端解释后再逐个发 `run/stream`;后端只做单轮执行 + 把派活/完成「广播」追加进各 thread 历史消息 | `multi_agent.py:24-29,487-502,1108-1142` |
| 派活格式:`agent_orchestration` 工具调用 `{agent_name, task}`(旧式 bash 解析兼容) | `multi_agent.py:525-552` |
### 0.3 可复用模板:AI 写作的「后台多阶段流程」
AI 写作已经把「后台跑多阶段流程 + 前端 SSE 订阅 + 可 resume + 定时清理」跑通了,**圆桌后台作业直接照搬这套**:
| 模板要素 | 位置 |
|------|------|
| LangGraph 图驱动多阶段(researching/writing_outline/writing_draft/reviewing/…),条件路由推进,`interrupt()` 暂停点等用户输入 | `packages/harness/deerflow/agents/ai_writing/graph.py`、`nodes/*`(`pause_material_confirm` 等 `45-290`) |
| Session 持久化表 `ai_writing_sessions`:`id/user_id/title/status/draft_*/transcript(LONGTEXT)/...`,`status∈{in_progress,done,error}` | `packages/harness/deerflow/persistence/ai_writing_sessions/model.py:14-39` |
| `transcript` 用 `PortableLongText`(MySQL LONGTEXT,避开 64KB),时间用 `BeijingDateTime` | 同上 `27-38` |
| 进度走 LangGraph Server 标准流 `stream_mode=["messages-tuple","values"]`,**graph 在 langgraph runtime 里跑,任意 worker 可 join stream**(这是后台健壮性的关键) | `ai_writing.py:1-17`、`langgraph.json:16-18` |
| Checkpointer 共享 `AsyncSqliteSaver`,落 `.deer-flow/data/checkpoints.db`(需挂数据卷) | `runtime/checkpointer/async_provider.py` |
| Cleanup cron(leader 文件锁、活跃保护、先删 CP 再删 DB、admin 手动触发) | `app/gateway/ai_writing_cleanup.py:26,34-59,111-135,193-215,307-367` |
| **多 worker 坑**:模块级内存 dict(`_event_queues`)每 worker 一份会丢会话;SSE 进程内直调会整段 buffer。解法:状态进 DB+checkpointer、SSE 走真 TCP loopback `127.0.0.1`(`trust_env=False` 禁代理) | `multi_agent.py:271-293`、`AIWritingPage.tsx` 文件头注释 §3/§5 |
### 0.4 决策(已与产品确认)
1. **后端真后台执行**:把编排循环搬到后端 run,刷新 / 关页 / 换设备都不中断。
2. **流程图节点 = 智能体派活链路**:横向排列的席位节点,正在发言的节点 + 连接线加载动画。
> ⚠️ **本方案最大的工作量在 Phase 2**:把目前跑在前端 TS 里的编排循环(`runOrchestration`/`runChainOrchestration`)
> 移植成一个后端 **LangGraph「圆桌总控编排图」**。这是与 AI 写作一致、可在 LangGraph runtime 里
> 后台跑且天然 checkpoint 的健壮路径。下文每个阶段都给出验收标准,可独立提测。
---
## 1. 总体架构
```
┌─────────────────────────────── 前端 (frontend-web) ───────────────────────────────┐
│ Step2Panel ──「后台挂起」按钮──▶ POST /api/roundtable-jobs (start) │
│ HistoryDropdown ── 进行中任务显示 spinner ──▶ 点击 ──▶ DispatchChainModal(大弹窗) │
│ DispatchChainModal ── 订阅 GET /api/roundtable-jobs/{id}/stream(SSE) │
│ 横向派活链路流程图(节点=席位, 活动节点+连线动画) ──「查看」──▶ 进入任务实时观察 │
└───────────────────────────────────────────────────────────────────────────────────┘
│ SSE 进度 / 快照
┌─────────────────────────────── 后端 (backend) ─────────────────────────────────────┐
│ /api/roundtable-jobs 路由层(start/get/stream/resume/cancel) ── 仿 ai_writing.py │
│ │ 启动后台 run │
│ LangGraph「圆桌总控编排图」 roundtable_orchestrator ── 仿 ai_writing graph │
│ init → leader_call → parse_dispatch → dispatch_seats(顺序) → loop ↺ │
│ → consensus → report_draw(写 HTML) → done (clarification 点 interrupt()) │
│ │ 每次状态转移持久化进度 │
│ roundtable_jobs 表(status/phase/dispatch_chain/active_speaker/...) + 共享 checkpointer│
│ cleanup cron (仿 ai_writing_cleanup.py) │
└───────────────────────────────────────────────────────────────────────────────────┘
```
**核心思路**:后台作业 = 一个 LangGraph 图的一次 run。图节点内部复用现有的单轮执行能力
(`multi_agent.py` 里 leader/special 的 run 逻辑),把「解释派活 → 顺序派活 → 回到 leader」的
循环从前端搬进图节点。前端从「驱动者」退化为「观察者」:通过 SSE 订阅进度渲染。
---
## 2. 数据契约(前后端共享,先定义后实现)
### 2.1 作业状态机 `JobStatus`
```
queued # 已创建,等待 worker 拉起
running # 编排进行中(Step2 派活循环 / Step3 绘制)
awaiting_input # 命中 clarification,等用户回答(interrupt 挂起)
done # 已跑到 Step3 绘制完成
error # 出错终止
cancelled # 用户取消
```
### 2.2 作业阶段 `JobPhase`(细化给流程图/进度条用)
```
initializing # 建 thread / 总控就绪
leader_thinking # 总控在决策派活
dispatching # 正在顺序派活给席位(配合 active_speaker)
consensus # 共识达成(dispatched=[])
report_drawing # Step3 报告绘制中
report_done # 绘制完成
```
### 2.3 派活链路节点 `DispatchChainNode`(流程图核心数据)
```ts
interface DispatchChainNode {
agentId: string; // 席位 agent_id(小写)
name: string; // 显示名
avatarType: string; // 复用 getAvatarTypeFor(agentId, index) → 决定颜色
order: number; // 横向排列次序(chain 模式=链条顺序;recommend 模式=席位/发言顺序)
state: 'pending' | 'active' | 'done'; // 节点状态(驱动颜色/动画)
lastSpokeCycle?: number; // 最近发言轮次(可选,做 tooltip)
}
```
### 2.4 进度事件 `JobProgressEvent`(SSE 推送的归一化事件)
```ts
type JobProgressEvent =
| { type: 'status'; status: JobStatus }
| { type: 'phase'; phase: JobPhase; cycle: number }
| { type: 'chain'; nodes: DispatchChainNode[] } // 派活链路全量/增量
| { type: 'active'; agentId: string | null } // 当前发言席位(null=总控/无)
| { type: 'consensus'; percentage: number }
| { type: 'dialogue'; bubble: Step2DialogueLike } // 新增/收尾的对话气泡(供「查看」实时渲染)
| { type: 'clarification'; question: string } // 命中澄清
| { type: 'done'; step3: DraftStep3Snapshot } // 终态:最终报告快照
| { type: 'error'; detail: string };
```
> SSE 通道可直接复用 LangGraph Server 的 `values` 流(图状态快照),由前端 hook 把 `values`
> 映射成上述归一化事件;也可在路由层做映射后再下发。**推荐前端映射**(少一层后端耦合)。
### 2.5 作业记录 `RoundtableJob`(持久化 + 列表展示)
```ts
interface RoundtableJob {
id: string;
draftId: string; // 关联的草稿
userId: string;
status: JobStatus;
phase: JobPhase;
cycle: number; // 当前轮次(0..MAX_CYCLES)
dispatchChain: DispatchChainNode[];
activeAgentId: string | null;
consensusPercentage: number;
pendingClarification: string | null;
threadIds: Record<string,string> | null; // 复用 ThreadIdMap
coordinatorName: string | null;
orchestrationMode: 'recommend' | 'chain';
chain?: { id: string; title: string } | null;
error: string | null;
createdAt: string;
updatedAt: string;
}
```
---
## Phase 1 —— 后端:圆桌作业持久化模型
**目标**:建一张 `roundtable_jobs` 表(仿 `ai_writing_sessions`),存作业状态/阶段/派活链路,
并在 `roundtable_drafts` 列表里能 join 出当前作业状态。
**涉及文件(新增/改)**
- 新增 `packages/harness/deerflow/persistence/roundtable_jobs/model.py`(ORM 模型,仿 `ai_writing_sessions/model.py:14-39`)
- 新增 `.../roundtable_jobs/sql.py`(Repository:`create / get / update_progress / list_by_user / list_older_than / delete`,仿 `ai_writing_sessions/sql.py`)
- 新增 alembic 迁移 `persistence/migrations/versions/<date>_create_roundtable_jobs.py`
- 改 `roundtable_drafts` 的列表查询:返回里带上 `status`/`jobId`(LEFT JOIN roundtable_jobs,或在 draft 行上冗余一个 `status` 字段,二选一,见下)
**步骤**
1. 字段设计(对齐 §2.5):`id(PK)`、`draft_id`、`user_id`、`status`、`phase`、`cycle(Int)`、
`dispatch_chain(PortableLongText/JSON)`、`active_agent_id`、`consensus_percentage(Int)`、
`pending_clarification(Text)`、`thread_ids(JSON)`、`coordinator_name`、`orchestration_mode`、
`chain(JSON)`、`error(Text)`、`created_at/updated_at(BeijingDateTime)`。
2. `dispatch_chain` / `thread_ids` / `chain` 用 `PortableLongText` 存 JSON 字符串,repo 层序列化。
3. 列表 join:给 `DraftMeta` 增加 `status?: JobStatus` 与 `jobId?: string`。
**推荐方案**:草稿与作业 1:1(一个草稿最多一个活跃作业),在 draft 列表查询 LEFT JOIN
`roundtable_jobs` 取最新作业的 `status`。
4. 索引:`(user_id, updated_at)`、`(draft_id)`、`(status)`(cleanup/列表用)。
**验收**
- 迁移可 `alembic upgrade head` 跑通,MySQL 上 `dispatch_chain` 为 `longtext`。
- 单测:create→update_progress→get 往返;`list_by_user` 按 `updated_at` 倒序;`list_older_than` 分批。
---
## Phase 2 —— 后端:圆桌总控编排 LangGraph 图(核心 / 最大工作量)
**目标**:把前端的编排循环移植成一个后端 LangGraph 图,可在 LangGraph runtime 里后台跑、天然
checkpoint,一路跑到 Step3 绘制完成。
**涉及文件(新增)**
- `packages/harness/deerflow/agents/roundtable_orchestrator/graph.py`(图装配,仿 `ai_writing/graph.py`)
- `.../roundtable_orchestrator/state.py`(图状态 schema:intent、selectedAgents、threadIds、coordinatorName、
mode/chain、dialogues、dispatchChain、activeAgentId、cycle、consensus、phase、step3 等)
- `.../roundtable_orchestrator/nodes/*.py`(各节点)
- 在 `langgraph.json` 注册新 assistant `roundtable_orchestrator`(仿 ai_writing assistant 注册,参考 `langgraph.json:16-18`)
**图节点设计(把前端循环逐段翻译过来)**
| 节点 | 职责 | 对应前端逻辑 |
|------|------|------|
| `init` | 复用 `_create_thread_direct` 批量建席位 thread + 总控就绪(或接收前端已建好的 `threadIds` 直接用) | `useStep2Orchestration.ts:1485-1531` / `multi_agent.py:647-823` |
| `leader_call` | 跑一轮总控(agent_type=leader),拿 `status`(派活列表 / `[]` / `clarification`) | 单轮逻辑 `multi_agent.py:1076-1142` |
| `route_after_leader`(条件边) | `clarification`→`interrupt`;有派活→`dispatch_seats`;`[]`→`consensus` | `useStep2Orchestration.ts:1091-1181` |
| `dispatch_seats` | **顺序**派活:对 dispatched 里每个席位依次跑 special run;每开始一个就更新 `activeAgentId` + 进度持久化 | `useStep2Orchestration.ts:1101-1181`(务必串行,勿并行,详见前端 §5.4 注释) |
| `clarification_pause` | `interrupt()` 挂起,`status→awaiting_input`,写 `pending_clarification` | 仿 ai_writing `pause_*` `45-290` |
| `consensus` | `setHasConsensus`、`consensus=100`,落库后进入 `report_draw` | `1091-1099` |
| `report_draw` | 跑 `roundtable-report`(先 report/init 建 thread),流式写 `outputs/方案总览.html`,产出 `DraftStep3Snapshot` | `useStep3Report.ts:91-149,636-652,560-596` |
| `done` | `status→done`、写 `step3` 进作业 + 回写草稿 step3 快照 | `RoundtablePlanningPage.tsx:613-617` |
**关键实现要点**
1. **复用单轮执行**:节点内部不要重写 LLM 调用,抽出 `multi_agent.py` 里 leader/special/report 的
单轮 run 为可被图节点直接 `await` 的内部函数(去掉 HTTP 层,进程内调 LangGraph)。
2. **派活广播**:保留现有把「总控给 X 派了活 / X 完成」追加进各 thread 历史的广播
(`multi_agent.py:487-502`),让席位读到全局上下文。
3. **MAX_CYCLES 兜底**:图里维护 `cycle`,≥8 强制收敛(对齐 `roundtable-constants.ts:13`)。
4. **chain 模式**:`orchestration_mode==='chain'` 时按 `chain.seats` 固定顺序派活,跳过总控自由决策的
部分(对齐 `runChainOrchestration` `1228-1439`)。
5. **进度落库**:每个节点结束 `update_progress(jobId, {phase, cycle, dispatchChain, activeAgentId,
consensus, dialogues_delta})`,前端 SSE 才有实时数据。
6. **dialogues 累积**:把每轮气泡写进图状态 `dialogues`,既供「查看」实时渲染,也供作业结束回写草稿 step2。
**验收**
- 用脚本(仿 `scripts/probe_ai_writing_assistant.py`)直接对图发起一次 run,不开前端,能从 intent +
selectedAgents 一路跑到写出 `方案总览.html`,作业 `status` 终态 `done`。
- 命中 clarification 时图 `interrupt`,`status=awaiting_input`,`pending_clarification` 有值。
- chain 模式按链条顺序派活,recommend 模式由总控自由派活,两条路径都能收敛。
---
## Phase 3 —— 后端:作业控制 + 进度 SSE 接口
**目标**:暴露 start/get/stream/resume/cancel,后台 run 走 LangGraph Server(任意 worker 可 join)。
**涉及文件(新增/改)**
- 新增 `app/gateway/routers/roundtable_jobs.py`(仿 `ai_writing.py` 的 CRUD + 控制壳)
- 在 gateway `app.py` 注册路由
- 新增 `app/gateway/roundtable_jobs_cleanup.py`(仿 `ai_writing_cleanup.py`)+ 在启动处挂 cron
**接口契约**
| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/api/roundtable-jobs` | 入参:`{draftId, intent, selectedAgents, threadIds?, coordinatorName?, model, orchestrationMode, chain?}`。建作业记录(`queued`) + 启动图 run(LangGraph Server thread),返回 `RoundtableJob` |
| `GET` | `/api/roundtable-jobs/{id}` | 作业快照(轮询兜底 / 弹窗首帧) |
| `GET` | `/api/roundtable-jobs/{id}/stream` | SSE 进度流:透传图 `values`/`messages-tuple`,或映射成 §2.4 事件下发。**走真 TCP loopback**(`multi_agent.py:271-293`),禁代理 |
| `POST` | `/api/roundtable-jobs/{id}/resume` | 入参 `{answer}`,回填 clarification → 图 resume,`status→running` |
| `POST` | `/api/roundtable-jobs/{id}/cancel` | 取消图 run,`status→cancelled` |
| `GET` | `/api/roundtable-jobs?draftId=` | 按草稿查活跃作业(页面恢复用) |
| `POST` | `/api/roundtable-jobs/admin/cleanup` | 管理员手动清理(仿 ai_writing admin cleanup `307-367`) |
**多 worker 健壮性(务必遵守,照搬 ai_writing 教训)**
- 作业状态只存 **DB + checkpointer**,**不要**用模块级内存 dict 存 per-session 状态。
- SSE 必须经真 TCP `127.0.0.1` loopback,不能 `httpx.ASGITransport(app=...)` 进程内直调(会整段 buffer)。
- 图 run 跑在 LangGraph runtime(独立于处理 HTTP 的 worker),刷新/换 worker 都能重新 join stream。
- 数据卷:`.deer-flow/data/checkpoints.db` 必须挂出,否则容器重启丢状态。
**验收**
- `POST /start` 后立刻 `GET /{id}` 能拿到 `queued/running`;关掉 SSE 再重连能继续收进度。
- 杀掉发起 start 的 worker(或多 worker 轮询),`resume`/`stream` 仍命中同一作业。
- cleanup 干跑:活跃作业永不删,先删 CP 再删 DB。
---
## Phase 4 —— 前端:作业 API 客户端 + 数据模型扩展
**目标**:前端有一层干净的作业 API 与类型,历史列表能拿到 status。
**涉及文件(新增/改)**
- 新增 `src/roundtable-planning/api/roundtable-jobs.ts`:
```ts
export async function startJob(payload: StartJobPayload): Promise<RoundtableJob>
export async function getJob(id: string): Promise<RoundtableJob>
export function streamJob(id: string, on: (e: JobProgressEvent) => void): () => void // 返回 unsubscribe
export async function resumeJob(id: string, answer: string): Promise<void>
export async function cancelJob(id: string): Promise<void>
```
- `streamJob` 复用项目现有 SSE/`streamMultiAgent` 的封装方式(`api/multi-agent.ts:403+`),把
LangGraph `values` 映射成 §2.4 的归一化事件。
- 改 `src/roundtable-planning/api/drafts.ts`:`DraftMeta` 增 `status?: JobStatus; jobId?: string`
(`drafts.ts:76-83` + 字段映射 `122-178` 加 snake_case 转换)。
- 新增 `src/roundtable-planning/lib/job-types.ts`:把 §2.1–2.5 的类型集中导出。
**验收**:`pnpm typecheck` 通过;`getJob`/`streamJob` 能拉到 Phase 3 的数据。
---
## Phase 5 —— 前端:Step2「后台挂起」按钮
**目标**:Step2 顶栏/底栏加按钮,点击把当前任务交给后端后台跑,前端停止本地循环、转为观察者。
**涉及文件(改)**
- `src/roundtable-planning/components/Step2Panel.tsx`(按钮放在现有「进入第三步」附近 `370-381`)
- `src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(接线 + 观察者模式)
- `src/roundtable-planning/hooks/useStep2Orchestration.ts`(暴露「停止本地循环并交后台」的方法)
**步骤**
1. Step2Panel 加「⏼ 后台挂起」按钮,`onBackgroundSuspend` 回调。
2. 页面 `handleBackgroundSuspend`:
- `ensureDraft(2)` 拿到 `draftId`(`useDraftPersistence.ts` ensureDraft)。
- 收集当前状态:`intentReady`、`selectedAgents`、`threadIds`、`coordinatorName`、`model`、
`orchestrationMode`、`chain`(全部来自 `step2`/`step1` 现有状态)。
- `startJob(...)` → 拿到 `jobId`。
- **停止前端本地编排**:调用 `step2.stopOrchestration()` + 置一个 `backgroundedJobId` 标记,
让 `currentStep===2` 的 useEffect **不再自启本地循环**(在 `useStep2Orchestration.ts:1468-1541`
的启动条件里加 `&& !backgrounded`)。
- toast「已转入后台,可离开页面,任务会自动跑到结果绘制完成」。
- 刷新历史列表(`drafts` 重新拉,进行中项即出现 spinner)。
3. **观察者模式**:若当前停留在 Step2 且该 draft 有 running 作业,订阅 `streamJob`,把进度事件渲染成
对话气泡/活动席位(复用现有 Step2 渲染),而不是本地驱动。
**注意:避免「双跑」**——后台已接管后,前端绝不能再并行驱动同一批 thread。用 `backgroundedJobId`
互斥;加载草稿时若发现有 running 作业,直接进观察者模式。
**验收**:点「后台挂起」→ 可立即切到别的任务/路由;回来仍在跑;不会出现前后端同时派活。
---
## Phase 6 —— 前端:历史下拉框「进行中」加载动画
**目标**:历史下拉里 `status==='running'`(或 `awaiting_input`)的草稿条目显示加载动画;点击进行中
条目不直接 load,而是打开流程图弹窗。
**涉及文件(改)**
- `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` 的 `HistoryDropdown`(`771-892`)
**步骤**
1. 列表项左侧名称前/后,按 `d.status` 渲染状态标记:
- `running` → 旋转的 `Loader2`(`animate-spin`)+「进行中」。
- `awaiting_input` → 呼吸点 +「待澄清」(琥珀色)。
- `done`/无 → 维持现状(显示 `Step {furthestStep}`)。
- `error` → 红点 +「出错」。
2. 点击行为分流:
- `running`/`awaiting_input` → `openDispatchChainModal(d.jobId)`(Phase 7),**不**直接 `onLoad`。
- 其它 → 维持 `onLoad(id)`。
3. 列表需要 `status`,确保 Phase 4 的 `DraftMeta.status` 已就位;下拉打开时 `refresh()` 拉最新。
**验收**:后台挂起后,历史下拉对应条目转圈;点击弹出流程图弹窗。
---
## Phase 7 —— 前端:派活链路流程图弹窗(横向 / 大弹窗 / 动画)
**目标**:大弹窗,横向排列的智能体派活链路。节点 = 席位(图标 + 名称),不同颜色;正在发言的
节点和它的连接线带加载动画;含「查看」按钮。
**涉及文件(新增)**
- `src/roundtable-planning/components/DispatchChainModal.tsx`
**实现选型**:链路是「总控 → 席位序列」的线性结构 + 需要对**单个节点和单条连线**做精确动画,
**推荐手写 flex 横向布局 + CSS 动画连接线**(轻、可控)。项目已装 `@xyflow/react@12`(带 `animated`
边)作为备选,但手写对本场景的「节点 loading + 连线流动」更直接。
**结构(手写方案草图)**
```tsx
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[88vh] flex-col gap-0 sm:max-w-5xl"> {/* 大弹窗 */}
<DialogHeader>… 任务标题 + 阶段标签(leader_thinking/dispatching/report_drawing…) …</DialogHeader>
{/* 横向链路:总控 ──▶ 席位1 ──▶ 席位2 ──▶ … ──▶ [共识] ──▶ [报告绘制] */}
<div className="custom-scrollbar overflow-x-auto py-8">
<div className="flex items-center gap-0 min-w-max px-6">
<CoordinatorNode active={phase==='leader_thinking'} />
{nodes.map((n,i)=>(
<Fragment key={n.agentId}>
<Connector active={n.state==='active'} /> {/* 活动连线:流动虚线动画 */}
<ChainNode node={n} /> {/* 活动节点:彩色+脉冲光环 */}
</Fragment>
))}
<Connector active={phase==='consensus'} />
<MilestoneNode label="共识" done={consensus===100} />
<Connector active={phase==='report_drawing'} />
<MilestoneNode label="报告绘制" active={phase==='report_drawing'} done={phase==='report_done'} />
</div>
</div>
<DialogFooter className="border-t pt-3 sm:justify-between">
<span className="text-xs text-muted-foreground">轮次 {cycle}/8 · {statusLabel}</span>
<Button onClick={onView}>查看</Button> {/* Phase 8 */}
</DialogFooter>
</DialogContent>
</Dialog>
```
**节点颜色**:`avatarType = getAvatarTypeFor(agentId, order)` → `chainTagClasses(avatarType)`
(复用 `lib/business-chain.ts:56-72` + `lib/roundtable-constants.ts`),与业务链条卡片同一套色。
**节点状态样式**
- `pending`:灰色描边、低透明度。
- `active`:席位色填充 + 外圈 `animate-ping`/脉冲光环 + 图标旋转或呼吸。
- `done`:席位色实心 + 右上角 ✓。
**连接线加载动画(active 连线)**:CSS 流动虚线(marching-ants)或渐变扫光:
```css
/* roundtable-planning.css 追加 */
@keyframes chain-flow { to { background-position: 20px 0; } }
.chain-connector--active {
background-image: linear-gradient(90deg, var(--brand-500) 50%, transparent 50%);
background-size: 20px 100%;
animation: chain-flow 0.6s linear infinite;
}
```
**数据来源**:弹窗 mount 时 `getJob(jobId)` 取首帧,随后 `streamJob(jobId, onEvent)` 实时更新
`nodes`/`activeAgentId`/`phase`/`consensus`/`cycle`;`done` 事件后停止动画、全节点置 `done`。
弹窗关闭时 `unsubscribe`。
**验收**:弹窗够大、横向滚动;活动席位节点和它左侧连线在动;阶段推进时高亮顺移;`done` 后静止。
---
## Phase 8 —— 前端:「查看」进入进行中任务并实时观察
**目标**:弹窗「查看」→ 进入该任务,停在 Step2 实时看后台进度(气泡/活动席位随 SSE 刷新),
跑完自动呈现 Step3 结果。
**涉及文件(改)**
- `RoundtablePlanningPage.tsx`(接 `onView`)
- `useDraftPersistence.ts`(`handleLoadDraft` 增「带作业」分支)
- `useStep2Orchestration.ts`(观察者渲染)
**步骤**
1. `onView`:关弹窗 → `handleLoadDraft(draftId)` 恢复该草稿到 Step2(`useDraftPersistence.ts:308-368`),
但**跳过本地编排自启**(识别到有 running 作业 → 进观察者模式,对齐 Phase 5 的互斥标记)。
2. 订阅 `streamJob(jobId)`,把 `dialogue` 事件 append 进 `step2RoundtableDialogues`、`active` 事件
驱动 `activeSpeakerId` 高亮、`consensus` 更新百分比。
3. 命中 `awaiting_input`:在 Step2 弹出澄清输入(复用现有 `pendingClarification` UI),用户回答 →
`resumeJob(jobId, answer)`(而非本地 `submitClarification`)。
4. `done` 事件:用返回的 `DraftStep3Snapshot` 走 `hydrateStep3` + 允许切到 Step3 查看报告
(`useStep3Report.hydrateSaved` `708-767`);作业列表该项变「已完成」。
**验收**:从历史「查看」进入能实时看到后台在跑;澄清能在前端回答并续跑;跑完能看到最终报告。
---
## Phase 9 —— 边界、健壮性与收尾
1. **双跑互斥**:任一 draft 同一时刻只允许一个执行者(前端本地 or 后台作业)。加载草稿/进入 Step2
前先 `GET /api/roundtable-jobs?draftId=`,有 running 就进观察者模式。
2. **后台命中澄清**:`awaiting_input` 时历史下拉显示「待澄清」徽标;流程图弹窗显示「等待澄清」;
只有「查看」进去才能回答(或在弹窗里直接给一个回答框,调用 `resumeJob`)。
3. **取消**:流程图弹窗/Step2 提供「停止后台」→ `cancelJob`,`status→cancelled`,前端可恢复本地编辑。
4. **错误恢复**:`error` 事件展示 detail;允许「重试」= 重新 `startJob`(沿用已建 threadIds)。
5. **MAX_CYCLES / 预算**:后台图内兜底(对齐前端 `MAX_CYCLES=8`),到顶强制收敛并照常进 Step3。
6. **多 worker**:严格遵守 Phase 3 的「状态进 DB+checkpointer、SSE 走 loopback」;部署若多 worker
需 sticky 或全靠 DB 态(推荐后者)。
7. **清理**:`roundtable_jobs` cleanup cron(活跃保护、先删 CP 再删 DB),与 draft 生命周期对齐。
8. **历史列表性能**:draft 列表 join 作业 status 时注意 N+1;用一次 LEFT JOIN 取最新作业。
---
## 端到端验证清单(提测前逐条过)
- [ ] Step2 点「后台挂起」→ 立刻可离开页面 / 切到别的任务,后台继续跑。
- [ ] 历史下拉里该任务显示加载动画(转圈)。
- [ ] 点击进行中任务 → 大弹窗、横向派活链路、活动节点+连线在动、节点不同颜色。
- [ ] 阶段推进(leader→派活→共识→报告绘制)时弹窗高亮顺移。
- [ ] 弹窗「查看」→ 进入任务实时观察气泡/活动席位。
- [ ] 后台命中澄清 → 列表「待澄清」+ 弹窗提示 + 可回答续跑(`resumeJob`)。
- [ ] 一路自动跑到 Step3「绘制完成」,最终报告可在 Step3 查看,作业 `done`。
- [ ] 浏览器刷新 / 关页重开后,任务仍在后台跑、状态可恢复(真后台验证)。
- [ ] 取消后台 → `cancelled`,无残留双跑。
- [ ] 多 worker(或杀 worker)下 stream/resume 不丢会话。
- [ ] cleanup 干跑不误删活跃作业。
---
## 工作量与排期建议
| 阶段 | 内容 | 粗估 | 依赖 |
|------|------|------|------|
| P0 | 数据契约定型(本文 §2) | 0.5d | — |
| P1 | 后端作业表 + repo + 迁移 | 1d | P0 |
| **P2** | **后端编排图(移植循环)** | **3–5d(核心难点)** | P0,P1 |
| P3 | 后端控制 + SSE + cleanup | 1.5d | P1,P2 |
| P4 | 前端作业 API + 类型 + draft status | 0.5d | P3 |
| P5 | Step2 后台挂起按钮 + 互斥 | 1d | P4 |
| P6 | 历史下拉 loading 动画 | 0.5d | P4 |
| P7 | 派活链路流程图弹窗 | 1.5d | P4 |
| P8 | 「查看」实时观察 + 澄清续跑 | 1.5d | P5,P7 |
| P9 | 边界/健壮性/清理 | 1d | 全部 |
> 建议先打通 **P1→P2→P3** 的后端最小闭环(脚本验证图能后台跑到出报告),再做前端 P4–P8。
> P2 是整条链路成败的关键,务必先用脱离前端的探针脚本(仿 `scripts/probe_ai_writing_assistant.py`)跑通。
---
## 附:关键参考代码索引
- 前端编排循环(待移植到后端):`useStep2Orchestration.ts:992-1204,1228-1439,1468-1541`
- 共识判定 / 进 Step3:`useStep2Orchestration.ts:1091-1099`、`Step2Panel.tsx:370-381`、`RoundtablePlanningPage.tsx:586-595`
- Step3 绘制完成标志:`useStep3Report.ts:51,165,560-596,636-652`
- 草稿模型 / API / 自动保存 / 加载恢复:`api/drafts.ts:25-110,184-225`、`useDraftPersistence.ts:248-316,308-368`
- 历史下拉 UI:`RoundtablePlanningPage.tsx:771-892`
- 后端单轮执行 / 派活 / 广播:`multi_agent.py:647-823,1076-1142,525-552,487-502`、loopback `271-293`
- AI 写作模板:`agents/ai_writing/graph.py`、`ai_writing_sessions/model.py:14-39`、`ai_writing.py`、`ai_writing_cleanup.py:307-367`、`AIWritingPage.tsx` 文件头注释
- 颜色体系复用:`lib/business-chain.ts:56-72`、`lib/roundtable-constants.ts`(`getAvatarTypeFor`)
- 流程图备选库:`@xyflow/react@12`(已安装)
```
@@ -1,124 +0,0 @@
# 多智能体「后台挂起」—— 当前状态、遗留问题与下次继续指南
> 配套设计文档:`multi-agent-background-run-dev.md`(分 Phase 0-9 的总方案)。
> 本文记录**截至本次会话的实际进展 + 还没解决的遗留问题**,方便下次直接续接。
---
## 一、整体状态:跑通到哪了
「后台挂起」= Step2 把研讨交给后端**进程内驱动真模型**(leader→各席位→共识→报告),
survive 刷新/关页,对话与报告作为一个 **run 写进草稿**(和手动跑同一存取),可在历史回看。
**真网关已在本地验证可跑通**(2026-06-04 日志确认:roundtable-coordinator + 各席位 +
roundtable-report 真调 deepseek、真搜索、跑到 report run success)。但还有几个**内容/产物层面的遗留问题**(见第三节)。
### 已完成(可用)
- ✅ 后端持久化:`roundtable_jobs` 表(迁移 `20260604_01` / `20260605_01` dialogues / `20260605_02` step3)。
- ✅ 编排引擎 `packages/harness/deerflow/agents/roundtable_orchestrator/`(recommend + chain + 报告,纯逻辑可单测)。
- ✅ 控制面 `app/gateway/routers/roundtable_jobs.py`:start/get/by-draft/stream(SSE)/resume/cancel/admin-cleanup。
- ✅ 执行器 `app/gateway/roundtable_job_executor.py`:后台任务 + `set_current_user` + 生命周期 + **每轮把对话作为 `job-<id>` run 写进 `draft.step2.runs[]`(同手动存取)** + done 写 step3。
- ✅ 真网关 `app/gateway/roundtable_inprocess_gateway.py`:`SimpleNamespace(app)` + `start_run` 进程内驱动 agent;复用 `multi_agent._extract_dispatch` 解析派活;checkpointer 读最终 messages。
- ✅ 模拟网关 `roundtable_orchestrator/simulated_gateway.py`(不调真模型,前端联调用)。
- ✅ 前端:`api/roundtable-jobs.ts`、`lib/job-types.ts`、Step2「后台挂起」按钮、历史下拉 loading、`DispatchChainModal`/`DispatchChainJobModal`(流程图 + 对话面板 markdown + 加宽 + 澄清作答 + 取消 + 查看)。
- ✅ 上下文「广播」(inline):席位/总控 prompt 带上「任务背景 + 前序各席位交付 + 别提问」约束(修「席位拿不到关键信息、反问用户、总控瞎编」)。
- ✅ 后端测试:`tests/test_roundtable_jobs.py` / `test_roundtable_orchestrator.py` / `test_roundtable_job_executor.py` / `test_roundtable_jobs_api.py` 全过。
---
## 二、怎么启动 / 验证(每次开工先做)
1. **开真网关**(默认是模拟网关):已写进 `offline-backend-20260512/.env`:
```
ROUNDTABLE_JOB_GATEWAY=inprocess
```
启动日志应出现 `[roundtable-jobs] gateway = inprocess (real models)`。
2. **必须用 `make gateway`(不带 --reload)跑后端!**
`make dev` 的 `--reload` 监视 `backend/` 目录,agent 运行时往 `.deer-flow/` 写文件
(memory.json / checkpoint / 沙箱输出)会触发热重载,**把长时后台任务杀掉**。
(改进项:给 uvicorn 加 `--reload-exclude '.deer-flow/*'`,或始终用 `make gateway`。)
3. **直接查库验证**(最有效,PowerShell 控制台是 GBK,导出到文件看中文):
```bash
cd offline-backend-20260512/backend
# 查最新作业的 dialogues / step3
PYTHONPATH=. uv run python -c "import sqlite3,json; con=sqlite3.connect('.deer-flow/data/deerflow.db'); ..."
```
- 作业表:`.deer-flow/data/deerflow.db` → 表 `roundtable_jobs`(`dialogues`/`step3` 列)。
- 草稿表:`roundtable_drafts` → `step2` 列里应有 `runs[ {id:"job-<jobId>", jobId, jobStatus, step2RoundtableDialogues:[...], step3:{...}} ]`。
4. **前端验证**:后台挂起 → 等跑完 → 从历史加载该草稿 → Step2 应显示后台研讨完整对话(走现有 `normalizeStep2Runs`/`hydrateStep2Run`)。
---
## 三、遗留问题(下次重点,已按优先级排)
### P0 —— 报告 agent 不写 HTML 文件(结果绘制拿不到产物)
- **现象**:后台报告 run 跑完,日志 `report html: no .html found in ...threads/<id>/user-data/outputs`;
`run.step3.html` 回退成报告 agent 的**纯文字**(不含 `<html>`),第3步画不出东西。
- **已做**:`build_report_prompt`(`roundtable_orchestrator/engine.py`)已对齐前端
`useStep3Report.buildReportPrompt`,明确「用 write_file 写入 /mnt/user-data/outputs/方案总览.html」;
`_read_report_html`(`roundtable_inprocess_gateway.py`)已改为扫 outputs 目录任意 `.html`。
- **仍没解决**:报告 agent(deepseek-chat)还是没调 `write_file`。
- **下次怎么查**:
1. 对比**前端那条能写出 HTML 的报告 run** 的确切调用(`useStep3Report.ts` 里 `streamMultiAgent`
调 `/api/multi-agent/run/stream` 的 body:`agent_type`/`excluded_tools`/`context` 等),
和后台 `_run_agent_turn`(`roundtable_inprocess_gateway.py`,agent_name=roundtable-report,
`excluded_tools=_REPORT_EXCLUDED`)逐项比对,找出差异(怀疑 reasoning_effort / thinking /
某个 context 字段 / recursion 步数导致它没走到 write_file)。
2. 看报告 run 的最终 messages(checkpoint)里有没有 write_file 的 tool_call(被 dangling 截断?)。
3. 报告 agent 的 SOUL:`app/gateway/routers/_roundtable_seed/roundtable-report/SOUL.md`——确认它默认行为。
### P1 —— 内容质量(本次刚加 inline 广播,待真机验证)
- **已做**:席位/总控 prompt 注入「任务背景 + 前序交付 + 别提问」。
- **待验证**:真机跑一次,看席位是否基于真实上下文产出(不再反问/凭记忆瞎编)。
- **若 inline 不够**:考虑实现**真·thread 广播**(像 `multi_agent._append_thread_message` 那样把
「用户意图 / 派活 / 各席位交付」追加进各 thread 历史)。进程内要避开 HTTP loopback 鉴权,
得用 checkpointer 直接 append human 消息(参考 `threads` 路由 POST `/state` 的内部逻辑)。这块较重,先看 inline 够不够。
### P2 —— 对话「没有步骤(steps)」
- **现象**:手动跑的气泡带工具调用步骤卡(`onPhaseChange` → `appendStepIfNew` → `Step2Dialogue.steps`);
后台对话只存了最终正文,没有 steps。
- **下次怎么做**:后台 `_run_agent_turn` 跑完后,从 checkpoint 的 messages 里抽出 tool_calls,
映射成 `Step2Dialogue.steps`(`{id,key,label,ts,args}`),在执行器 `_to_step2_dialogue` 里带上。
纯展示增强,不影响内容/报告。
### P3 —— 双跑浪费 + --reload 坑(工程健壮性)
- 进 Step2 时前端会自动跑一遍本地研讨(真模型、真线程);点后台挂起后前端那条 run 在后端还会跑完
(`stopOrchestration` 只断客户端、停不了后端 run)→ 和后台作业短暂双跑、双倍 token。
- 优化方向:进 Step2 改成「显式开始」而非自动跑;或后台挂起时真正 cancel 前端那条 run。
- `make dev` 的 `--reload` 会杀后台任务(见第二节);改进项:`--reload-exclude '.deer-flow/*'`。
---
## 四、关键文件清单(下次定位用)
**后端**
| 文件 | 作用 |
|------|------|
| `app/gateway/roundtable_inprocess_gateway.py` | 真网关:进程内驱动 leader/seat/re 编排循环 + `build_report_prompt` + inline 广播(`_seat_message`/`_deliveries_block`) |
| `.../roundtable_orchestrator/progress.py` | 进度落库 + `on_dialogue` 每轮回调 |
| `.../persistence/roundtable_jobs/` | job 表 model/sql |
| `app/gateway/deps.py` | `ROUNDTABLE_JOB_GATEWAY` 选网关 + 注入 executor(draft_store) |
**前端**
| 文件 | 作用 |
|------|------|
| `src/roundtable-planning/api/roundtable-jobs.ts` | 作业 API + SSE |
| `src/roundtable-planning/lib/job-types.ts` | 类型(RoundtableJob/JobDialogue/...) |
| `src/roundtable-planning/components/DispatchChainModal.tsx` / `DispatchChainJobModal.tsx` | 流程图弹窗 + 对话面板 |
| `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 后台挂起按钮、历史下拉、**runs[] 多运行模型**(`hydrateStep2Run`/`getStep2SnapshotWithRuns`/`normalizeStep2Runs`) |
| `src/roundtable-planning/api/drafts.ts` | `DraftStep2RunSnapshot`(带 jobId/jobStatus/step3) |
---
## 五、关键架构事实(避免重复踩坑)
1. **真网关进程内驱动**:`start_run(body, thread_id, SimpleNamespace(app=app))` + `set_current_user`,
不走 HTTP(loopback 调 `/api/multi-agent/*` 不可行:`_auth_headers` 只转发用户 token + owner check)。
2. **后台作业首启自建全新线程**(`StartParams.is_resume`),绝不复用前端本地研讨的线程(会撞 409)。
3. **存取统一**:后台作业 = `draft.step2.runs[]` 里一个带 jobId 的 run,和手动跑同一存取/hydrate 路径,
**不要再开 observe/回显分支**(之前试过,被否)。
4. **聊天记录确实存上了**(job.dialogues + draft 的 run);之前"没存上"是测了旧代码 / 看的是前端本地残缺数据。
5. 多 worker / SSE:状态进 DB、SSE 走 DB 轮询;真网关进程内无 loopback。
@@ -1,400 +0,0 @@
# 业务链条模式 · 需求与分步实现文档
> 面向「智能体推荐弹窗」的扩展:在现有**智能推荐**之外,新增**业务链条选择**与**业务链条配置**两块能力;选定业务链条后,Step 2 圆桌研讨**按链条内的固定顺序**逐个发言(而非现在的总控派活)。
>
> 配套阅读:
> - 前端总览:`multi-agent-frontend-dev.md`(推荐弹窗 §6.2、Step 2 编排 §5.4、草稿持久化 §5.7)
> - 后端总览:`multi-agent-backend-dev.md`(Step 2 §6、草稿/历史持久化 §7A)
>
> 本文档约定:**「席位 / 智能体」**指一个 agent;**「业务链条 / chain」**指一组有序排列的 agent 配置,是可命名、可复用、可持久化的实体。
---
## 0. 需求原文拆解
用户原始诉求逐条拆成可实现条目:
| # | 原始诉求 | 拆解 |
|---|----------|------|
| 1 | 推荐弹窗要有「模式选择」 | 弹窗顶部加模式切换(智能推荐 / 业务链条),两种模式互斥,确认后都产出**有序**的 `SelectedAgent[]` |
| 2 | 「智能推荐」就是现在这个 | 现有 `RecommendAgentsDialog` 整体收进「智能推荐」模式,行为不变 |
| 3 | 「业务链条选择」是卡片列表 | 卡片 = 标题 + 一排智能体名 tag;tag 不同颜色;tag 之间有**向左箭头**连接,表达链条次序 |
| 4 | 业务链条需要一个**配置页面** | 左侧智能体列表(复用 `GET /api/agents`),右侧有序长条卡片(名称 + 描述);支持右侧内部拖拽排序、左→右拖入指定位置 |
| 5 | 配置页用弹窗还是新页面? | **本文档决策:独立路由页面**(理由见 §2.1) |
| 6 | 选定链条后,Step 2 用链条内智能体研讨 | 复用既有 Step 2 init/stream;**总控(leader)按链条顺序逐个派活**(§6) |
| 7 | 规划成分步可实现的需求文档 | 见 §7 分阶段实现 |
---
## 1. 目标与非目标
### 1.1 目标
- 推荐弹窗成为「**席位来源选择器**」:无论智能推荐还是业务链条,最终都向 `onConfirm` 交付一个**有序的 `{ agent_id, name }[]`** + 一个 `orchestrationMode`。
- 业务链条是**用户可创建、命名、保存、复用、删除**的有序智能体编排模板。
- Step 2 支持两种编排:
- `recommend` 模式 → 现有总控**自由派活**循环(不动)。
- `chain` 模式 → 总控仍是派活方,但**被约束按链条顺序逐个派活**(新增)。
### 1.2 非目标(本期不做,留扩展点)
- 链条内每个席位的 per-seat prompt/temperature 真实覆盖(仍仅前端记录,沿用现状)。
- 链条的分支 / 条件流转(只做线性顺序链)。
- 链条跨用户共享 / 市场(先做「本人私有链条」)。
- 链条版本管理 / 协作编辑。
---
## 2. 关键设计决策
### 2.1 决策 A:配置页面用「独立路由页面」而非弹窗 ✅
**结论:业务链条的「编辑/管理」用独立路由页面 `/page/workspace/roundtable/chains`;业务链条的「选择」仍内嵌在推荐弹窗里(卡片列表)。**
理由:
| 维度 | 弹窗 | 独立页面 ✅ |
|------|------|------------|
| 双栏 + 拖拽 + 长条卡片 | 弹窗宽度(`sm:max-w-3xl`)+ `max-h-[85vh]` 拖拽空间局促,左右栏挤 | 整屏宽度,拖拽手感好 |
| 复用入口 | 只能从推荐弹窗进 | 可从侧栏、推荐弹窗「管理链条」按钮、深链接进入 |
| 可深链接/刷新 | 不可 | `/roundtable/chains` 可直达、可分享 |
| 推荐弹窗内嵌编辑器 | 弹窗套弹窗 / 模式套编辑,状态复杂 | 关注点分离:弹窗只「选」,页面只「编」 |
**分工**:
- **推荐弹窗**:模式选择 + 业务链条**选择**(只读卡片列表,可点「去配置」跳到页面)。
- **配置页面**:业务链条的**增删改**(双栏拖拽编辑器 + 链条列表管理)。
> 备选:若产品坚持不加路由,可改为**全屏 Dialog(`max-w-[90vw] h-[90vh]`)**承载编辑器,从推荐弹窗内打开。结构与页面版一致,仅外壳不同。本文档按「独立页面」实现,§7 标注了切换成本。
### 2.2 决策 B:链条持久化 —— 后端 MySQL(确定) ✅
**结论:业务链条存后端 MySQL,仿 `roundtable_drafts`(§7A)新建 `roundtable_chains` 表 + `/api/roundtable-chains` 路由,按登录用户隔离、跨设备保留。** 不走 localStorage。
理由:链条是可复用、需跨设备/会话保留的模板,与草稿/推荐历史性质一致;草稿当年正是因 localStorage 换设备即丢才迁后端(§7A),链条不重复踩坑。
落地:
- 表/路由设计见 §5.2,是「复制 §7A 草稿持久化、把字段换成 chain」的机械工作。
- 前端 `api/chains.ts` 直接打后端接口(snake_case↔camelCase 在这层收口,仿 `api/drafts.ts`),**不提供 localStorage 实现**。
- 后端表必须先于前端 UI 落地(§7 阶段 0 即包含后端表/路由)。
### 2.3 决策 C:拖拽库选型 —— `@dnd-kit`
仓库**当前无任何拖拽库**(`package.json` 无 `@dnd-kit` / `react-dnd` / `sortablejs`;`@xyflow/react` 是节点图,不适合列表排序)。
| 方案 | 取舍 |
|------|------|
| **`@dnd-kit/core` + `@dnd-kit/sortable`**(推荐) | React 19 友好、可访问性好、跨容器拖入 + 列表排序 + 插入指示器开箱即用;新增 ~30KB gzip 依赖 |
| 原生 HTML5 DnD(零依赖兜底) | 不加依赖,但跨容器插入位置指示、键盘可达性都要手写,易出 bug |
**建议**:新增 `@dnd-kit/core`、`@dnd-kit/sortable`、`@dnd-kit/utilities`。若产品方禁止新增依赖,退回原生 HTML5 DnD(§7 阶段 2 标注差异)。
### 2.4 决策 D:Step 2「总控按链条顺序派活」,后端零改动 ✅
**形态:总控(leader)仍是唯一派活方,但每一轮被前端约束「只能派给链条里的下一个席位」,逐个走完整条链,最后由总控收口。** 区别于 recommend 模式的「总控自由决定派给谁」。
为什么后端无需改动:
- Step 2 后端 `streamMultiAgent({ agentType: "leader" })` 每轮返回 `dispatched = [[agent, task], ...]`;`agentType: "special"` 单独驱动某席位跑一轮。这两个原语已足够。
- 每个 special 跑完,后端 `_broadcast` 会把「子智能体 X 完成…交付内容为:…」append 进**其它所有席位 + 总控 thread** 的历史(后端 §6.4),所以链路上下文天然累积。
- chain 模式只是**改变前端怎么提示 leader、以及怎么校验它的派活**——把每轮 leader 的提示词约束成「请向链条第 i 位【X】派活」,并对返回的 `dispatched` 做「必须命中链条下一席位」的校验/纠偏。**后端路由、SOUL、表结构都不动。**
→ chain 模式 = 在 `useStep2Orchestration` 里新增 `runChainOrchestration`:按链条游标逐位调 leader(约束派给该位)→ 拿到 task → 调 special → 下一位 → 链尾后调一次 leader 收口。详见 §6。
> 与「前端绕过 leader 直接顺序 special」的区别:本方案**保留总控在每一步的协调/串接职责**(leader 结合前序进展为下一席位编写针对性任务、并在派活说明里承上启下),更符合「总控按链条顺序派活」的语义,圆桌可视化里总控气泡也照常出现。
---
## 3. 数据模型与类型
新增前端类型,建议放 `frontend-web/src/roundtable-planning/lib/business-chain.ts`:
```ts
/** 业务链条里的一个有序席位。 */
export interface ChainSeat {
agent_id: string;
/** 落库时的快照名/描述,避免 agent 改名后链条显示错乱;展示优先用实时 agent 数据,回退到快照。 */
name: string;
description?: string;
}
/** 一条可复用的业务链条。 */
export interface BusinessChain {
id: string;
title: string;
description?: string;
/** 有序席位列表;seats[0] 是第一个发言者。 */
seats: ChainSeat[];
createdAt?: string;
updatedAt?: string;
}
/** 推荐弹窗确认后回传给主页面的统一结果。 */
export interface SeatSelectionResult {
/** 有序席位(与现有 handleConfirmAgents 入参兼容)。 */
agents: Array<{ agent_id: string; name: string }>;
/** 决定 Step 2 走哪种编排。 */
mode: "recommend" | "chain";
/** chain 模式下带上来源链条(用于草稿快照 / Step 2 展示链名)。 */
chain?: Pick<BusinessChain, "id" | "title">;
}
```
**约束**:链条席位数沿用圆桌的 `2 ≤ N ≤ 8`(`MIN_SELECTED`/`MAX_SELECTED`),配置页保存时校验。
**tag 配色**:复用 `roundtable-constants.ts::getAvatarTypeFor(agentId, index)`,得到 `blue|purple|emerald|amber|pink|human`,再映射到已有的 avatar 配色 token(与圆桌可视化、Step 2 气泡同一套色板,视觉统一)。
---
## 4. 现有代码改动点速查
| 位置 | 改动 | 说明 |
|------|------|------|
| `components/RecommendAgentsDialog.tsx` | 包一层「模式选择」外壳,现有内容收进「智能推荐」Tab | `onConfirm` 签名从 `agents[]` 升级为 `SeatSelectionResult`(或加可选第二参 `mode`,见 §7 阶段 3 的兼容策略) |
| `pages/RoundtablePlanningPage.tsx` | `handleConfirmAgents` 接收 `mode`,存到新状态 `orchestrationMode` | 透传给 `useStep2Orchestration` |
| `hooks/useStep2Orchestration.ts` | 新增 `runChainOrchestration` + `orchestrationMode` 分支 | init effect 里按 mode 选择 `runOrchestration` 或 `runChainOrchestration` |
| `lib/roundtable-constants.ts` | 复用 `getAvatarTypeFor` / `SelectedAgent` | 不改,仅引用 |
| `hooks/useDraftPersistence.ts` + `api/drafts.ts` | Step2 快照新增 `orchestrationMode` + `chain` 字段 | 草稿能记住「这次是链条模式」 |
| `pages/WorkspaceRoutes.tsx` | 注册 `roundtable/chains` 路由 | 仿 `roundtable/planning` |
| `components/page-sidebar.tsx`(如有侧栏入口) | 加「业务链条」入口(可选) | 也可只从推荐弹窗进 |
| `api/chains.ts`(新增) | 链条 CRUD 客户端 | 直接打后端 `/api/roundtable-chains` |
| 后端 `roundtable_chains` | 表 + 路由(MySQL) | 仿 `roundtable_drafts`,**必做** |
---
## 5. 链条持久化 API 设计
### 5.1 前端客户端 `api/chains.ts`(直接打后端)
```ts
listChains(): Promise<BusinessChain[]> // 当前用户全部链条
getChain(id: string): Promise<BusinessChain>
createChain(input: Omit<BusinessChain,"id"|"createdAt"|"updatedAt">): Promise<BusinessChain>
updateChain(id: string, patch: Partial<BusinessChain>): Promise<BusinessChain>
deleteChain(id: string): Promise<void>
```
- 实现用 `apiFetch` 打 `/api/roundtable-chains`(§5.2),snake_case↔camelCase 在这层收口(仿 `api/drafts.ts`)。
- 不提供 localStorage 实现;后端表是前端 UI 的硬依赖。
### 5.2 后端表 + 路由(仿 §7A `roundtable_drafts`)
- 表 `roundtable_chains`:`id`(PK) / `user_id`(idx) / `title`(512) / `description`(text) / `seats`(`PortableLongText`,存 `ChainSeat[]` JSON) / `created_at` / `updated_at`(`BeijingDateTime`)。
- 路由 `app/gateway/routers/roundtable_chains.py`,前缀 `/api/roundtable-chains`:`GET ``(列表)/ `POST ``(建)/ `GET /{id}` / `PUT /{id}`(partial,`exclude_unset`)/ `DELETE /{id}`。
- 鉴权/属主:`_current_user_id(request)`,跨用户访问一律 404,与 `roundtable_drafts` 完全一致。
- 启动 `Base.metadata.create_all` 自动建表(MySQL 生产 / sqlite 本地走同一套 SQLAlchemy 代码,`PortableLongText` 各自降级),登记进 `persistence/models/__init__.py`,store 接到 `app.state` 并 `include_router`。
> 整体是「复制 §7A 的草稿持久化、把字段换成 chain」的机械工作,风险低。
---
## 6. Step 2 链条模式编排设计(`runChainOrchestration`)
### 6.1 与现有 `runOrchestration` 的差异
| 维度 | `runOrchestration`(recommend) | `runChainOrchestration`(chain,新增) |
|------|--------------------------------|----------------------------------------|
| 谁派活 | 总控 leader | 总控 leader(**不变**) |
| 派给谁由谁定 | leader 自由决定 | 前端按链条游标**约束 leader 只能派给下一席位** |
| 发言顺序 | leader 每轮自由派 | **链条数组的固定顺序** |
| 循环结构 | `while` 直到 leader 返回 `[]` 共识 / MAX_CYCLES | 按链条游标走一趟(每位:leader 派活 → special 交付) |
| 收口 | leader 返回 `[]` 即共识 | 链尾后再调一次 leader 做总结收口,产出 `lastLeaderContent` + `hasConsensus` |
> **核心**:leader 始终在场,只是**每轮的派活目标被前端钉死成链条的下一席位**——前端在 leader 的 `newMessage` 里写明「本轮请通过 `agent_orchestration` 仅向【X】派活」,并对 `leaderRes.dispatched` 做命中校验/纠偏。
### 6.2 伪代码
```
runChainOrchestration(seedMessage, ids, coord, chainSeats):
isOrchestrating = true
cancel = false
N = chainSeats.length
for (let i = 0; i < N; i++): // 链条游标,逐位推进
if cancel: break
seat = chainSeats[i]
seatMeta = agentNameToRole[seat.agent_id]
// ── (a) 总控派活给「链条下一席位」──────────────────────────
highlightSpeaker(null) // leader 阶段不高亮任何 sub
leaderBubble = appendStreamingDialogue("总控协调", confidence:"链条派活中")
leaderTask = buildChainLeaderTask({
seedMessage, // 任务目标/约束(首轮)
position: i, total: N,
nextSeat: seat, // 「本轮请仅向【X(seat.agent_id)】派活」
isFirst: i === 0,
})
leaderRes = await streamMultiAgent(leader, ids, coord, leaderTask, model=globalModel)
// 约束/纠偏:取 dispatched 中命中 seat.agent_id 的那条 task;
// 若 leader 没派或派错(派给非 seat、或派给多个),前端兜底:
// - 命中就用它给出的 task 文本(保留总控的承上启下措辞);
// - 没命中则用 leader 的正文 content 作为兜底 task,或回退到模板 task。
task = pickChainTask(leaderRes, seat)
// leader 若返回 clarification → 暂停链条,走与现状一致的澄清条(§6.3)
finalizeDialogue(leaderBubble, leaderRes.content, "派活说明")
// ── (b) 被派席位交付(special,顺序、不并行)──────────────────
markSpeakerStreaming(seatMeta.speakerId)
seatBubble = appendStreamingDialogue(seatMeta.displayName, confidence:"调度中")
perSeatModel = agentConfigs[seatMeta.speakerId]?.model || globalModel
specRes = await streamMultiAgent(special, ids, seat.agent_id, task, model=perSeatModel)
finalizeDialogue(seatBubble, specRes.content, "子智能体交付")
markSpeakerIdle(seatMeta.speakerId)
// 后端 _broadcast 已把本席位交付注入其余 thread 历史 → 下一席位天然可见
if cancel: return
// ── (c) 总控收口:综合全链交付,产出最终结论 ──────────────────
leaderBubble = appendStreamingDialogue("总控协调", confidence:"汇总链条结论")
leaderRes = await streamMultiAgent(leader, ids, coord,
"各链条席位已按顺序完成发言,请综合所有交付,输出最终方案,不要再派活。")
finalizeDialogue(leaderBubble, leaderRes.content, "全案盖印")
setLastLeaderContent(leaderRes.content)
setHasConsensus(true); setConsensusPercentage(100)
isOrchestrating = false
```
要点:
- **派活方仍是总控**,符合「总控按链条顺序派活」的语义;圆桌可视化里每位席位前都先出现一个总控派活气泡,再出现该席位交付气泡。
- **约束手段是 prompt + 前端校验,不动后端**:`buildChainLeaderTask` 把「本轮只能派给【X】」写进 leader 的 `newMessage`;`pickChainTask` 对 `leaderRes.dispatched` 做命中校验,派错/未派时兜底,保证链条游标严格推进。
- **复用现有三段式气泡 / 高亮 / abort 基建**(`appendStreamingDialogue` / `finalizeDialogue` / `markSpeakerStreaming` / `activeAbortersRef` / `orchestrationCancelRef`),只是驱动顺序不同。
- **special 仍是顺序**(不并行),与现状不变量一致(文件顶部注释 §1)。
- **收口 leader 调用默认开**(产出 Step 3 结论);若链条末位本身是「总结陈述」类,可关掉收口,直接拿末位交付当 `lastLeaderContent`。配置位 `chainNeedsLeaderSummary`(默认 true)。
### 6.3 澄清 / 挂起 / 恢复 / 干预
- **澄清**:链条中途某轮 leader 返回 `clarification` → 与现状一致,暂停链条、底部弹澄清条;用户回答后从**当前游标**继续(需记录 `chainCursor`)。
- **挂起/恢复**:`isRoundTableRunning` toggle 复用现有机制;恢复时从 `chainCursor` 续跑。MVP 若不做断点续跑,可先只支持「整体停止 / 从头重跑」,UI 文案说明(扩展点,§7 阶段 4 标注)。
- **干预**:可在两席位之间插入一条用户指令,由下一轮 leader 派活前消化。MVP 同上可后置。
### 6.4 多轮链条(扩展点,非 MVP)
单趟遍历是 MVP。若要「链条跑完后总控判断是否再来一轮」,可在收口 leader 处复用 `dispatched===[]?` 判定:收口 leader 若仍派活则回到链条头再跑一轮,直到共识或 MAX_CYCLES。MVP 不做,避免复杂度。
### 6.5 init 复用
chain 模式的 Step 2 init **完全复用** `initMultiAgent`:`agentNames = chainSeats.map(s => s.agent_id)`(**按链条顺序**传入,使 `buildRoleTemplates` 的 seat id 顺序 = 链条顺序,且后端注入 coordinator 的「可调度席位清单」也按此序)。init effect 里按 `orchestrationMode` 决定 init 完成后调 `runOrchestration` 还是 `runChainOrchestration`。
---
## 7. 分阶段实现计划
> 每阶段都可独立交付、独立验收。建议顺序:**数据层 → 配置页 → 弹窗模式 → 链条选择 → Step2 编排 → 持久化兼容与收尾**。
### 阶段 0 · 后端链条表 + 脚手架(1.5 天) ✅ 已完成
**目标**:打地基(含后端持久化),不含前端 UI。
- [x] **后端**:仿 §7A `roundtable_drafts` 新建 `roundtable_chains` 表 + `/api/roundtable-chains` 路由(§5.2):
- [x] `persistence/roundtable_chains/`(model + sql repo + `make_roundtable_chain_store`),登记进 `persistence/models/__init__.py`。
- [x] `app/gateway/routers/roundtable_chains.py`(GET 列表 / POST / GET{id} / PUT{id} / DELETE),鉴权属主同 `roundtable_drafts`;席位 2–8 + 去重校验。
- [x] `deps.py` 接 `app.state.roundtable_chain_store`,`app.py` `include_router`。
- [x] 后端测试 `tests/test_roundtable_chains.py`:CRUD + 部分更新 + 跨用户隔离(5 用例全过)。
- [x] **前端**:新增 `lib/business-chain.ts`(`ChainSeat` / `BusinessChain` / `SeatSelectionResult`,§3)+ `api/chains.ts`(直接打后端,§5.1)。
- [x] (决策 C)安装 `@dnd-kit/core` `@dnd-kit/sortable` `@dnd-kit/utilities`。
- **验收**:✅ 后端 5 用例通过;✅ 表注册进 `Base.metadata`、路由 `/api/roundtable-chains` 加载正常;✅ ruff 0 错;✅ 前端 typecheck 新增文件 0 错。
### 阶段 1 · 业务链条配置页面(2–3 天) ✅ 已完成
**目标**:能创建/编辑/保存/删除链条。
- [x] 路由:`WorkspaceRoutes.tsx` 注册 `roundtable/chains`(套 `WorkspaceLayout`;本页**不需要** `ChatRuntime`,无沙箱依赖)。
- [x] 页面 `roundtable-planning/pages/BusinessChainEditorPage.tsx`:
- [x] 顶部:链条列表下拉(`<select>` 列出已存链条 + 新建项)/ 新建按钮 / 标题+描述输入 / 保存 / 删除(`window.confirm`)。
- [x] **左栏**:候选智能体(`listAgents()` + `filterRecommendCandidates`),竖排卡片,可拖、可点「+」快捷加入;已加入的置灰「已加入」。
- [x] **右栏**:有序长条卡片(序号 tag + 名称 + 描述 + 移除),`SortableContext` 内拖拽排序;空态有占位提示。
- [x] 拖拽用 `@dnd-kit`:左栏 `useDraggable`,右栏 `useDroppable` + `SortableContext`/`useSortable`,`onDragEnd` 计算插入 index(pool→右栏按落点插入;seat→seat `arrayMove`);`DragOverlay` 跟手卡片。
- [x] 校验:`2 ≤ seats ≤ 8`(前端禁用保存 + toast,后端 `_validate_seats` 兜底),重复 agent 去重提示。
- [x] 保存走 `api/chains.ts`(create 用 `nanoid()` 客户端 id,update 走同一 id)。
- **验收**:✅ typecheck 0 新增错误(76 个预存量错误均在无关模块)。**人工验收待跑**:新建「情报→方案→风险→总结」链保存 → 刷新后下拉重载 → 右栏拖拽排序 → 左栏拖入指定位 → 2–8 边界禁用保存。
- **切换成本备注**:若改全屏 Dialog 方案,编辑器组件原样复用,仅去掉路由、外面套 `Dialog`。
- **入口说明**:圆桌本身未在侧栏(`sidebar-menu.ts` 入口被注释),本页暂经直链 `/page/workspace/roundtable/chains` 或顶栏「返回圆桌」往返;阶段 3 推荐弹窗「去配置」按钮会导航到此。
- **视觉(后续重画)**:本页**不**套圆桌 `roundtable-planning` scope,直接遵循《浅红色/深蓝色 UI 规范》——用 app 语义 token(光 `--primary` 红 hue27 / 暗 蓝 hue259,已内置自适应)+ 规范小圆角(输入/按钮/tag/卡片内项 2px = `R_CTRL`,分栏/面板 4px = `R_CARD`)+ 克制阴影(`PANEL_SHADOW`)。筛选框复用通用 `TagSearchBar`,配色随 app 中性 token,不再被红 scope 染色。
### 阶段 2 · 推荐弹窗「模式选择」外壳(1 天) ✅ 已完成
**目标**:弹窗顶部模式切换,智能推荐行为不变。
- [x] `RecommendAgentsDialog` 顶部加**分段按钮**模式切换:`智能推荐 | 业务链条`(active 态品牌色 + 卡底;DialogDescription 随模式切换文案)。
- [x] 现有全部内容原样收进「智能推荐」面板(`{mode === "recommend" && (<>…</>)}` 包裹,**零行为变更**)。
- [x] 「业务链条」面板放占位:居中提示 + 「去配置业务链条」按钮(`handleGoConfig` → 关弹窗 + 跳 `roundtable/chains`)。
- [x] `onConfirm` 统一升级为回传 `SeatSelectionResult`(含 `mode`):智能推荐确认回传 `{ agents, mode: "recommend" }`;主页面 `handleConfirmAgents` 同步改签名取 `result.agents`(`result.mode` 预留给阶段 4)。
- **验收**:✅ typecheck 0 新增错误(总数仍 76);智能推荐分支 JSX/逻辑原样保留(流式/勾选/进入研讨未改);模式切换只切显示、`state`/`selectedIds` 不卸载 → 候选池不丢。**人工验收待跑**:切到业务链条→再切回,推荐结果与勾选仍在。
### 阶段 3 · 业务链条选择卡片列表(1.5 天) ✅ 已完成
**目标**:弹窗「业务链条」面板里选链条。新增 `components/BusinessChainPicker.tsx`,替换阶段 2 的占位。
- [x] 进入该 Tab 时 `listChains()` 拉本人链条 + `listAgents()` 取存活 id/名称,渲染**卡片列表**:
- [x] 卡片标题(链条 title)+ 「N 席」徽章 + 选中 ✓;描述行。
- [x] 标题下一排**智能体名 tag**:按链条顺序**从左到右**、不同颜色(`getAvatarTypeFor`→`chainTagClasses`),tag 之间插 `→`(`ArrowRightIcon`)箭头连接(`seats[0]` 最左、先发言)。
- [x] 底部「配置链条」按钮(跳 `roundtable/chains`)。
- [x] 空态:无链条时引导「去配置页面创建第一条业务链条」+ 按钮。
- [x] 选中一条 → 高亮(`ring-primary`);底部「进入研讨」`onConfirm({ agents: 存活席位有序, mode:"chain", chain:{id,title} })`。
- [x] 链条里有 agent 已不存在(被删)→ 该 tag 置灰删除线 + `title` 提示 + 卡片下方 amber 警告;confirm 按**存活席位**过滤;存活 < `CHAIN_MIN_SEATS` 则禁用「进入研讨」并提示。agent 列表加载失败时 `existingIds=null` → 不误判删除。
- **验收**:✅ typecheck 0 新增错误(总数仍 76)。卡片渲染标题 + 彩色 tag + ← 箭头;选链 → `onConfirm(mode:"chain")` → `handleConfirmAgents` 按链条顺序 `setSelectedAgents` → Step 2 左侧「参与角色」顺序 = 链条顺序(**按顺序派活在阶段 4**)。**人工 UI 验收待跑**。
- **箭头方向**(已确认):tag 左→右排列、`→`(`ArrowRightIcon`)连接,`seats[0]` 最左、先发言,箭头顺读即链条研讨顺序。
### 阶段 4 · Step 2 链条顺序编排(2–3 天) ✅ 已完成
**目标**:chain 模式按链顺序逐席发言。**后端零改动。**
- [x] 主页面新增 `orchestrationMode` 状态,`handleConfirmAgents` 从 `SeatSelectionResult.mode` 写入(chain 模式 toast 带链名);`handleStartNewTask` 复位为 `recommend`;透传给 `useStep2Orchestration`。chain 顺序 = `selectedAgents`(确认时已按链条顺序写入),无需单独 `chainSeats`。
- [x] `useStep2Orchestration` 新增 `runChainOrchestration`(§6.2)+ `orchestrationModeRef`/`selectedAgentsRef`;复用现有三段式气泡/高亮/abort 基建。
- [x] 新增 `runActiveOrchestration` 分发器:init / 恢复 / 干预 / 澄清回复 4 处调用点统一改走它,按 `orchestrationModeRef` 选 `runOrchestration`(recommend)或 `runChainOrchestration`(chain)。
- [x] `runChainOrchestration`:按 `selectedAgents` 游标,每位先 leader 约束派活(`buildChainLeaderTask` 钉死目标席位 + `pickChainTask` 命中校验/纠偏,派错/未派都兜底推进)→ 再对**该席位**跑 special;中途 leader 若 `clarification` 则暂停(MVP 回复后整链重跑)。
- [x] `initMultiAgent` 的 `agentNames` 即 `roundtableAgentIds`(由 `selectedAgents` 派生,已是链条顺序,§6.5)。
- [x] 收口 leader(`finishedAllSeats` 时触发),产出 `lastLeaderContent` + `hasConsensus=true` + `consensusPercentage=100` 解锁 Step 3。
- **验收**:✅ typecheck 0 新增错误(总数仍 76);✅ 唯一直接引用 `runOrchestration` 的是 dispatcher。逻辑层:每席「总控派活气泡 → 席位交付气泡」、严格顺序、单 special 在跑、`_broadcast` 注入前序上下文、leader 纠偏推进、跑完解锁 Step 3、abort 可中断。**人工 UI 验收待跑**。
- **MVP 边界**:chain 模式的「挂起→断点续跑」「逐席间干预」未做断点续跑,`clarification`/恢复/干预当前为**整链重跑**(`chainCursor` 列为扩展点)。
### 阶段 5 · 草稿持久化兼容 + 收尾(1 天) ✅ 已完成
**目标**:草稿记住模式,回归不破。
- [x] `Step2Snapshot`(`useStep2Orchestration.ts`)+ `DraftStep2Snapshot`(`api/drafts.ts`)新增 `orchestrationMode?: "recommend"|"chain"` 与 `chain?: {id,title}|null`。
- [x] 恢复路径:`orchestrationMode`/`chainMeta` 是**主页面 state**,`getStep2Snapshot` 写入;**包裹版 `hydrateStep2`** 在加载时 `setOrchestrationMode(snapshot.orchestrationMode ?? "recommend")` + `setChainMeta(snapshot.chain ?? null)` 再调 `step2.hydrateFromDraft`;老草稿无字段 → 默认 `recommend`(向后兼容)。`handleStartNewTask` 复位两者。
- [x] 文档回写:`multi-agent-frontend-dev.md` §5.7(快照形状 + `orchestrationMode/chain` 说明)、§6.2(席位来源选择器导语);`multi-agent-backend-dev.md` 新增 §7B(`roundtable_chains` 表/路由)。
- **验收**:✅ `pnpm typecheck` 0 新增错误(总数仍 76)。逻辑:chain 会话保存草稿 → 重载 `orchestrationMode="chain"` + 链信息恢复;老草稿(无字段)默认 recommend 照常加载。**人工 UI 验收 + `pnpm build` 待跑**。
### 工期粗估
| 阶段 | 估时 |
|------|------|
| 0 后端链条表 + 脚手架 | 1.5d |
| 1 配置页 | 2–3d |
| 2 弹窗模式壳 | 1d |
| 3 链条选择卡片 | 1.5d |
| 4 Step2 链编排 | 2–3d |
| 5 持久化兼容收尾 | 1d |
| **合计** | **约 9–11 人日**(含后端 `roundtable_chains` 表/路由) |
---
## 8. 验收清单(端到端)
1. 推荐弹窗顶部有「智能推荐 / 业务链条」切换;智能推荐与现状逐像素一致。
2. 配置页 `/page/workspace/roundtable/chains` 左栏列出智能体、右栏有序长条卡片;左→右拖入指定位、右栏内拖拽排序均生效;2–8 校验生效;保存/重载/删除正常。
3. 业务链条卡片:标题 + 彩色智能体 tag + ← 箭头连接,渲染正确。
4. 选定链条 → Step 2 席位顺序 = 链条顺序,并**按顺序逐个发言**,后序能看到前序交付。
5. chain 跑完解锁 Step 3;草稿记住 chain 模式并可重载。
6. 删除链条中引用的 agent 后,选择端有降级提示,不崩。
---
## 9. 风险与开放问题
| # | 项 | 说明 / 建议 |
|---|----|-------------|
| 1 | 配置页:弹窗 vs 页面 | 本文档定为**独立页面**(§2.1)。若产品要弹窗,编辑器组件可复用,仅换外壳。**请确认。** |
| 2 | 链条持久化 | **已定:后端 MySQL `roundtable_chains`**(§2.2 / §5.2),仿草稿持久化。 |
| 3 | Step 2 链编排 | **已定:总控按链条顺序派活**(§2.4 / §6),后端零改动。 |
| 4 | 新增 `@dnd-kit` 依赖 | 若禁止新增依赖,退原生 HTML5 DnD(手感与可达性下降)。**请确认。** |
| 5 | ← 箭头方向 / tag 阅读顺序 | 默认 `seats[0]` 在最左、先发言;箭头仅作视觉连接。**请确认是否要「从右往左读」。** |
| 6 | chain 模式是否需要收口 leader 总结 | 默认需要(产出 Step 3 结论)。若末位席位自带总结可关掉。 |
| 7 | leader 派活纠偏策略 | leader 派错/未派时前端兜底推进游标(§6.2 `pickChainTask`);可加 toast 提示「已按链条强制推进」。 |
| 8 | chain 模式断点续跑 / 逐席干预 | MVP 不做,列为扩展点(§6.3 / §7 阶段 4)。 |
| 9 | 链条是否私有 | 本期仅本人私有(仿草稿按 `user_id` 隔离)。共享/市场留扩展。 |
---
## 10. 路径速查(新增/改动)
| 资源 | 路径 | 状态 |
|------|------|------|
| 链条类型 | `frontend-web/src/roundtable-planning/lib/business-chain.ts` | 新增 |
| 链条 API 客户端 | `frontend-web/src/roundtable-planning/api/chains.ts` | 新增 |
| 配置页 | `frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx` | 新增 |
| 链条选择面板 | `frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx` | 新增 |
| 链条卡片 | `frontend-web/src/roundtable-planning/components/BusinessChainCard.tsx` | 新增 |
| 推荐弹窗(加模式壳) | `frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx` | 改 |
| 主页面(mode 透传) | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 改 |
| Step2 编排(加 chain) | `frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts` | 改 |
| 草稿快照(加 mode) | `hooks/useDraftPersistence.ts` + `api/drafts.ts` | 改 |
| 路由注册 | `frontend-web/src/pages/WorkspaceRoutes.tsx` | 改 |
| 后端链条持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/` | 新增 |
| 后端链条路由 | `offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py` | 新增 |
| 后端 store 接线 | `offline-backend-20260512/backend/app/gateway/deps.py`(`app.state.roundtable_chain_store`)+ `app.py` | 改 |
</content>
</invoke>
@@ -1,781 +0,0 @@
# 多智能体圆桌研讨 · 前端开发文档
本文档面向维护 `frontend-web` 圆桌规划功能的开发者。后端实现见 `multi-agent-backend-dev.md`;HTTP 字段速查见 `multi-agent-api.md`;推荐弹窗专项进度见 `recommend-agents-progress.md`(若不存在则为待补文件)。
> ⚠️ 历史版本曾引用 `multi-agent-step1-api.md`、`multi-agent-step3-api.md`,当前仓库未提供这两份文件;Step 1(intent)/ Step 3(artifacts)的接口签名直接参考本页第 4 节即可。
---
## 0. 速读:你需要先知道的事
- **三步 UI 通过 `currentStep ∈ {1,2,3}` 切换**,不使用 React Router 子路由。主页面 `RoundtablePlanningPage.tsx`(≈ 1 150 行)只负责组合 + 编排,业务状态全部下沉到三个 hook(见下面)。
- **业务状态分三块**:`useStep1Intent`(任务理解 + 澄清)、`useStep2Orchestration`(编排循环 + 对话流 + Persona)、`useDraftPersistence`(草稿)。改 Step X 的逻辑请直接进对应 hook,不要回到主页面里写。
- **消息气泡有共享件**:Step 1 / Step 2 的对话气泡共用 `MessageBubble.tsx` 里的 `MessageBubble` + `MessageLoadingPlaceholder` + `StreamingCursor` + `MarkdownContent`。改气泡通用样式(头像、header、loading、流式 cursor、Markdown 渲染配置)请改这里,不要在两个 Panel 各改一遍。
- **步骤折叠卡与主聊天对齐**:思考过程 / 工具调用展示走 `MessageStepsCard`,**always-show-last-tool + 折叠 "查看其他 N 个步骤"** 的视觉与主聊天 `chats/new` 的 `MessageGroup` 一致。工具图标 / 文案映射在 `lib/step-display.tsx`,新增工具时与 `src/core/i18n/locales/zh-CN.ts` 的 `toolCalls` 字段对齐。
- **`<think>` 标签自动提取为「思考过程」**:`lib/reasoning.ts::splitInlineReasoning(text)` 把 `<think>...</think>`(含未闭合的流式中段)抽离成 `reasoning`,正文走 markdown;由 `MessageBubble.tsx::ReasoningBlock` 渲染一个折叠卡。Step 1 / Step 2 气泡和推荐弹窗的 rationale 都走这一套,与主聊天 `Reasoning` 元素的视觉对齐。
- **不使用 LangGraph SDK**:所有 API 都是自研的 `apiFetch` + `ReadableStream` SSE 解析(见 `api/intent.ts`、`api/recommend.ts`、`api/multi-agent.ts`)。
- **草稿/历史持久化在后端(2026-06)**:草稿不再存浏览器 `localStorage`,改走后端 MySQL(`/api/roundtable-drafts`,按登录用户隔离、跨设备保留)。`api/drafts.ts` 封装 CRUD,`useDraftPersistence` 异步对接、对外接口不变。**推荐弹窗带「会话级推荐历史」**:每个草稿一串历史,重开弹窗先显示上次推荐结果,可「重新分析」重跑(详见 §5.7 / §6.2)。后端实现见 backend doc 的「圆桌草稿持久化」一节。
- **不写死模型名**:与 `/page/canvas/ai-writing` 同样的策略 —— 全局选中模型由 `selectedModel` state 维护,首次 `useModels()` 完成后初始化为 `availableModels[0].name`,顶栏「模型」下拉可切换。所有 API 调用统一传 `model: selectedModel || undefined`;后端缺省由 `lead_agent._resolve_model_name()` 回退到 `config.yaml` 的 `models[0]`。**内网部署只需在 `config.yaml` 配自家模型即可,前后端均无需改动**。
- **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。
- **协调智能体单例(2026-05)**:`COORDINATOR_AGENT_ID = "roundtable-coordinator"` 是固定常量,不再每会话生成新 id。后端 seeder 自动落地,「本次席位列表」改由后端通过 thread 历史 human 消息注入,前端无感。`newCoordinatorName()` 仅作为兼容性壳保留。
- **总控编排循环上限**:`MAX_CYCLES = 8`,超出后强制停止并 toast。
- **席位派活策略**:**顺序**(不是并行)。旧实现并行触发多路 SSE 会让浏览器卡顿,现已改为顺序。
- **调试开关**:`window.__MULTI_AGENT_INIT_TIMING__` 与 `window.__MULTI_AGENT_DEBUG__`(**不是** `__MULTI_AGENT_STREAM_DEBUG__`,文档历史版本有误)。
---
## 1. 功能定位
**页面路由**:`/page/workspace/roundtable/planning`(侧栏「圆桌规划」)
**入口组件**:`src/roundtable-planning/pages/RoundtablePlanningPage.tsx`
**核心能力**:
| 步骤 | 用户目标 | 真实后端对接 |
|------|----------|--------------|
| 1 | 与「任务理解智能体」多轮澄清,得到结构化意图 | `intent.ts` |
| 1→2 | 从候选池选 2–8 个研讨席位 | `recommend.ts` + `RecommendAgentsDialog` |
| 2 | 总控派活、子智能体顺序交付、可澄清/挂起 | `multi-agent.ts` + 编排循环 |
| 3 | 查看总控结论、席位交付、产出文件下载 | `HighFidelityReport` + `listSessionArtifacts` |
**步骤门禁**:
- **Step 2**:`intentReady` 非空才可点击(顶栏按钮禁用 + toast 提示)。
- **Step 3**:`hasConsensus === true` 才可点击。Leader 派活返回空数组才会自然收敛触发 `hasConsensus`,手动跳转的「达成共识生成全案成果」按钮也会校验。
---
## 2. 目录结构
```
frontend-web/src/roundtable-planning/
├── index.ts # 导出 RoundtablePlanningPage、HighFidelityReport
├── pages/
│ └── RoundtablePlanningPage.tsx # 主页面:组合 hook + 顶栏/侧栏 + 三步路由(≈1 150 行)
├── api/
│ ├── intent.ts # Step 1:initIntent、streamIntent
│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent;**+ 推荐历史 listRecommendHistory / saveRecommendHistory**
│ ├── drafts.ts # **草稿后端 CRUD**:listDrafts / getDraft / createDraft / updateDraft / deleteDraft / deriveDraftName(替代旧 localStorage utils/drafts.ts)
│ └── multi-agent.ts # Step 2/3:initMultiAgent、streamMultiAgent、listSessionArtifacts、buildArtifactDownloadUrl
├── components/
│ ├── Avatars.tsx # 所有 SVG 头像 + getAvatar 选择器
│ ├── MessageBubble.tsx # Step 1/2 共享:消息壳 + loading 占位 + 流式 cursor + MD 配置
│ ├── MessageStepsCard.tsx # Step 1/2 共享:工具调用 + 思考过程的折叠卡(对齐主聊天 MessageGroup)
│ ├── Step1Panel.tsx # Step 1 对话面板(不含状态,只读 props)
│ ├── Step2Panel.tsx # Step 2 主面板(圆桌对话流 + 澄清条 + 干预输入)
│ ├── Step3Panel.tsx # Step 3 薄壳,内部仍是 HighFidelityReport
│ ├── PersonaDrawer.tsx # Step 2 Persona 抽屉(System Prompt / Temp / Model)
│ ├── IntentClarificationCard.tsx # Step 1 选项卡片(单选/多选)+ formatIntentClarificationAnswer
│ ├── RecommendAgentsDialog.tsx # Step 1→2 推荐弹窗(含流式 rationale + 候选筛选)
│ ├── HighFidelityReport.tsx # Step 3 报告 + 文件清单
│ ├── ArtifactPreviewModal.tsx # Markdown/纯文本/JSON/CSV/YAML 等文本类文件 in-app 预览
│ └── ScrollToBottomButton.tsx # 对话区「回到底部」浮动按钮
├── hooks/
│ ├── useAutoScroll.ts # 滚动跟随 + 用户上滑暂停跟随
│ ├── useStep1Intent.ts # Step 1 状态机:init、send、stream、abort、INTENT_READY 同步
│ ├── useStep2Orchestration.ts # Step 2 状态机:initMultiAgent + 编排循环 + 对话流管理 + Persona
│ └── useDraftPersistence.ts # 草稿(后端版):异步走 api/drafts;自动保存(前进切换)/手动保存/加载/重命名/删除/ensureDraft
├── lib/
│ ├── clarification.ts # resolveAllowMultiple(单选/多选解析),与主聊天 ClarificationCard 对齐
│ ├── reasoning.ts # splitInlineReasoning:从模型流式文本里提取 `<think>...</think>` 思考过程
│ ├── roundtable-constants.ts # 类型(RoleEntry / SelectedAgent) + 常量 + 纯工具函数(buildRoleTemplates / splitIntentReadyBlock 等)
│ └── step-display.tsx # Step 2 ChainOfThought 折叠卡图标 + 文案 + 子节点渲染
├── utils/ # (旧 drafts.ts 已删除 —— 草稿改走后端 api/drafts.ts)
└── styles/
└── roundtable-planning.css # 页面主题变量(浅红品牌色 + 深色覆盖)
```
**模块依赖方向(自上而下)**:
```
pages/RoundtablePlanningPage
├─ uses → hooks/useStep1Intent
├─ uses → hooks/useStep2Orchestration
├─ uses → hooks/useDraftPersistence (依赖 step1 / step2 的 getSnapshot + hydrate)
├─ uses → components/Step{1,2,3}Panel (纯 props 驱动)
├─ uses → components/RecommendAgentsDialog
└─ uses → components/Avatars
hooks/useStep1Intent → api/intent
hooks/useStep2Orchestration → api/multi-agent + components/PersonaDrawer 的类型
hooks/useDraftPersistence → utils/drafts + step1/step2 的 Snapshot 类型
```
主页面通过 ref 桥接两个 hook 之间的依赖(`buildInitialIntent` 在 Step 2 init 时从 Step 1 状态读取),避免 hook 互相引用造成的循环导入。
**路由注册**(`src/pages/WorkspaceRoutes.tsx`):
```tsx
<Route path="roundtable/planning" element={<WorkspaceLayout><RoundtablePlanningPage /></WorkspaceLayout>} />
```
侧栏入口:`src/components/page-sidebar.tsx` → `/page/roundtable/planning`。
**环境变量**(由 `scripts/start-frontend.sh` 注入):
- `VITE_BACKEND_BASE_URL` → `getBackendBaseURL()`,所有 API 前缀
- `VITE_LANGGRAPH_BASE_URL` → 本功能**不**直接用 LangGraph SDK,全部走 Gateway 封装
---
## 3. 三步数据流(前端视角)
```
[进入 Step 1]
useEffect → runStep1Init()
initIntent(model) → { agent_id, thread_id }
setIntentThreadId、setTimelineEntries
interactiveMessages seed 欢迎语(本地 seed,非 API)
[用户发送 / 选项卡片提交]
sendIntentMessage(text)
追加 user 气泡 + agent 占位(streaming + phase)
streamIntent({ onTextDelta, onPhaseChange, signal })
终态:
done + summary → setIntentReady,解锁 Step 2 / 推荐弹窗
clarification → IntentClarificationCard + pendingIntentClarificationMsgId
asking → 继续对话
error → toast + 红色 (错误) 文案
[点击「进入多智能体研讨」且 intentReady]
setIsRecommendDialogOpen(true)
RecommendAgentsDialog
listAgents() → filterRecommendCandidates()
streamRecommend({ onRationaleUpdate, onPicksUpdate, onPhaseChange })
用户勾选 2–8 个 → onConfirm(agentIds)
handleConfirmAgents
setSelectedAgents(agentIds.map(...))
step2InitRef.current = false // 复位 Step 2 init 闸门
setCurrentStep(2)
[进入 Step 2]
useEffect(step2InitRef 闸门)→ initMultiAgent(SSE)
onSeatReady:右侧席位面板逐个「在线」
onCoordinatorReady / init_done → threadIds + coordinatorName
runOrchestration(buildInitialIntent(), threadIds, coordinatorName)
while !cancelled && cycles<8:
leader stream → 若 clarification:暂停循环,展示 Step 2 底部澄清条
否则 dispatched[] → **顺序** streamMultiAgent(special) → 再 leader 综合…
dispatched.length === 0 → hasConsensus = true,可进 Step 3
[进入 Step 3]
HighFidelityReport
consensusText ← lastLeaderContent
seatDeliveries ← step2RoundtableDialogues.filter(d => d.confidence === "子智能体交付")
Object.entries(threadIds).filter(name !== coordinatorName).map → listSessionArtifacts(threadId)
文本类文件 → ArtifactPreviewModal;其它 → window.open(downloadUrl)
```
---
## 4. API 层
### 4.1 `api/intent.ts` — Step 1
| 函数 | 说明 |
|------|------|
| `initIntent(model?)` | `POST /api/intent/init` → `{ status, agent_id, thread_id }` |
| `streamIntent(req)` | `POST /api/intent/stream`,读 SSE 至终态帧 |
**`StreamIntentRequest` 重要字段**:
- `threadId`:必填,跨多轮对话保持
- `signal`:必填(页面通过 `intentAborterRef` 控制暂停 / 切步取消)
- `onTextDelta(text, messageId)`:每个 AI 文本增量
- `onPhaseChange(phase, toolName?)`:`connecting → connected → streaming|tool_calling`
- **`onClarification(clarification, index)`(2026-06)**:每解析到一条 `ask_clarification` 工具结果帧就回调一次,让前端**实时**逐个渲染澄清卡。任务理解智能体**一轮可能问多个问题**,这是「实时接收显示协助过程」的关键入口。
**`StreamIntentResult`**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `status` | `"asking" \| "clarification" \| "done" \| "error"` | 终态分支 |
| `content` | `string` | 完整 AI 文本(含 `[INTENT_READY]` 块) |
| `summary` | `IntentSummary \| null` | 仅 `done` 有;前端 fallback 也会从 `content` 里用 `splitIntentReadyBlock` 解 |
| `clarification` | `ClarificationPayload \| null` | **第一个**澄清(向后兼容字段) |
| `clarifications` | `ClarificationPayload[]` | **本轮全部澄清问题**(2026-06)。来源是流式 `type:"tool"` 帧而非终态帧——**终态帧只带第 1 个**,多问题必须靠它。每条带 `id`(= `tool_call_id`)供前端按题维护选择。 |
**SSE 解析要点**:
- `event: metadata` → phase `connected`
- `event: messages` + `extractAiTextDelta` → `onTextDelta`
- `event: messages` + `type:"tool"` 且 `name==="ask_clarification"` → `extractClarificationToolMessage` 解出完整澄清(options 已是 `{id,label}`),按 `tool_call_id` 去重后 `onClarification` 实时回调并收进 `result.clarifications`
- tool call 帧(`tool_calls` / `tool_call_chunks` 非空)→ phase `tool_calling`(含 `ask_clarification` 工具名)
- 终态帧识别:`isFinalIntentFrame`(必须有 `status` + `content`);`status==="clarification"` 且**未**收到任何流式 tool 澄清帧时,才用终态帧字段兜底合成单条澄清
**Abort 行为**:
- `sendIntentMessage` 内建 `AbortController`,保存到 `intentAborterRef`
- `stopIntentStream()` 用于「暂停」按钮、`currentStep !== 1` 自动调用、`handleStartNewTask`、`handleLoadDraft`
- 触发 AbortError 时不当作错误:在气泡尾部追加「(已暂停)」并 toast「⏸️ 已暂停任务理解回复」
### 4.2 `api/recommend.ts` — 推荐弹窗
| 函数 | 说明 |
|------|------|
| `filterRecommendCandidates(agents)` | 从 `GET /api/agents` 结果剔除 intent / recommender / `roundtable-coordinator-*` |
| `streamRecommend(req)` | `POST /api/recommend/stream`,无 init(一次性、无状态) |
| `parseRecommendStreamContent(raw)` | 将累计文本拆成 `{ rationale, picks[] }`,供边流边渲染 |
**`EXCLUDED_AGENT_IDS`** 内置常量(仅 `recommend.ts` 内可见):
- `roundtable-intent`
- `roundtable-recommender`
- 任何以 `roundtable-coordinator-` 开头的 agent_id(动态创建的协调器壳)
**流式 UI 拆分**(与主聊天的 `[RECOMMEND_READY]` 分界类似):
- 标记常量:`RECOMMEND_READY_MARKER = "[RECOMMEND_READY]"`
- `parseRecommendStreamContent(raw)`:用 `PICK_OBJECT_PATTERN` 正则增量匹配出已完成的 `{agent_id, reason}` 对象
- `onRationaleUpdate`:仅 marker 之前的自然语言(弹窗顶部展示)
- `onPicksUpdate`:从部分 JSON 增量解析 `picks[]`(卡片边流边出,过滤已见 id)
- 过滤 `TitleMiddleware` 产生的标题增量(`langgraph_node` 含 `TitleMiddleware` 或 `tags` 含 `middleware:title`)
**终态语义**:
- `status === "done"` 且 `picks.length >= 2` → 弹窗用 picks 作为默认勾选
- 否则 → `phase: "fallback"`,使用 `FALLBACK_AGENT_IDS`(6 个内置 roundtable-* 席位)与候选池交集
### 4.3 `api/multi-agent.ts` — Step 2 & Step 3 文件
| 函数 | 说明 |
|------|------|
| `initMultiAgent(payload)` | SSE:`seat_ready`(N 次) → `coordinator_ready` → `init_done` |
| `streamMultiAgent(req)` | leader / special 一轮 |
| `listSessionArtifacts(threadId)` | `GET /api/threads/{id}/artifacts` |
| `buildArtifactDownloadUrl(threadId, virtualPath, { download? })` | 预览或附件下载 URL |
**`initMultiAgent` 事件**(`data:` JSON 的 `event` 字段):
```ts
{ event: "seat_ready", agent_name, thread_id, display_name, description }
{ event: "coordinator_ready", main_agent_name, thread_id }
{ event: "init_done", main_agent_name, thread_ids } // thread_ids: Record<agent_name, thread_id>
{ event: "error", detail }
```
**`streamMultiAgent` 终态**(`isFinalStatusFrame`):
| `status` | 含义 | 前端处理 |
|----------|------|----------|
| `[["agent","task"], ...]` | 派活列表(数组形态) | **顺序**触发 special;最终用 `nextLeaderTask` 综合 |
| `[]` | 共识达成(空数组) | `hasConsensus = true`,跳出循环 |
| `"clarification"` | 总控追问 | `setPendingClarification`,暂停循环 |
| `"子智能体 X 完成..."` | special 完成 | 更新对应对话气泡 confidence |
| `"error"` | 失败 | toast + 红色气泡 |
**为什么 special 是顺序而不是并行**:旧版并行触发多路 SSE,每路都在 token 级别对 `step2RoundtableDialogues` 整表 setState,N 个并行 stream 让浏览器卡顿明显。改为顺序后同一时刻只有一个 SSE 在跑,UX 平滑很多(详见 `RoundtablePlanningPage.tsx` 编排循环里的注释)。
**`buildArtifactDownloadUrl` 路径编码**:将 `virtualPath` 按 `/` 切段后逐段 `encodeURIComponent` 再拼回,避免文件名里的中文 / 空格触发后端 404;`download=true` 加在 query string 上而非 path。
**调试开关**:
```js
window.__MULTI_AGENT_INIT_TIMING__ = true; // 默认开启;init 各阶段耗时(fetch.start / first-byte / seat_ready#n / coordinator_ready / init_done / total)
window.__MULTI_AGENT_DEBUG__ = true; // 默认开启;stream 帧计数 + lastEvent + bad JSON 提示,前缀 [stream:<agentType>:<agentName>]
```
> ⚠️ 文档历史版本曾写作 `__MULTI_AGENT_STREAM_DEBUG__`,**实际代码并不识别**这个名字。
---
## 5. 主页面状态(`RoundtablePlanningPage`)
> **注意**:自 2026-05 重构后,绝大多数业务状态从主页面下沉到 3 个 hook。下面列出的 state 仍按"位置"分组以便对照原代码,实际所有者请看右列。
| 维度 | 所有者 | 暴露给主页面的方式 |
|------|--------|-------------------|
| `currentStep`、`showToast`、`selectedModel`、`roles`、`isRecommendDialogOpen` | 主页面 | useState |
| Step 1 全部 state + handler | `useStep1Intent` | hook 返回值 |
| Step 2 全部 state + handler + 编排循环 | `useStep2Orchestration` | hook 返回值 |
| 草稿(drafts / currentDraftId / 重命名 / 历史下拉) | `useDraftPersistence` | hook 返回值 |
| `loadedFromDraftRef` 共享 latch | `useDraftPersistence` 拥有,Step 2 init effect 通过 options 读 | hook 间共享 ref |
### 5.1 步骤与门禁
- `currentStep`: `1 | 2 | 3`
- **Step 2 锁定**:`intentReady == null` 时顶栏 Step 2 按钮禁用(visual + `aria-disabled`),同时点击「进入多智能体研讨」会 toast 「⚠️ 请先与任务理解智能体完成澄清…」
- **Step 3**:`hasConsensus === false` 时顶栏 Step 3 与底部「达成共识生成全案成果」按钮均禁用
### 5.2 Step 1 关键 state
| 状态 | 用途 |
|------|------|
| `intentThreadId` | `/api/intent/init` 返回的线程 id;未就绪前发送按钮置灰 |
| `intentReady` | `IntentSummary`,驱动 Step 2 初始 prompt + 右侧摘要面板 + 顶栏 step2 解锁 |
| `interactiveMessages` | 问答气泡列表(user / agent,含 streaming/phase/clarification 元数据) |
| `pendingIntentClarificationMsgId` | 当前可操作的 agent 消息 id(保证只有最新一条 clarification 展示可点卡片) |
| `intentClarificationSelections` | `Map<msgId, {selectedIds, custom}>`,多选 / 多消息时各自维护 |
| `isIntentSending` | 发送中(屏蔽双发 / 显示「暂停」按钮) |
| `intentAborterRef` | 当前 stream 的 AbortController |
| `intentInitError` | init 失败信息;驱动右侧角色面板「异常」状态与红色提示 |
| `step1InitRef` | 闸门:true 即认为已 init 过 |
| `step1InitGenRef` | **单调递增**的代数标记,详见下文 |
| `timelineEntries` | 右侧「澄清记录」时间线(init / user 反馈 / 任务意图已明确) |
**`step1InitGenRef` 的存在原因**:
React 18 StrictMode 开发环境下,effect 会 mount → cleanup → mount 两次。早期实现用闭包 `cancelled` 标志做防抖,结果是:第二次 mount 因 `step1InitRef.current === true` 早返回,但第一次 mount 的 async 仍未完成;当它最终 resolve 时,`cancelled` 已被 cleanup 置 true,因此 `setIntentThreadId` 被跳过,右栏卡在「正在上线」。改为单调代数后:每次 `runStep1Init` 自增 `step1InitGenRef`;async 完成时检查 `myGen === step1InitGenRef.current`,等价于「我是当前最新一次 init」,否则丢弃结果。这也覆盖了「新建任务 / 加载草稿期间旧 init 晚归」的场景。
**`splitIntentReadyBlock(text)`**(页面内工具函数,使用 `INTENT_READY_RE` 正则):
- 从气泡正文解析 `[INTENT_READY]\n```json\n{...}\n``` ` 块
- 返回 `{ before, summary, after }`,前后文与结构化卡片分别渲染
- 单独的 `useEffect` 监听 `interactiveMessages`:若已存在含 `[INTENT_READY]` 的 agent 消息但 `intentReady` 仍为 null(极少数终态帧丢 summary 的场景),主动 `setIntentReady`,与后端 "READY 优先" 对齐
### 5.3 推荐与 Step 2 席位
| 状态 | 用途 |
|------|------|
| `selectedAgents` | `{ agent_id, name }[]`,默认 `DEFAULT_SELECTED_AGENTS`(6 内置) |
| `roleTemplates` / `roles` | 右侧席位面板(`buildRoleTemplates`,commander 占 id=1,agents 从 id=2 开始) |
| `agentNameToRole` | 流式输出时根据 `agent_name`(小写)反查 speakerId、avatarType、displayName |
| `roundtableAgentIds` | `selectedAgents.map(a => a.agent_id)`,传给 `/api/multi-agent/init` 的 `agent_names` |
| `threadIds` / `coordinatorName` | `/api/multi-agent/init` 结果;离开 Step 2 **不**清空,允许回来继续看 |
| `step2RoundtableDialogues` | Step 2 对话流(含 `streaming` / `confidence` / `time` / `avatarType`) |
| `lastLeaderContent` / `hasConsensus` | Step 3 总控结论 + 共识标记 |
| `pendingClarification` | Step 2 总控澄清(复用 `ClarificationPayload`) |
| `clarificationDraft` | 底部自定义回答输入框(受 `allowCustom` 控制) |
| `isRoundTableRunning` | 用户挂起 / 恢复协商的 UI 开关 |
| `isOrchestrating` | 编排循环正在 run(屏蔽并发触发) |
| `orchestrationCancelRef` | 与 `abortAllInFlight` 配合中断当前循环 |
| `activeAbortersRef` | 当前 Step 2 所有 in-flight stream 的 AbortController 集合 |
| `activeSpeakerId` / `activeSpeakerSet` | 圆桌可视化高亮:单 leader 时用单值,多席位并行时用 Set |
| `selectedAgentId` | 当前 Persona 抽屉所在的席位 id(点击圆桌头像打开) |
| `agentConfigs` | `Map<seatId, {model, weight, prompt, temp}>` Persona 覆盖;下一轮该席位的派活会用此 model |
| `isUpperPanelCollapsed` | 是否折叠圆桌可视化 + 调控台,给对话区让出垂直空间 |
**协调智能体命名(2026-05 改造)**:现在是**固定单例 id** `roundtable-coordinator`(见 `lib/roundtable-constants.ts::COORDINATOR_AGENT_ID`)。`newCoordinatorName()` 函数为兼容性保留,内部直接返回这个常量字符串。后端 `ensure_roundtable_functional_agents()` 缺失自动落地,不再每次新建。`filterRecommendCandidates` 在 `EXCLUDED_AGENT_IDS` 集合里同时排除:
- `roundtable-coordinator`(新版单例 id,永远过滤);
- `roundtable-coordinator-*` 前缀(历史残留,管理员清理之前先在 UI 隐藏)。
> 历史草稿兼容性:用户加载 2026-05 前保存的草稿时,存储的 `coordinatorName` 仍是 `roundtable-coordinator-<ts36>` 形式。只要那个旧 agent 目录还在磁盘上(即运维没手动删过),加载后继续在 Step 2 干活就能正常工作;如果旧 agent 被清理掉了,需要让用户「重新起草」从头跑一遍 Step 1 → Step 2。
### 5.4 编排循环 `runOrchestration(seedMessage, ids, coord)`
伪代码:
```
isOrchestrating = true
orchestrationCancelRef = false
nextLeaderTask = seedMessage
cycles = 0
MAX_CYCLES = 8
while !cancelled && cycles < MAX_CYCLES:
cycles += 1
highlight(speakerId=2) // 总控
leaderBubble = appendStreamingDialogue("总控协调", confidence: "调度中")
res = streamMultiAgent(leader, ids, coord, nextLeaderTask, onTextDelta, onPhaseChange)
if res.clarification:
finalize(leaderBubble, content, "等待用户澄清")
setPendingClarification(res.clarification)
break // 等用户回复后由 submitClarification 重启循环
finalize(leaderBubble, content, dispatched.length === 0 ? "全案盖印" : "派活说明")
if res.content: setLastLeaderContent(res.content)
if res.dispatched.length === 0:
setHasConsensus(true); setConsensusPercentage(100); break
for [subName, task] of res.dispatched: // **顺序**,不是并行
meta = agentNameToRole[subName.toLowerCase()]
markSpeakerStreaming(meta.speakerId)
perSeatModel = agentConfigs[meta.speakerId]?.model || DEFAULT_MODEL
subBubble = appendStreamingDialogue(meta.displayName, confidence: "调度中")
specRes = streamMultiAgent(special, ..., perSeatModel, signal=subAborter)
finalize(subBubble, specRes.content, "子智能体交付")
markSpeakerIdle(meta.speakerId)
if cancelled: break
setConsensusPercentage(prev => min(95, prev + 8))
nextLeaderTask = "各子智能体已就上一轮派活完成交付,请你结合最新结论..."
if cycles >= MAX_CYCLES:
toast("⏱️ 已达到最大研讨轮次,自动停止以避免循环")
clearAllStreamingSpeakers()
isOrchestrating = false
```
要点:
- **`buildInitialIntent()`**:
- 有 `intentReady` → 拼装 `objective / 约束条件 / 关键假设` + 「不要再反问用户」终结语
- 无 `intentReady`(用户跳过澄清 / 顶栏直接切到 Step 2)→ 拼接 Step 1 内 `interactiveMessages` 里 sender==='user' 的内容,逐行 `用户#N: ...`,**绝不**注入 mock 的「预算 500 万 / 72 小时」数字
- 用户输入完全为空 → 让总控先调用 `ask_clarification` 而不是凭空派活
- **用户「干预研审」**:`handleInjectToRoundtable` 追加一个「💡 (人工指令介入) ...」气泡,然后构造 `runOrchestration("用户人工干预新指令:...", ...)`
- **挂起 / 恢复**:
- 挂起 = `setIsRoundTableRunning(false)` → `stopOrchestration()` → `orchestrationCancelRef = true` + `abortAllInFlight()`
- 恢复 = `setIsRoundTableRunning(true)`,effect 触发 `runOrchestration("用户已恢复研讨,请继续上一轮的协调工作。", ...)`
- **澄清恢复**:`submitClarification` 把用户回答拼到 prompt 里再 `runOrchestration`,不会重建 thread
### 5.5 Persona 抽屉(Step 2 圆桌可视化点击)
- 点击圆桌头像 → `setSelectedAgentId(seatId)` → 在上半部分下方展开抽屉
- 抽屉控件:
- **System Prompt**:仅前端记录(后端覆写未开放)
- **Temp**:仅前端记录
- **推理模型**:选项来自 `useModels()`;显示值优先级 `agentConfigs[seat]?.model > selectedModel`
- 「参数覆盖并重启动算研讨」按钮 → `handleSaveAgentAndRecalculate`:
- 把新的 `(seatId, model)` 写入 `agentConfigs`
- 追加一条「⚡ [模型偏好已保存] ...」对话气泡
- **不**立即重启编排;下一轮该席位被派活时 `runOrchestration` 内会读 `agentConfigs[meta.speakerId]?.model || selectedModel || undefined` 作为 special 调用的 `model` 参数
### 5.6 全局模型选择器(右侧 sidebar 顶部)
- **位置**:右侧 sidebar `currentStep !== 3` 时显示的第一个面板 —— 在「当前意图摘要」**正上方**。Step 3 时整个 sidebar 切换成「最终共识结论 / 风险与预算摘要 / 导出与交付」,所以模型选择器仅在 Step 1 / Step 2 可见(此时也是唯一会发起新模型调用的阶段)
- **state**:`const [selectedModel, setSelectedModel] = useState<string>("")`
- **初始化**:`useEffect(() => ...)` 在 `availableModels` 首次返回后把 `selectedModel` 设为 `availableModels[0].name`;后端配置变化导致当前选中 model 不在列表里时也回退到首项
- **传参规则**:所有 5 个 API 调用点(`initIntent` / `streamIntent` / `streamRecommend` / `initMultiAgent` / `streamMultiAgent` leader / `streamMultiAgent` special)都传 `selectedModel || undefined`;special 还会先看 `agentConfigs[seat]?.model` per-seat 覆盖
- **禁用态**:`availableModels.length === 0` 或 `modelsLoading` → `disabled`,placeholder 显示「加载中…」或「未配置模型」
- **可改换为 Radix Select**:当前用原生 `<select>` 与 Persona 抽屉保持一致;如需统一样式可改为 `@/components/ui/select`,行为不变
### 5.7 草稿持久化(`api/drafts.ts` + 后端 MySQL,2026-06 改造)
> **重大变更**:草稿从浏览器 `localStorage` 迁到**后端 MySQL**,按登录用户隔离、跨设备/会话保留。旧 `utils/drafts.ts` 已删除;`useDraftPersistence` 仍保持对外接口不变(列表项含 `name`,由后端 `title` 映射而来),主页面无需改动。后端表/路由见 backend doc「圆桌草稿持久化」。
- **API 客户端 `api/drafts.ts`**:`listDrafts()`(轻量元数据)/ `getDraft(id)`(全量 step1+step2)/ `createDraft` / `updateDraft`(partial)/ `deleteDraft` / `deriveDraftName`。snake_case↔camelCase 映射在这一层收口(后端 `furthest_step/created_at/updated_at` ↔ 前端 `furthestStep/...`)。
- **快照形状**:`DraftStep1Snapshot.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`DraftStep2Snapshot.{selectedAgents, threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit, seatStubMessages, orchestrationMode?, chain?}`;以 JSON 整存进后端 `step1`/`step2`(`PortableLongText`/LONGTEXT,避免大对话超 64KB)。
- **`selectedAgents`(2026-06 修复)**:本次研讨实际选中的席位。**不存的话加载草稿后 Step 2/3 左侧「参与角色」会回退到 `DEFAULT_SELECTED_AGENTS`(默认 6 个)而非当时真正选的那几个** —— `roleTemplates`/`agentNameToRole` 都由 `selectedAgents` 派生。`useStep2Orchestration.hydrateFromDraft` 在该字段非空时 `setSelectedAgents`,老草稿(无此字段)保持当前值不强行覆盖。
- **`orchestrationMode?` / `chain?`(2026-06 业务链条)**:Step 2 编排模式(`recommend`=总控自由派活 / `chain`=按业务链条顺序派活)+ 来源链条 `{id,title}`。是**主页面 state**,由页面在 `getStep2Snapshot` 写入、在**包裹版 `hydrateStep2`** 里读出后 `setOrchestrationMode`/`setChainMeta`(step2 hook 的 `hydrateFromDraft` 不直接消费)。老草稿无字段 → 默认 `recommend`。业务链条完整设计见 `multi-agent-business-chain-dev.md`。
- `deriveDraftName({intentReady, interactiveMessages})`:优先 `intentReady.objective`,否则第一个 user 消息,否则 `未命名草稿 <时间>`,截断 40 字。**仅在 create 时写一次**(见下方 bug 修复)。
- **列表加载**:挂载时 `useEffect` 调 `listDrafts()`;增删改后 `refreshList()` 重拉。
- **自动保存策略**:仅在「前进」步骤切换时保存(1→2、2→3),后退不触发。
- **手动保存 / `ensureDraft`**:`handleManualSave` 走同一条保存队列;`ensureDraft(furthestStep?)` 幂等保证后端有一条对应草稿并返回 id(**推荐弹窗打开前调用**,让推荐历史有 `draft_id` 归属)。
- **加载草稿 `handleLoadDraft`**:
- 走 `getDraft(id)` **实时拉服务端最新全量**(不再读内存里可能过期的列表快照);404 → toast「草稿已不存在」并刷新列表。
- **先**置 `loadedFromDraftRef.current = true`,再 `setCurrentStep(draft.furthestStep)`(顺序不能反)。
- `setStep2InitGate(draft.furthestStep >= 2)`:曾走到 Step 2 才跳过自动 init。
- 角色面板基线:被恢复时全部「在线」,否则 init 流程逐个「在线」。
**本次随手修复的 4 个旧 localStorage 版 bug**(对接后端时一并解决):
1. **自动保存覆盖手动重命名** —— 现在所有 `updateDraft` 一律**不带 title**;标题只由 create(派生)与显式「重命名」改写。
2. **快速连续前进(1→2→3)产生重复草稿** —— 所有保存经一条**串行队列**(`saveQueueRef`)+ 同步镜像 `currentDraftIdRef`,create 完成即写 id,后续保存读到 id 走 update 而非再 create。
3. **加载读到内存旧列表快照** —— 改为 `getDraft(id)` 实时拉。
4. **localStorage 配额静默写失败 / 数据丢失** —— 迁后端后不复存在。
---
## 6. 组件说明
### 6.1 `IntentClarificationCard`
- Props:`payload`(`ClarificationPayload`)、`selection`、`onSubmit(overrideSelection?)`、`disabled`、`submitting`、`fallbackContent`、**`deferSubmit?`(2026-06)**
- **单选**(默认 `allowMultiple === false`):`resolveAllowMultiple` 返回 false → 点击选项即 `onSubmit({selectedIds:[id], custom: ""})`,UI 不显示「发送回答」按钮(除非 `allowCustom`)
- **多选**:勾选多个 + 输入补充,再点「发送回答」;答案格式见 `formatIntentClarificationAnswer`:
- 仅选项 → `我选择:A、B`
- 仅自定义 → 直接发自定义文本
- 同时存在 → `我选择:A、B;补充:…`
- **单问题 vs 多问题的判断(2026-06)**:`Step1Message` 按 `clarifications.length` 分流,**默认单选**(后端 `allow_multiple` 缺省 false,`resolveAllowMultiple` 兜底):
- **单问题(最常见)**:`deferSubmit={false}`,沿用原交互 —— 单选**点击选项即发送**;多选 / 允许自定义则走卡片自带的「发送回答」按钮。**不**显示合并按钮。
- **多问题**:`deferSubmit={true}` —— 连单选点击也只**记录**选择(不立即发送),卡片自身隐藏按钮;父组件在卡片下方渲染**一个**「发送全部回答」按钮,统一提交。
- 该判断纯在前端(不动工具/后端):依据 `clarifications.length` + `allow_multiple`,后端 `ask_clarification` 本就默认单选。
- **合并提交逻辑**:`submitIntentClarifications(msgId, clarifications, overrideByKey?)` 逐题取选择、格式化、拼成一条消息(多题带 `1. <问题>\n答:<答案>` 编号;单题直接发答案;至少答一题才发)。`overrideByKey` 供「单题单选点击即发送」用——此时选择还没写进 state map(setState 异步),把即时选择按 `clarificationKey` 传进去直接用。
- **逐题选择隔离**:选择 Map 改为 `Map<string, IntentClarificationSelection>`,key 由 `clarificationKey(msgId, clar.id, index)` 生成(`clar.id` = `tool_call_id`,缺省退化为 `idx-N`)。
- 展示条件:`isAgent && clarifications.length>0 && !intentReady && !parts.summary`;**流式期也展示**(卡片 `disabled`,只读,体现「协助过程」实时出现),仅当终态且 `pendingIntentClarificationMsgId === msg.id && !streaming` 时才可交互。出现 `[INTENT_READY]` / summary 后隐藏。
- **步骤链强化**:`onClarification` 同时往 `msg.steps` 追加一条 `追问 N:<问题>`,让 `MessageStepsCard` 把每个 `ask_clarification` 调用显示成独立步骤。
### 6.2 `RecommendAgentsDialog`
> **席位来源选择器(2026-06 业务链条)**:弹窗顶部加了**模式分段切换** `智能推荐 | 业务链条`。智能推荐 = 本节描述的现状(零行为变更,整体收进 `{mode==="recommend"}` 分支);业务链条 = `BusinessChainPicker`(选一条已配置链条,卡片标题 + 彩色席位 tag + `→` 箭头)。`onConfirm` 统一回传 `SeatSelectionResult{ agents, mode, chain? }`;`mode==="chain"` 时 Step 2 由总控**按链条顺序派活**(`useStep2Orchestration.runChainOrchestration`)。配置页与编排详见 `multi-agent-business-chain-dev.md`。
- **新增 `draftId` prop(2026-06)**:本次推荐归属的草稿/会话 id。由主页面在打开弹窗前 `await drafts.ensureDraft(1)` 拿到并传入(见 §6.6)。`null` 时退化为「直接推荐、不存历史」的旧行为。
- 打开时管线(`start(forceFresh=false)`):`listAgents() → filterRecommendCandidates()` → **若 `draftId` 有历史 → `listRecommendHistory()` 取最近一次直接展示(不重跑模型)**;否则 `streamRecommend()` 跑新推荐。
- 阶段 UI(`InternalState.phase`):
- `idle` → `loading_agents` → **`loading_history`** → **`history`**(展示上次推荐,可直接进入研讨或「重新分析」)
- 或 `loading_agents` → `recommending`(顶部「模型生成内容」流式;推荐卡 streaming=true)→ `ready` / `fallback` / `error`
- **推荐历史(会话级)**:
- 打开时若该 `draftId` 有历史,进入 `history` 态:蓝色提示条标注上次推荐时间,picks 默认勾选、rationale 回显,**不**自动重跑模型。
- 「**重新分析**」按钮(`history` 态显示该文案,其它态为「重新推荐」)→ `start(true)` 强制跑新推荐,跳过历史。
- 每次**新推荐**完成(`ready`/`fallback`)后 `saveRecommendHistory(draftId, {objective,status,model,rationale,picks,candidates})` best-effort 追加一条(失败仅告警不阻断)。
- `history`/`fallback`/`ready` 三态都允许 `onConfirm`(用户可直接采纳历史 picks)。
- **布局分两段**:
- **顶部「模型生成内容」**:`RecommenderOutput` —— 状态行(`spinner + 调度推荐 / 已连接 / 正在生成`)+ `MessageStepsCard` 步骤卡 + `ReasoningBlock`(从 `<think>` 拆出来)+ markdown rationale + 末尾 `StreamingCursor`。视觉与 Step 1/Step 2 气泡的内容区一致,参考主聊天 `chats/new`。
- **底部「推荐参会智能体」**:候选卡片(`AgentCard`),分「推荐参会 / 其他候选」两组。
- 选择约束:`2 ≤ selected ≤ 8`;底部实时显示「至少再选 N 个」「已选 X / 8」
- 失败兜底:候选为空 → error;status==="asking" 或 picks 不足 2 → `fallback`,自动用 `FALLBACK_AGENT_IDS` 与候选池交集勾选
- 反竞态:内部 `generationRef`,每次 `start()` 自增,所有 setState(含历史读取/流式回调)都校验 `myGen === generationRef.current`,避免「重新分析」时旧 stream / 旧历史写脏 state
- `onConfirm(agentIds)` → 父组件 `handleConfirmAgents`:复位 `step2InitRef` 并 `setCurrentStep(2)`,触发 Step 2 init
### 6.3 `HighFidelityReport`(Step 3)
- 输入:`threadIds`、`coordinatorName`、`consensusText`、`hasConsensus`、`seatDeliveries`、`seatNameMap`、`onReset`
- `seatThreadEntries`:过滤掉 `coordinatorName`,给每个 sub-thread 拉一次 `listSessionArtifacts`(`Promise.all` 并行)
- `reloadKey`:「刷新文件清单」按钮自增,触发 effect 重新拉
- **文件预览策略**(`shouldPreviewInModal`):
- 命中 → `ArtifactPreviewModal`(Markdown 用 react-markdown 渲染,其它当 plain text)
- 不命中(pdf / png / html / 二进制)→ `window.open(buildArtifactDownloadUrl)`,交给浏览器原生 viewer
- 命中规则:
- mimeType 是 `text/markdown` / `text/plain` / `text/csv` / `application/json` / `text/*`
- 或文件名后缀属于 `md / markdown / txt / json / csv / log / yml / yaml / xml`
- 各席位最终交付(中段折叠区)数据源:父组件传入的 `seatDeliveries`,按发送者去重(后到的覆盖前者)
- 「重新起草」→ 父组件 `handleStartNewTask`(清空并 `runStep1Init`)
- 「打印 / 另存 PDF」→ `window.print()`,浏览器原生流程
- **Step 3 不显示左侧栏(2026-06)**:`LeftSidebar` 在 `currentStep === 3` 时 `return null`(原「参与与结论贡献」列已移除),右侧栏在 Step !== 1 时本就 `return null`,因此中间 `middle-viewport`(`flex-1`)会自适应撑满整行,报告区获得最大宽度。
### 6.4 `ArtifactPreviewModal`
- 通过 `buildArtifactDownloadUrl(threadId, path)`(不带 `download=true`)GET 文件正文
- Markdown 文件(mime===`text/markdown` 或后缀 `.md/.markdown`)→ react-markdown + remark-gfm
- 其它文本类 → `<pre>` 直出
- 头部「下载」按钮单独走 `download=true` URL,触发浏览器附件下载
- 关闭:Esc / 点击外层遮罩(精确匹配 `e.target === e.currentTarget`,避免内部冒泡误关)
### 6.5 共享 UI 模式与辅助
- **三段式流式气泡**:`appendStreamingDialogue` → `appendToDialogue`(仅追加增量)→ `finalizeDialogue`(设最终 content + confidence + `streaming=false`)。三函数都通过 `id` 匹配,避免数组下标错位
- **Phase 到中文文案**:`phaseLabel(phase, agentType, toolName)`:
- `connecting` → 「调度中」
- `connected` → 「已连接,等待响应」
- `tool_calling` 按 toolName:`agent_orchestration → 正在派活`、`ask_clarification → 正在提问`、`present_files → 正在交付文件`,其它 → 「调用工具:<name>」
- `tool_result` → 「处理工具结果」
- `streaming` → leader 时「正在派活说明」、special 时「正在输出」
- Step 1 的 `intentPhaseLabel`:`ask_clarification → 正在生成追问`、`streaming → 正在思考问题`
- **Markdown 渲染**:Step 1 / 2 / 3 都用 `react-markdown` + `remark-gfm`,自定义紧凑间距的 `components`(`p` 用 `my-1 whitespace-pre-wrap` 等)
- **自动滚动**:`useAutoScroll(ref, [list])` 跟随;用户上滑后停止跟随;`ScrollToBottomButton` 一键回底
- **`activeSpeakerSet`**:圆桌可视化里多席位同时高亮需要 Set,单 leader 时仍可用 `activeSpeakerId`;两者均触发 ring 动画
- **状态条颜色**:`研讨中` 红 / `在线` 绿 / `已完成` 绿勾 / `正在上线` 黄旋 / 其它灰
---
## 7. 澄清交互(与主聊天对齐)
| 能力 | 实现位置 |
|------|----------|
| 结构化 payload 类型 | `multi-agent.ts` → `ClarificationPayload`(intent.ts 复用) |
| 单选/多选 | `lib/clarification.ts` → `resolveAllowMultiple`(缺省单选) |
| 答案拼接 | `formatIntentClarificationAnswer`(`IntentClarificationCard`) |
| Step 1 卡片 | `IntentClarificationCard` + `submitIntentClarification` |
| Step 2 卡片 | 页面底部 `pendingClarification` 区块 + `submitClarification` → 再 `runOrchestration` |
| 主聊天参考 | `src/components/workspace/messages/message-list.tsx` → `ClarificationCard` |
后端字段 `allow_multiple` 缺省为 **false**(单选、点击即发送)。
**关键差异**:
- Step 1 用 `IntentClarificationCard` 组件(嵌在气泡里),Step 2 直接在页面底部渲染 amber 色带(**没有**抽象成独立组件,紧贴对话区)。
- Step 2 澄清回复后**不**重建 thread;新 prompt 自带「用户已回复你刚才的澄清问题。问题:... 用户回答:...」上下文。
---
## 8. 与主聊天的差异
| 维度 | 主聊天 `/page/workspace/chats/new` | 圆桌规划 |
|------|-----------------------------------|----------|
| SDK | `@langchain/langgraph-sdk` 直连 | 自研 SSE 解析(`apiFetch` + ReadableStream) |
| 澄清展示 | `message-list` ToolMessage `additional_kwargs` | 终态帧 `status: "clarification"` |
| Agent | 用户可选 assistant | 内置 intent + 动态 coordinator + 可选席位 |
| 线程 | 单 thread | Step 1 一线程;Step 2 每席位 + 协调各一线程 |
| 终态语义 | LangGraph state | 自定义 `{status, content, ...}` 终态帧 |
| 暂停 | LangGraph interrupt | `AbortController.abort()` |
复用思路:澄清卡片交互、答案文案格式;**不要**假设能直接复用 LangGraph React hooks。
---
## 9. 关键交互清单
| 交互 | 行为 |
|------|------|
| Step 1 发送 | Enter 发送(Shift+Enter 换行);发送中显示「暂停」 |
| Step 1 暂停 | `stopIntentStream()`,气泡尾部追加 `(已暂停)` 并 toast |
| Step 1 进入 Step 2 | 需 `intentReady`;打开推荐弹窗而非直接切步 |
| Step 1 跳过此项 | 仅本地追加「系统」气泡,不发请求;适合演示 |
| Step 2 init | 首次进入自动 SSE init;离开 Step 2 **不**销毁 `threadIds`(可返回继续看,不会二次 init) |
| Step 2 挂起/恢复 | `isRoundTableRunning` + `stopOrchestration` / 恢复时再 leader 一轮 |
| Step 2 干预 | 底部输入框 → `runOrchestration` 新 seed,wrap 进「用户人工干预新指令:...」 prompt |
| Step 2 Persona | 点圆桌头像打开抽屉;保存后下一轮该席位使用所选模型 |
| 新建任务 | `handleStartNewTask`:abort 全部、清 state、`runStep1Init`,与 Step 3「重新起草」共用 |
| 历史草稿 | 顶栏下拉:加载 / 重命名 / 删除(删除带 `window.confirm`) |
| Step 3 预览 | 文本类 → 模态预览;其它 → 新窗口下载 |
---
## 10. 类型与常量速查
```ts
// Step 1 输出 → Step 2 输入
interface IntentSummary {
objective: string;
constraints: string[];
assumptions?: string[];
}
// Step 2 线程映射(init_done.thread_ids)
type ThreadIdMap = Record<string, string>;
// 派活
type LeaderDispatch = [string, string]; // [agent_name, task]
// 当前选中的研讨席位
type SelectedAgent = { agent_id: string; name: string };
// Step 1 单条澄清问题
interface ClarificationPayload {
question: string;
clarificationType: string;
context: string;
options: unknown[];
allowCustom: boolean;
allowMultiple?: boolean;
}
// Step 2 stream 终态
interface StreamMultiAgentResult {
dispatched: LeaderDispatch[];
message: string | null;
content: string;
agentName: string | null;
clarification: ClarificationPayload | null;
}
```
**核心常量**:
| 常量 | 值 | 位置 |
|------|----|------|
| `COMMANDER_SEAT_ID` | `1` | RoundtablePlanningPage |
| `MAX_CYCLES` | `8` | RoundtablePlanningPage(`runOrchestration` 内) |
| `MIN_SELECTED` / `MAX_SELECTED` | `2` / `8` | RecommendAgentsDialog |
| `FALLBACK_AGENT_IDS` | 6 内置 roundtable-* | RecommendAgentsDialog |
| `BUILTIN_AGENT_META` | 6 内置头像/中文名 | RoundtablePlanningPage |
| `FALLBACK_AVATAR_TYPES` | `[blue, purple, emerald, amber, pink]` | RoundtablePlanningPage |
| ~~`STORAGE_KEY`~~ | 已废弃 —— 草稿改存后端 `/api/roundtable-drafts`,不再用 localStorage | (旧 utils/drafts.ts,已删除) |
| `INTENT_READY_RE` | `/\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/` | RoundtablePlanningPage |
| `RECOMMEND_READY_MARKER` | `"[RECOMMEND_READY]"` | recommend.ts |
| `EXCLUDED_AGENT_IDS` / 前缀 | `roundtable-intent`、`roundtable-recommender`、`roundtable-coordinator-*` | recommend.ts |
---
## 11. 故障排查
| 现象 | 可能原因 | 排查 |
|------|----------|------|
| Step 1 一直「等待澄清」但气泡里出现绿色 [INTENT_READY] 摘要 | 终态帧未带 `summary` | 看 `splitIntentReadyBlock` + 同步 `useEffect`,必要时手动刷新 |
| Step 1 角色面板卡在「正在上线」 | StrictMode 双 mount 导致 init 异步被丢弃 | 已用 `step1InitGenRef` 修复;检查是否被改坏 |
| 选项全是多选 | `allow_multiple` 未传或为 true | Network 终态帧;`resolveAllowMultiple` |
| Step 2 init 卡住 | init SSE error / 代理 | Network `init`;控制台 `[init-timing]` |
| Step 2 stream 看不到任何文字 | TitleMiddleware 过滤错杀 / phase 卡在 connecting | 控制台搜 `[stream:` 前缀(需 `__MULTI_AGENT_DEBUG__`) |
| 总控不派活 | `dispatched` 一直 `[]` 且非 clarification | 看 leader 终态帧;模型 SOUL 配置 |
| 推荐弹窗为空 | `filterRecommendCandidates` 把所有候选都过滤了 | `GET /api/agents` 列表;检查 `EXCLUDED_AGENT_IDS` |
| 推荐弹窗一直「等待推荐结果」 | recommender 模型死循环 | 控制台看 `[stream:` 日志,或点「重新推荐」 |
| Step 3 各席位文件为空 | 子 agent 未写 outputs 或 thread id 错 | 对应 thread 调 `listSessionArtifacts` |
| Step 3 预览空白 | 文件不是文本类,但被错误命中 `shouldPreviewInModal` | 检查 mime + 后缀;不在白名单的应该跳 `window.open` |
| Step 3 文件名乱码 / 404 | `buildArtifactDownloadUrl` 编码错 | 看 path 是否被分段 encode;不能整体 encode(会把 `/` 也编了) |
| 草稿加载后重复 init | `loadedFromDraftRef` 未置位 | `handleLoadDraft` 流程,注意 ref 必须先于 `setCurrentStep` |
| 顶栏 Step 2 / Step 3 一直灰 | `intentReady` / `hasConsensus` 没设上 | 看终态帧;leader.dispatched 是否真为 `[]` |
| Persona 抽屉改模型不生效 | 下一轮才生效 / 抽屉 model 未传给 streamMultiAgent | 看 `agentConfigs[meta.speakerId]?.model` 的传递路径 |
| 并行 SSE 卡顿 | 误改为 parallel 派活 | 保持顺序 dispatch,详见 §5.4 注释 |
**本地调试开关**:
```js
window.__MULTI_AGENT_INIT_TIMING__ = true; // init 各阶段耗时(默认开)
window.__MULTI_AGENT_DEBUG__ = true; // stream 帧计数(默认开)
```
---
## 12. 扩展指南
### 12.1 新增默认研讨席位
1. 后端 `.deer-flow/agents/roundtable-xxx/`
2. `BUILTIN_AGENT_META` + `DEFAULT_SELECTED_AGENTS` + `RecommendAgentsDialog` 的 `FALLBACK_AGENT_IDS`
3. 无需改 API 层(`agent_names` 由推荐结果传入)
### 12.2 调整 Step 1 澄清轮次上限
逻辑在 agent SOUL,不在前端;前端只需处理任意轮 `asking` / `clarification` / `done`。
### 12.3 调整 Step 2 编排上限
`RoundtablePlanningPage.tsx` 内 `MAX_CYCLES = 8`,超出会 toast「⏱️ 已达到最大研讨轮次,自动停止以避免循环」。若放宽:注意 `step2RoundtableDialogues` 数组会线性增长,超长讨论需要考虑虚拟滚动。
### 12.4 把 special 改回并行
不推荐;若一定要做:
- 把 `for ... of leaderRes.dispatched` 改成 `await Promise.all(leaderRes.dispatched.map(...))`
- 评估浏览器性能:每 token 一次 `setStep2RoundtableDialogues(prev => prev.map(...))`,N=6 时实测明显掉帧
- 用更高效的派发结构(如按 id 分桶 `Map<id, Bubble>` + ref 缓存)改善之后再启用
### 12.5 接入真实 Persona 覆盖
当前 prompt / temp 只在前端记录;要落地需:
- 后端开放 per-agent override 接口或在 `streamMultiAgent` 新增 `prompt_override` / `temperature` 字段
- 前端:`agentConfigs[meta.speakerId]` 一并传入
### 12.6 拆分页面(已完成 · 2026-05)
主页面早期是 4 000+ 行单文件,2026-05 重构按"hooks 抽逻辑、components 抽 UI"原则拆分:
- `hooks/useStep1Intent.ts` —— Step 1 全部状态机、init effect、INTENT_READY 同步
- `hooks/useStep2Orchestration.ts` —— Step 2 状态、编排循环、对话流、Persona、澄清
- `hooks/useDraftPersistence.ts` —— 草稿自动保存 / 手动保存 / 加载 / 重命名 / 删除
- `components/Step{1,2,3}Panel.tsx` —— 纯 UI,只接收 props,不持有业务状态
- `components/PersonaDrawer.tsx` —— Persona 抽屉
- `components/Avatars.tsx` —— 所有 SVG 头像 + getAvatar
- `lib/roundtable-constants.ts` —— 常量、类型、纯工具函数(buildRoleTemplates 等)
- `lib/step-display.tsx` —— ChainOfThought 折叠卡的图标 + 文案 + 子节点渲染
**主页面只保留**:顶栏(StepNavigation / HistoryDropdown)、左右两侧栏(LeftSidebar / RightSidebar)、步骤路由、推荐弹窗,以及编排 hook 与跨步 handler(`handleConfirmAgents` / `handleStartNewTask`)。
继续重构请遵守:
1. **不要在 Panel 组件里写 fetch / setState 业务流程** —— Panel 只渲染,事件透传到 hook。
2. **hook 之间不要互相 import** —— 跨 hook 的数据通过主页面的 ref / callback 桥接(参考 `buildInitialIntent` 与 `loadedFromDraftRef` 的实现)。
3. **草稿 snapshot 形状**(Step 1 的 `Step1Snapshot` / Step 2 的 `Step2Snapshot`)与 `api/drafts.ts` 的 `DraftStep{1,2}Snapshot` 是同一份语义,改字段时两边都要改;后端以 JSON 整存,不强约束嵌套字段。
4. **lint/typecheck**:重构后 `pnpm typecheck` 与 `pnpm build` 必须保持 0 个新增错误(预存量错误来自其它无关模块如 `strategy-components` / `next/link` shim,与本特性无关)。
### 12.7 文档协同
`@/roundtable-planning/` 内一切公共 API 都应当:
1. 在 `api/*.ts` 文件级注释里说明 endpoint
2. 在本页第 4 节 / 第 10 节同步对照
3. 后端字段变化(如 `init_done` 字段名)需要同步改 `initMultiAgent` 解析逻辑 + `multi-agent-api.md`
---
## 13. 路径速查
| 资源 | 路径 |
|------|------|
| 主页面(组合 + 顶栏 + 侧栏) | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
| Step 1 hook | `frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts` |
| Step 2 hook(含编排循环) | `frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts` |
| 草稿 hook | `frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts` |
| Step 1 面板 | `frontend-web/src/roundtable-planning/components/Step1Panel.tsx` |
| Step 2 面板 | `frontend-web/src/roundtable-planning/components/Step2Panel.tsx` |
| Step 3 面板 | `frontend-web/src/roundtable-planning/components/Step3Panel.tsx` |
| 消息气泡共享件 | `frontend-web/src/roundtable-planning/components/MessageBubble.tsx` |
| 步骤折叠卡(工具/思考) | `frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx` |
| `<think>` 提取工具 | `frontend-web/src/roundtable-planning/lib/reasoning.ts` |
| Persona 抽屉 | `frontend-web/src/roundtable-planning/components/PersonaDrawer.tsx` |
| 头像集合 | `frontend-web/src/roundtable-planning/components/Avatars.tsx` |
| 常量/工具函数 | `frontend-web/src/roundtable-planning/lib/roundtable-constants.ts` |
| ChainOfThought 渲染 | `frontend-web/src/roundtable-planning/lib/step-display.tsx` |
| Step 1 API | `frontend-web/src/roundtable-planning/api/intent.ts` |
| 推荐 API(含推荐历史) | `frontend-web/src/roundtable-planning/api/recommend.ts` |
| 草稿 API(后端 CRUD) | `frontend-web/src/roundtable-planning/api/drafts.ts` |
| Step 2/3 API | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
| 澄清卡片 | `frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx` |
| 推荐弹窗 | `frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx` |
| Step 3 报告 | `frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx` |
| 文件预览 | `frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx` |
| 回底按钮 | `frontend-web/src/roundtable-planning/components/ScrollToBottomButton.tsx` |
| 滚动跟随 | `frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts` |
| 澄清单选逻辑 | `frontend-web/src/roundtable-planning/lib/clarification.ts` |
| 后端草稿/历史路由 | `offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py` |
| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
| 样式 | `frontend-web/src/roundtable-planning/styles/roundtable-planning.css` |
| 主聊天澄清参考 | `frontend-web/src/components/workspace/messages/message-list.tsx` |
| 后端开发文档 | `frontend-web/docs/multi-agent-backend-dev.md` |
| API 速查 | `frontend-web/docs/multi-agent-api.md` |
@@ -1,404 +0,0 @@
# 多智能体会商 Step2 多结果线方案
## 背景
当前「多智能体会商 / 多智能体规划」里,一个历史草稿只保存一份 `step2` 快照。用户从历史记录加载草稿后,如果再次点击「智能体推荐」并在弹窗里点击「进入研讨」,新的 Step2 研讨会继续写入同一份 `draft.step2`,结果是第一次生成内容会被第二次生成内容覆盖,或者页面只能展示最新一次结果。
产品期望是:
- 用户从历史记录进入已有草稿时,进入第二步后,左下角能看到第一次生成结果。
- 用户再次点击「智能体推荐」并点击「进入研讨」时,要开启一条新的研讨结果线。
- 第二次生成结果显示在左下角,但不能影响第一次生成结果。
- 用户之后再次查看同一个历史草稿时,可以分别查看第一次生成结果和第二次生成结果。
因此,这个需求的核心不是简单增加一个展示区域,而是把 Step2 从「单结果」升级为「一个草稿下多条研讨 run」。
## 现状
相关位置:
- `src/roundtable-planning/api/drafts.ts`
- `src/roundtable-planning/pages/RoundtablePlanningPage.tsx`
- `src/roundtable-planning/hooks/useStep2Orchestration.ts`
- `src/roundtable-planning/components/Step2Panel.tsx`
- `src/roundtable-planning/components/RecommendAgentsDialog.tsx`
当前 `DraftStep2Snapshot` 结构大致是:
```ts
export interface DraftStep2Snapshot {
selectedAgents?: Array<{ agent_id: string; name: string }>;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
seatStubMessages?: Record<string, unknown[]>;
orchestrationMode?: "recommend" | "chain";
chain?: { id: string; title: string } | null;
}
```
这意味着一个草稿只有一份:
- 参会智能体
- 多智能体 thread 映射
- 研讨气泡
- 总控最终内容
- 共识状态
- 沙箱产物 stub
- 编排模式和业务链条
当前 `RoundtablePlanningPage.tsx` 里的 `handleConfirmAgents` 在用户点击「进入研讨」后会:
1. 规范化选中的 agents。
2. 写入 `step2.selectedAgents`。
3. 调用 `step2.resetInitGate()`。
4. 设置 `orchestrationMode` / `chainMeta`。
5. 切到 `currentStep = 2`。
它没有先把当前 Step2 结果归档成「第一次生成结果」,也没有为新的研讨创建独立 run。因此第二次研讨天然会复用同一个 `draft.step2` 写入位置。
## 目标模型
建议引入 Step2 run 概念:一个草稿可以有多条 Step2 结果线,每条结果线保存一份完整的 Step2 快照。
```ts
interface Step2RunSnapshot {
id: string;
title: string;
createdAt: string;
source: "initial" | "recommend" | "chain" | "manual";
selectedAgents?: Array<{ agent_id: string; name: string }>;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
seatStubMessages?: Record<string, unknown[]>;
orchestrationMode?: "recommend" | "chain";
chain?: { id: string; title: string } | null;
step3?: DraftStep3Snapshot | null;
}
```
`DraftStep2Snapshot` 保留现有字段作为当前激活 run 的镜像,同时新增 `runs` 和 `activeRunId`:
```ts
export interface DraftStep2Snapshot {
activeRunId?: string;
runs?: Step2RunSnapshot[];
selectedAgents?: Array<{ agent_id: string; name: string }>;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
seatStubMessages?: Record<string, unknown[]>;
orchestrationMode?: "recommend" | "chain";
chain?: { id: string; title: string } | null;
}
```
这样做的好处:
- 老代码仍然可以从 `draft.step2.step2RoundtableDialogues` 读取当前结果。
- 新 UI 可以从 `draft.step2.runs` 展示第一次、第二次、第三次生成结果。
- 老草稿没有 `runs` 时,可以在 hydrate 时把原来的 `step2` 包装成一条默认 run。
- 暂时不需要改数据库表结构,因为 `step2` 本身就是 JSON 快照。
## 行为设计
### 1. 历史记录加载
用户点击历史记录加载草稿时:
1. 拉取完整草稿。
2. 如果 `draft.step2.runs` 存在,使用 `activeRunId` 找到当前结果线。
3. 如果 `draft.step2.runs` 不存在,但旧字段里有 `step2RoundtableDialogues`,自动包装成一条 run:
```ts
{
id: "legacy-run",
title: "第一次生成结果",
createdAt: draft.updatedAt,
source: "initial",
...draft.step2
}
```
4. hydrate 当前 active run 到 Step2 runtime。
5. 左下角展示 run 列表。
### 2. 再次点击智能体推荐进入研讨
用户在已有历史草稿中再次点击「智能体推荐」并确认进入研讨时:
1. 先把当前页面里的 Step2 状态保存为当前 active run。
2. 创建一条新的 run:
```ts
{
id: crypto.randomUUID(),
title: `第 ${runs.length + 1} 次生成结果`,
createdAt: new Date().toISOString(),
source: result.mode,
selectedAgents: normalizedAgents,
threadIds: null,
coordinatorName: null,
step2RoundtableDialogues: [],
lastLeaderContent: "",
hasConsensus: false,
consensusPercentage: 0,
budgetLimit: DEFAULT_BUDGET,
seatStubMessages: {},
orchestrationMode: result.mode,
chain: result.mode === "chain" ? result.chain ?? null : null
}
```
3. 将 `activeRunId` 切到新 run。
4. 清空 Step2 runtime。
5. 设置新选中的 agents / mode / chain。
6. `resetInitGate()`,进入 Step2 后重新创建一组独立 thread。
7. 自动保存草稿。
关键点:第二次 run 必须使用新的 `threadIds`,不能沿用第一次 run 的 `threadIds`,否则后端 thread 历史会混在一起。
### 3. 左下角结果列表
左下角建议展示一个「生成结果」区域:
- 第一次生成结果
- 第二次生成结果
- 第三次生成结果
每条展示:
- 标题
- 创建时间
- 状态:研讨中 / 已达成共识 / 未完成 / 已生成报告
- 使用模式:智能体推荐 / 业务链条
点击某条结果时:
1. 保存当前 active run。
2. 切换 `activeRunId`。
3. hydrate 被点击的 run。
4. 中间 Step2 对话区显示该 run 的研讨气泡。
5. 如果该 run 已有 `step3`,进入 Step3 时展示对应报告。
### 4. Step3 结果归属
目前 `draft.step3` 是草稿级别的一份报告。如果只保留这一份,第二次结果生成报告后仍会覆盖第一次报告。
建议:
- `draft.step3` 继续作为当前 active run 的镜像,兼容现有 Step3 页面。
- 每条 `Step2RunSnapshot.step3` 保存自己的报告。
- `onReportSaved` 时同时写入当前 active run 的 `step3`。
这样用户切换到第一次生成结果时,可以看到第一次的 Step3 报告;切换到第二次生成结果时,可以看到第二次的 Step3 报告。
## 建议改动点
### 1. `api/drafts.ts`
扩展类型:
- 新增 `Step2RunSnapshot`。
- `DraftStep2Snapshot` 增加 `activeRunId?: string` 和 `runs?: Step2RunSnapshot[]`。
同时保留旧字段,避免影响现有保存、加载和 Step3。
### 2. `useStep2Orchestration.ts`
新增或暴露两个能力:
- `getCurrentRunSnapshot()`:从当前 Step2 runtime 生成一条 run 快照。
- `hydrateRunSnapshot(run)`:把某条 run 恢复到 Step2 runtime。
`hydrateFromDraft` 继续存在,但内部可以优先选择 active run 来 hydrate。
### 3. `RoundtablePlanningPage.tsx`
新增页面级状态:
```ts
const [step2Runs, setStep2Runs] = useState<Step2RunSnapshot[]>([]);
const [activeStep2RunId, setActiveStep2RunId] = useState<string | null>(null);
```
调整几个流程:
- `getStep2Snapshot` 输出 `runs` 和 `activeRunId`。
- `hydrateStep2` 兼容旧草稿并恢复 runs。
- `handleConfirmAgents` 在新建研讨前先归档当前 active run,再创建新 run。
- `onReportSaved` 把 Step3 报告写回当前 active run。
### 4. `Step2Panel.tsx`
新增左下角结果列表展示。建议作为一个独立组件,避免 Step2Panel 继续变大:
```tsx
<Step2RunSwitcher
runs={step2Runs}
activeRunId={activeStep2RunId}
onSelect={handleSelectStep2Run}
/>
```
组件职责只做展示和选择,不直接读写 draft。
### 5. `Step3Panel.tsx`
传入当前 active run 的报告:
- 如果当前 run 有 `step3`,展示它。
- 如果没有,按现有逻辑生成新报告。
- 生成完成后通过 `onReportSaved` 写回 run。
## 兼容策略
老草稿没有 `runs` 字段时,不做数据迁移,前端加载时即时包装。
判断规则:
```ts
function normalizeStep2Runs(step2: DraftStep2Snapshot): {
runs: Step2RunSnapshot[];
activeRunId: string | null;
} {
if (step2.runs?.length) {
return {
runs: step2.runs,
activeRunId: step2.activeRunId ?? step2.runs[0].id,
};
}
const hasLegacyContent =
step2.step2RoundtableDialogues?.length > 0 ||
Boolean(step2.threadIds) ||
Boolean(step2.lastLeaderContent);
if (!hasLegacyContent) {
return { runs: [], activeRunId: null };
}
const legacyRun: Step2RunSnapshot = {
id: "legacy-run",
title: "第一次生成结果",
createdAt: new Date().toISOString(),
source: step2.orchestrationMode ?? "initial",
selectedAgents: step2.selectedAgents,
threadIds: step2.threadIds,
coordinatorName: step2.coordinatorName,
step2RoundtableDialogues: step2.step2RoundtableDialogues,
lastLeaderContent: step2.lastLeaderContent,
hasConsensus: step2.hasConsensus,
consensusPercentage: step2.consensusPercentage,
budgetLimit: step2.budgetLimit,
seatStubMessages: step2.seatStubMessages,
orchestrationMode: step2.orchestrationMode,
chain: step2.chain,
};
return { runs: [legacyRun], activeRunId: legacyRun.id };
}
```
## 风险点
### 1. 自动保存覆盖
Step2 现在会在每个气泡收尾时自动保存。如果引入多 run,自动保存必须先把当前 runtime 合并回 active run,再写入 `draft.step2.runs`。
否则会出现 UI 切到第二次结果,但保存时仍更新第一次 run 的情况。
### 2. threadIds 混用
每条 run 必须拥有自己的 `threadIds`。新建 run 时要清空 `threadIds` 并让 Step2 重新 init。
如果复用旧 thread,会导致两次研讨的后端上下文互相污染。
### 3. Step3 报告覆盖
如果只保存草稿级 `step3`,多 run 的报告无法分别查看。
建议把报告写入当前 run,同时保留草稿级 `step3` 作为 active run 镜像。
### 4. 正在运行中的 run
如果配合后台任务能力,run 还需要带 job 信息:
```ts
jobId?: string | null;
jobStatus?: JobStatus | null;
```
这样左下角可以显示「第二次生成结果正在运行」,并打开派活链路弹窗。
## 分阶段实现建议
### P0:纯前端多 run 快照
目标:先解决“第一次 / 第二次结果分别可查看”。
- 扩展 `DraftStep2Snapshot` 类型。
- 页面维护 `step2Runs` / `activeStep2RunId`。
- `handleConfirmAgents` 新建 run。
- 左下角展示 run 列表。
- 切换 run 能恢复不同 Step2 对话。
不改后端表结构。
### P1:Step3 绑定 run
目标:每次研讨结果对应自己的报告。
- `Step2RunSnapshot.step3` 保存报告。
- `onReportSaved` 写回 active run。
- 切换 run 时同步 Step3 显示。
### P2:后台任务绑定 run
目标:和后台挂起执行方案打通。
- run 增加 `jobId` / `jobStatus`。
- 历史列表和左下角都能显示某条 run 正在执行。
- 点击正在执行的 run 打开派活链路弹窗。
### P3:后端 run 表
如果后续一个草稿下 run 数量很多,或者需要服务端按 run 查询、删除、重命名,再考虑新增后端表:
- `roundtable_runs`
- `draft_id`
- `run_id`
- `title`
- `status`
- `step2_snapshot`
- `step3_snapshot`
- `created_at`
- `updated_at`
短期不建议一开始就上表,先用现有 `step2` JSON 快照落地更快。
## 验收清单
- 从历史记录加载旧草稿时,左下角出现「第一次生成结果」。
- 点击「第一次生成结果」能看到原来的 Step2 研讨气泡。
- 在历史草稿中重新点击「智能体推荐」并「进入研讨」后,左下角新增「第二次生成结果」。
- 第二次研讨开始时,Step2 对话区为空或只显示新 run 初始化内容。
- 第二次 run 使用新的 `threadIds`,不会复用第一次 run 的后端线程。
- 切回「第一次生成结果」时,第一次的对话、共识状态、沙箱产物仍可查看。
- 切回「第二次生成结果」时,第二次的对话继续显示。
- 刷新页面后再次加载该历史草稿,两条结果线都还在。
- 如果两次都生成了 Step3 报告,切换不同结果线时能看到各自对应的报告。
@@ -1,610 +0,0 @@
# 圆桌 DAG 编排 + 并行执行显示 —— 分步实现需求文档
> 目标读者:实现本功能的前端 / 后端工程师。
> 本文是**可逐步落地**的需求 + 设计文档,每个 Phase 都能独立交付、独立验收。
---
## 0. 背景与目标
### 0.1 现状
Step 2 圆桌目前有两种编排模式(`orchestrationMode`,见 `hooks/useStep2Orchestration.ts`):
- **`free`(自由模式)**:总控(leader)自由决定派活给谁,`runOrchestration` 循环驱动。
- **`chain`(线性链条)**:用户把席位排成一条**线性**链 `A → B → C`,`runChainOrchestration` 按顺序逐个派活,最后总控收口。链路用 `DispatchChainModal.tsx` 的**横向流程图**展示(总控 → 席位1 → 席位2 → … → 共识 → 报告)。
链条数据现在只是一个**有序数组** `selectedAgents`(线性),节点状态类型见 `lib/job-types.ts`(`ChainNodeState = pending | active | done`)。
### 0.2 目标(本次新增)
1. **DAG 编排**:用户可视化指定**智能体之间的直接依赖关系**——有的串行(`A → B → C`),有的并行(`A B C` 同时跑)。编排结果是一张**流程图(DAG)**。
2. **并行执行**:DAG 里同一层(互不依赖)的智能体**并行**跑,墙钟时间从「串行求和」降到「并行取最慢」。
3. **并行禁止写文件**:并行的智能体**禁用文件写入 / 沙箱命令**,避免它们同时打开沙箱互相打架。产物以消息正文交付;落文件留给串行 / 收口阶段。
4. **并行高性能显示**:并行时收起左侧、顶部头像条切换、主区同时显示 2 个智能体的消息流,其余**只接流不渲染**,避免页面卡死。
### 0.3 设计总原则
- **复用** 现有 special run / leader run / rollback 抢占 / 轻量交付状态接口(`GET /threads/{id}/last-ai-message`),不另起炉灶。
- **线性链是 DAG 的特例**:`chain` 模式可平滑迁移成"每层 1 个节点"的 DAG,老数据不破坏。
- **分层(Stage)优先于自由连线**:编辑交互首选「分层」模型(每个 stage 放若干并行席位,stage 间串行),实现成本低、覆盖 95% 的"串 / 并"诉求;自由连线画布作为后续可选增强(见 §2.3)。
### 0.4 已确认的设计决策(✅ 本期按这些实现)
> 以下三项已与需求方确认锁定,实现时直接按此,不再二选一。
1. **✅ 编排模型 = 分层 Stage**:本期只做分层(每个 stage 放若干并行席位、stage 间串行)。**自由连线画布本期不做**,仅在数据模型层预留(§1.2)以免将来返工。
2. **✅ 并行跳过 leader、前端直派**:DAG 由用户显式指定,执行时前端**直接**对 stage 内席位发起 special run,**不**每个 stage 再跑一轮总控派活;只在最后 `finalSynthesis` 跑一轮 leader 综合(即 §3.2 的方案 B)。
3. **✅ 主区固定 2 列,窄屏降 1 列**:并行时主区最多并排显示 2 个被选中智能体的消息流;窄屏(断点见 §5.3)降为 1 列 + 头像条横向滚动。不做 3+ 列。
---
## 1. 数据模型
### 1.1 DAG 的「分层」表示(推荐,主模型)
把 DAG 表示成**有序的 stage 列表**,每个 stage 内的席位并行、stage 之间串行:
```ts
// lib/dag-types.ts (新建)
/** 一个并行批次:stage 内所有席位并行执行。 */
export interface OrchestrationStage {
/** stage 稳定 id(拖拽重排 / diff 用)。 */
id: string;
/** 本 stage 并行执行的席位(agent_id 列表)。1 个 = 退化为串行节点。 */
agentIds: string[];
}
/** 用户编排出的执行计划。stages 顺序即串行顺序。 */
export interface OrchestrationPlan {
/** 编排模式标识,持久化进 step2。 */
mode: "dag";
/** 串行执行的批次;每批内并行。 */
stages: OrchestrationStage[];
/** 收口:是否在所有 stage 完成后由总控综合(默认 true)。 */
finalSynthesis: boolean;
}
```
- **串行** `A → B → C` = `stages: [{agentIds:[A]}, {agentIds:[B]}, {agentIds:[C]}]`。
- **并行** `A B C` = `stages: [{agentIds:[A,B,C]}]`。
- **混合** `A →(B C)→ D` = `stages: [{[A]}, {[B,C]}, {[D]}]`。
> 为什么用分层而不是「邻接表 / 边」:执行调度本就是「拓扑分批」,分层就是分批的结果,前端不必再做拓扑排序,也天然避免环;编辑器交互更直观(拖卡片进 stage)。真正的自由连线 DAG 见 §2.3(可选)。
### 1.2 通用 DAG 表示(§2.3 自由连线时才需要)
若将来做自由连线画布,再引入边表示,并在执行前做一次**拓扑排序 → 分层**,落回 §1.1 的 stages 结构给调度器用:
```ts
export interface DagEdge { from: string; to: string } // from 完成后才能跑 to
export interface DagGraph { nodes: string[]; edges: DagEdge[] }
// 执行前:toposortToStages(graph) -> OrchestrationStage[]
```
### 1.3 持久化
- 复用 `roundtable_drafts.step2` JSON(见后端 `app.gateway.routers` roundtable-drafts + `hooks/useStep2Orchestration.ts` 里对 `step2` 的读写)。
- 在 `step2` 增加字段:`orchestrationPlan: OrchestrationPlan | null`。
- `orchestrationMode` 扩展为 `"free" | "chain" | "dag"`;`chain` 旧数据读取时即时转换为单节点-stage 的 `dag`(兼容层,不改老 draft)。
### 1.4 校验(纯函数,可单测)
`lib/dag-types.ts` 内提供:
```ts
validatePlan(plan: OrchestrationPlan, selectedAgentIds: string[]): {
ok: boolean;
errors: string[]; // 面向用户的中文提示
}
```
校验项:
- 每个 `agentId` 必须在本次选定席位 `selectedAgents` 内(防止编排了不存在的席位 → 复用后端 `_partition_dispatch` 同思路的"必须在 roster 内")。
- 不允许空 stage;不允许同一席位出现在多个 stage(一个席位只跑一次)。
- 至少 1 个 stage。
- (可选)单 stage 并行度上限提示(如 > 6 并行给 warning:远程模型并发 + 沙箱压力)。
---
## 2. 编排编辑器(流程图构建)交互
### 2.1 入口与整体布局
- 在 Step 2 顶部模式切换里新增 **「依赖编排」**(`dag`)模式(与现有 自由 / 链条 并列)。
- 选择 `dag` 后弹出 / 进入**编排编辑器**(复用 Dialog 大弹窗,参考 `DispatchChainModal` 的尺寸 `w-[94vw] max-w-[1240px]`)。
### 2.2 推荐交互:分层(Stage)编辑器(Phase 2 实现)
```
┌ 依赖编排 ───────────────────────────────────────── [校验✓] [保存] ┐
│ 可选席位(点/拖加入某个 stage) │
│ [情报收集] [环境评估] [方案设计] [风险审查] [执行规划] [前端实现] │
│ ─────────────────────────────────────────────────────────────── │
│ Stage 1(并行) Stage 2(并行) Stage 3 │
│ ┌──────────┐ ─────▶ ┌──────────┐ ┌────────┐ ─▶ ┌──────────┐ │
│ │ 情报收集 │ │ 方案设计 │ │风险审查│ │ 执行规划 │ │
│ └──────────┘ └──────────┘ └────────┘ └──────────┘ │
│ [+ 拖席位到此] [+] [+] │
│ [+ 新建 Stage(串在最后)] │
│ ─────────────────────────────────────────────────────────────── │
│ 说明:同一 Stage 内的席位会**并行**执行;Stage 之间**串行** │
│ (后一个 Stage 能看到前面所有 Stage 的交付摘要)。 │
└───────────────────────────────────────────────────────────────────┘
```
交互要点:
- **加入 stage**:从顶部可选席位**拖拽**到某个 stage,或点席位 →「加入 Stage N」。
- **移动 / 重排**:席位可在 stage 间拖动;stage 可整体左右拖动重排(改串行顺序)。
- **删除**:席位 / 空 stage 可删。
- **并行度提示**:stage 内 ≥2 席位即标「并行」徽标;过多给 warning。
- **实时校验**:`validatePlan` 实时跑,错误在顶部红条提示,「保存」在校验通过前禁用。
- **预览流程图**:编辑区下方实时渲染只读流程图缩略(复用 §7 的 DAG 流程图组件)。
> 拖拽可用现成轻量方案(HTML5 DnD 或 `@dnd-kit`,看项目是否已有依赖;没有就先用「点选 + 加入/移出按钮」的无拖拽版,Phase 2 先上无拖拽、Phase 2.5 再加拖拽)。
### 2.3 可选增强:自由连线画布(后续,非本期必须)
- 节点自由摆放 + 拉线建边 → `DagGraph`(§1.2)。
- 实现成本高(画布 / 连线 / 命中检测 / 自动布局),且分层模型已覆盖绝大多数诉求。**本期不做**,仅在数据模型上预留 §1.2,避免将来返工。
### 2.4 验收(Phase 2)
- 能拖 / 点把席位分配到多个 stage,保存进 `step2.orchestrationPlan`。
- 刷新 / 重进 draft 能恢复编排。
- 非法编排(空 stage / 席位重复 / 席位不在 roster)有明确中文提示且无法保存。
---
## 3. 执行调度(前端编排循环 + 后端复用)
### 3.1 新增 `runDagOrchestration`(`useStep2Orchestration.ts`)
与现有 `runOrchestration` / `runChainOrchestration` 并列,逻辑:
```
for (stageIndex, stage of plan.stages):
if isStale() break // 复用现有 epoch 抢占(人工干预/暂停)
// 1) 总控按本 stage 的席位并行派活(一轮 leader,dispatched = stage.agentIds)
// —— 或跳过 leader,前端直接对 stage 内每个席位发起 special run(见 3.2)
// 2) 并行跑 stage 内所有席位
await Promise.all(stage.agentIds.map(seat =>
streamMultiAgent({ agentType:"special", agentName:seat,
newMessage: buildStageTask(seat, plan, stageIndex),
parallelNoFile: stage.agentIds.length > 1, // §4
... })))
// 3) 等本 stage 全部 ✅(Promise.all 天然 barrier)再进入下一 stage
// 4) finalSynthesis:最后一轮 leader(synthesisMode=true)综合所有 stage 交付
```
要点:
- **批内并行**:`Promise.all`(不是现在的 `for await` 串行)。
- **批间串行**:`Promise.all` 即 barrier,自动等齐。
- **依赖可见性**:进入 stage N 的席位时,把**前面所有 stage 已交付席位的摘要**拼进它的 `newMessage`(复用 §交付状态:网关 `_collect_delivery_status` / `GET last-ai-message?max_chars=N`,前端也可在 stage 边界拉一次摘要拼进 task)。这样 `(B C)` 能看到 `A` 的产出。
- **抢占 / 干预 / 暂停**:每个 stage 边界检查 `isStale()`(epoch),与现有自由 / 链条模式一致。
- **rollback**:special run 已统一 `multitask_strategy=rollback`,并行多席位各自不同 thread,互不冲突。
### 3.2 是否每 stage 都走 leader? — ✅ 已定:前端直派(方案 B)
**本期采用方案 B**:DAG 由用户**显式**指定,不需要 leader 再"决定派给谁"。前端**直接**对 stage 内每个席位发起 special run,省掉每 stage 的 leader LLM 往返(更快、更可控);只在所有 stage 完成后、`finalSynthesis` 为真时跑**一轮** leader(`synthesisMode=true`)综合所有交付。
> (备选方案 A:每 stage 跑一轮 leader 派活——更贴现有架构但每 stage 多一次 leader LLM、更慢。**本期不采用**,留档备查。)
### 3.3 后端改动
- **基本不需要新接口**:special run(`POST /api/multi-agent/run/stream` agent_type=special)、leader run、轻量交付读(`GET /threads/{id}/last-ai-message`)、轻量追加(`POST /threads/{id}/messages/append`)都已就绪。
- 仅需 §4 的「并行禁写文件」run policy 开关。
### 3.4 验收(Phase 3)
- `(B C)` 两席位**同时**出现在执行(后端日志两条 special run 时间重叠;前端两个流并行增长)。
- stage 间严格串行(stage 2 不早于 stage 1 全部完成)。
- stage N 席位的输入里能看到 stage <N 的交付摘要。
---
## 4. 并行时禁止写文件(防沙箱打架)
### 4.1 后端 run policy
`app/gateway/roundtable_run_policy.py` 现有角色:`leader` / `seat` / `report`。新增一个角色或开关:
- 方案:给 `seat` 增加一个「并行变体」`seat_parallel`,其 `excluded_tools` 在 `seat` 基础上**再禁** `write_file` / `bash` / `str_replace` / `present_files`(与 `report` 角色禁写的思路一致)。
- `_special_run`(`multi_agent.py`)按请求里的一个新 flag(如 `no_file: true`)选择 `seat_parallel` policy。
### 4.2 前端传参
- `streamMultiAgent` 请求体新增 `parallel_no_file?: boolean`(或复用 `no_file`)。
- `runDagOrchestration` 在 `stage.agentIds.length > 1`(真并行)时置 `true`;单节点 stage(实际串行)不限制,可正常写文件。
### 4.3 产物归属
- 并行席位的产物 = **消息正文**(被 `_collect_delivery_status` / 综合块读取)。
- 需要落文件 / 画图的(如 Step3 报告、`roundtable-report`)放在**串行的收口阶段**单独跑,不在并行批次里。
### 4.4 验收(Phase 4)
- 并行 stage 的席位 run 日志显示 `Excluded ... ['write_file','bash','str_replace','present_files', ...]`。
- 并行期间不再出现多个席位抢同一沙箱 / 写文件冲突。
---
## 5. 并行执行的前端显示
### 5.1 布局总览(并行进行时)
```
┌ 多智能体圆桌会商中心 ─────────────────────────────────────────────┐
│ [头像条:进行中的并行智能体] [模型选择 ▾] [暂停会商] │
│ (情报)● (方案)● (风险)● (执行) … │
│ ↑选中 ↑选中 loading hidden │
│ ───────────────────────────────────────────────────────────────── │
│ ┌─ 方案设计(选中)──────────┐ ┌─ 风险审查(选中)──────────┐ │
│ │ 一、整体架构…(流式渲染) │ │ 1. 合规风险 IDFA/OAID… │ │
│ │ … │ │ … │ │
│ └────────────────────────────┘ └────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
(左侧"参与角色"栏在并行时整体收起,腾出横向空间放两列消息)
```
### 5.2 关键交互(与需求逐条对齐)
1. **收起左侧**:并行执行时把左侧「参与角色 / 人工参与」栏整体折叠(一个 `isParallelRunning` 状态控制;非并行恢复)。
2. **头像条**:位置在**消息列表上方、模型选择器左侧**。展示**当前并行批次的所有进行中智能体头像**:
- **进行中** → 头像加 loading 动画(复用 `DispatchChainModal` 的 `animate-ping` 光环 + `Loader2Icon animate-spin` 角标)。
- **已完成** → 打勾(复用现有 `CheckIcon` 角标)。
- **选中** → 高亮描边(当前正在主区显示的那 2 个)。
3. **主区同时显示 2 个**:被选中的 2 个智能体的消息列表**并排两列**渲染。
4. **多于 2 个先隐藏**:未选中的智能体**不渲染 DOM**(见 §6 性能)。
5. **点头像切换**:点击未选中的头像 → 把它设为选中之一(替换当前 2 个里的一个,或按"最近点击保留 2 个"的 LRU 策略);被换下的智能体**停止渲染但继续接流**。
### 5.3 选中策略 — ✅ 固定 2 列,窄屏降 1 列
- 维护 `selectedParallelSeats: string[]`,**最多 2**(固定 2 列)。**不做 3+ 列**。
- **响应式**:宽屏 2 列并排;窄屏(建议断点 `< 1024px` / `lg`)降为**1 列**,头像条横向滚动切换;此时 `selectedParallelSeats` 仍可存 2 个,但主区只渲染当前 1 个(点头像切换哪个在前)。
- 默认选中本批次**最先开始**的(宽屏 2 个 / 窄屏 1 个)。
- 点头像:若已选中则取消;未选中则加入(超过上限时按 LRU 踢掉最早选中的)。被换下的席位**停止渲染但继续接流**(§6)。
### 5.4 验收(Phase 5)
- 并行时左侧收起、出现头像条、主区两列。
- 进行中头像有 loading;完成打勾;选中有高亮。
- 点头像能切换主区显示的智能体,且切换是**即时**的(数据已在内存,见 §6)。
---
## 6. 流式高性能策略(避免页面卡死)
> 核心矛盾:N 个并行席位同时高频 `onTextDelta`,若都 setState + Markdown 渲染会卡死。
### 6.1 全接收、惰性渲染
- **所有**并行 run 的流都正常订阅(不丢数据):每个席位的增量文本**累积进 store / ref**(`Map<agentId, { text, status, charCount }>`)。
- **只有被选中(主区显示)的席位**才把累积文本挂到 DOM 渲染(Markdown)。
- **未选中**席位:只更新内存 + 头像条上的轻量进度(字数 / loading),**不挂载消息 DOM**。
- 切换选中时:把该席位已累积的全文一次性渲染 + 继续接增量。
### 6.2 渲染节流
- 选中席位的流式渲染**节流/合帧**:用 `requestAnimationFrame` 或 ~50–100ms 批量 flush 累积的 token,而不是每个 token 一次 setState。
- Markdown 渲染对"流式中"可降级(纯文本 / 轻量渲染),**完成后**再做完整 Markdown(现有 `MessageBubble` 的 `streaming` 标志可复用:streaming 时简渲、done 后全渲)。
### 6.3 头像条进度
- 头像条只显示**廉价**信息:loading 动画 + 可选字数(`charCount`,从内存 Map 取,节流更新,例如每 500ms 刷一次)。不触发消息体渲染。
### 6.4 数据流(建议结构)
```
streamMultiAgent(seat).onTextDelta(text) →
parallelStore.append(seatId, text) // 纯内存累积,O(1)
if seatId ∈ selectedParallelSeats: // 仅选中的进 React state(节流)
scheduleFlush(seatId) // rAF / 节流 setState
else:
bumpCharCount(seatId) // 仅更新头像条进度(节流)
```
### 6.5 验收(Phase 6)
- 6+ 席位并行时页面不卡(输入 / 滚动 / 切换流畅)。
- 切到一个之前隐藏的席位,能立刻看到它**到目前为止的全部**输出(证明流没丢、只是没渲染)。
---
## 7. 流程图执行态可视化(DAG 版)
- 把现有 `DispatchChainModal.tsx`(横向**线性**流程图)扩展 / 复刻成 **DAG 流程图**:
- 按 stage 分**列**,stage 内席位**纵向并列**,stage 间用连接线。
- 节点状态复用 `ChainNodeState`(pending / active / done)+ 现有动画(`animate-ping` / `Loader2Icon` / `CheckIcon`)。
- 末尾接 `共识 → 结果绘制` 里程碑(沿用现有)。
- 用途:编辑器里的只读预览(§2.2 底部)+ 执行时的总览(可选小窗 / 顶部缩略)。
```
总控 ─▶ ┌ Stage1 ┐ ─▶ ┌ Stage2 ┐ ─▶ ┌ Stage3 ┐ ─▶ 🤝共识 ─▶ 📊报告
│ 情报● │ │ 方案✓ │ │ 执行○ │
└────────┘ │ 风险● │ └────────┘
└────────┘
```
### 7.1 验收(Phase 7)
- 编辑器底部 / 执行时能看到 DAG 流程图,节点状态随执行实时更新(active/done)。
### 7.2 实施结论(2026-06-07,✅ 已完成)
- **新组件** `components/DagFlowChart.tsx`:纯展示组件,复刻 `DispatchChainModal` 的节点 / 连线 / 里程碑视觉(`animate-ping` 光环 + `Loader2Icon` 转圈 + `CheckIcon` 打勾 + `rt-chain-connector` 连线动画),但按 **stage 分列**:总控 ─▶ ┌Stage1┐ ─▶ ┌Stage2┐ ─▶ 🤝共识 ─▶ 📊结果绘制,stage 内席位**纵向并列**。`compact` 紧凑模式给缩略图用。所有状态由 props 传入(`stages` / `resolveSeat` / `seatStates` / `coordinatorState` / `consensusState` / `reportState`);不传 `seatStates` → 全 pending(只读预览)。`aggregateStageState` 把 stage 内席位聚合成列状态(任一 active→active、全 done→done)驱动 stage 间连线。
- **编辑器只读预览**(§2.2 底部):`BusinessChainEditorPage` 底部加「编排预览」section,把 `stageGroups`(去空 stage) 映射成 `DagFlowChart` 的 stages + `resolvePreviewSeat`(席位名/配色),`compact`、全 pending、随编辑实时变。
- **执行态总览**(Step 2 顶部缩略):`useStep2Orchestration` 新增 `dagNodeStates: Record<agentId, ChainNodeState>`,`runDagOrchestration` 跨 stage 维护(进入 stage→该批 active、席位终态→done、`resetStep2State` 清空);经 `RoundtablePlanningPage` 把 `orchestrationMode/orchestrationPlan/dagNodeStates` 传到 `Step2Panel`,dag 模式下会话区顶部 `compact` 渲染 DagFlowChart。总控/共识里程碑状态由 `Step2Panel` 从节点状态 + `isOrchestrating`/`hasConsensus` 派生(中转轮时无席位 active + isOrchestrating → 总控 active)。
- 改动文件:`components/DagFlowChart.tsx`(新)、`pages/BusinessChainEditorPage.tsx`、`hooks/useStep2Orchestration.ts`、`components/Step2Panel.tsx`、`pages/RoundtablePlanningPage.tsx`。typecheck 零新增(基线 65)。
- **下次实地验收**:①编辑器底部随分层编辑实时画出流程图;②Step2 dag 模式顶部出现缩略流程图,跑起来后当前 stage 列点亮(active 转圈)、完成打勾、中转时总控亮、最后共识亮。
---
## 8. 分步实现计划(每步可独立交付 + 验收)
| Phase | 内容 | 主要文件 | 依赖 | 可独立验收 |
|---|---|---|---|---|
| **1** | DAG 数据模型 + 校验 + 持久化字段 | `lib/dag-types.ts`(新)、draft step2 读写 | — | 纯函数单测:`validatePlan` / `chain→dag` 兼容转换 |
| **2** | 分层编排编辑器(先无拖拽:点选加入/移出) | `components/DagPlanEditor.tsx`(新)、Step2 模式切换 | P1 | 能编排并保存/恢复,非法编排有提示 |
| **2.5** | 编辑器拖拽 | 同上 + `@dnd-kit`(若引入) | P2 | 拖拽分配/重排 |
| **3** | `runDagOrchestration`:批内并行 + 批间串行 + 依赖摘要注入 | `hooks/useStep2Orchestration.ts`、`api/multi-agent.ts` | P1 | 后端日志并行 special run 时间重叠;stage 严格串行 |
| **4** | 并行禁写文件 run policy | `roundtable_run_policy.py`、`multi_agent.py`、`api/multi-agent.ts` | P3 | 并行 run 的 excluded_tools 含 write_file/bash/... |
| **5** | 并行显示骨架:收起左侧 + 头像条 + 两列 + 选中切换 | `components/Step2Panel.tsx`、`components/ParallelSeatStrip.tsx`(新) | P3 | 见 §5.4 |
| **6** | 流式高性能:全接收 / 惰性渲染 / 节流 | `hooks/useStep2Orchestration.ts`(parallelStore)、`Step2Panel` | P5 | 见 §6.5(6+ 并行不卡) |
| **7** | DAG 流程图执行态可视化 | `components/DagFlowChart.tsx`(复刻自 `DispatchChainModal`) | P2、P3 | 见 §7.1 |
| **8** | 边界 / 异常 | 各处 | 全部 | 见 §9 |
> 落地顺序建议:**P1 → P3 → P4 → P5 → P6**(先把"能并行跑 + 能看 + 不卡"打通),**P2 编辑器**可与 P3 并行开发(先用一份 hardcode/简单 UI 的 plan 驱动 P3 联调),**P7 / P2.5 / 2.3** 作为增强后置。
---
## 9. 边界与异常(Phase 8)
- **某并行节点失败 / 超时**:单个席位失败不拖垮整 stage;标该节点 error,stage 其余 ✅ 仍推进;收口时把"X 失败"如实告知总控(复用交付状态 ⬜ + 失败标记)。
- **依赖未满足**:调度器保证 stage 串行,理论不会发生;防御性校验 stage N 启动前 stage <N 必须全部终态(done/failed)。
- **人工干预 / 暂停 / 恢复**:在 stage 边界检查 `isStale()`(epoch);干预指令插入后,从"下一个未开始的 stage"继续(已完成 stage 不重跑)。
- **并行禁写文件 vs 席位本想写文件**:在席位 SOUL / 派活提示里说明"本轮为并行研讨,产出请直接写在回复正文,不要写文件",降低模型困惑。
- **窄屏**:主区两列降为一列 + 头像条横向滚动。
- **单 stage 全并行(一次性 N 个)**:给并行度 warning(远程模型并发限流 / 成本)。
---
## 10. 与现有代码的衔接点速查
- 编排循环:`hooks/useStep2Orchestration.ts`(`runOrchestration` / `runChainOrchestration` 旁新增 `runDagOrchestration`;复用 `isStale()` epoch、`abortAllInFlight`、`appendStreamingDialogue` / `appendToDialogue`)。
- 流式 API:`api/multi-agent.ts`(`streamMultiAgent`,新增 `parallel_no_file` 入参;`onTextDelta(text, messageId)` 已是按席位回调,天然支持并行分流)。
- 显示:`components/Step2Panel.tsx`(消息列表 `step2RoundtableDialogues.map`;新增头像条 `ParallelSeatStrip` + 两列容器 + 折叠左侧)。
- 流程图:`components/DispatchChainModal.tsx` / `lib/job-types.ts` / `lib/business-chain.ts`(复刻成 DAG 版)。
- 常量 / 元数据:`lib/roundtable-constants.ts`(`BUILTIN_AGENT_META` 头像/中文名、`buildAgentNameToRole`)。
- 后端 run policy:`app/gateway/roundtable_run_policy.py`(新增 `seat_parallel`)、`app/gateway/routers/multi_agent.py`(`_special_run` 读 `no_file` flag)。
- 交付摘要:`GET /api/threads/{id}/last-ai-message?max_chars=N`(依赖注入 stage N 输入用)。
---
## 11. 一句话总结
把「线性链条」升级为「分层 DAG」:**编辑器**用分层 stage(拖卡片)表达串/并;**调度**用 `Promise.all` 批内并行、批间 barrier 串行,依赖摘要在 stage 边界注入;**并行禁写文件**避免沙箱打架;**显示**收起左侧、头像条切换、主区两列、未选中只接流不渲染(rAF 节流)保证不卡。全部建立在现有 special/leader run + 轻量交付接口之上,后端几乎零新接口。
---
## 12. 已知问题与排查记录(2026-06-06)
### 12.1 ❗并行席位「步骤条」不显示 —— 根因:`seat_parallel` 工具面禁得过多
**现象**:并行两列里的席位气泡看不到步骤条(`MessageStepsCard`),而串行席位有。
**排查结论(已定位,待修)**:
1. **步骤条只渲染「工具调用」步骤**。`MessageStepsCard` 的 `isToolStep` 只认 `tool_calling:<toolName>` 的步骤进折叠卡;纯阶段步骤(`connecting:` / `streaming:` 这类 reasoning)只渲染成**底部一行小灯泡**,不出大卡(见 `MessageStepsCard.tsx` L80-100)。
2. **并行席位几乎调不动任何会产生步骤的工具**。后端 `app/gateway/roundtable_run_policy.py`:
- `_SEAT_EXCLUDED = [web_search, ask_clarification, present_files, view_image, agent_orchestration]` —— **注意 `web_search` 对所有席位(串行也)就禁了**(注释「圆桌研讨里联网搜索一律禁用」)。
- `_SEAT_PARALLEL_EXCLUDED = _SEAT_EXCLUDED + [write_file, bash, str_replace]`。
- 于是并行席位可用工具几乎只剩 `read_file / ls / task / write_todos / memory / hindsight_*`,研究/分析类席位基本一个都不会调 → **没有任何 `tool_calling` 步骤** → 步骤条(大卡)不出现。
3. **对比串行为何「有步骤条」**:串行席位(`seat` policy)**能 `write_file`**,会产生 `tool_calling:write_file` 步骤 → 大卡出现。并行被禁了 write_file + 又没有 search,所以一个工具步都没有。
**用户诉求**:并行时**只禁真正会冲突的写操作**,搜索信息 / 调用技能等**必要操作不要禁**。
**关键事实(影响方案选择)**:圆桌每个席位是**独立 thread = 独立 sandbox**(后端 per-thread 隔离 `.deer-flow/users/{uid}/threads/{tid}/`)。所以并行席位写文件**在后端并不会互相冲突**;当初 §4「并行禁写文件」的「打架」其实主要是**前端单沙箱预览**(`activeArtifactThreadId` 只能同时显示一个)+ 产物归集到正文的简化考量,并非后端真冲突。→ 这意味着「并行禁写」的必要性比原设想弱,可放宽。
**✅ 决策:选 A(2026-06-06 已实施)**:把 `seat_parallel` 放宽到**只禁 `write_file / bash / str_replace`**(防前端单沙箱预览串台 + 产物走正文)+ 仍禁 `ask_clarification / present_files / view_image / agent_orchestration`(打断/越权类),但**放开 `web_search` 与技能**(搜索不碰 sandbox;技能靠 `read_file`)。改在 `app/gateway/roundtable_run_policy.py` 的 `_SEAT_PARALLEL_EXCLUDED`(**不再继承** seat 的 web_search 禁用,显式列举),测试 `tests/test_roundtable_run_policy.py` 同步更新(`test_seat_parallel_allows_web_search_and_skills`)。
→ 并行席位现在能联网搜索/用技能 → 产生 `tool_calling` 步骤 → 步骤条显示。
> 备选 B(连写文件也放开,沙箱按 thread 隔离)/ C(重新评估「圆桌串行也一律禁 web_search」)**本期不做**,留档。串行席位目前仍禁搜索(`seat` policy 不变)。
### 12.2 其它已修(本轮反馈)
- 最左侧**全局工作区侧栏**并行时收起:改用与「打开沙箱」同一路径 `useSidebarSafe()?.setOpen(false)`(之前只收了圆桌「参与角色」栏 `step2SidebarExpanded`)。并行结束**不自动展开**(与沙箱收起不回弹一致)。
- 并行两列改回**完整 Markdown**(撤销 §6.2 流式纯文本降级 `plainWhileStreaming`),与串行同款 `Step2Message`。
- 头像条**不再显示字数**(去掉 charCount 徽标 + 字段)。
- **致命 bug 已修**:用户干预 / 并行跑完后**不再重跑整张 DAG**。`dagPlanExecutedRef` 闸门 + DAG 各阶段跑完后释放锁 `await runOrchestration(handoff)` 交回总控继续派活/收口;后续干预/恢复一律走 leader 自由派活。
- **并行两列宽表溢出已修(2026-06-07)**:模型输出的 Markdown **表格**(如「法律免责条款」表)没有宽度约束 / 横向滚动容器,在并行两列的窄列里会**超出左列、盖到相邻列**。修法:`MessageBubble.sharedMarkdownComponents.table`(+ `markdown-components.tsx`)把 `<table>` 套进 `<div class="max-w-full overflow-x-auto">`——表格保持自然宽度、超出时**列内横向滚动**而非撑破列宽。`min-w-0` 链(grid `minmax(0,1fr)` → 列 `min-w-0` → 气泡 `flex-1 min-w-0`)本就齐全,只差 table 这一层。
- **关于「并行席位无步骤条」**:步骤条(`MessageStepsCard` 大卡)只在席位产生 `tool_calling` 步骤时出现(§12.1)。纯分析型席位若本轮**直接写分析正文、没调用任何工具**(web_search 等),就只显示一行「正在输出」思考指示、不出大卡——这是设计使然,非解析失败。要确认是否真调了工具:浏览器 devtools 设 `window.__MULTI_AGENT_DEBUG__=true` 看 `frame# event=messages` 里有无 tool_calls,或查后端 special run 日志。
### 12.3 ✅需求变更:Stage 间「经总控中转 + 广播」(2026-06-06 提出,2026-06-07 已实施)
**当前实现(方案 B,§3.2)**:DAG 各 stage 由前端**直接**派活,stage 间不跑 leader,靠前端把 `buildDependencySummary` 拼进下一 stage 的 task(`priorSummary`)。各 stage 跑完后才 `await runOrchestration` 交回总控收口。
**新诉求**:业务链条调用时,**每一轮 stage 之间都要经总控中转 + 广播**,而不是前端直接串下去。具体:
1. 第 1 轮并行调用 stage1 的 A、B、C;
2. A/B/C **全部完成后**,把这一批的**输出广播出来**(广播给其它席位 + 总控 thread);
3. **总控(leader)接收**这批交付 → 再**往下继续派活**(派 stage2 的 D、E);
4. 派活时把(承上的)消息**广播给下一轮的 D、E**;
5. 以此类推,直到所有 stage 跑完再收口。
> 即把 §3.2 的「方案 B 前端直派」改成 **「方案 A' = 计划约束下的总控中转」**:stage 边界插入一轮 leader,由它综合上一批交付并驱动下一批(下一批席位仍由编排计划**钉死**为 D、E,不是 leader 自由乱派——leader 负责「承上启下 + 广播上下文」,计划负责「派给谁」)。
**实施要点(下次会话)**:
- **每个 stage 完成 → 广播该批输出**:后端 `_special_run` 已对每个 seat 做 detached 后广播(「子智能体X完成…交付内容为:…」→ 其它席位 + 总控)。但它是 **detached/best-effort + 截断**;要让总控**可靠**地在下一轮前看到,需在 stage barrier 处**等广播落地**(或前端在 stage 边界用 `GET /threads/{id}/last-ai-message` 主动收齐各席位交付再喂给 leader)。
- **插入 leader 中转轮**:`runDagOrchestration` 在进入下一 stage 前,跑一轮 leader(`synthesisMode=true` 让后端注入上一批**完整**交付),让总控产出一段「承上启下」说明;把这段说明(而非机械 `priorSummary`)作为下一 stage 席位 task 的上下文 / 或广播给下一批席位 thread(复用 `POST /threads/{id}/messages/append`)。
- **下一批仍由计划钉死**:leader 中转轮**不**自由决定派给谁(计划已定 D、E);可参考 `buildChainLeaderTask` 的「约束 leader 只派给指定席位」思路,或干脆 leader 只做综合、前端照计划派 D、E 并把 leader 综合 + 广播注入其 task。
- **与「并行禁写」「§12.1 放开搜索」并存**:中转/广播不影响工具面。
- **与致命 bug 修复并存**:最后一个 stage 跑完仍走「交回总控(`runOrchestration`)收口/继续派活」;用户干预仍走 leader、不重跑 DAG。
**影响文件(预估)**:`hooks/useStep2Orchestration.ts`(`runDagOrchestration` 的 stage 循环里加 leader 中转 + 广播收齐)、可能复用后端 `_leader_run`(synthesisMode 注入全文)/ `/messages/append`(广播)/ `/last-ai-message`(收齐交付)。后端基本无需新接口。
**✅ 实施结论(2026-06-07)**:后端**零改动**,全部在前端 `runDagOrchestration` 内完成,复用既有「传摘要按需传全文」优化链路:
1. **中转轮位置**:每个**非最后**的 stage barrier 到达后(`Promise.all` 收齐本批交付),插入**一轮 leader**「中转轮」,展示为「总控协调(承上启下)」气泡。最后一个 stage 后**不**插中转,综合/收口仍交给末尾的 `runOrchestration` 接管(与致命 bug 修复一致)。
2. **可靠收齐交付(传摘要)**:中转轮跑 `streamMultiAgent({agentType:"leader", synthesisMode:false})`。后端 `_leader_run` 本就在每轮 leader 前用 `_collect_delivery_status`(走**轻量 `GET /last-ai-message?max_chars=600` 接口**)**客观核对**各席位真实交付并把**摘要**注入总控输入(`_build_seat_status_block`)——比 detached 广播可靠(直接实读各席位 thread,不依赖 best-effort 广播是否落地)。故中转轮传 `synthesisMode:false`:只综合本批、**不收口**,只注入摘要;**完整交付正文**留到末尾收口的 synthesis 轮(`_build_full_deliverables_block`,全 ✅ 才注入)按需读取——即「**传摘要按需传全文**」。
3. **承上启下驱动下一批**:中转轮产出的综合说明存进 `leaderBridge`,作为**下一 stage** 席位 task 的上下文(`buildStageTask({priorSummary: leaderBridge, priorIsBridge:true})`,措辞为「承上启下综合说明」而非机械「交付摘要」),替代原来的 `buildDependencySummary(deliveries)`。中转失败/无产出时回退机械依赖摘要,不阻断。
4. **下一批由计划钉死**:中转轮提示(`buildDagTransitTask`)明确告知总控「下一阶段席位已由编排计划钉死为 X、Y,你**不要**自行 `agent_orchestration` 派活、**不要**收口」,前端仍照计划直派下一批;即便弱模型仍派了活,前端忽略其 `dispatched`、只取 `content` 当 bridge。
5. **广播**:本批输出广播给其它席位 + 总控 = 后端 `_special_run` 既有 detached 后广播(截断摘要);承上消息给下一批 = 注入其 task `new_message`(席位 run 输入必见,比 `/messages/append` 更可靠,省一次写盘)。
6. **澄清/抢占/失败**:中转轮触发澄清 → 暂停 DAG(回复后走 leader 自由派活、不重跑 DAG,沿用 `dagPlanExecutedRef` 闸门);被 `isStale()`/abort 抢占 → 静默收尾;异常 → 该气泡标错误、清空 bridge 继续。
**纯函数**:`lib/dag-types.ts` 新增 `buildDagTransitTask`(中转轮提示)+ `buildStageTask` 加 `priorIsBridge` 形参;`lib/dag-types.test.ts` 加 4 个单测(共 27 个全过)。
> ⚠️ **§12.3 的「单独中转轮 + 前端直派」已被 §12.4 取代**(2026-06-07 同日):见下。`buildDagTransitTask` / `buildStageTask` / `buildDependencySummary` 仍保留在 lib(带单测),但 `runDagOrchestration` 不再调用它们。
### 12.4 ✅ 需求再变更:DAG 改成「总控驱动每 stage 派活」(2026-06-07,§3.2 方案 A)
**新诉求(用户原话)**:把并行(DAG)逻辑改得和串行一样——串行时总控给多个智能体派活、它们完成后汇报给总控、总控再继续往下派活;并行也改成「由总控判断要执行哪些步骤,这些步骤完成后再汇报给总控,总控继续往下派活」。
**已确认的两个关键决策**:
1. **每批「派给谁」**:✅ **计划钉死成员,但走总控派活**(不是总控自由乱派)。即 stage 成员仍由编排计划定死,但改成总控每轮**真正** `agent_orchestration` 派活给这些席位(约束只派给它们、且每个都派到)。= §3.2「方案 A」+ 批内并行。
2. **适用范围**:✅ **只改 dag 模式**(recommend 仍串行逐个、chain 不动)。
**实现(后端零改动,全在 `runDagOrchestration`)**:每个 stage 两步——
- **(a) 总控派活轮**:跑一轮 `leader`,提示 `buildDagStageLeaderTask`(首 stage 带任务背景;后续 stage 靠后端注入的此前交付摘要承上;列出本 stage 钉死席位的 `agent_name`,要求逐个派到、不派清单外、不收口)。`synthesisMode:false` → 后端 `_build_seat_status_block` 走轻量 `last-ai-message` 注入各席位**摘要**(传摘要按需传全文)。
- **(b) 批内并行**:本 stage 席位并行跑总控派给各自的 task——`pickDagSeatTask(leaderRes.dispatched, content, agentId, name)` 命中其 `agent_name`;总控漏派/写偏则回退总控正文/模板,**不借用别席位 task**(多席位并行避免张冠李戴)。完成后交付经后端 `_special_run` detached 后广播回总控;下一 stage 的派活轮即可看到。
- barrier 后进入下一 stage,总控再派下一批。全部 stage 跑完 → 交回 `runOrchestration` 收口(synthesisMode 按需注入全文)。
**相比 §12.3 的变化**:§12.3 是「前端直派 + stage 间单独插一轮只综合的中转轮」;§12.4 把**每个 stage 本身**就改成「总控派活轮 + 批内并行」——总控既承上(注入的摘要)又派活,不再需要单独的中转轮。与 chain 模式同构,区别是一次钉死一批、批内并行。
**纯函数**:`lib/dag-types.ts` 新增 `buildDagStageLeaderTask`(每 stage 派活提示)+ `pickDagSeatTask`(取某席位 task,多席位不张冠李戴);`lib/dag-types.test.ts` 加 5 个单测(共 32 个全过)。`runDagOrchestration` 不再用 `buildStageTask/buildDependencySummary/buildDagTransitTask`(保留在 lib)。
**Phase 7 流程图兼容**:总控派活轮无席位 active + `isOrchestrating` → 流程图「总控」节点显示 active(承上派活中),随后该 stage 列点亮,无缝衔接。
**下次实地验收**:①每个 stage 前出现「总控协调(派活中/承上派活中)」气泡,内容是总控对本 stage 席位的派活说明;②后端日志该轮 leader 的 `_partition_dispatch` 派给的正是本 stage 钉死席位;③这些席位并行跑(special 日志时间重叠);④完成后下一 stage 总控派活轮能看到上一批交付摘要(`[leader-roster]` 日志)。
### 12.5 ✅ 流程图弹窗化 + DAG「快速/沙箱」执行子模式(2026-06-07)
三个改动一起做:
**(1) 流程图改成弹窗**:Step2 顶部不再内联 `DagFlowChart`(挤占两列消息高度,截图 `样式优化-1.png`)。改成一条细栏「分层编排执行中 · 共 N 个阶段 + [查看编排流程图]」按钮,点开 `Dialog` 看**全尺寸**流程图(非 compact)。编辑器(`BusinessChainEditorPage`)底部预览保持内联不变。改 `Step2Panel.tsx`(加 `dagFlowOpen` state + Dialog)。
**(2) 推荐弹窗新增 DAG 执行子模式(快速/沙箱)**:`BusinessChainPicker` 在选中链条**有分层并行**(走 dag)时,「进入研讨」旁出现「执行方式:快速·双列禁写 / 沙箱·单列可写」二选一(只 dag 时出现,§已确认)。
- **快速(`fast`,默认)**:同 stage 席位**并行**、主区**双列**、`parallelNoFile=true`(seat_parallel **禁写文件**)。= 之前的行为。
- **沙箱(`sandbox`)**:同 stage 席位**仍并行**,但主区**单列**(`selectedParallelSeats` 最多 1)、`parallelNoFile=false`(seat policy **允许写文件**);点头像切换查看,沙箱跟随当前席位。
- 数据流:`SeatSelectionResult.dagExecMode` → `RoundtablePlanningPage` state(持久化进 `DraftStep2(Run)Snapshot.dagExecMode`,mirror orchestrationPlan)→ `useStep2Orchestration` option → `runDagOrchestration`。
**(3) 沙箱跟随当前显示席位(防 bug 重点,用户特别强调)**:`useStep2Orchestration`
- `sandboxFollowThreadIdRef` = 当前显示席位的 thread;`openSeatVirtualArtifact` 的 auto 守卫:sandbox 模式下,**后台并行席位**写文件时若不是当前显示席位,**不自动打开**(否则沙箱内容和主区列表错位)。
- 切换显示席位的 effect(keyed on `selectedParallelSeats` + `dagExecMode`):该席位**有产物**(stub 里有 write_file/str_replace)→ `openSeatLatestArtifact` 强制打开它最新的文件;**无产物**→ 关闭沙箱面板(`setActiveArtifactThreadId(null)` + `deselectArtifact` + `setArtifactsOpen(false)`)。
- 当前显示席位 live 写文件 → `handleArtifactToolPhase` auto-open,守卫放行(follow===该席位)。后台席位写 → 守卫拦掉、只缓存 stub;切到它时 effect 再打开。
- `toggleParallelSeat`:sandbox 单列 = 点头像切换(始终保留 1 个,不取消到空);fast 双列 = 原 LRU 2 个。
**改动文件**:`lib/business-chain.ts`(SeatSelectionResult.dagExecMode)、`components/BusinessChainPicker.tsx`(子模式 UI)、`api/drafts.ts`(快照 dagExecMode)、`pages/RoundtablePlanningPage.tsx`(state/snapshot/hydrate/confirm/reset 全链)、`hooks/useStep2Orchestration.ts`(option + parallelNoFile/单列 + 沙箱跟随 + toggle cap)、`components/Step2Panel.tsx`(流程图弹窗 + 单列 grid)。typecheck 零新增(基线 65),dag-types 32 单测全过。
**下次实地验收(沙箱跟随是重点)**:①推荐弹窗选有并行的链条 → 出现「快速/沙箱」二选一;②快速=双列禁写(无沙箱);③沙箱=单列、席位能写文件、点头像切换列表时沙箱内容**同步**切换:有产物显示、无产物关闭;④后台席位写文件不会把沙箱抢到非当前列表;⑤流程图点按钮弹窗、全尺寸、节点随执行点亮。
### 12.6 ✅ 修:并行席位「跟着派活」(2026-06-07)
**现象**(截图 `bug-1.png`):总控派活后,并行席位的气泡里显示的是**总控的整段派活叙述**(「我来调度各智能体…第1阶段任务清单:席位1…席位2…」),还去调 `skill_list` 找 `agent_orchestration`——席位在**模仿派活**。
**根因**:总控这轮**没真正调用 `agent_orchestration` 工具**(弱模型 deepseek 用文字罗列了派活计划)→ 后端解析的 `dispatched=[]` → 前端 `pickDagSeatTask` 回退把**总控整段叙述**喂给**每个**席位 → 席位照抄叙述、模仿派活。(席位本就被 run policy 禁了 `agent_orchestration`,**派不了活**,只是空转一轮叙述 + 调 `skill_list`。)
**修 v1(护栏,效果不足)**:`pickDagSeatTask` 加角色边界护栏 + 回退措辞改「挑出属于你的那份」;`buildDagStageLeaderTask` 加「必须真正调用工具」。→ **没拦住**:总控对 deepseek 仍只写文字不调工具(`dispatched` 还是空),席位拿到的**任务正文本身**就是那段「我来调度…席位1/2/3」,护栏被淹没;3/4 席位仍模仿派活,有的甚至绕道 `bash`+`python requests.get('/api/agents')` 调内部接口派活(bug-1~4)。
**✅ 修 v2(根治:回退绝不注入派活叙述)**:`pickDagSeatTask` 去掉 `leaderContent` 入参——
- **命中**总控结构化派活(真调了工具)→ `请你作为「X」,完成总控派给你的本阶段任务:<dispatched task>` + 护栏;
- **未命中 / `dispatched` 空**(总控只写文字)→ **绝不**把总控派活叙述喂给席位,改给**干净本职任务**:「请你作为「X」,从你的专业职责出发,独立完成本阶段属于你的分析与交付。任务背景与此前各阶段交付已在你的会话上下文里(后端广播注入),据此承接」+ 护栏。护栏强化到「不要用 bash/python/脚本访问内部接口(/api/agents、agent_orchestration)派活、不要复述『我来调度』总控口吻」。
- `buildDagStageLeaderTask` 的「必须真正调用工具」保留(让命中率更高、尽量走 exact-match)。
- `dag-types.test.ts` 改 2 单测(共 33 过)。typecheck 65。
> ⚠️ **残留次要污染源(若仍复发再处理)**:DAG 每个 stage 的 leader 轮,后端 `_leader_run` 会把 leader 的输入(含「请你作为总控…派活」)**预广播**进所有席位线程(`总控智能体收到用户消息:…`)。席位的**当轮任务**已干净(修 v2),这条只是历史上下文,理论上被「最新的干净任务 + 护栏」压制。若实测仍模仿派活,下一步:后端给 DAG leader 轮加 `suppress_pre_broadcast` / 或广播一条**净化**过的背景(去掉派活措辞)。
### 12.7 ✅ 修 4 项(2026-06-07):席位仍派活(v3 后端)+ 沙箱关不掉 + 并行不自动下滑 + 流程图只到 Step2
实测 §12.6 仍有席位派活(视觉设计师 `skill_manage` 找 agent-orchestration、列 dispatch all 4)——**坐实**了「残留次要污染源」:后端 leader 预广播把派活 prompt 推进席位线程,弱模型从历史里捡到派活意图。一起修了 4 项:
1. **席位仍派活(根治·后端)**:`_leader_run` 加 `suppress_pre_broadcast` 参数;DAG「每 stage 派活轮」前端传 `suppressPreBroadcast: true`(`api/multi-agent.ts` → `suppress_pre_broadcast`,run/stream 读取)。为真时:**不**把总控派活 prompt 预广播给席位、**也不**把「总控给 X 派活」detached 广播给兄弟席位。recommend/chain 不传该 flag,行为不变。配套:`pickDagSeatTask` 加 `seedMessage` 入参——预广播抑制后席位线程没有用户原始消息了,把**任务背景**直接写进席位 task(此前各阶段交付仍由 special 完成后广播注入)。→ 席位线程里**任何地方都没有派活语句**,根治模仿。
2. **沙箱关闭按钮关不掉**:沙箱跟随 effect 之前每次 render(deps 身份变)都重跑 → 把用户刚关的沙箱强制重开。加 `lastReconciledSeatRef` 守卫:**只在显示席位真正变化时**才开/关沙箱;用户手动关闭(席位没变)时 effect 早退,不再回弹。
3. **并行时自动下滑失效**:平铺列表 ↔ 网格(两列/单列)切换时 `scrollHeight` 突变、`scrollTop` 被浏览器 clamp → `useAutoScroll` 误判「用户上滚」而停跟随。`Step2Panel` 加 effect:`isParallelRunning` / `selectedParallelSeats` 变化时 `scrollStep2ToBottom()` 重新拉到底 + 恢复跟随。
4. **流程图只展示 Step2**:`DagFlowChart` 加 `showReportMilestone`(默认 true);Step2 弹窗 + 编辑器预览传 `false` —— 只到「共识」(Step2 终点),去掉「结果绘制」(那是 Step3)。
**改动**:后端 `multi_agent.py`(`_leader_run` suppress 两处广播 + run/stream 读 flag);前端 `api/multi-agent.ts`、`lib/dag-types.ts`(pickDagSeatTask + seedMessage)、`hooks/useStep2Orchestration.ts`(传 flag + seed + 沙箱跟随守卫)、`components/Step2Panel.tsx`(自动下滑 effect + showReportMilestone)、`components/DagFlowChart.tsx`、`pages/BusinessChainEditorPage.tsx`。`dag-types.test.ts` 共 **34 单测过**;前端 tsc 65 基线零新增;后端 `py_compile` OK。
### 12.8 ✅ 席位产出 md 文件 + 禁写 .py(2026-06-07)
**现象**:某次 7 席位**全都没产出 md 文件**;且一直有的问题——席位**有时写 .py 脚本**(用户要求:不写 .py,其它格式可以)。
**根因(没 md)**:两层都堵了——(a)**快速模式** `seat_parallel` policy 禁了 `write_file`(原「产物走正文」设计);(b)DAG 席位**提示词**写的是「直接输出你的交付正文」,即使沙箱模式(允许写)也不会去写文件。
**修**:
1. **后端 policy 放开写文件、统一禁 bash**(`roundtable_run_policy.py`):
- `seat_parallel`:去掉 `write_file` / `str_replace` 禁用(**可写 md**),保留禁 `bash` + 打断/越权类;仍放开 `web_search`。
- `seat`:新增禁 `bash`(原来允许)——圆桌席位是「文档产出者」,不该跑 shell/python 脚本(弱模型爱写 .py 再跑生成产物,乱且抓不到预览);改为**直接 write_file 写成品文档**。
- `report`:`bash` 已随 `seat` 禁,改为 `[*_SEAT_EXCLUDED, "str_replace"]`(集合不变)。
- 测试 `test_roundtable_run_policy.py`:`test_seats_can_write_files_but_not_run_bash`(seat / seat_parallel 都可写文件、都禁 bash),6 用例过。
2. **前端提示词**(`pickDagSeatTask`)加「产物要求」:**把完整交付写成一个 .md 文件**(write_file 到 outputs)+ 正文给摘要;**禁写 .py 或任何脚本文件**,.md/.html/.txt 等其它格式都可以,唯独不要 .py。
3. **快速模式(双列)不自动开沙箱**(`openSeatVirtualArtifact` 新增守卫):放开写文件后,双列里多席位并发写文件会让单沙箱预览反复串台——fast 模式 DAG 并行批次**完全不自动开沙箱**(文件仍写盘,Step3/文件卡可查看);沙箱模式(单列)仍按「沙箱跟随当前席位」预览。
**关于 .py 的强度**:`write_file` 是全局工具(所有 agent 共用),无法在网关层按扩展名硬拦,故 .py 禁止走**提示词**(强约束)+ **禁 bash**(写了也跑不了,脚本生成产物的动机被掐断)。若实测仍偶发写 .py 且需**硬拦**,下一步:给 lead_agent 的 `write_file` 工具加一个「按 run context 禁某些扩展名」的开关,网关 `_run_payload` 对圆桌席位置 `[".py"]`(只影响圆桌 run,不动其它 agent)。
**改动**:`roundtable_run_policy.py`、`test_roundtable_run_policy.py`、`lib/dag-types.ts`、`lib/dag-types.test.ts`(35 过)、`hooks/useStep2Orchestration.ts`。前端 tsc 65、后端 py_compile OK + 6 policy 单测过。⚠️ **改了后端,需重启 Gateway**。
### 12.9 ✅ 席位「按需取前序全文」只读工具 read_peer_delivery(2026-06-07)
**诉求**:A,B,C → D,E 时,D,E 默认拿 A,B,C 的**摘要**,但识别到需要**全文**时也能取到。之前只有总控(leader)能「摘要默认+按需全文」(跨 thread 读),下游席位**不能**(席位间 thread 隔离、只有 1500 字截断广播)。
**实现**(真·按需,全文只在调用工具时才进 LLM 上下文):
1. **新 harness 只读工具** `read_peer_delivery(seat_name)`(`packages/harness/deerflow/tools/builtins/roundtable_peers_tool.py`):从 `runtime.context["roundtable_peer_deliveries"]`(网关注入的 `[{name,content}]`)按席位名取全文;空名→列出可取席位;非圆桌场景→「无」。只读 context dict,**不跨 thread、不碰 sandbox、无网络**,纯 `deerflow.*`(不违反 harness→app 防火墙)。注册进 `tools/tools.py` 的 builtin(始终在,只读无副作用)。
2. **网关注入 context**:`_run_payload` 加 `extra_context`;`_special_run` 收 `peer_deliveries` → 注入 `context.roundtable_peer_deliveries`;run/stream 读 `peer_deliveries`(规整 `[{name,content}]`)。**不进 prompt → 不调工具就不耗 token**。
3. **run policy**:`read_peer_delivery` 从 `leader`(总控自己跨 thread 读,不需要)+ `report`(Step3 不读前序)排除;**席位(seat/seat_parallel)保留**。
4. **前端**:`runDagOrchestration` 跨 stage 累积**完整交付** `priorDeliveries`,stage>0 席位的 special run 带 `peerDeliveries`(`api/multi-agent.ts` → `peer_deliveries`);`pickDagSeatTask` 加 `hasPeers` 形参,有前序时在 task 里提示「需要某前序席位全文时调用 read_peer_delivery('<名>'),平时用摘要」。
**最终语义**:D、E 默认看 A、B、C 的**摘要**(1500 字广播);识别到不够 → 调 `read_peer_delivery('情报收集')` 取**全文**。总控仍是摘要默认+综合轮全文。**= 用户要的「摘要默认、按需全文」对席位也成立了。**
**改动**:后端 `roundtable_peers_tool.py`(新)、`tools/tools.py`、`roundtable_run_policy.py`、`multi_agent.py`;前端 `api/multi-agent.ts`、`lib/dag-types.ts`、`lib/dag-types.test.ts`(36 过)、`hooks/useStep2Orchestration.ts`。前端 tsc 65;后端 py_compile OK + 6 policy 单测过 + 工具导入/行为 smoke 通过。⚠️ **改了后端 + 新增 harness 工具,需重启 Gateway**。
### 12.10 ✅ 修:沙箱模式第一个席位不自动开沙箱(2026-06-07,前端)
**现象**:沙箱(单列)模式并行时,**第一个**席位的消息列表不自动打开沙箱(要手动开);切换到其它席位时正常自动打开。
**根因**:沙箱跟随 effect(§12.5)在选中席位**还没写产物**时走「关闭沙箱」分支,里面同时调了 `deselectArtifact()` **和** `setArtifactsOpen(false)`。后者是 context 的 **wrapped setOpen**(`context.tsx:73`):`关闭时若 autoOpen 为真 → 把 autoOpen 置 false`。于是第一个席位刚选中(还没写文件)→ autoOpen 被关 → 该席位**随后 live 写文件**时,`openSeatVirtualArtifact` 的 `!flags.autoOpen` 守卫拦掉自动打开。切换到**已写过**文件的席位能开,是因为那条路走 `openSeatLatestArtifact`(force,auto=false,绕过 autoOpen 守卫)。
**修**(一行):关闭分支只保留 `deselectArtifact()`(它走 raw `setOpen(false)`,**不**动 autoOpen),删掉多余的 `setArtifactsOpen(false)`。这样面板关掉、但 autoOpen 仍为真 → 第一个席位 live 写文件时正常自动开沙箱。(若用户**手动**关过沙箱,autoOpen 本就为 false,不会自动重开 —— 尊重用户。)改 `hooks/useStep2Orchestration.ts` 沙箱跟随 effect。tsc 65。
### 12.11 ✅ 修:快速模式又生成文件了 + Fragment 警告(2026-06-07);记录无关 404
1. **快速模式不应生成文件(回退 §12.8 的过度放开)**:§12.8 为「让席位产 md」把 `seat_parallel`(快速模式)也放开了 write_file —— 但**快速模式(双列)的设计就是产物走正文、不写文件**,写文件归**沙箱模式**(单列 + 沙箱预览)。修:
- `roundtable_run_policy.py`:`seat_parallel` 重新**禁 write_file / str_replace**(保留禁 bash);`seat`(沙箱模式)仍可写。
- `useStep2Orchestration.ts`:`parallelNoFile = dagExecMode !== "sandbox"`(快速模式一律禁写,含单节点 stage;沙箱模式放开)。
- `pickDagSeatTask` 加 `allowFiles` 形参:沙箱(true)=写 .md 文件 + 禁 .py;快速(false)=产物走正文、不写文件。hook 按 `dagExecMode === "sandbox"` 传。
- 测试:`test_sandbox_seat_writes_files_fast_parallel_does_not`(6 过);`dag-types.test.ts` 拆成沙箱/快速两个产物要求用例(37 过)。
- **语义**:快速=双列、产物走正文、不写文件;沙箱=单列、写 .md、沙箱跟随预览。
2. **`Invalid prop data-lov-id supplied to React.Fragment`**:`DagFlowChart` 的 stage 循环用了具名 `<Fragment key>`,dev 代码标注插件(lovable-tagger)给它注入 `data-lov-id`,而 Fragment 只接受 key/children → React 警告。修:换成 `<div className="contents" key=...>`(display:contents 不生成盒子,Connector/StageColumn 仍是外层 flex 直接子项,布局不变,且能接收 data-*)。移除 `Fragment` import。
3. **(无关,非本期引入)`/api/getTokenByEmail?email=…%40local.deerflow` 404 ×60**:来自 `src/core/studio/api.ts` 的 `fetchStudioToken`(studio/expert-auth 引导),用户邮箱被映射到 legacy `@local.deerflow` 域、后端 `getTokenByEmail` 不认 → 404,React Query 跨多个 `useStudioToken` 调用点重试 → 重复多次。**与圆桌 DAG 改动无关**(圆桌头像是本地 SVG,本期未新增任何前端 GET)。需要的话单独排查 studio token 链路。
---
## 13. 实施进度(截至 2026-06-06,下次会话续)
| Phase | 内容 | 状态 |
|---|---|---|
| 1 | DAG 数据模型 + 校验 + 持久化字段(`lib/dag-types.ts`,24 单测) | ✅ 完成 |
| 2 | 分层编辑器(**做进业务链条页**):链条加 `stages` 字段(前后端 + alembic `20260607_03` + 11 后端单测);`BusinessChainEditorPage` 竖列分组 Stage(DnD + 按钮);纯逻辑 `lib/chain-stages.ts`(19 单测) | ✅ 完成 |
| 3 | `runDagOrchestration`:批内 `Promise.all` 并行 + 批间 barrier 串行 + 依赖摘要注入(方案 B 前端直派) | ✅ 完成 |
| 4 | 并行 run policy `seat_parallel`(`roundtable_run_policy.py` + `multi_agent.py` 读 `parallel_no_file`,6 后端单测);**§12.1 方案 A 已放开 web_search/技能** | ✅ 完成 |
| 5 | 并行显示:收起左侧 + 头像条 + 两列 + 选中切换 | ✅ 完成(本轮修了侧栏/Markdown/字数) |
| 6 | 流式高性能:ref 全量累积 + 节流 flush(仅选中渲染) | ✅ 完成 |
| 7 | DAG 流程图执行态可视化(`DispatchChainModal` 复刻成按 stage 分列;后改成**弹窗**见 §12.5) | ✅ 完成(2026-06-07) |
**端到端闭环已通**:链条页编排分层 → 存库 → 选链条进 Step2 → `runDagOrchestration` 并行直派 → 各阶段完交回总控派活/收口。
**✅ §12.3 已实施(2026-06-07)**:见 §12.3「实施结论」。`runDagOrchestration` 在每个非末尾 stage barrier 后插入 leader 中转轮(`synthesisMode:false` 走轻量 `last-ai-message` 注入摘要、收口轮才按需全文),承上启下驱动下一批;后端零改动;`dag-types.ts` 加 `buildDagTransitTask` + `buildStageTask.priorIsBridge`,27 单测全过。
**下次会话 TODO(优先级从高到低)**:
1. **运行时实地验收 §12.3**:①每个 stage 之间出现「总控协调(承上启下)」气泡,内容是对上一批的综合;②后端日志中转轮 `[leader-roster] ... 注入席位交付状态` 显示上一批 ✅、未跑的 ⬜;③下一 stage 席位 task 里带的是总控承上说明(非机械摘要);④中转轮不应真的派活(即使派了前端也忽略)。
2. 运行时实地验收(旧):①§12.1 后并行席位能搜索、步骤条显示;②并行 special 日志时间重叠 + `Excluded` 不含 web_search 含 write_file;③用户干预后是「总控派活」而非重跑 DAG;④最左全局侧栏 + 参与角色栏并行收起且不回弹。
3. **✅ Phase 7 DAG 流程图可视化已完成(2026-06-07)**:见 §7.2。`DagFlowChart` 组件 + 编辑器只读预览 + Step2 执行态缩略(`dagNodeStates` 实时驱动)。
4. (可选)串行也确定性注入上一轮交付摘要。
**关键文件速查**:
- 前端:`hooks/useStep2Orchestration.ts`(`runDagOrchestration` / `dagPlanExecutedRef` / 并行 store+flush)、`components/Step2Panel.tsx`(两列+头像条)、`components/ParallelSeatStrip.tsx`、`lib/dag-types.ts`、`lib/chain-stages.ts`、`pages/BusinessChainEditorPage.tsx`、`pages/RoundtablePlanningPage.tsx`(侧栏收起 effect + handleConfirmAgents dag 分支)、`components/BusinessChainPicker.tsx`(链条→dag)。
- 后端:`app/gateway/roundtable_run_policy.py`(`seat_parallel`,**待放宽**)、`app/gateway/routers/multi_agent.py`(`_special_run` 读 `parallel_no_file`)、`persistence/roundtable_chains/`(`stages` 列)、`migrations/versions/20260607_03_chain_stages.py`。
- 测试:`frontend-web` 用 esbuild 转译 `.test.ts` 后 `node --test`(项目无 vitest);后端 `PYTHONPATH=. uv run pytest tests/test_roundtable_chain_stages.py tests/test_roundtable_run_policy.py`。
@@ -1,207 +0,0 @@
# 圆桌 Step 2 沙箱预览 · 开发进度
> 最后更新:2026-05-27
> 关联文档:`multi-agent-frontend-dev.md`(总览)、`multi-agent-backend-dev.md`(后端)、`multi-agent-api.md`(接口)
> 对照参考:主聊天 `/page/workspace/chats/:threadId`(`ChatBox` + `ArtifactsProvider` + `message-group.tsx`)
---
## 1. 目标
在 Step 2「多智能体圆桌研讨」中,当子智能体调用 `write_file` / `str_replace` / `present_files` 时,**行为与主聊天一致**:
| 能力 | 主聊天 | Step 2 目标 |
|------|--------|-------------|
| `write_file` 流式期间自动打开右侧沙箱 | ✅ | ✅ 虚拟 URL |
| 步骤卡里点击文件路径打开沙箱 | ✅ | ✅ |
| 消息下方文件卡片(`present_files`)点击打开 | ✅ | ✅ `PresentFilesRow` |
| 沙箱打开时收起 workspace 左侧栏 | ✅(`ArtifactsProvider.select`) | ✅ Step 2 左栏联动 |
| 60/40 对话 \| 产物预览分栏 | ✅ `ChatBox` | ✅ `Step2SandboxLayout` |
| 流式 MD 从 tool_call.args.content 预览 | ✅ 虚拟 URL + stub messages | ✅ `seatStubMessages`(双通道:`onPhaseChange` + `onUpstreamFrame`) |
| 落盘后 HTTP 拉取真实文件 | ✅ artifacts API | ✅ `present_files` 卡片走 HTTP |
| 草稿 reload 后历史文件可预览 | ✅ | ✅ `seatStubMessages` 一并入草稿 |
---
## 2. 已完成
### 2.1 Step 2 布局调整
| 项 | 状态 | 说明 |
|----|------|------|
| 推理模型下拉 | ✅ | 从右侧栏迁至 Step 2 顶栏「多智能体圆桌会商中心」右侧 |
| 意图理解 | ✅ | 移至左侧栏顶部,支持展开/收起;收起时仅显示「目标」 |
| 任务概览 | ✅ | 放在「人工参与」下方 |
| 右侧栏 Step 2/3 隐藏 | ✅ | `RightSidebar` 仅在 `currentStep === 1` 渲染 |
| 左侧栏整体收起 | ✅ | 宽度 56px;展开按钮在顶部;研讨中角色 ring + ping 动画 |
| 沙箱打开 → 左栏收起 | ✅ | `Step2SandboxSidebarSync` 监听 `useArtifacts().open` |
**主要文件**:`RoundtablePlanningPage.tsx`(`step2SidebarExpanded`、`LeftSidebar`、`Step2SandboxSidebarSync`)
### 2.2 沙箱基础设施(Phase 1)
| 项 | 状态 | 说明 |
|----|------|------|
| 路由包 `ChatRuntime` | ✅ | `WorkspaceRoutes.tsx` → `roundtable/planning` 外包 `ArtifactsProvider` |
| 60/40 分栏壳 | ✅ | `Step2SandboxLayout.tsx`,对齐 `chat-box.tsx` |
| `ArtifactFileDetail` / `ArtifactFileList` | ✅ | 嵌在右栏 ResizablePanel |
| `ThreadContext` stub | ✅ | 最小 `BaseStream` stub,供 `useArtifactContent` 工作 |
### 2.3 沙箱打开逻辑(Phase 2)
| 项 | 状态 | 说明 |
|----|------|------|
| `write_file` 自动打开 | ✅ | `handleArtifactToolPhase` 在 `tool_calling` + 完整 `args.path` 时触发 |
| 步骤卡点击打开 | ✅ | `MessageStepsCard` → `openArtifactFromStep` |
| 虚拟 URL 机制 | ✅ | `write-file:<path>?message_id=...&tool_call_id=...`(对齐主聊天) |
| Stub messages 注入 | ✅ | `seatStubMessages` + `upsertSeatToolCall` → `loadArtifactContentFromToolCall` |
| 重置清理 | ✅ | `resetStep2State` 清空 artifacts / stub / deselect |
| `present_files` 步骤点击 | ✅ | 走真实路径 `openArtifactPreview`(HTTP API) |
### 2.4 上游帧兜底注入(Phase 3)
| 项 | 状态 | 说明 |
|----|------|------|
| `onPhaseChange` 主通道 | ✅ | `tool_calling` 阶段每帧把完整 `args` 喂进 stub;首次出现 `path` 即触发自动打开 |
| `onUpstreamFrame` 兜底通道 | ✅ | `handleUpstreamFrame` 扫描 messages-tuple 帧的 `tool_calls[]`,对 `write_file`/`str_replace` 持续 upsert `args.content`;覆盖 phase 已切走但 tool_calls 又回包的边界 |
| 双通道幂等 | ✅ | `upsertSeatToolCall` 按 `(toolName, path)` 去重,两个通道并发写入安全 |
### 2.5 `present_files` 文件卡(Phase 4)
| 项 | 状态 | 说明 |
|----|------|------|
| 气泡下方文件卡行 | ✅ | `Step2Message` → `collectPresentedFiles` → `PresentFilesRow` |
| 卡片样式 | ✅ | 文件名 + 扩展名徽章(FILE_EXT_LABELS),最小宽度 30/最大 50(hover 边框亮色) |
| 点击 → HTTP 预览 | ✅ | `openArtifactPreview(agentThreadId, path)`,与步骤卡 `present_files` 走同一通道 |
| 席位 thread 缺失保护 | ✅ | 没有 `agentThreadId` 时 disabled + tooltip |
| 同气泡多次 present_files | ✅ | `filepaths` 顺序去重,全部聚合到一行卡片 |
### 2.6 草稿持久化(Phase 5)
| 项 | 状态 | 说明 |
|----|------|------|
| `seatStubMessages` 进 `Step2Snapshot` | ✅ | `useStep2Orchestration.ts` Step2Snapshot 新增字段 |
| `seatStubMessages` 进 `DraftStep2Snapshot` | ✅ | `drafts.ts` 加 optional 字段,老草稿 reload 时回退到 `{}` |
| `hydrateFromDraft` 还原 stub | ✅ | 同步写 `seatStubMessagesRef` + state,加载草稿后点击历史 write_file 步骤可直接预览 |
| `getStep2Snapshot` 透出 stub | ✅ | `RoundtablePlanningPage.tsx` 给 `useDraftPersistence` 的 getter 加上该字段 |
**主要文件**:
```
frontend-web/src/pages/WorkspaceRoutes.tsx
frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts
frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx
frontend-web/src/roundtable-planning/components/Step2Panel.tsx
frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx
frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
frontend-web/src/roundtable-planning/utils/drafts.ts
```
### 2.7 与主聊天对齐的核心数据流
```
special seat SSE (streamMultiAgent)
├── onPhaseChange("tool_calling", "write_file", args)
│ ↓
│ upsertSeatToolCall(seatThreadId, "write_file", args)
│ → seatStubMessages[seatThreadId] = [{ type:"ai", tool_calls:[{ id, name, args }] }]
│ ↓
│ buildWriteFileVirtualUrl(path, messageId, toolCallId)
│ → "write-file:/mnt/user-data/outputs/foo.md?message_id=...&tool_call_id=..."
│ ↓
│ openSeatVirtualArtifact(seatThreadId, virtualUrl, auto=true)
│ → selectArtifact(virtualUrl) + setArtifactsOpen(true)
│
└── onUpstreamFrame(frame) ← 兜底通道
↓
handleUpstreamFrame —— 持续 upsert tool_calls 里完整的 args(不打开沙箱)
Step2SandboxLayout
ThreadContext.thread.messages = seatStubMessages[activeThreadId]
ArtifactFileDetail(filepath=virtualUrl, threadId=seatThreadId)
↓
useArtifactContent → loadArtifactContentFromToolCall
→ 从 thread.messages 读 tool_call.args.content(不发 HTTP)
```
**为何不用真实 path 直接预览**:流式期间文件可能尚未落盘,直接调 `/api/threads/:id/artifacts/...` 会返回 `404 {"detail":"Not Found"}`。主聊天同样用虚拟 URL 绕过此问题。
---
## 3. 未完成 / 待验证
### 3.1 功能缺口(按优先级)
| 优先级 | 项 | 状态 | 说明 |
|--------|----|------|------|
| P0 | 端到端回归 | ⚠️ 待验证 | 虚拟 URL + 双通道 + 文件卡 + 草稿持久化全链路尚未在浏览器里跑一遍 |
| P2 | `str_replace` 流式编辑预览 | ❌ | 主聊天 `applyPendingTextEdit` 依赖完整 message 链;stub 未模拟 tool result / 多轮 str_replace 累加 |
| P2 | 沙箱 `artifacts[]` 列表与虚拟 URL 混排 | ❌ | 虚拟 URL 不写入 `setArtifacts([])`;多文件切换、文件名下拉体验未完全对齐主聊天 |
| P3 | Step 3 与沙箱衔接 | ❌ | Step 3 仍用 `HighFidelityReport` + `listSessionArtifacts`;与 Step 2 沙箱选中态无联动 |
| P3 | 文档同步 | ❌ | `multi-agent-frontend-dev.md` 仍描述旧布局(右侧栏模型选择器等),需更新 § 布局 + 沙箱章节 |
### 3.2 已知风险 / 边界
1. **打开时机**:仅在 SSE 进入 `tool_calling` 且 `tool_calls[0].args` 为完整对象时触发自动打开(`multi-agent.ts` 刻意忽略 `tool_call_chunks` 的拼接 args)。若模型只发 chunks、迟迟不发完整 tool_calls,自动打开会延迟到 args 完整那一帧——与主聊天一致。
2. **多 write_file 同气泡**:`upsertSeatToolCall` 按 `(toolName, path)` 幂等;同 path 多次 write 会更新 args,不会新建多条 stub tool_call。
3. **leader 席位**:当前仅 special 流绑定 `agentThreadId` 并接 `onUpstreamFrame`;总控若直接 write_file,需单独接 thread id 与帧回调。
4. **`present_files` 文件大小**:流式期间 backend 尚未给出真实大小;卡片只显示扩展名徽章,不显示 size。落盘后用户点开走 HTTP API 自然能看到完整内容。
5. **调试日志**:控制台 `[roundtable-sandbox]` 前缀;生产环境可考虑加 `window.__ROUNDTABLE_SANDBOX_DEBUG__` 门控。
---
## 4. 分阶段计划(原始规划 vs 现状)
| 阶段 | 内容 | 状态 |
|------|------|------|
| Phase 1 | `ChatRuntime` + 分栏壳 + 侧栏联动 | ✅ 完成 |
| Phase 2 | `write_file` 自动/点击打开 + 虚拟 URL 预览 | ✅ 完成 |
| Phase 3 | `onUpstreamFrame` 注入完整 LangGraph messages | ✅ 完成(双通道兜底) |
| Phase 4 | `present_files` 卡片 + Step 3 文件区衔接 | ⚠️ 卡片完成;Step 3 衔接遗留 |
| Phase 5 | 草稿 stub 持久化 + 文档更新 | ✅ stub 完成;本文档同步更新 |
---
## 5. 测试清单
在 `/page/workspace/roundtable/planning` Step 2 下逐项勾选:
- [ ] 子智能体 `write_file` 进入 `tool_calling` 后,右侧沙箱 **自动** 打开(约 100ms 延迟,对齐主聊天)
- [ ] 沙箱标题为文件名(如 `sample-paper.md`),**不是** `{"detail":"Not Found"}`
- [ ] 流式期间 MD 内容随 `args.content` 更新(双通道:phase 切换帧 + 兜底帧)
- [ ] 点击 ChainOfThought 步骤「写入文件」/ 路径 chip → 手动打开同一文件
- [ ] `present_files` 后气泡下方出现文件卡(带扩展名徽章),点击 → HTTP 预览
- [ ] 沙箱打开时左侧「意图理解」栏 **自动收起**
- [ ] 关闭沙箱(X)后对话区恢复全宽
- [ ] 「新建任务」后 artifacts / 沙箱 / `seatStubMessages` 全部清空
- [ ] 「保存草稿」→「加载草稿」后,点击历史 `write_file` 步骤可立即预览(stub 已还原)
- [ ] 与主聊天 `/page/workspace/chats/:threadId` 对比:同一 write_file 行为一致
**调试**:DevTools Console 过滤 `[roundtable-sandbox]` 或 `[stream:special:...]`。
---
## 6. 关键代码索引
| 用途 | 路径 |
|------|------|
| 主聊天 write_file 虚拟 URL + 自动打开 | `src/components/workspace/messages/message-group.tsx` |
| 主聊天 `RichFileCard`(present_files) | `src/components/workspace/messages/message-list-item.tsx` |
| 主聊天 60/40 分栏 | `src/components/workspace/chats/chat-box.tsx` |
| 虚拟 URL 内容加载 | `src/core/artifacts/loader.ts` → `loadArtifactContentFromToolCall` |
| Artifacts 上下文 | `src/components/workspace/artifacts/context.tsx` |
| Step 2 编排 + 沙箱状态 + 草稿快照 | `src/roundtable-planning/hooks/useStep2Orchestration.ts` |
| Step 2 沙箱布局 | `src/roundtable-planning/components/Step2SandboxLayout.tsx` |
| Step 2 文件卡 + 步骤渲染 | `src/roundtable-planning/components/Step2Panel.tsx` |
| SSE phase 推断 + `onUpstreamFrame` | `src/roundtable-planning/api/multi-agent.ts` → `setPhase` |
| 主页面侧栏 + 联动 | `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
| 草稿持久化 | `src/roundtable-planning/utils/drafts.ts`、`hooks/useDraftPersistence.ts` |
---
## 7. 下一步建议(给接续开发者)
1. **先跑通 P0 测试清单**,确认 Phase 3/4/5 改造后无回归;重点观察双通道并发写入 stub 时 args 是否被旧帧覆盖(理论上 LangGraph 完整 tool_calls 是单调累积的,应该没问题)。
2. **Phase 4 收尾**:把 Step 3 的 `listSessionArtifacts` / `HighFidelityReport` 接进沙箱选中态——Step 3 进入时复用 Step 2 的 `ArtifactsProvider`,让用户在 Step 3 也能预览 Step 2 留下的产物。
3. **Phase 2 边界**:`str_replace` 多轮编辑的 stub 模拟(追加 tool result 消息让 `applyPendingTextEdit` 能 replay edit 序列),优先级低。
4. **更新** `multi-agent-frontend-dev.md`:Step 2 布局、沙箱架构、`seatStubMessages` 数据流,避免与本文档重复——可在总览文档加一节「见 `roundtable-step2-sandbox-progress.md`」。
@@ -1,580 +0,0 @@
# 深蓝色 UI 规范 · 00 基础(色彩 / 字体 / 阴影 / 圆角 / 间距 / 分割线)
> 本文件是《深蓝色 UI 规范》拆分后的**基础篇(§1–§7)**,是全站设计令牌的唯一来源。
> 其余分册见同目录 `README.md`;可直接 import 的变量见 `tokens.css`。
> 跨章节 `§N` 编号全局唯一,所属文件见 README 的「§ 索引」。
**本篇目录(§1–§7)**:`1` 色彩 · `2` 字体 · `3` 阴影 · `4` 圆角 · `5` 按钮 · `6` 分割线 · `7` 间距
---
## 1. 色彩
### 1.1 标准色
色彩体系由四个色系构成,每个色系分主色与辅色两组,每组按明度从浅到深编号(01 最浅,数字越大越深)。
---
#### 1.1.1 色系总览
| 色系 | 用途定位 | 代码前缀 |
|------|----------|----------|
| 渐红系 | 警示、错误、强调操作 | ZS-001 |
| 渐蓝系 | 信息、链接、次级操作 | ZS-002 |
| 渐青系 | 成功、在线状态、数据高亮 | ZS-003 |
| 深蓝系 | 页面背景、容器、导航底色 | ZS-004 |
---
#### 1.1.2 渐红系(ZS-001)
用于错误提示、危险操作、重要告警。
| 编号 | 色值 | 说明 |
|------|------|------|
| ZS-001-01 | `#FFF1F0` | 极浅红,错误背景底色 |
| ZS-001-02 | `#FFCCC7` | 浅红,输入框错误边框 |
| ZS-001-03 | `#FFA39E` | — |
| ZS-001-04 | `#FF7875` | — |
| ZS-001-05 | `#FF4D4F` | 标准红,Tag / Badge |
| ZS-001-06 | `#F5222D` | 主色红,按钮 / 图标 |
| ZS-001-07 | `#CF1322` | 深红,Hover 状态 |
| ZS-001-08 | `#A8071A` | 深红,Active / Press |
| ZS-001-09 | `#820014` | 极深红 |
| ZS-001-10 | `#5C0011` | 最深红 |
---
#### 1.1.3 渐蓝系(ZS-002)
用于信息提示、链接、次级按钮。
| 编号 | 色值 | 说明 |
|------|------|------|
| ZS-002-01 | `#E6F4FF` | 极浅蓝,信息背景底色 |
| ZS-002-02 | `#BAE0FF` | 浅蓝 |
| ZS-002-03 | `#91CAFF` | — |
| ZS-002-04 | `#69B1FF` | — |
| ZS-002-05 | `#4096FF` | 标准蓝,链接 / 信息 Tag |
| ZS-002-06 | `#1677FF` | 主色蓝,主操作按钮 |
| ZS-002-07 | `#0958D9` | 深蓝,Hover |
| ZS-002-08 | `#003EB3` | 深蓝,Active |
| ZS-002-09 | `#002C8C` | 极深蓝 |
| ZS-002-10 | `#001D6C` | 最深蓝 |
---
#### 1.1.4 渐青系(ZS-003)
用于成功状态、在线标识、数据图表高亮。
| 编号 | 色值 | 说明 |
|------|------|------|
| ZS-003-01 | `#E6FFFB` | 极浅青,成功背景 |
| ZS-003-02 | `#B5F5EC` | 浅青 |
| ZS-003-03 | `#87E8DE` | — |
| ZS-003-04 | `#5CDBD3` | — |
| ZS-003-05 | `#36CFC9` | 标准青,成功 Tag |
| ZS-003-06 | `#13C2C2` | 主色青,在线点 / 图标 |
| ZS-003-07 | `#08979C` | 深青,Hover |
| ZS-003-08 | `#006D75` | 深青,Active |
| ZS-003-09 | `#00474F` | 极深青 |
| ZS-003-10 | `#002329` | 最深青 |
---
#### 1.1.5 深蓝系(ZS-004)
用于页面背景、导航栏、侧边栏、卡片容器。这是深蓝主题的核心色系。
| 编号 | 色值 | 说明 |
|------|------|------|
| ZS-004-01 | `#E8EDF5` | 极浅,分割线 / 禁用底色 |
| ZS-004-02 | `#C5CFE0` | 浅,边框 |
| ZS-004-03 | `#9BADC8` | — |
| ZS-004-04 | `#6E88B0` | — |
| ZS-004-05 | `#4A6491` | 中深蓝,次级文字 |
| ZS-004-06 | `#2D4473` | 深蓝,卡片容器背景 |
| ZS-004-07 | `#1A2F5A` | 深蓝,侧边栏背景 |
| ZS-004-08 | `#122347` | 深蓝,导航栏背景 |
| ZS-004-09 | `#0C1836` | 极深蓝,页面背景 |
| ZS-004-10 | `#060E24` | 最深,全局底色 |
---
#### 1.1.6 功能色(主色 / 字体色 / 辅色 / 背景色)
从标准色系中提取,用于具体 UI 角色指定。
| 角色 | 色值 | 来源 |
|------|------|------|
| 主色(Primary) | `#1677FF` | ZS-002-06 |
| 主色 Hover | `#0958D9` | ZS-002-07 |
| 主色 Active | `#003EB3` | ZS-002-08 |
| 正常字体色 | `#C5CFE0` | ZS-004-02 |
| 次级字体色 | `#6E88B0` | ZS-004-04 |
| 禁用字体色 | `#4A6491` | ZS-004-05 |
| 辅色(Accent) | `#13C2C2` | ZS-003-06 |
| 页面背景色 | `#0C1836` | ZS-004-09 |
| 容器背景色 | `#1A2F5A` | ZS-004-07 |
| 白色 | `#FFFFFF` | — |
---
#### 1.1.7 小面积装饰色
用于图标点缀、徽标、少量强调,不用于大面积背景。
| 色值 | 说明 |
|------|------|
| `#6388FF` | 紫蓝,图标装饰 |
| `#F5222D` | 红色,危险徽标 |
| `#12306C` | 深蓝,品牌点缀 |
| `#478880` | 青绿,辅助点缀 |
| `#950404` | 深红,重要告警点 |
---
#### 1.1.8 图表配色
**通用图表色序**(面积较大,如柱状图、折线图、饼图):
| 序号 | 色值 |
|------|------|
| 1 | `#1677FF` |
| 2 | `#13C2C2` |
| 3 | `#4096FF` |
| 4 | `#36CFC9` |
| 5 | `#69B1FF` |
| 6 | `#5CDBD3` |
| 7 | `#91CAFF` |
| 8 | `#87E8DE` |
**小面积图表色序**(面积较小,如散点、小徽章、迷你图):
| 序号 | 色值 |
|------|------|
| 1 | `#4096FF` |
| 2 | `#36CFC9` |
| 3 | `#FF4D4F` |
| 4 | `#FFA39E` |
| 5 | `#6388FF` |
| 6 | `#478880` |
---
#### 1.1.9 功能语义色(Functional / Status)
承载「成功 / 警告 / 错误 / 信息 / 处理中」语义。其中**错误红、信息蓝、处理中青**直接复用标准色;**成功绿、警告橙**为标准色系之外的补充色(深色背景下 ZS-003 青偏冷、且无现成橙),在此正式收录。全站语义场景一律引用本表,不再各处自定义。
| 语义 | 色值 | 来源 | 典型用途 |
|------|------|------|----------|
| 成功 / 已完成 | `#52C41A` | 补充色(绿) | 成功提示、完成状态、通过校验 |
| 警告 / 进行中 | `#FAAD14` | 补充色(橙) | 警告提示、进行中、需注意 |
| 错误 / 失败 | `#F5222D` | ZS-001-06 | 错误提示、删除、失败状态 |
| 信息 / 默认 | `#1677FF` | ZS-002-06 | 信息提示、主操作、链接 |
| 处理中 / 等待 | `#13C2C2` | ZS-003-06 | 处理中、排队、等待反馈 |
> 浅底变体(Message / Alert / Tag 底色):前景色取 16% 透明度,如成功底 `rgba(82,196,26,0.16)`、警告底 `rgba(250,173,20,0.16)`。
> CSS Token 见 `tokens.css` 的 `--func-success / --func-warning / --func-error / --func-info / --func-processing`。
---
## 2. 字体
### 2.1 全局样式 - 字体
---
#### 2.1.1 大屏页面字体规范
##### 中文字体:Noto Sans S Chinese
| 样式 | 使用场景 |
|------|----------|
| 24px Bold | 一级标题 |
| 24px Medium | 次要一级标题 |
| 22px Medium | 二级标题 |
| 22px Regular | 正文 |
| 20px Regular | 排版空间有限的正文 |
##### 数字字体:D-DIN
| 样式 | 使用场景 |
|------|----------|
| 32px Bold | 重要数据展示 |
| 32px Regular | 普通数据展示 |
| 26px Bold | 常规数据展示 |
| 26px Regular | 常规数据展示 |
##### 特殊数字:DS-Digital
| 样式 | 使用场景 |
|------|----------|
| 50px Bold | 大屏主数据展示 |
##### 表格字体:Arial
| 样式 | 使用场景 |
|------|----------|
| 20px Regular | 表格横纵轴数字 |
---
#### 2.1.2 后台系统字体规范
##### 中文字体:Noto Sans S Chinese
| 样式 | 使用场景 |
|------|----------|
| 18px Bold | 导航、一级标题 |
| 18px Medium | 次要一级标题 |
| 16px Bold | 二级标题 |
| 16px Medium | 次要二级标题 |
| 16px Regular | 正文、按钮内部文字等 |
| 16px Light | 次要正文 |
##### 数字字体:D-DIN
| 样式 | 使用场景 |
|------|----------|
| 26px Bold | 重要数据展示 |
| 26px Regular | 普通数据展示 |
| 24px Bold | 常规数据展示(小) |
| 24px Regular | 常规数据展示(小) |
##### LOGO 字体:DOUYUFont
| 样式 | 使用场景 |
|------|----------|
| 20px 文字样式 | 页面顶部系统名称 |
##### 表格数字:Arial
| 样式 | 使用场景 |
|------|----------|
| 16px Regular | 表格横纵轴数字 |
| 14px Regular | 表格横轴数字(空间有限) |
---
## 3. 阴影
### 3.1 容器与卡片配色
阴影组通过深浅层级叠加形成空间感:外层容器最深,内层卡片次之,边框与高亮文字使用主色或字体色。
#### 3.1.1 外层容器
| 角色 | 色值 | 来源 |
|------|------|------|
| 页面 / 最外层背景 | `#060E24` | ZS-004-10 |
| 分栏容器背景 | `#0C1836` | ZS-004-09 |
#### 3.1.2 卡片(标题 + 操作)
| 角色 | 色值 | 来源 |
|------|------|------|
| 卡片背景 | `#122347` | ZS-004-08 |
| 卡片边框 | `#1A2F5A` | ZS-004-07 |
| 卡片阴影(外发光) | `rgba(22, 119, 255, 0.12)` | 基于 ZS-002-06 主色蓝 12% 透明度 |
| 标题文字(16px Bold) | `#C5CFE0` | ZS-004-02(正常字体色) |
| 操作按钮文字 | `#4096FF` | ZS-002-05(标准蓝,链接) |
| 次级操作图标(✏️ / ⋯) | `#6E88B0` | ZS-004-04(次级字体色) |
#### 3.1.3 卡片新增(占位卡片)
虚线描边的"新增入口"卡片,用于引导用户添加内容。
| 角色 | 色值 | 来源 |
|------|------|------|
| 卡片背景 | `#0C1836` | ZS-004-09(与外层同色,营造凹陷感) |
| 虚线边框 | `#4096FF` | ZS-002-05 |
| 加号图标 | `#4096FF` | ZS-002-05 |
#### 3.1.4 阴影投影规则
| 层级 | 阴影值 | 使用场景 |
|------|--------|----------|
| 一级阴影(卡片悬浮) | `0 2px 8px rgba(22, 119, 255, 0.12)` | 普通卡片 |
| 二级阴影(hover / 强调) | `0 4px 16px rgba(22, 119, 255, 0.24)` | 卡片 hover、弹出层 |
| 三级阴影(弹窗 / Modal) | `0 8px 32px rgba(6, 14, 36, 0.6)` | Dialog、Drawer |
---
## 4. 圆角
### 4.1 圆角统一规范
| 元素 | 圆角值 | 说明 |
|------|--------|------|
| 按钮 | `2px` | 所有按钮(主按钮 / 次按钮 / 文本按钮)统一使用 |
| 卡片 | `4px` | 内容卡片、标题+操作卡片、卡片新增占位 |
| 页面分栏框 | `4px` | 外层容器、分栏区域 |
| 输入框 / 选择器 | `2px` | 与按钮一致,保持表单内对齐 |
| Tag / Badge | `2px` | 与按钮一致 |
| Modal / Drawer | `4px` | 与卡片一致 |
### 4.2 按钮配色(圆角 2px)
#### 4.2.1 主按钮(Primary)
| 角色 | 色值 | 来源 |
|------|------|------|
| 背景 | `#1677FF` | ZS-002-06 |
| 文字 | `#FFFFFF` | — |
| Hover 背景 | `#0958D9` | ZS-002-07 |
| Active 背景 | `#003EB3` | ZS-002-08 |
| 禁用背景 | `#4A6491` | ZS-004-05 |
| 禁用文字 | `#6E88B0` | ZS-004-04 |
#### 4.2.2 次按钮(Outline / Default)
| 角色 | 色值 | 来源 |
|------|------|------|
| 背景 | `transparent` | — |
| 边框 | `#6E88B0` | ZS-004-04 |
| 文字 | `#C5CFE0` | ZS-004-02 |
| Hover 边框 | `#4096FF` | ZS-002-05 |
| Hover 文字 | `#4096FF` | ZS-002-05 |
| Active 边框 | `#1677FF` | ZS-002-06 |
### 4.3 卡片与分栏(圆角 4px)
| 角色 | 色值 | 来源 |
|------|------|------|
| 卡片背景 | `#122347` | ZS-004-08 |
| 卡片边框 | `#1A2F5A` | ZS-004-07 |
| 分栏框背景 | `#0C1836` | ZS-004-09 |
| 分栏框边框 | `#1A2F5A` | ZS-004-07 |
---
## 5. 按钮
> 全部圆角 `2px`,文字字号 14px(小尺寸)/ 14px(正常尺寸),字体 Noto Sans S Chinese Regular。
### 5.1 按钮尺寸
| 规格 | 高度 | 内边距 | 使用场景 |
|------|------|--------|----------|
| 小 | `28px` | `0 10px` | 空间小的板块内部,如表格中、卡片标题等 |
| 正常 | `32px` | `0 14px` | 默认尺寸,所有常用按钮 |
### 5.2 按钮类型与配色
#### 5.2.1 主按钮(Primary)
> 一个操作区域只能有一个主按钮。
| 状态 | 背景 | 边框 | 文字 |
|------|------|------|------|
| 默认 | `#1677FF` (ZS-002-06) | — | `#FFFFFF` |
| Hover | `#0958D9` (ZS-002-07) | — | `#FFFFFF` |
| Active | `#003EB3` (ZS-002-08) | — | `#FFFFFF` |
| 失效 | `rgba(22, 119, 255, 0.35)` | — | `#6E88B0` (ZS-004-04) |
#### 5.2.2 默认按钮(Default / Outline)
用于"取消 / 返回"等次要操作,与主按钮成对出现时主按钮在右。
| 状态 | 背景 | 边框 | 文字 |
|------|------|------|------|
| 默认 | `transparent` | `#6E88B0` (ZS-004-04) | `#C5CFE0` (ZS-004-02) |
| Hover | `rgba(22, 119, 255, 0.08)` | `#4096FF` (ZS-002-05) | `#4096FF` (ZS-002-05) |
| Active | `rgba(22, 119, 255, 0.16)` | `#1677FF` (ZS-002-06) | `#1677FF` (ZS-002-06) |
| 失效 | `transparent` | `#4A6491` (ZS-004-05) | `#4A6491` (ZS-004-05) |
#### 5.2.3 文本按钮(Text)
用于表格操作、文件下载等。无背景与边框,靠文字色识别。
| 状态 | 文字 | 装饰 |
|------|------|------|
| 默认 | `#4096FF` (ZS-002-05) | 无下划线 |
| Hover | `#1677FF` (ZS-002-06) | 显示下划线(与文字同色) |
| Active | `#0958D9` (ZS-002-07) | 下划线 |
| 失效 | `#4A6491` (ZS-004-05) | 无装饰 |
#### 5.2.4 图标按钮(Icon-only)
用于增强辨识度或节省空间。仅图标,无文字。
| 变体 | 背景 | 图标颜色 |
|------|------|----------|
| 主色(常用) | `#1677FF` (ZS-002-06) | `#FFFFFF` |
| 主色 Hover | `#0958D9` (ZS-002-07) | `#FFFFFF` |
| 危险(删除等) | `#F5222D` (ZS-001-06) | `#FFFFFF` |
| 危险 Hover | `#CF1322` (ZS-001-07) | `#FFFFFF` |
| 透明(仅图标) | `transparent` | `#6E88B0` (ZS-004-04) → hover `#4096FF` (ZS-002-05) |
### 5.3 按钮组合
#### 5.3.1 文字 + 下拉按钮(含 ▾)
两种填充:
| 形态 | 配色规则 |
|------|----------|
| 默认(Outline) | 沿用 §5.2.2 默认按钮,箭头 ▾ 与文字同色 `#C5CFE0` |
| 主色(Filled) | 沿用 §5.2.1 主按钮,箭头 ▾ 同步白色 `#FFFFFF` |
#### 5.3.2 图标 + 文字按钮(如 `+ 正常`)
| 形态 | 配色规则 |
|------|----------|
| 默认(Outline) | 沿用 §5.2.2,左侧图标颜色与文字一致 `#C5CFE0` |
| 主色(Filled) | 沿用 §5.2.1,左侧图标与文字一致 `#FFFFFF` |
#### 5.3.3 文本按钮 + 图标("更多 »"、"展开 ⌄"、"下一页 »"、"« 上一页")
| 角色 | 色值 |
|------|------|
| 文字 + 图标(默认) | `#4096FF` (ZS-002-05) |
| 文字 + 图标(Hover) | `#1677FF` (ZS-002-06) |
| 失效 | `#4A6491` (ZS-004-05) |
> 图标可在文字前或后;多个文本按钮排成一行时,间距 `16px`,无分隔符。
#### 5.3.4 主按钮 + 默认按钮组合(如"确定 / 取消 / 返回")
- **排列顺序**:默认按钮在左,主按钮在右(最右侧为主操作)
- **按钮间距**:`8px`
- **示例**:`[ 返回 ] [ 取消 ] [ 确定 ]`
- **配色**:主按钮沿用 §5.2.1,其余沿用 §5.2.2
### 5.4 其他按钮
#### 5.4.1 悬浮状态(Floating)
用于 Banner / 登录 / 视觉冲击强等展示场景。配色与主按钮一致,叠加阴影抬升。
| 状态 | 阴影 |
|------|------|
| 默认 | `0 2px 8px rgba(22, 119, 255, 0.12)`(§3.1.4 一级阴影) |
| Hover | `0 4px 16px rgba(22, 119, 255, 0.24)`(§3.1.4 二级阴影) |
| Active | 阴影消失,按下感 |
#### 5.4.2 加载中(Loading)
用于异步操作等待反馈。点击瞬间将按钮锁定,文字前显示旋转图标。
| 角色 | 色值 |
|------|------|
| 背景 | `#1677FF` (ZS-002-06) |
| 文字 | `#FFFFFF` |
| 加载图标 | `#FFFFFF`,旋转动画 `1.2s linear infinite` |
| 不可点击 | 鼠标 `cursor: not-allowed` |
> 与失效态的区别:失效是"不可操作",加载是"操作进行中"——保留品牌主色,只是不可再次触发。
---
## 6. 分割线
> 统一线宽 `1px`,颜色全部从 ZS-004 深蓝系取色,与深色背景保持低对比度但仍可识别。
### 6.1 水平分割线
水平分割线常用来对不同元素内容进行分割。
| 类型 | 色值 | 来源 | 样式 | 使用场景 |
|------|------|------|------|----------|
| 实线(默认) | `#4A6491` | ZS-004-05 | `border-top: 1px solid` | 模块间、列表项间分割 |
| 实线(强调) | `#6E88B0` | ZS-004-04 | `border-top: 1px solid` | 主区块标题下方、卡片头部分割 |
| 实线(弱化) | `#2D4473` | ZS-004-06 | `border-top: 1px solid` | 卡片内部、密集列表分隔 |
| 虚线 | `#4A6491` | ZS-004-05 | `border-top: 1px dashed` | 次级 / 层级内分割,提示"非主结构" |
| 主色实线(高亮) | `#1677FF` | ZS-002-06 | `border-top: 1px solid` | 强调分隔,少量使用(如当前激活区块底边) |
### 6.2 垂直分割线
垂直分割线常用来做行内分割(如导航项之间、状态指示之间、表格列头之间)。
| 类型 | 色值 | 来源 | 样式 | 使用场景 |
|------|------|------|------|----------|
| 实线(默认) | `#4A6491` | ZS-004-05 | `border-left: 1px solid` | 行内 inline 元素分割(面包屑、操作按钮组) |
| 实线(弱化) | `#2D4473` | ZS-004-06 | `border-left: 1px solid` | 表格列分割、紧凑布局 |
| 虚线 | `#4A6491` | ZS-004-05 | `border-left: 1px dashed` | 多列对齐辅助、辅助参考线 |
### 6.3 规格与间距
| 规则 | 取值 |
|------|------|
| 线宽 | `1px` |
| 水平分割线与上下元素间距 | `≥ 8px`(紧凑布局可用 `4px`) |
| 垂直分割线与左右元素间距 | `≥ 8px`(行内紧凑 `4px`) |
| 垂直分割线高度 | 与文字行高一致,约 `12–14px`(也可全高跟随容器) |
| 透明度 | 必要时叠 `opacity: 0.6` 实现"更弱"层级 |
### 6.4 CSS 参考
```css
/* 默认水平分割线 */
.divider-h { border-top: 1px solid #4A6491; }
.divider-h-strong { border-top: 1px solid #6E88B0; }
.divider-h-weak { border-top: 1px solid #2D4473; }
.divider-h-dashed { border-top: 1px dashed #4A6491; }
.divider-h-accent { border-top: 1px solid #1677FF; }
/* 垂直分割线 */
.divider-v { border-left: 1px solid #4A6491; height: 14px; display: inline-block; vertical-align: middle; }
.divider-v-weak { border-left: 1px solid #2D4473; height: 14px; display: inline-block; vertical-align: middle; }
.divider-v-dashed { border-left: 1px dashed #4A6491; height: 14px; display: inline-block; vertical-align: middle; }
```
---
## 7. 间距
> 全站以 `8px (0.5rem)` 为对照基准单位,划分"密 / 疏"两个层次。所有间距取值均来自下列阶梯,禁止使用非阶梯值(如 5px、13px)。
### 7.1 间距阶梯
| 阶 | px | rem | 分组 | 典型场景 |
|----|------|--------|------|----------|
| 1 | `4px` | `0.25rem` | 密 | 图标与文字间距、Tag 内边距、紧凑徽标 |
| 2 | `8px` | `0.5rem` | 密 | 行内元素相邻、按钮组间距、表单 label 与控件 |
| 3 | `12px` | `0.75rem` | 密 | 表单项垂直间距、列表项内边距 |
| 4 | `16px` | `1rem` | 密 | 卡片内容内边距、模块内分隔 |
| 5 | `24px` | `1.5rem` | 疏 | 模块之间、Section 内边距 |
| 6 | `32px` | `2rem` | 疏 | 卡片之间、表单组之间 |
| 7 | `40px` | `2.5rem` | 疏 | 主区块之间、页面 Header / Footer 内边距 |
| 8 | `56px` | `3.5rem` | 疏 | 页面段落级隔断、Banner 区域 |
> "密"对应紧凑信息密度(表格、表单、卡片内部);"疏"对应视觉呼吸(模块之间、页面整体留白)。**16px 是"密"的上限,24px 是"疏"的下限**,两者之间不应再插入自定义值。
### 7.2 使用原则
- **同层级一致**:同一布局层级内的间距必须使用同一阶值;多种取值会破坏节奏。
- **就近取阶**:相邻区块的间距尽量从相邻阶梯取值(如 `16px → 24px`),跨越过多阶(如 `8px → 56px`)会让视觉断裂。
- **密疏不混用**:一个组件内部全部用"密",外部用"疏",避免内/外间距颠倒。
- **优先 8 的倍数**:除最小阶 `4px` 外,所有阶皆为 8 的倍数,方便 4/8 网格对齐。
- **响应式按比例衰减**:移动端可整体降一阶(如 `24px → 16px`),但仍在阶梯内。
### 7.3 CSS Token 建议
```css
:root {
--spacing-1: 4px; /* 0.25rem */
--spacing-2: 8px; /* 0.5rem */
--spacing-3: 12px; /* 0.75rem */
--spacing-4: 16px; /* 1rem */
--spacing-5: 24px; /* 1.5rem */
--spacing-6: 32px; /* 2rem */
--spacing-7: 40px; /* 2.5rem */
--spacing-8: 56px; /* 3.5rem */
}
```
### 7.4 与已有规范的对齐
| 已有规则 | 对应阶 |
|----------|--------|
| §4.x 卡片圆角内边距 | `--spacing-4` (16px) |
| §5.3.4 主+默认按钮组合间距 `8px` | `--spacing-2` |
| §5.3.3 文本按钮间距 `16px` | `--spacing-4` |
| §6.3 分割线与上下元素间距 `≥ 8px` | `--spacing-2` 起 |
---
@@ -1,780 +0,0 @@
# 深蓝色 UI 规范 · 01 导航(§8–§13)
> 《深蓝色 UI 规范》分册之一。基础令牌见 `00-基础-色彩字体间距.md`,可 import 的变量见 `tokens.css`,总目录与 §索引见 `README.md`。`§N` 编号全局唯一。
**本篇目录**:`8` 导航组件 · `9` 锚点与页签 · `10` 步骤条 · `11` 面包屑 · `12` 页头 · `13` 分页
---
## 8. 导航组件
> 适用于"大屏后台 / 控制台"类深蓝主题界面。整体结构 = 顶部导航 + 左侧分级菜单 + 多级下拉。所有颜色取自 ZS-002 / ZS-004 两个色系。
### 8.1 整体结构
```
┌─ 顶部导航(Header)─ 高 56px ──────────────────────────────────────────────┐
│ ≡ 二级菜单 二级菜单 二级菜单 〈一级菜单〉〈一级菜单〉 ⟪ XXXX系统 ⟫ 〈一级菜单〉 ⟳ 时间 ⌂ 返回首页 │
├──┬──────────────────────────────────────────────────────────────────────────┤
│ 三│ ┌─ 四级菜单(下拉面板)──┐ │
│ 级│ │ ▾ 四级菜单 │ │
│ 菜│ │ ▾ 五级菜单 │ │
│ 单│ │ - 六级菜单 (active)│ │
│ 三│ │ - 六级菜单 │ │
│ 级│ │ ▸ 五级菜单 │ │
│ 菜│ │ ▸ 四级菜单 │ │
│ 单│ └────────────────────────┘ │
│ …│ │
└──┴──────────────────────────────────────────────────────────────────────────┘
```
### 8.2 顶部导航(Header)
#### 8.2.1 规格
| 属性 | 取值 |
|------|------|
| 高度 | `56px` |
| 左右 padding | `--spacing-4` (16px) |
| 背景 | 渐变 `linear-gradient(180deg, #122347 0%, #0C1836 100%)`(ZS-004-08 → ZS-004-09) |
| 底边 | `border-bottom: 1px solid #1A2F5A` (ZS-004-07) |
| 阴影 | `0 2px 8px rgba(6, 14, 36, 0.6)`(§3.1.4 一级阴影变体) |
| 文字基线 | `Noto Sans S Chinese`,垂直居中 |
#### 8.2.2 二级菜单(左侧 Tabs,小号 14px)
| 状态 | 文字 | 背景 | 装饰 |
|------|------|------|------|
| 默认 | `#6E88B0` (ZS-004-04) | `transparent` | — |
| Hover | `#C5CFE0` (ZS-004-02) | `rgba(22,119,255,0.08)` | — |
| 激活 | `#4096FF` (ZS-002-05) | `transparent` | 底部 `2px solid #4096FF` 高亮线 |
- 字号 `14px Regular`,间距 `--spacing-3` (12px)
- 与最左侧的"折叠菜单 ≡"图标间距 `--spacing-4` (16px)
#### 8.2.3 一级菜单(中部主导航,16px 带切角装饰)
| 状态 | 文字 | 装饰 |
|------|------|------|
| 默认 | `#C5CFE0` (ZS-004-02) | 两端切角斜边,`border: 1px solid #1A2F5A` |
| Hover | `#4096FF` (ZS-002-05) | 切角线变 `#4096FF` |
| 激活 | `#FFFFFF` | 切角线 `#1677FF` (ZS-002-06),背景 `rgba(22,119,255,0.16)` |
- 字号 `16px Medium`
- 切角推荐 `clip-path: polygon(8px 0, calc(100% - 8px) 0, 100% 100%, 0 100%)`
- 项间距 `--spacing-2` (8px)
#### 8.2.4 系统标题(XXXX系统)
- 字号 `20px Bold`,颜色 `#FFFFFF`
- 字体 `DOUYUFont` 或 Noto Sans Medium
- 左右装饰用主色蓝渐变线条:`linear-gradient(90deg, transparent 0%, #1677FF 50%, transparent 100%)`,线宽 `1px`
- 居中相对 Header 几何中心,左右最少留 `--spacing-6` (32px) 间距给一级菜单
#### 8.2.5 右侧工具区(时间 / 图标 / 返回首页)
| 元素 | 字号/尺寸 | 颜色 |
|------|----------|------|
| 更新时间文字 | `12px Regular` | `#6E88B0` (ZS-004-04) |
| 时间数值(数字) | `12px D-DIN Regular` | `#C5CFE0` (ZS-004-02) |
| 工具图标(刷新/上/家) | `16px` | `#6E88B0`,hover → `#4096FF` |
| 返回首页文字 | `14px Regular` | `#C5CFE0`,hover → `#4096FF` |
- 元素间距 `--spacing-3` (12px)
- 整体右 padding `--spacing-4` (16px)
### 8.3 侧边栏(三级菜单)
#### 8.3.1 规格
| 属性 | 取值 |
|------|------|
| 宽度 | `64px`(仅图标 / 竖排文字) |
| 背景 | `#0C1836` (ZS-004-09) |
| 右边界 | `border-right: 1px solid #1A2F5A` (ZS-004-07) |
| 项高度 | `64px`(与宽度一致,呈正方形) |
| 项内间距 | 上下 `--spacing-3` (12px) |
| 字号 | `14px Medium`,竖排(`writing-mode: vertical-rl`)或正常排两行 |
#### 8.3.2 状态配色
| 状态 | 背景 | 文字 | 左侧指示条 |
|------|------|------|-----------|
| 默认 | `transparent` | `#6E88B0` (ZS-004-04) | 无 |
| Hover | `rgba(22,119,255,0.08)` | `#C5CFE0` (ZS-004-02) | 无 |
| 激活 | `#1A2F5A` (ZS-004-07) | `#FFFFFF` | 左侧 `2px solid #1677FF` (ZS-002-06) |
### 8.4 下拉菜单面板(四级 / 五级 / 六级)
#### 8.4.1 面板规格
| 属性 | 取值 |
|------|------|
| 背景 | `#122347` (ZS-004-08) |
| 边框 | `1px solid #1A2F5A` (ZS-004-07) |
| 阴影 | `0 4px 16px rgba(6, 14, 36, 0.6)`(§3.1.4 三级阴影变体) |
| 圆角 | `4px` |
| 内边距 | 上下 `--spacing-2` (8px) |
| 最小宽度 | `150px` |
#### 8.4.2 菜单项规格
| 层级 | 缩进(padding-left) | 字号 | 展开图标 | 前缀 |
|------|---------------------|------|----------|------|
| 四级菜单 | `--spacing-3` (12px) | `14px Medium` | `▾ / ▸` | — |
| 五级菜单 | `--spacing-5` (24px) | `14px Regular` | `▾ / ▸` | — |
| 六级菜单 | `--spacing-6` (32px) | `14px Regular` | — | `-` 短横线 |
- 行高 `32px`
- 项之间无分割线(靠缩进与图标区分层级)
#### 8.4.3 菜单项配色
| 状态 | 背景 | 文字 | 展开图标 |
|------|------|------|----------|
| 默认 | `transparent` | `#C5CFE0` (ZS-004-02) | `#6E88B0` (ZS-004-04) |
| Hover | `rgba(22,119,255,0.08)` | `#4096FF` (ZS-002-05) | `#4096FF` |
| 激活(当前选中) | `rgba(22,119,255,0.16)` | `#4096FF` (ZS-002-05) | `#1677FF` (ZS-002-06) |
| 父级(含激活子项) | `transparent` | `#FFFFFF` | `#1677FF` |
| 失效 | `transparent` | `#4A6491` (ZS-004-05) | `#4A6491` |
### 8.5 交互与动画
| 行为 | 动效 |
|------|------|
| 下拉面板展开 | `transform-origin: top; transform: scaleY(0.95) → scaleY(1); opacity 0 → 1`,`200ms ease-out` |
| 展开图标旋转 | `transform: rotate(0 → 90deg)`,`200ms ease-out` |
| Hover 背景过渡 | `background-color 120ms ease-out` |
| 激活底部高亮线(二级菜单) | `width 0% → 100%`,`240ms ease-out` |
### 8.6 间距引用
| 位置 | 取值 |
|------|------|
| Header 内部元素间距 | `--spacing-2 / --spacing-3` |
| Header 与下方内容间距 | `0`(贴合,靠底边线与阴影区分) |
| 侧边栏与下拉面板间距 | `--spacing-1` (4px) |
| 下拉面板与触发元素间距 | `--spacing-1` (4px) |
| 同级菜单项之间 | `0`(紧贴排列,靠 hover/active 背景区分) |
---
## 9. 锚点与页签
> 对应"4.2 导航组件-锚点"。三类组件共用主色蓝指示当前位置:**页签 / 锚点 / 回到顶部**。
>
> 视觉参考:标签横向 / 标签纵向 / 锚点的三联图见设计稿「选项卡.png」——三种形态在本节均已规范化(§9.1 = 标签横纵向、§9.2 = 锚点)。
### 9.1 页签(Tabs)
#### 9.1.1 横向页签(下划线-横向)
| 状态 | 文字 | 下划线 |
|------|------|--------|
| 默认 | `#C5CFE0` (ZS-004-02) | 无 |
| Hover | `#4096FF` (ZS-002-05) | 无 |
| 激活 | `#4096FF` (ZS-002-05) | `2px solid #4096FF`,宽度与文字齐 |
| 失效 | `#4A6491` (ZS-004-05) | 无 |
- 字号 `14px Medium`
- 项间距 `--spacing-5` (24px)
- 下划线与文字底部间距 `--spacing-2` (8px)
- 容器底边可叠一条 `1px solid #1A2F5A` (ZS-004-07) 作为基线,激活态的高亮线压在上面
#### 9.1.2 纵向页签(下划线-纵向)
| 状态 | 文字 | 右侧指示线 |
|------|------|-----------|
| 默认 | `#C5CFE0` (ZS-004-02) | 无 |
| Hover | `#4096FF` (ZS-002-05) | 无 |
| 激活 | `#4096FF` (ZS-002-05) | 右侧 `2px solid #4096FF`,高度与文字齐 |
| 失效 | `#4A6491` (ZS-004-05) | 无 |
- 字号 `14px Medium`,项高度 `32px`
- 项间距 `--spacing-2` (8px)
- 容器右边可叠一条 `1px solid #1A2F5A` 基线,激活指示线压在上面
### 9.2 锚点(Anchor)
用于长内容页(如详情页、文档页)的章节快速跳转。
#### 9.2.1 结构
```
│ 一级
│ 一级 (active)
│ 二级
│ 二级
│ 一级
│ 一级
```
#### 9.2.2 规格
| 属性 | 取值 |
|------|------|
| 容器背景 | `#122347` (ZS-004-08) 或 `transparent`(嵌入页面侧栏时) |
| 左侧基线 | `1px solid #1A2F5A` (ZS-004-07) 通栏 |
| 项高度 | `32px` |
| 一级项左 padding | `--spacing-3` (12px) |
| 二级项左 padding | `--spacing-5` (24px) |
| 项间距 | `0`(贴合排列) |
| 字号 | `14px Regular` |
#### 9.2.3 状态配色
| 状态 | 一级文字 | 二级文字 | 左侧指示条 |
|------|----------|----------|-----------|
| 默认 | `#C5CFE0` (ZS-004-02) | `#6E88B0` (ZS-004-04) | 无 |
| Hover | `#4096FF` (ZS-002-05) | `#4096FF` (ZS-002-05) | 无 |
| 激活 | `#4096FF` (ZS-002-05) | — | 左侧 `2px solid #4096FF`,覆盖容器基线 |
| 父级(含激活子项) | `#FFFFFF` | — | — |
- 激活态指示条与基线重合,宽 `2px`,色压主色蓝 `#4096FF`
- 二级项**不单独支持激活态**:当二级被点中时,其父级一级显示激活态,左侧指示条按父级位置渲染
#### 9.2.4 滚动同步
| 行为 | 规则 |
|------|------|
| 页面滚动 | 监听锚点对应区块进入视口的相对位置,自动切换激活态 |
| 点击锚点 | `behavior: smooth` 滚动到目标,激活指示条 `top` 过渡 `200ms ease-out` |
| 边界处理 | 滚动到页面顶部 → 第一个锚点激活;到页尾 → 最后一个锚点激活 |
### 9.3 回到顶部(BackToTop)
固定在视口右下角的悬浮按钮,向上滚动到页面顶部。
#### 9.3.1 规格
| 属性 | 取值 |
|------|------|
| 形状 | 圆形 `40px × 40px`(`border-radius: 50%`) |
| 图标 | `↑` 上箭头,`16px`,`#FFFFFF` |
| 距视口右边 | `--spacing-5` (24px) |
| 距视口下边 | `--spacing-6` (32px) |
| 出现时机 | 页面 `scrollTop > 200px` 时淡入;回到顶部后淡出 |
| 淡入/淡出 | `opacity 0 → 1` / `1 → 0`,`200ms ease-out` |
#### 9.3.2 状态配色
| 状态 | 背景 | 阴影 | 图标 |
|------|------|------|------|
| 正常(默认) | `#1677FF` (ZS-002-06) | `0 2px 8px rgba(22,119,255,0.12)` (§3.1.4 一级阴影) | `#FFFFFF` |
| 悬停(Hover) | `#0958D9` (ZS-002-07) | `0 4px 16px rgba(22,119,255,0.24)` (§3.1.4 二级阴影) | `#FFFFFF` |
| 点按(Active) | `#003EB3` (ZS-002-08) | 阴影消失,按下感 | `#FFFFFF` |
| 失效 / 不显示 | — | — | 整体 `opacity: 0`,`pointer-events: none` |
#### 9.3.3 悬停 Tooltip
Hover 状态下方显示 `回到顶部` 提示气泡:
| 属性 | 取值 |
|------|------|
| 文字 | `回到顶部`,`12px Regular` |
| 文字颜色 | `#C5CFE0` (ZS-004-02) |
| 背景 | `#1677FF` (ZS-002-06) |
| 圆角 | `2px` |
| 内边距 | 上下 `4px`,左右 `--spacing-2` (8px) |
| 与按钮间距 | `--spacing-2` (8px) |
| 出现时机 | Hover 进入 `300ms` 后显示,离开立即消失 |
### 9.4 三者的差异与选用
| 组件 | 主要用途 | 视觉特征 |
|------|----------|---------|
| 页签 | 同层级内容切换(多 Tab 互斥) | 文字 + 下划线(横/纵) |
| 锚点 | 长页面内章节跳转 | 文字 + 左侧指示条 + 层级缩进 |
| 回到顶部 | 长页面快速返回起点 | 圆形悬浮按钮 + 上箭头 |
---
## 10. 步骤条
> 对应「4.7 导航组件-步骤条」。用于多步流程的进度指引,按布局方向分**横向**与**纵向**两类,按使用场景分**弹窗步骤条**、**页面步骤条**、**纵向步骤条**三种。所有颜色取自 ZS-002 主色蓝与 ZS-004 深蓝系。
### 10.1 步骤节点(公共)
每个步骤由「指示圆 + 标题(可选描述)」组成。指示圆按状态切换三种形态:
| 状态 | 圆形外观 | 圆内符号 | 来源 |
|------|----------|----------|------|
| 已完成(Completed) | 描边圆,`2px solid #1677FF`,背景 `transparent` | `✓`(对勾,`#1677FF`) | ZS-002-06 |
| 当前(Current / Active) | 实心圆,背景 `#1677FF` | 步骤数字,`#FFFFFF` | ZS-002-06 / 白 |
| 未开始(Upcoming) | 描边圆,`1px solid #4A6491`,背景 `transparent` | 步骤数字,`#6E88B0` | ZS-004-05 / ZS-004-04 |
| 失效(Disabled) | 描边圆,`1px solid #2D4473`,背景 `transparent` | 步骤数字,`#4A6491` | ZS-004-06 / ZS-004-05 |
**指示圆规格**:
| 属性 | 取值 |
|------|------|
| 直径 | `24px` |
| 圆内字号 | `12px Bold`(D-DIN 或 Noto Sans Bold) |
| 对勾图标 | `12px`,居中 |
| 字体水平居中 | 数字 / 对勾 在圆几何中心 |
**标题与描述文字**:
| 元素 | 状态 | 字号 / 颜色 | 来源 |
|------|------|-------------|------|
| 标题 | 已完成 / 当前 | `14px Medium`,`#FFFFFF` | — |
| 标题 | 未开始 | `14px Medium`,`#6E88B0` | ZS-004-04 |
| 标题 | 失效 | `14px Medium`,`#4A6491` | ZS-004-05 |
| 描述 | 已完成 / 当前 | `12px Regular`,`#C5CFE0` | ZS-004-02 |
| 描述 | 未开始 / 失效 | `12px Regular`,`#6E88B0` → `#4A6491` | ZS-004-04 → ZS-004-05 |
**连接线(步骤之间)**:
| 类型 | 色值 | 来源 | 样式 |
|------|------|------|------|
| 已完成段(前一节点已完成) | `#1677FF` | ZS-002-06 | `1px dashed`(弹窗)/ `1px solid`(页面) |
| 未完成段 | `#4A6491` | ZS-004-05 | `1px dashed` |
> 连接线在指示圆**两侧水平居中**(横向)或**正下方垂直**(纵向);线两端与圆外缘留 `--spacing-1` (4px) 间隙,避免线穿圆。
### 10.2 弹窗步骤条(横向 · 描边圆为主)
用于 Modal / Drawer 内引导多步操作的紧凑步骤条。指示圆**完成态用描边 + 对勾**而非实心,避免在弹窗背景上过于抢眼。
#### 10.2.1 布局
```
( ✓ ) ┄┄┄┄┄ (●2) ┄┄┄┄┄ ( 3 ) ┄┄┄┄┄ ( 4 ) ┄┄┄┄┄ ( 5 )
标题 标题 标题 标题 标题
(描述) (描述) (描述) (描述) (描述)
```
#### 10.2.2 规格
| 属性 | 取值 |
|------|------|
| 容器水平 padding | `--spacing-5` (24px) |
| 指示圆 → 标题间距 | `--spacing-2` (8px) |
| 标题 → 描述间距 | `--spacing-1` (4px) |
| 节点之间水平间距 | 节点间距自适应等分;连接线占用中间空间 |
| 连接线 | `1px dashed`,已完成段 `#1677FF`,未完成段 `#4A6491` |
| 文字对齐 | 标题、描述居中对齐于指示圆下方 |
#### 10.2.3 两种变体
| 变体 | 适用 |
|------|------|
| **仅标题** | 步骤含义已经清晰(如「填写信息 / 确认 / 完成」),节省垂直空间 |
| **标题 + 描述** | 步骤名抽象需要一行小字补充时使用 |
### 10.3 页面步骤条(横向 · 实心圆 + 自定义首项标题)
用于页面级流程(如「新建集群」类向导)。和弹窗步骤条相比,**已完成态指示圆改为实心填充 + 白色对勾**,视觉权重更高;首步标题可显示具体名称(如「新建集群」)而非「标题」占位。
#### 10.3.1 布局
```
(✓) ────── (●2) ┄┄┄┄┄ ( 3 ) ┄┄┄┄┄ ( 4 ) ┄┄┄┄┄ ( 5 )
新建集群 标题 标题 标题 标题
(描述文本) (描述文本) (描述文本) (描述文本) (描述文本)
```
#### 10.3.2 与 §10.2 弹窗步骤条的差异
| 维度 | 弹窗步骤条 | 页面步骤条 |
|------|-----------|-----------|
| 完成态圆形 | 描边圆 + 蓝色对勾 | 实心蓝圆 + 白色对勾 |
| 完成段连接线样式 | `1px dashed #1677FF` | `1px solid #1677FF` |
| 标题文字号 | `14px Medium` | `14px Medium`(同) |
| 第一项 | 通用「标题」占位 | 可显示具体名称(自定义文案) |
#### 10.3.3 完成态指示圆补充
| 角色 | 色值 | 来源 |
|------|------|------|
| 已完成圆背景 | `#1677FF` | ZS-002-06 |
| 已完成圆内对勾 | `#FFFFFF` | — |
| 已完成圆边框 | 无(实心) | — |
### 10.4 纵向步骤条
用于侧栏 / 详情页内的纵向流程指引。两种变体——「仅标题」与「标题 + 描述」。
#### 10.4.1 布局
```
仅标题: 标题 + 描述:
(✓) 新建集群 (✓) 新建集群
┊ 描述文本描述
(●2) 标题 ┊
┊ (●2) 标题
( 3 ) 标题 描述文本描述
┊ ┊
( 4 ) 标题 ( 3 ) 标题
描述文本描述
┊
( 4 ) 标题
描述文本描述
```
#### 10.4.2 规格
| 属性 | 取值 |
|------|------|
| 指示圆 → 标题水平间距 | `--spacing-2` (8px) |
| 节点垂直间距(仅标题) | `--spacing-5` (24px),圆心到圆心 |
| 节点垂直间距(标题 + 描述) | `--spacing-6` (32px),圆心到圆心 |
| 标题 → 描述间距 | `--spacing-1` (4px) |
| 连接线位置 | 上下两个指示圆**正下方垂直**,居中于圆心 |
| 连接线样式 | `1px dashed`,已完成段 `#1677FF`,未完成段 `#4A6491` |
| 文字左对齐 | 标题与描述左对齐,与指示圆右侧对齐基线 |
#### 10.4.3 与横向步骤条共用规则
- 指示圆三态规格沿用 §10.1
- 已完成态默认用**描边 + 对勾**(与 §10.2 弹窗一致);若纵向步骤条用于页面级主流程,可切换为 §10.3 的实心圆变体
### 10.5 交互与动画
| 行为 | 动效 |
|------|------|
| 步骤切换(next) | 当前圆 `background: transparent → #1677FF`,`200ms ease-out`;同时上一步圆 `border-color: #4A6491 → #1677FF`,再用 `200ms` 切换为对勾图标淡入 |
| 步骤切换(prev / 回退) | 反向,未完成态恢复 `#4A6491` 边框,圆内数字淡入 |
| 连接线高亮 | 完成段 `border-color: #4A6491 → #1677FF`,`240ms ease-out` |
| Hover 节点(可点击) | 圆边框色暂时变 `#4096FF` (ZS-002-05),标题文字 `#4096FF`,`120ms ease-out`;不可点击节点(未来步骤)无 hover |
| 点击节点 | 节点必须**已完成**才能点(回看),否则视为禁用;点击合法节点触发 `step:change` 事件 |
### 10.6 状态机与可达性
| 当前步 = N | 节点 i 的状态 |
|-----------|----------------|
| `i < N` | Completed(已完成,可点回看) |
| `i === N` | Current(当前,不可点) |
| `i > N` | Upcoming(未开始,不可点) |
| `i.disabled` | Disabled(被显式禁用,例如条件未满足) |
**键盘导航**:
- `Tab` 顺序在「已完成」节点之间流转,跳过 `Current / Upcoming / Disabled`
- 焦点节点圆外加 `2px solid #4096FF` 焦点环(`outline`,`offset 2px`)
**屏幕阅读器**:
- 指示圆 `role="img"`,`aria-label="已完成第 N 步"` / `"当前第 N 步"` / `"未开始第 N 步"`
- 容器 `role="list"`,节点 `role="listitem"`
- 当前节点额外 `aria-current="step"`
### 10.7 CSS Token 参考
```css
:root {
/* 步骤条节点 */
--step-circle-size: 24px;
--step-circle-current-bg: #1677FF; /* ZS-002-06 */
--step-circle-current-text: #FFFFFF;
--step-circle-done-border: #1677FF; /* ZS-002-06 */
--step-circle-done-icon: #1677FF; /* 弹窗变体 */
--step-circle-done-bg-page: #1677FF; /* 页面变体:实心 */
--step-circle-done-icon-page: #FFFFFF;
--step-circle-upcoming-border: #4A6491; /* ZS-004-05 */
--step-circle-upcoming-text: #6E88B0; /* ZS-004-04 */
--step-circle-disabled-border: #2D4473; /* ZS-004-06 */
--step-circle-disabled-text: #4A6491; /* ZS-004-05 */
/* 连接线 */
--step-line-done: #1677FF; /* 已完成段 */
--step-line-upcoming: #4A6491; /* 未完成段:ZS-004-05 */
/* 文字 */
--step-title-active: #FFFFFF;
--step-title-inactive: #6E88B0; /* ZS-004-04 */
--step-desc-active: #C5CFE0; /* ZS-004-02 */
--step-desc-inactive: #6E88B0; /* ZS-004-04 */
}
```
### 10.8 三种步骤条的选用决策
| 场景 | 选择 |
|------|------|
| Modal / Drawer 内多步表单 | §10.2 弹窗步骤条(描边圆,紧凑) |
| 页面级向导(独占大区域) | §10.3 页面步骤条(实心圆,视觉重) |
| 侧栏 / 详情页纵向流程 | §10.4 纵向步骤条 |
| 步骤数 ≤ 3 且无描述 | 优先「仅标题」变体 |
| 步骤名抽象需补充说明 | 用「标题 + 描述」变体 |
| 步骤数 > 7 | 不推荐用步骤条,改用「锚点」(§9.2)或分页 |
---
## 11. 面包屑
> 对应「4.3 导航组件-面包屑」。用于显示当前位置在整个站点层级中的路径,配合「← 返回」按钮支持逐级回溯。颜色取自 ZS-002 主色蓝与 ZS-004 深蓝系。
### 11.1 标准形态
容器顶部带一个独立的容器 / 边框包裹:
```
🏠 | 概况 / 总概况
```
带「返回」按钮的层级形态(一级到四级):
```
一级: ← 返回 │ 现在位置
二级: ← 返回 │ 上一级位置 / 现在位置
三级: ← 返回 │ 上二级位置 / 上一级位置 / 现在位置
四级: ← 返回 │ 上三级位置 / 上二级位置 / 上一级位置 / 现在位置
```
### 11.2 规格
| 属性 | 取值 |
|------|------|
| 容器高度 | `40px` |
| 容器背景 | `#0C1836` (ZS-004-09) |
| 容器底边 | `1px solid #1A2F5A` (ZS-004-07) |
| 容器水平 padding | `--spacing-4` (16px) |
| 字号 | `14px Regular` |
| 元素间距(图标 / 文字 / 分隔符之间) | `--spacing-2` (8px) |
| 「返回」与首段路径之间分隔符 `│` | `1px solid #4A6491`,高度 `14px` |
### 11.3 配色
| 角色 | 色值 | 来源 | 备注 |
|------|------|------|------|
| Home 图标 🏠 | `#4096FF` | ZS-002-05 | 默认主色,hover → `#1677FF` (ZS-002-06) |
| 「← 返回」图标 + 文字 | `#4096FF` | ZS-002-05 | 链接样式,hover 显示下划线 |
| 历史路径文字(可点击) | `#4096FF` | ZS-002-05 | 同链接配色 |
| 当前位置文字(最后一段) | `#FFFFFF` | — | 加粗 `14px Medium`,不可点击 |
| 分隔符 `/` | `#4A6491` | ZS-004-05 | 静态、不可点 |
| 分隔符 `│`(返回按钮与路径之间) | `#4A6491` | ZS-004-05 | 同上 |
| Hover 路径项 | `#1677FF` | ZS-002-06 | 文字色 + 下划线 |
| 失效路径项(无对应页) | `#4A6491` | ZS-004-05 | `cursor: not-allowed` |
### 11.4 交互规则
- **点击「← 返回」**:逐步向上返回一级(等同浏览器 back)
- **点击文字路径**:快速跳回到对应任意上级页面
- **当前位置**(最后一段):不响应点击,仅作展示
- **层级建议**:尽可能不超过 **4 级**;超过 4 级会让面包屑过长,应改用侧栏 / 锚点辅助
### 11.5 与导航的关系
面包屑**不替代**主导航(§8):
- 主导航 = 「我能去哪些地方」(站点结构)
- 面包屑 = 「我当前在哪里」(路径回溯)
两者并存时,面包屑紧贴页面主内容区顶部,位于主导航下方、页头(§12)上方。
### 11.6 CSS 参考
```css
.breadcrumb {
display: flex;
align-items: center;
height: 40px;
padding: 0 var(--spacing-4);
background: #0C1836;
border-bottom: 1px solid #1A2F5A;
font-size: 14px;
gap: var(--spacing-2);
}
.breadcrumb-back { color: #4096FF; cursor: pointer; }
.breadcrumb-back:hover { color: #1677FF; text-decoration: underline; }
.breadcrumb-sep-bar { width: 1px; height: 14px; background: #4A6491; }
.breadcrumb-sep { color: #4A6491; }
.breadcrumb-link { color: #4096FF; cursor: pointer; }
.breadcrumb-link:hover { color: #1677FF; text-decoration: underline; }
.breadcrumb-current { color: #FFFFFF; font-weight: 500; }
```
---
## 12. 页头
> 对应「4.6 导航组件-分页」上半段示意。用于页面主内容区顶部、面包屑下方,承载页面标题与可选的说明 / Tabs。共有 4 种变体。
### 12.1 通用规格
| 属性 | 取值 |
|------|------|
| 容器高度 | `56px`(仅标题)/ `64px`(含 Tabs) |
| 背景 | `#0C1836` (ZS-004-09) |
| 底边 | `1px solid #1A2F5A` (ZS-004-07) |
| 水平 padding | `--spacing-5` (24px) |
| 文字基线 | 垂直居中 |
### 12.2 四种变体
#### 12.2.1 基础页头
只有标题:
```
页头标题
```
| 角色 | 色值 / 字号 | 来源 |
|------|-------------|------|
| 标题 | `18px Bold`,`#FFFFFF` | — |
#### 12.2.2 页头 + 说明
标题后跟一段灰色说明文字:
```
页头标题 (这是一句简要说明)
```
| 角色 | 色值 / 字号 | 来源 |
|------|-------------|------|
| 标题 | `18px Bold`,`#FFFFFF` | — |
| 说明文字 | `14px Regular`,`#6E88B0` | ZS-004-04 |
| 标题 → 说明间距 | `--spacing-3` (12px) | — |
说明用半角圆括号包裹,颜色与括号一致(不要让括号比文字更醒目)。
#### 12.2.3 页头 + 说明 icon
标题后跟一个可悬停查看说明的图标:
```
页头标题 ⓘ
```
| 角色 | 色值 / 字号 | 来源 |
|------|-------------|------|
| 标题 | `18px Bold`,`#FFFFFF` | — |
| 说明 icon `ⓘ` 或 `?` | `16px`,`#6E88B0` | ZS-004-04 |
| Icon hover | `#4096FF` | ZS-002-05 |
| 标题 → 图标间距 | `--spacing-2` (8px) | — |
Hover 弹出 Tooltip(沿用 §9.3.3 Tooltip 配色),承载完整说明文字。
#### 12.2.4 分 Tabs 页头
标题位置直接放横向页签(沿用 §9.1.1 横向页签配色),点击切换右侧主内容区:
```
标题 标题 标题
─────
```
| 复用规则 | 说明 |
|----------|------|
| Tab 配色 | 完全沿用 §9.1.1 横向页签 |
| Tab 字号 | `16px Medium`(页头位置比一般 tab 略大) |
| Tab 间距 | `--spacing-5` (24px) |
| 激活态下划线 | `2px solid #4096FF`,宽与文字齐 |
### 12.3 与右侧操作区的组合
页头右侧常承载主操作按钮(「+ 新建」、「导出」等)。规则:
- 按钮组靠右对齐,右 padding 与左 padding 一致 (`--spacing-5`)
- 多按钮排列顺序参照 §5.3.4(默认按钮在左、主按钮在右),按钮间距 `--spacing-2`
- Tabs 页头中右侧操作区与 Tabs 同高居中
---
## 13. 分页
> 对应「4.6 导航组件-分页」下半段。三种形态——**正常分页 / 精简分页 / 导航式分页**——按页面信息量与可用空间选用。
### 13.1 正常分页
承载大列表,提供页码、跳转、每页条数选择:
```
共 240 项 < ① 2 3 4 5 … 24 > 每页 [10▾] 条 前往 [ ] 页
```
#### 13.1.1 元素配色与规格
| 元素 | 字号 / 色值 | 来源 / 说明 |
|------|-------------|-------------|
| 「共 N 项」总数文字 | `14px Regular`,`#6E88B0` | ZS-004-04 |
| 页码按钮(默认) | `14px Regular`,`#C5CFE0`,背景 `transparent` | ZS-004-02 |
| 页码按钮(hover) | `#4096FF`,背景 `rgba(22,119,255,0.08)` | ZS-002-05 |
| 页码按钮(激活/当前页) | `#FFFFFF`,背景 `#1677FF`(实心圆) | ZS-002-06 |
| 页码按钮(失效) | `#4A6491`,背景 `transparent` | ZS-004-05 |
| 左右翻页箭头 `<` `>` | `14px`,`#6E88B0`,hover → `#4096FF` | ZS-004-04 → ZS-002-05 |
| 翻页箭头失效(首/末页) | `#4A6491` | ZS-004-05 |
| 省略号 `…` | `#6E88B0` | ZS-004-04,静态展示,不可点 |
| 每页条数下拉 `[10▾]` | 沿用 §14.1 输入框规格 | 高度 `28px`,宽 `60px` |
| 「前往 N 页」输入框 | 沿用 §14.1 输入框规格 | 高度 `28px`,宽 `48px` |
#### 13.1.2 页码按钮规格
| 属性 | 取值 |
|------|------|
| 形状 | 圆形 `28px × 28px`(`border-radius: 50%`) |
| 默认背景 | `transparent` |
| 当前页背景 | `#1677FF` (ZS-002-06),实心圆,白色数字 |
| 字号 | `14px Regular`(数字 D-DIN 或 Noto Sans) |
| 项间距 | `--spacing-1` (4px) |
#### 13.1.3 元素间距
| 区段 | 间距 |
|------|------|
| 「共 N 项」→ 翻页区 | `--spacing-4` (16px) |
| 翻页区内(箭头 / 页码) | `--spacing-1` (4px) |
| 翻页区 → 每页下拉 | `--spacing-5` (24px) |
| 每页下拉 → 前往输入 | `--spacing-4` (16px) |
### 13.2 精简分页
承载弹窗 / 表格紧凑场景,只暴露「当前页 / 总页数」与左右翻页:
```
共 1 项 < [ 1 ] / 10 >
```
#### 13.2.1 规格
| 元素 | 配色 / 备注 |
|------|------------|
| 「共 N 项」 | 同 §13.1.1 |
| `<` `>` 翻页箭头 | 同 §13.1.1 |
| `[ 1 ]` 当前页输入框 | 沿用 §14.1 输入框规格,高 `28px`,宽 `40px`,居中文字 |
| `/ N` 总页数 | `14px Regular`,`#6E88B0` (ZS-004-04) |
| 元素间距 | `--spacing-2` (8px) |
#### 13.2.2 与正常分页的差异
| 维度 | 正常分页 | 精简分页 |
|------|----------|----------|
| 页码 | 点击式数字列表 | 输入框 + 总页数文字 |
| 跳转方式 | 点击数字 / 前往输入 | 直接修改输入框 |
| 每页条数 | 暴露下拉 | 不暴露(默认值由调用方决定) |
| 适用 | 列表 / 表格主内容区 | 弹窗、抽屉、卡片内嵌列表 |
### 13.3 导航式分页
承载长文 / 详情页的「上一篇 / 下一篇」式翻页,只有方向,不暴露页码:
```
下一页 »
« 上一页
```
#### 13.3.1 规格
| 元素 | 配色 / 备注 |
|------|------------|
| 「下一页」/「上一页」文字 + 箭头 | `14px Regular`,`#4096FF` (ZS-002-05) |
| Hover | `#1677FF` (ZS-002-06) + 下划线 |
| 失效(首/末篇) | `#4A6491` (ZS-004-05),`cursor: not-allowed` |
| 箭头位置 | `下一页 »`(箭头在后)/ `« 上一页`(箭头在前) |
| 文字 → 箭头间距 | `--spacing-2` (8px) |
| 两个按钮纵向排列时项间距 | `--spacing-3` (12px) |
### 13.4 三种分页的选用决策
| 场景 | 选择 |
|------|------|
| 后台列表 / 表格 | §13.1 正常分页 |
| Modal / Drawer 内嵌列表 | §13.2 精简分页 |
| 详情页前后翻 | §13.3 导航式分页 |
| 列表少于 1 页(项数 ≤ pageSize) | **不显示**分页 |
| 项数明确但页码可能很多(>50 页) | 用正常分页 + 隐藏中间页码(`...` 省略) |
---
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,573 +0,0 @@
# 深蓝色 UI 规范 · 04 图表(§35–§41)
> 《深蓝色 UI 规范》分册之一。基础令牌见 `00-基础-色彩字体间距.md`,可 import 的变量见 `tokens.css`,总目录与 §索引见 `README.md`。`§N` 编号全局唯一。
**本篇目录**:`35` 图表通用规范 · `36` 柱状图 · `37` 折线图 · `38` 甘特图 · `39` 环形图 · `40` 关系图/散点图 · `41` 图表色值
---
## 35. 图表通用规范(Chart 基础)
> 对应设计稿「8. 图表」系列(柱状图 / 折线图 / 甘特图)。本节先约定**所有图表共用**的容器、标题、坐标轴、网格、图例、Tooltip、配色与空状态;§36 起按图表类型补充各自细则。配色一律取自 §1.1.8 图表配色与 §1.1.5 深蓝系,不另造色。
### 35.1 图表容器
| 属性 | 取值 | 来源 |
|------|------|------|
| 背景 | `#0C1836` (ZS-004-09),嵌入卡片时随 §22 卡片 `#122347` | §3.1 |
| 圆角 | `4px` | §4.1 |
| 内边距 | `--spacing-5` (24px) | §7.1 |
| 多图并排间距 | `--spacing-6` (32px) | §7.1 |
| 边框(可选) | `1px solid #1A2F5A` (ZS-004-07) | — |
### 35.2 图表标题
设计稿中标题统一为「**主色蓝竖条 ▌ + 文字**」的小标题样式。
| 元素 | 规格 | 来源 |
|------|------|------|
| 左侧竖条 | `3px × 14px`,`#1677FF` (ZS-002-06),圆角 `1px` | §1.1.6 |
| 竖条 → 文字间距 | `--spacing-2` (8px) | §7.1 |
| 标题文字 | `16px Bold`,`#FFFFFF`(或 `#C5CFE0`) | §2.1.2 |
| 标题 → 图表区间距 | `--spacing-4` (16px) | §7.1 |
| 副标题 / 单位 | `12px Regular`,`#6E88B0` (ZS-004-04) | — |
> 部分卡片标题不带竖条(如「标题(BW)」「数据采集XXX」),此时直接用 `16px Bold #C5CFE0`,与 §22 卡片标题一致。
### 35.3 坐标轴
| 元素 | 色值 / 规格 | 来源 |
|------|-------------|------|
| 轴线(X / Y 基线) | `1px solid #2D4473` (ZS-004-06) | §6.1 |
| 刻度文字(轴标签) | `12px Regular`,`#6E88B0` (ZS-004-04) | — |
| 轴标题 / 类目名(如「类型」「2012」) | `12px Regular`,`#4096FF` (ZS-002-05) 或 `#6E88B0` | — |
| 刻度线(tick) | 通常隐藏,仅留网格线 | — |
| 轴名称单位 | `12px`,`#6E88B0`,置于轴末端 | — |
### 35.4 网格线(splitLine)
| 类型 | 色值 | 样式 | 使用场景 |
|------|------|------|----------|
| 主网格(默认) | `#2D4473` (ZS-004-06) | `1px dashed` | 柱状 / 折线的 Y 向参考线 |
| 弱化网格 | `#1A2F5A` (ZS-004-07) | `1px dashed` | 密集图表、次要参考 |
> 深色背景下网格一律用**虚线**且低对比,避免抢戏;纵向类目方向通常不画网格。
### 35.5 图例(Legend)
| 属性 | 取值 | 来源 |
|------|------|------|
| 位置 | 右上角(多数)或顶部居中 | — |
| 色块(marker) | `10px` 方块 / 圆点,取系列色 | §1.1.8 |
| 色块 → 文字间距 | `--spacing-1` (4px) | §7.1 |
| 图例文字 | `12px Regular`,`#C5CFE0` (ZS-004-02) | — |
| 项间距 | `--spacing-3` (12px) | §7.1 |
| 未选中态(点击隐藏系列) | 色块 + 文字置灰 `#4A6491` (ZS-004-05) | §1.1.5 |
### 35.6 Tooltip(悬浮提示)
鼠标悬停时跟随的浮层(如折线图「1月10日 异常率12.9%」、「5.32」)。
| 属性 | 取值 | 来源 |
|------|------|------|
| 背景 | `rgba(12,24,54,0.92)`(ZS-004-09 92%) | §3.1 |
| 边框 | `1px solid #1A2F5A` (ZS-004-07) | — |
| 圆角 | `4px` | §4.1 |
| 阴影 | `0 4px 16px rgba(6,14,36,0.6)` | §3.1.4 |
| 内边距 | 上下 `--spacing-2`,左右 `--spacing-3` | §7.1 |
| 标题行(日期 / 类目) | `12px Medium`,`#FFFFFF` | — |
| 数值行 | `12px Regular`,色块(系列色)+ 名称 `#6E88B0` + 数值 `#C5CFE0` | — |
| 指示线(axisPointer) | `1px dashed #4096FF` (ZS-002-05),或半透明竖向高亮带 `rgba(22,119,255,0.12)` | §6.1 |
> 设计稿中数据点 hover 常显示一个**实心小标签**(如圆点 + 数值 `5.32`):标签背景 `#122347`,文字 `#FFFFFF`,圆点取系列色,圆角 `2px`。
### 35.7 数据点与标记
| 元素 | 规格 | 来源 |
|------|------|------|
| 折线拐点(默认) | 不显示或 `2px` 实心点 | — |
| 折线拐点(hover / 强调) | `空心圆`,描边 `2px` 系列色,填充 `#0C1836`,直径 `6px` | — |
| 高亮竖带(选中区间) | `rgba(22,119,255,0.12)` 矩形,无边框 | §3.1 |
### 35.8 图表配色引用
| 用途 | 色序 | 来源 |
|------|------|------|
| 多系列(面积大:柱 / 折线 / 饼) | `#1677FF → #13C2C2 → #4096FF → #36CFC9 → #69B1FF → #5CDBD3 → #91CAFF → #87E8DE` | §1.1.8 通用图表色序 |
| 小面积(散点 / 迷你图 / 徽标) | `#4096FF → #36CFC9 → #FF4D4F → #FFA39E → #6388FF → #478880` | §1.1.8 小面积色序 |
| 语义系列(成功 / 警告 / 错误) | 绿 `--func-success` / 橙 `--func-warning` / 红 `#F5222D` | §27.4 / §1.1.2 |
> 设计稿柱状图出现的「绿(Paper) / 橙(Project) / 蓝(Book)」三系列,对应**语义化分类**:绿取 §27.4 成功绿、橙取 §27.4 警告橙、蓝取主色 `#1677FF`;若为无语义的并列分类,应改用通用图表色序避免误读。
### 35.9 空状态 / 加载
| 状态 | 表现 |
|------|------|
| 加载中 | 图表区居中显示旋转图标 `#4096FF` + 文字「加载中…」`12px #6E88B0`(沿用 §5.4.2) |
| 无数据 | 居中占位插画 / 文字「暂无数据」`14px #6E88B0` |
---
## 36. 柱状图(Column / Bar)
> 对应设计稿「8.1 图表-柱状图」。涵盖**堆叠柱 / 分组柱 / 条形图(横向)/ 排行进度条**四种形态。容器、坐标轴、网格、图例、Tooltip 全部沿用 §35。
### 36.1 通用规格
| 属性 | 取值 | 来源 |
|------|------|------|
| 柱体圆角 | 顶部 `2px`(堆叠柱仅最顶段圆角) | §4.1 |
| 柱宽 | 类目宽度的 `40%~60%` | — |
| 同组柱间距 | `--spacing-1` (4px) | §7.1 |
| 类目(组)间距 | 自适应等分 | — |
| 数值标签(可选) | `12px Regular`,`#C5CFE0`,置柱顶上方 `--spacing-1` | — |
### 36.2 堆叠柱状图(如「标题」Paper/Project/Book)
| 元素 | 配色 | 来源 |
|------|------|------|
| 系列 1(Book / 底段) | `#69B1FF` (ZS-002-04) 或主色 `#1677FF` | §1.1.3 |
| 系列 2(Project / 中段) | `--func-warning`(橙) | §27.4 |
| 系列 3(Paper / 顶段) | `--func-success`(绿) | §27.4 |
| 段间分隔 | `1px` 容器底色 `#0C1836` 描边,制造堆叠缝隙 | — |
| Y 轴类目标签「类型」 | `12px`,`#4096FF` (ZS-002-05) | §35.3 |
### 36.3 分组柱状图(如「标题(BW)」「标题」双系列)
| 元素 | 配色 | 来源 |
|------|------|------|
| 系列依次取色 | 通用图表色序 `#1677FF / #13C2C2 / #4096FF …` | §1.1.8 |
| 设计稿 BW 三色变体 | 蓝 `#69B1FF` / 绿 `--func-success` / 橙 `--func-warning` | §35.8 |
| 双系列变体 | 蓝 `#69B1FF`(ZS-002-04) + 橙 `--func-warning` | — |
### 36.4 条形图(横向,如「标题」x 轴 0–400)
| 属性 | 取值 | 来源 |
|------|------|------|
| 方向 | 水平,类目在 Y 轴、数值在 X 轴 | — |
| 条体颜色 | `#69B1FF` (ZS-002-04)(单系列)或图表色序 | §1.1.3 |
| 条体圆角 | 右端 `2px` | §4.1 |
| 类目标签「类型」 | 左对齐,`12px #6E88B0` | §35.3 |
| X 轴刻度 | `0 / 100 / 200 / 300 / 400`,`12px #6E88B0` | §35.3 |
### 36.5 排行进度条(如「数据采集XXX」TOP1–5)
横向「标签 + 渐变进度条 + 数值」的榜单形态。
```
TOP1 项目任务名称一 15,465
■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■▓▓▓▓▓▓▓▓
TOP2 项目任务名称二 15,234
■■■■■■■■■■■■■■■■■■■■■■■■■▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
```
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 排名 + 名称(TOPn 项目任务名称) | `14px Regular`,`#C5CFE0` (ZS-004-02) | — |
| 数值(右对齐) | `16px D-DIN Regular`,`#FFFFFF` | §2.1.2 |
| 进度条高度 | `8px`,圆角 `4px` | §4.1 |
| 进度填充 | 渐变 `linear-gradient(90deg, #1677FF 0%, #13C2C2 100%)`(主色蓝→主色青) | §1.1.8 |
| 进度轨道(底槽) | `#1A2F5A` (ZS-004-07) | §1.1.5 |
| 行间距 | `--spacing-4` (16px) | §7.1 |
| 名称行 → 进度条间距 | `--spacing-2` (8px) | §7.1 |
### 36.6 交互
| 行为 | 动效 |
|------|------|
| 入场 | 柱体由 `0` 向目标高度 / 长度增长,`scaleY/scaleX 0→1`,`400ms ease-out`,按系列依次 `60ms` 错峰 |
| Hover 柱 | 该柱亮度 `+8%`,弹出 §35.6 Tooltip,axisPointer 高亮该类目竖带 |
| Hover 进度条 | 数值放大或显示绝对值 Tooltip |
---
## 37. 折线图(Line / Area)
> 对应设计稿「8.2 图表-折线图」与「8.3 多系列折线」。涵盖**迷你面积图 / 单线(含异常点 Tooltip)/ 长时序面积图(带缩略轴)/ 多系列折线**。容器、轴、网格、图例、Tooltip 沿用 §35。
### 37.1 线条与面积
| 元素 | 规格 | 来源 |
|------|------|------|
| 线宽 | `2px` | — |
| 线条颜色 | 取图表色序,单线默认 `#1677FF` (ZS-002-06) | §1.1.8 |
| 平滑 | `smooth`(曲线)或折线,按数据性质选用 | — |
| 面积填充(单线) | 自顶向下渐变 `rgba(22,119,255,0.32) → rgba(22,119,255,0)` | §3.1 |
| 拐点 | 默认隐藏,hover 显示空心圆(§35.7) | — |
### 37.2 迷你面积图(如左上「图表标题」+ 已选/未选 标签)
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 顶部切换标签(已选 / 未选) | §29 标签:选中 `#1677FF` 实心白字 / 未选 `transparent` + `1px #6E88B0` 边、`#6E88B0` 字 | §29 |
| 线条 | `2px #4096FF` (ZS-002-05) | — |
| 面积渐变 | `rgba(64,150,255,0.32) → 0` | — |
| 当前值标签(如 `59.32`) | 实心小牌 `#122347` 背景 / `#FFFFFF` 文字,圆角 `2px`,定位于末端点 | §35.6 |
| 轴标签(日期) | `12px #6E88B0` | §35.3 |
### 37.3 单线异常点图(如中部「5-1…5-12」+「1月10日 异常率12.9%」)
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 主线 | `2px #1677FF` | — |
| 面积渐变 | `rgba(22,119,255,0.32) → 0` | — |
| 选中竖带 | `rgba(22,119,255,0.12)` 矩形高亮当前 X 区间 | §35.7 |
| 异常点标记 | 空心圆 `6px`,描边 `#FAAD14`(警告橙),填充 `#0C1836` | §27.4 |
| Tooltip(日期 + 指标) | §35.6;指标行如「异常率 12.9%」用 `--func-warning` 橙强调 | §35.6 |
| Y 轴(0–2500) | `12px #6E88B0`,虚线网格 `#2D4473` | §35.3 / §35.4 |
### 37.4 长时序面积图 + 缩略轴(如底部「图表标题」1986–2024)
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 主线 | `2px #36CFC9`(标准青)或 `#4096FF` | §1.1.4 |
| 面积渐变 | `rgba(54,207,201,0.24) → 0` | — |
| 数据点标签(如 `2.43`) | 实心小牌(§35.6),定位于选中点上方 | — |
| 缩略轴(dataZoom,右下迷你图) | 缩略折线 `#4A6491`;滑窗手柄 `#1677FF`,选中区 `rgba(22,119,255,0.16)` | §1.1.5 / §1.1.6 |
| X 轴年份 | `12px #6E88B0`,密集时隔项显示 | §35.3 |
### 37.5 多系列折线图(如「图表标题」XXX/XXX/都是/无反应)
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 图例(顶部居中) | 方块 marker + 文字,项间距 `--spacing-3` | §35.5 |
| 系列 1(XXX) | 红 `#FF4D4F` (ZS-001-05) | §1.1.2 |
| 系列 2(XXX) | 蓝 `#1677FF` (ZS-002-06) | §1.1.3 |
| 系列 3(都是) | 橙 `--func-warning` | §27.4 |
| 系列 4(无反应) | 绿 `--func-success` 或青 `#36CFC9` | §27.4 / §1.1.4 |
| 线宽 | `2px`,多系列**不填充面积**(避免互相遮挡) | — |
| 拐点 | 全程显示 `4px` 空心圆(描边系列色,填充 `#0C1836`) | §35.7 |
| 选中竖带 + Tooltip | hover 竖带 `rgba(22,119,255,0.12)`,Tooltip 列出各系列值(如 `5.32`) | §35.6 |
| Y 轴(0–10) / X 轴(年份) | `12px #6E88B0`,横向虚线网格 `#2D4473` | §35.3 / §35.4 |
### 37.6 交互
| 行为 | 动效 |
|------|------|
| 入场 | 线条沿路径绘制 `stroke-dashoffset` 动画,`600ms ease-out`;面积同步淡入 |
| Hover | 显示 §35.6 Tooltip + 竖向 axisPointer 高亮带;对应拐点放大为空心圆 |
| 图例点击 | 切换系列显隐,隐藏态图例置灰 `#4A6491`(§35.5) |
| 缩略轴拖拽 | 主图 X 轴范围实时联动,`100ms` 节流 |
---
## 38. 甘特图(Gantt)
> 对应设计稿「甘特图」。以时间为横轴、任务行为纵轴的进度条排期图。深蓝背景上以**交替行底纹 + 渐变任务条 + 箭头收尾**呈现,配色取 §1.1.8 图表色序与语义色。
### 38.1 结构
```
1 2 3 … 15 16 17 … 31 ← 时间刻度表头
Jan ▐▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▶ ← 任务条(带箭头收尾)
Feb ▐▓▓▓▓▓▓▓▓▓▶
Mar ▐▓▓▓▓▓▓▓▶
…
Dec ▐▓▓▓▓▓▓▓▓▓▓▓▶
● Lorem ipsum 1 ● Lorem ipsum 2 … ← 底部图例
```
### 38.2 表格框架
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 容器背景 | `#0C1836` (ZS-004-09) | §35.1 |
| 时间刻度表头(1–31 / 月) | `12px Regular`,`#C5CFE0` (ZS-004-02) | §35.3 |
| 行标签(Jan…Dec) | `14px Regular`,`#C5CFE0` | — |
| 行高 | `28px`,行间距 `--spacing-1` (4px) | §7.1 |
| 行底纹(轨道) | `#122347` (ZS-004-08),整行通栏,圆角 `2px` | §1.1.5 |
| 行底纹(隔行可选) | 奇偶交替 `#122347` / `#0F1C3D` | — |
| 列分隔(可选) | `1px dashed #1A2F5A` (ZS-004-07) | §6.2 |
### 38.3 任务条
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 条高 | `16px`,垂直居中于行内 | — |
| 圆角 | `2px`(左端);右端收**箭头**三角表示方向 | §4.1 |
| 填充 | 沿条向右渐变(系列色 `0%` → 同色 70% 透明 `100%`),叠加斜纹/点纹理增强层次 | §1.1.8 |
| 纹理 | `repeating-linear-gradient(45deg, rgba(255,255,255,0.06) 0 4px, transparent 4px 8px)` | — |
| 箭头收尾 | 与条同色实心三角,`6px`,指向时间推进方向 | — |
| 进度(可选) | 条内再叠一段高饱和系列色表示完成比例 | — |
### 38.4 任务分类配色(对应底部图例 Lorem ipsum 1–5)
| 图例 | 圆点 / 任务条主色 | 来源 |
|------|------------------|------|
| Lorem ipsum 1 | 橙 `--func-warning` | §27.4 |
| Lorem ipsum 2 | 红 `#FF4D4F` (ZS-001-05) | §1.1.2 |
| Lorem ipsum 3 | 青绿 `#36CFC9` (ZS-003-05) | §1.1.4 |
| Lorem ipsum 4 | 浅蓝 `#91CAFF` (ZS-002-03) | §1.1.3 |
| Lorem ipsum 5 | 主色蓝 `#1677FF` (ZS-002-06) | §1.1.3 |
### 38.5 图例与表头
| 元素 | 规格 | 来源 |
|------|------|------|
| 底部图例圆点 | `10px` 圆形,取 §38.4 分类色 | §35.5 |
| 图例文字 | `14px Regular`,`#C5CFE0` | — |
| 图例项间距 | `--spacing-5` (24px) | §7.1 |
| 表头 → 首行间距 | `--spacing-3` (12px) | §7.1 |
### 38.6 交互
| 行为 | 动效 |
|------|------|
| 入场 | 任务条由左端起点向右展开 `scaleX 0→1`(`transform-origin:left`),`400ms ease-out`,按行错峰 `60ms` |
| Hover 任务条 | 整条亮度 `+8%`,弹出 §35.6 Tooltip(任务名 / 起止日期 / 进度) |
| Hover 行 | 行底纹由 `#122347` → `#1A2F5A` (ZS-004-07) |
| 今日线(可选) | 一条 `1px dashed #4096FF` (ZS-002-05) 竖线标注当前日期 | §6.2 |
### 38.7 CSS Token 参考(§35–§38 共用)
```css
:root {
/* 图表容器与标题 */
--chart-bg: #0C1836; /* ZS-004-09 */
--chart-title-bar: #1677FF; /* ZS-002-06 标题竖条 */
--chart-title-text: #FFFFFF;
--chart-subtitle: #6E88B0; /* ZS-004-04 */
/* 坐标轴与网格 */
--chart-axis-line: #2D4473; /* ZS-004-06 */
--chart-axis-label: #6E88B0; /* ZS-004-04 */
--chart-axis-cat: #4096FF; /* ZS-002-05 类目名 */
--chart-grid: #2D4473; /* ZS-004-06 虚线网格 */
--chart-grid-weak: #1A2F5A; /* ZS-004-07 */
/* 图例 / Tooltip */
--chart-legend-text: #C5CFE0; /* ZS-004-02 */
--chart-legend-off: #4A6491; /* ZS-004-05 未选中 */
--chart-tooltip-bg: rgba(12,24,54,0.92);
--chart-tooltip-border:#1A2F5A; /* ZS-004-07 */
--chart-tooltip-title: #FFFFFF;
--chart-tooltip-value: #C5CFE0;
--chart-axispointer: #4096FF; /* ZS-002-05 */
--chart-highlight-band:rgba(22,119,255,0.12);
/* 通用图表色序(面积大) */
--chart-c1: #1677FF; --chart-c2: #13C2C2; --chart-c3: #4096FF; --chart-c4: #36CFC9;
--chart-c5: #69B1FF; --chart-c6: #5CDBD3; --chart-c7: #91CAFF; --chart-c8: #87E8DE;
/* 小面积图表色序 */
--chart-s1: #4096FF; --chart-s2: #36CFC9; --chart-s3: #FF4D4F;
--chart-s4: #FFA39E; --chart-s5: #6388FF; --chart-s6: #478880;
/* 排行进度条(§36.5) */
--rank-bar-fill: linear-gradient(90deg, #1677FF 0%, #13C2C2 100%);
--rank-bar-track: #1A2F5A; /* ZS-004-07 */
--rank-value: #FFFFFF;
/* 甘特图(§38) */
--gantt-track: #122347; /* ZS-004-08 行底纹 */
--gantt-row-hover: #1A2F5A; /* ZS-004-07 */
--gantt-c1: #FAAD14; /* 警告橙 */
--gantt-c2: #FF4D4F; /* ZS-001-05 */
--gantt-c3: #36CFC9; /* ZS-003-05 */
--gantt-c4: #91CAFF; /* ZS-002-03 */
--gantt-c5: #1677FF; /* ZS-002-06 */
--gantt-today: #4096FF; /* ZS-002-05 今日线 */
}
```
> 项目当前未引入图表库;若后续接入 ECharts,可将以上 token 注入主题(`backgroundColor` / `axisLine.lineStyle.color` / `splitLine.lineStyle` / `color[]` 色序 / `tooltip.backgroundColor` 等),保持与本规范一致。
>
> **关于多系列填充色**:§36–§38 表格中标注的系列色用于说明语义映射;**实际多系列图表的系列填充色一律以 §41 图表色值(设计稿 8.7)为准**——即淡色系 `#FA9196 / #FFCC80 / #97EBCD / #A2E1F9 / #80BCFF …`,按系列数量取对应色序。§1.1.8 的高彩度色序仅用于需要强对比的强调场景(如单色进度条渐变、KPI 高亮)。
---
## 39. 环形图(Donut / Ring)
> 对应设计稿「8.3 图表-环形图」。中心带总量数字的占比环图,按分类数量分**三环 / 四环 / 五环 / 六环 / 七环 / 八环**等变体。环段配色取 §41 图表色值(淡色系列),中心总量与引导标签取深蓝系字体色。
### 39.1 结构
```
三环 / 四环 …(标题)
╭──────────╮
Media│ │Social
28% │ 34801 │ 36%
│ XXX总量 │
╰──────────╯
News 28%
```
### 39.2 环体规格
| 属性 | 取值 | 来源 |
|------|------|------|
| 外径 | 容器短边的 `60%~70%` | — |
| 内径(中空比) | 外径的 `62%`(环宽约 `38%`) | — |
| 环段间隙 | `2px`(用容器底色 `#0C1836` 描边分隔) | §35.1 |
| 环段圆角 | `2px`(段端微圆角,可选) | §4.1 |
| 环段配色 | 按分类数取 §41 对应「N 色」色序 | §41 |
| 起始角 / 方向 | 顶部 `90°` 起,顺时针 | — |
### 39.3 中心与标签
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 中心主数值(如 `34801`) | `26px Bold D-DIN`,`#FFFFFF` | §2.1.2 |
| 中心说明(如 `XXX总量`) | `12px Regular`,`#6E88B0` (ZS-004-04) | — |
| 引导标签名(如 `Social`) | `12px Regular`,`#C5CFE0` (ZS-004-02) | — |
| 引导标签占比(如 `36%`) | `12px Regular`,`#FFFFFF` 或对应环段色 | — |
| 引导线 | `1px solid #4A6491` (ZS-004-05) 折线,从环段引至外侧标签 | §6.1 |
| 变体标题(三环 / 四环…) | `14px Medium`,`#C5CFE0`,居中于图上方 | §35.2 |
### 39.4 交互
| 行为 | 动效 |
|------|------|
| 入场 | 环段沿顺时针扫出 `0°→终值`,`600ms ease-out`,按段错峰 `60ms` |
| Hover 环段 | 该段沿半径外扩 `4px`(`offset`),中心数值切换为该段值 + 占比,弹出 §35.6 Tooltip |
| 图例点击 | 切换分类显隐,其余环段占比按比例重算 |
---
## 40. 关系图 / 散点图(Network / Scatter)
> 对应设计稿「8.6 图表-散点图」「关系图」。用于展示账号、节点间关系的力导向网络图与散点分布。深蓝背景上以**发光节点 + 渐隐连线 + 平台聚类**呈现,节点配色取主色蓝 / 警告橙 / 危险红做分类,节点尺寸映射权重。
### 40.1 节点(Node)
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 形状 | 圆形(普通节点)/ 头像圆(账号节点,`2px` 描边)/ 六边形(聚合节点) | — |
| 直径 | `8px ~ 40px`,按权重(连接数 / 数值)线性映射 | — |
| 分类色 A(本方 / 主) | `#1677FF` (ZS-002-06) | §1.1.3 |
| 分类色 B(异常 / 外部) | `--func-warning`(橙) | §27.4 |
| 分类色 C(高危 / 标红) | `#FF4D4F` (ZS-001-05) | §1.1.2 |
| 节点发光(重点节点) | `0 0 12px` 同色 40% 透明外发光 | §3.1 |
| 节点文字(数值 / 名称) | `12px Regular`,`#FFFFFF`(节点内)/ `#C5CFE0`(节点外) | — |
| 中心 / 焦点节点 | 加大直径 + 强发光,橙色 `--func-warning` 高亮 | — |
### 40.2 连线(Edge)
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 线宽 | `1px`(普通)/ `2px`(强关系) | — |
| 颜色 | `#4A6491` (ZS-004-05) 半透明,或取**起点节点分类色** 40% 透明 | §1.1.5 |
| 渐隐 | 由中心向外渐隐(`opacity 0.6 → 0.15`),弱化边缘视觉噪声 | — |
| 方向(有向) | 末端箭头 `4px`,同线色 | — |
| 高亮态(hover 节点时) | 关联边升为节点分类色满饱和、`2px`,无关边降至 `opacity 0.08` | — |
### 40.3 散点图(Scatter,纯坐标分布)
| 元素 | 规格 / 配色 | 来源 |
|------|-------------|------|
| 散点 | 实心圆 `6px ~ 16px`(尺寸可映射第三维) | — |
| 配色 | 小面积色序(§41 或 §1.1.8 小面积),分类着色 | §41 |
| 坐标轴 / 网格 | 沿用 §35.3 / §35.4 | §35 |
| 气泡变体 | 半透明填充 `35%` + `1px` 同色描边,避免重叠遮挡 | — |
### 40.4 平台聚类与图例(如 Facebook / Twitter / 微博 / TikTok)
| 元素 | 规格 | 来源 |
|------|------|------|
| 聚类标签 | 平台名 `14px Medium`,对应聚类主色(如蓝 / 橙 / 红) | — |
| 聚类辅文 | 数值 `12px Regular`,`#6E88B0` | — |
| 图例(XXX / XXX) | 方块 marker + 文字,置右下角 | §35.5 |
| 更新提示(如「近30分钟更新数据」) | `12px Regular`,`#6E88B0`,右下角 | — |
### 40.5 控制区(关系图侧栏)
设计稿左侧含「选择目标」下拉 + 分类勾选列表(主页发帖 / 群组发帖 / 私信 / 点赞 / 转发 / 帖子评论 / Pages内容 / 目标的主页帖子)+「添加账号 / 全选账号」按钮。
| 元素 | 规格 | 来源 |
|------|------|------|
| 「选择目标」下拉 | 沿用 §15 选择器 | §15 |
| 分类列表项 | `14px Regular`,前缀分类色圆点 `8px`,沿用 §16 多选 | §16 |
| 「添加账号」 | §5.2.2 默认按钮(含 `+` 图标) | §5.3.2 |
| 「全选账号」 | §5.2.3 文本按钮 | §5.2.3 |
| 刷新图标(右上 ⟳) | `16px`,`#6E88B0`,hover `#4096FF` | §8.2.5 |
### 40.6 交互
| 行为 | 动效 |
|------|------|
| 力导向布局 | 节点入场后力导向收敛,`tick` 动画 `~800ms` 后静止 |
| Hover 节点 | 高亮关联边与邻接节点(§40.2),其余降透明;弹出 §35.6 Tooltip(节点名 / 类型 / 指标) |
| 点击节点 | 居中聚焦该节点并展开其一度关系;可下钻 |
| 缩放 / 平移 | 滚轮缩放 `0.5×~3×`,拖拽平移画布 |
| 拖拽节点 | 可手动拖拽固定节点位置 |
---
## 41. 图表色值(Chart Palette · 多系列色序)
> 对应设计稿「8.7 图表-色值」。这是**全站图表多系列填充的权威色序**(淡色系,深蓝背景上柔和、区分度高)。按系列数量从「三色」到「十色」各有固定取色顺序——系列数为 N 时直接取「N 色」整行,**不要自行截取或乱序**。如需超过十色或特殊场景,由 UI 设计师按整体风格补充。
### 41.1 基础色板
| 色名 | 色值 | 色相 |
|------|------|------|
| 粉红 | `#FA9196` | 红 |
| 橙红 | `#FCA58F` | 橙红 |
| 橙 | `#FDB887` | 橙 |
| 浅橙 | `#FFCC80` | 黄橙 |
| 黄绿 | `#DCD69A` | 黄绿 |
| 浅绿 | `#BAE1B3` | 绿 |
| 青绿 | `#97EBCD` | 青绿 |
| 浅青 | `#9EE4EA` | 青 |
| 浅蓝 | `#A2E1F9` | 天蓝 |
| 蓝 | `#80BCFF` | 蓝 |
### 41.2 按系列数取色序
| 系列数 | 取色顺序 |
|--------|----------|
| 三色 | `#FA9196` · `#97EBCD` · `#80BCFF` |
| 四色 | `#FA9196` · `#FFCC80` · `#97EBCD` · `#A2E1F9` |
| 五色 | `#FA9196` · `#FFCC80` · `#97EBCD` · `#A2E1F9` · `#80BCFF` |
| 六色 | `#FA9196` · `#FFCC80` · `#BAE1B3` · `#97EBCD` · `#A2E1F9` · `#80BCFF` |
| 七色 | `#FA9196` · `#FCA58F` · `#FFCC80` · `#BAE1B3` · `#97EBCD` · `#A2E1F9` · `#80BCFF` |
| 八色 | `#FA9196` · `#FCA58F` · `#FDB887` · `#FFCC80` · `#BAE1B3` · `#97EBCD` · `#A2E1F9` · `#80BCFF` |
| 九色 | `#FA9196` · `#FCA58F` · `#FDB887` · `#FFCC80` · `#DCD69A` · `#BAE1B3` · `#97EBCD` · `#A2E1F9` · `#80BCFF` |
| 十色 | `#FA9196` · `#FCA58F` · `#FDB887` · `#FFCC80` · `#DCD69A` · `#BAE1B3` · `#97EBCD` · `#9EE4EA` · `#A2E1F9` · `#80BCFF` |
> 规律:色序整体沿「红 → 橙 → 黄绿 → 绿 → 青 → 蓝」色相环渐变铺开;系列数增加时在中间插入过渡色,**首色恒为 `#FA9196`、末色恒为 `#80BCFF`**,保证冷暖两端锚定不变。
### 41.3 与既有规范的关系
| 色序 | 用途 | 来源 |
|------|------|------|
| §41 淡色系列(本节) | 图表**多系列填充**(柱 / 折线 / 环形 / 散点分类)默认色序 | 设计稿 8.7 |
| §1.1.8 通用图表色序(高彩度) | 强对比强调:单色渐变进度条、KPI 高亮、需要醒目的单系列 | §1.1.8 |
| §27.4 / §1.1.2 语义色 | 带语义的系列(成功 / 警告 / 错误 / 信息) | §27.4 |
> 三者优先级:**有语义 → 用语义色;多分类并列 → 用 §41;需强调单值 → 用 §1.1.8 高彩度**。§36–§40 中标注的具体系列色为语义示例,实际并列分类应回落到本节色序。
### 41.4 CSS Token 参考
```css
:root {
/* §41 图表多系列色板(淡色系,设计稿 8.7) */
--chart-p-red: #FA9196;
--chart-p-redorange:#FCA58F;
--chart-p-orange: #FDB887;
--chart-p-amber: #FFCC80;
--chart-p-olive: #DCD69A;
--chart-p-green: #BAE1B3;
--chart-p-teal: #97EBCD;
--chart-p-cyan: #9EE4EA;
--chart-p-skyblue: #A2E1F9;
--chart-p-blue: #80BCFF;
}
```
```js
// 按系列数取色序(接入 ECharts 时用作 option.color)
export const CHART_PALETTES = {
3: ['#FA9196', '#97EBCD', '#80BCFF'],
4: ['#FA9196', '#FFCC80', '#97EBCD', '#A2E1F9'],
5: ['#FA9196', '#FFCC80', '#97EBCD', '#A2E1F9', '#80BCFF'],
6: ['#FA9196', '#FFCC80', '#BAE1B3', '#97EBCD', '#A2E1F9', '#80BCFF'],
7: ['#FA9196', '#FCA58F', '#FFCC80', '#BAE1B3', '#97EBCD', '#A2E1F9', '#80BCFF'],
8: ['#FA9196', '#FCA58F', '#FDB887', '#FFCC80', '#BAE1B3', '#97EBCD', '#A2E1F9', '#80BCFF'],
9: ['#FA9196', '#FCA58F', '#FDB887', '#FFCC80', '#DCD69A', '#BAE1B3', '#97EBCD', '#A2E1F9', '#80BCFF'],
10: ['#FA9196', '#FCA58F', '#FDB887', '#FFCC80', '#DCD69A', '#BAE1B3', '#97EBCD', '#9EE4EA', '#A2E1F9', '#80BCFF'],
}
```
---
@@ -1,320 +0,0 @@
# 深蓝色 UI 规范 · 05 页面范例(§42–§44)
> 《深蓝色 UI 规范》分册之一。基础令牌见 `00-基础-色彩字体间距.md`,可 import 的变量见 `tokens.css`,总目录与 §索引见 `README.md`。`§N` 编号全局唯一。
**本篇目录**:`42` 表单弹窗范例 · `43` 卡片列表页 · `44` 数据概览仪表盘
---
## 42. 表单弹窗范例(添加 / 编辑标题)
> 对应设计稿「添加/编辑标题」弹窗(截图 ui-25)。这是 §21 弹出式窗口在「专项 / 标题录入」场景下的落地范例:容器 / 遮罩 / 标题栏 / 底部按钮全部沿用 §21,本节补充截图中出现而 §21 未单列的两个原子——**复合输入行**(输入框 + 行内下拉 + 行内按钮)与**行内「添加项」实心按钮**(+ 类别一 / + 类别二),并给出完整表单项清单。配色从 §1.1.2 渐红系(必填星号)、§1.1.3 渐蓝系(ZS-002,主色 / 下拉 / 按钮)、§1.1.5 深蓝系(ZS-004,label / 边框 / 容器)取。
### 42.1 整体布局
```
┌──────────────────────────────────────────────────────────────┐
│ 添加/编辑标题 ✕ │ ← 标题居中 + 关闭
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ ← 发光分割线
│ │
│ * 专项名称 [请输入内容 ] [一键导入历史专项模板▾] [预览] │ ← 复合输入行
│ * 专项简介 [请输入内容 ] │
│ * 国家地区 [ ▾ ] │
│ * 涉及类别 [ + 类别一 ] [ + 类别二 ] │ ← 行内添加按钮
│ * 显示指数 [请选择 ▾ ] │
│ │
│ [ 取消 ] [ 保存 ] │ ← 底部居中
└──────────────────────────────────────────────────────────────┘
```
### 42.2 容器与标题(沿用 §21)
| 元素 | 取值 | 来源 |
|------|------|------|
| 遮罩 / 容器 / 圆角 / 阴影 | 全部沿用 §21.1 | §21.1 |
| 容器宽度 | 大档 `800px`(复合输入行需要横向空间) | §21.1 |
| 标题文字 | `添加/编辑标题`,`20px Bold`,`#FFFFFF`,居中 | §21.2 |
| 标题发光分割线 | `linear-gradient(90deg, transparent, #1677FF, transparent)`,`1px` 高 | §8.2.4 / §21.2 |
| 关闭 `✕` | `16px`,`#6E88B0` (ZS-004-04),hover `#4096FF` (ZS-002-05) | §21.2 |
### 42.3 表单项(左 label + 右控件,沿用 §21.4)
| 行 | label | 控件 | 复用 |
|----|-------|------|------|
| 1 | `* 专项名称` | 复合输入行(见 §42.4) | §42.4 |
| 2 | `* 专项简介` | 单行输入框,占位「请输入内容」 | §14.1 |
| 3 | `* 国家地区` | 选择器(▾,空态占位) | §15.1 |
| 4 | `* 涉及类别` | 行内「添加项」实心按钮组(见 §42.5) | §42.5 |
| 5 | `* 显示指数` | 选择器(▾,占位「请选择」) | §15.1 |
- label `14px Regular`,`#C5CFE0` (ZS-004-02),右对齐;必填星号 `*` `#F5222D` (ZS-001-06) 置于 label 前(§21.4)
- 表单项纵向间距 `--spacing-4` (16px)(§7.1)
### 42.4 复合输入行(输入框 + 行内下拉 + 行内按钮)
「专项名称」行把主输入框、「一键导入历史专项模板」下拉、「预览」主按钮拼在同一行。
| 元素 | 色值 / 规格 | 来源 |
|------|-------------|------|
| 主输入框 | 沿用 §14.1,弹性撑满左侧剩余宽 | §14.1 |
| 行内下拉「一键导入历史专项模板 ▾」 | 沿用 §15.1 选择器触发器,固定宽(约 `200px`),占位文字 `#6E88B0` | §15.1 |
| 行内按钮「预览」 | §5.2.1 主按钮(实心 `#1677FF` + 白字) | §5.2.1 |
| 元素间水平间距 | `--spacing-2` (8px) | §7.1 |
| 三者垂直对齐 | 统一 `32px` 高,基线居中 | §5.1 |
### 42.5 行内「添加项」实心按钮(+ 类别一 / + 类别二)
「涉及类别」用一排实心主色蓝按钮,左侧 `+` 图标,点击进入对应类别的添加 / 编辑流程。
| 状态 | 背景 | 文字 / 图标 | 来源 |
|------|------|-------------|------|
| 默认 | `#1677FF` (ZS-002-06) | `#FFFFFF` | §5.2.1 / §5.3.2 |
| Hover | `#0958D9` (ZS-002-07) | `#FFFFFF` | §5.2.1 |
| Active | `#003EB3` (ZS-002-08) | `#FFFFFF` | §5.2.1 |
- 高度 `32px`,圆角 `2px`(§4.1),内边距 `0 14px`(§5.1)
- `+` 图标与文字同色 `#FFFFFF`,间距 `--spacing-1` (4px)(§5.3.2)
- 按钮之间间距 `--spacing-2` (8px)
- 与 §21.5.1 分段按钮组的区别:分段组是**互斥单选**(选中才高亮);此处每个按钮都是**独立的添加入口**,常驻主色实心。
### 42.6 底部操作(沿用 §21.6)
| 元素 | 规格 | 来源 |
|------|------|------|
| 对齐 | 居中(跟随截图) | §21.6 |
| 「取消」 | §5.2.2 默认按钮(Outline) | §5.2.2 |
| 「保存」 | §5.2.1 主按钮(实心蓝) | §5.2.1 |
| 顺序 / 间距 | 取消在左、保存在右;间距 `--spacing-2` (8px) | §5.3.4 |
### 42.7 CSS Token 参考
```css
:root {
/* 复合输入行 */
--field-row-gap: 8px; /* --spacing-2 元素间距 */
--field-addon-select-w: 200px; /* 行内下拉固定宽 */
--field-row-h: 32px; /* 统一高度 */
/* 行内添加按钮(沿用主按钮) */
--addbtn-bg: #1677FF; /* ZS-002-06 */
--addbtn-bg-hover: #0958D9; /* ZS-002-07 */
--addbtn-bg-active: #003EB3; /* ZS-002-08 */
--addbtn-text: #FFFFFF;
}
```
---
## 43. 卡片列表页(监测任务卡片)
> 对应设计稿「监测任务卡片列表」(截图 ui-26)。这是 §22 卡片在「监测任务管理」场景下的整页范例:顶部导航沿用 §8、左侧三级菜单沿用 §8.3、卡片容器沿用 §22,本节补充**页面工具条**(搜索 + 添加 + 排序)与**监测任务卡片**的专用布局(头部 + 总量 KPI + 平台统计矩阵 + 底部操作 + 空态)。配色从 §1.1.3 渐蓝系(ZS-002,主色 / KPI 数字)与 §1.1.5 深蓝系(ZS-004,容器 / 文字)取。
### 43.1 整体布局
```
┌─ Header(§8.2)──────────────────────────────────────────────┐
├──┬────────────────────────────────────────────────────────────┤
│三│ [🔍 搜索关键词/任务名称 ] [+ 添加任务] 排序: 创建时间▾ ↑升序 │ ← 工具条
│级│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌──────────┐ │
│菜│ │ 莫某某 10247│ │ 冯某某 10247│ │ 钱某艺 10247│ │ 赵磊 12 │ │ ← 卡片网格
│单│ │ 社交媒体(52)│ │ 社交媒体(52)│ │ 社交媒体(52)│ │ ... │ │
│ │ │ [开始监测] │ │ [开始监测] │ │ [开始监测] │ │ │ │
│ │ └────────────┘ └────────────┘ └────────────┘ └──────────┘ │
└──┴────────────────────────────────────────────────────────────┘
```
### 43.2 页面工具条
| 元素 | 色值 / 规格 | 来源 |
|------|-------------|------|
| 搜索框 | 沿用 §14.2 基本搜索框,占位「搜索关键词 / 任务名称」 | §14.2 |
| 「+ 添加任务」 | §5.3.2 图标 + 文字主按钮(实心 `#1677FF` + 白字 + `+`) | §5.3.2 |
| 排序标签「排序」 | `14px Regular`,`#6E88B0` (ZS-004-04) | — |
| 排序字段「创建时间 ▾」 | §15.1 选择器触发器(小号) | §15.1 |
| 升 / 降序切换「↑ 升序 / ↓ 降序」 | §5.2.3 文本按钮,激活向 `#4096FF` (ZS-002-05) | §5.2.3 |
| 工具条 → 卡片区间距 | `--spacing-5` (24px) | §7.1 |
| 工具条内元素间距 | `--spacing-3` (12px) | §7.1 |
### 43.3 监测任务卡片
```
┌──────────────────────────────────────┐
│ 👤 莫某某 10247 │ ← 头部:头像+名称 / 右上总量
│ 2023-09-09 09:13:00 总数量 │
│ ──────────────────────────────────── │
│ 社交媒体 (52) │ ← 统计区标题
│ 微博 16 推特 8 facebook 16 │ ← 平台统计矩阵
│ 抖音 12 土豆 6 大牛网 9 │
│ ──────────────────────────────────── │
│ url: t.djxqbjhgf9y.cn 也方开夫某利地址│
│ [ 开始监测 ] │ ← 底部操作
└──────────────────────────────────────┘
```
#### 43.3.1 结构与配色
| 元素 | 色值 / 规格 | 来源 |
|------|-------------|------|
| 卡片容器 | 同 §22.1(背景 `#122347` / 边框 `#1A2F5A` / 圆角 `4px` / 一级阴影) | §22.1 |
| 头像占位 | 圆 `40px`,底 `#1A2F5A` (ZS-004-07),图标 `#6E88B0` | §22.3 |
| 任务名(莫某某) | `16px Medium`,`#FFFFFF` | §22.3 |
| 创建时间 | `12px Regular`(D-DIN 数字),`#6E88B0` (ZS-004-04) | §8.2.5 |
| 总量数值(10247) | `26px Bold`(D-DIN),`#4096FF` (ZS-002-05) | §2.1.2 / §1.1.3 |
| 总量标签「总数量」 | `12px Regular`,`#6E88B0` (ZS-004-04) | — |
| 头部底分割线 | `1px solid #1A2F5A` (ZS-004-07) | §6.1 |
#### 43.3.2 平台统计矩阵
统计区为「平台名 + 数值」多列网格(如 社交媒体 (52) 下的 微博 / 推特 / facebook…)。
| 元素 | 色值 / 规格 | 来源 |
|------|-------------|------|
| 区标题「社交媒体 (52)」 | `14px Medium`,`#C5CFE0` (ZS-004-02);括号计数 `#6E88B0` | — |
| 平台名 | `14px Regular`,`#6E88B0` (ZS-004-04) | — |
| 平台数值(数字) | `16px Bold`(D-DIN),`#C5CFE0` (ZS-004-02) | §2.1.2 |
| 矩阵列数 | 3 列等分,行间距 `--spacing-2` (8px) | §7.1 |
| 名 / 值间距 | `--spacing-1` (4px) | §7.1 |
#### 43.3.3 底部操作
| 元素 | 色值 / 规格 | 来源 |
|------|-------------|------|
| 「开始监测」 | §5.2.1 主按钮(实心 `#1677FF`);运行中切 §5.4.2 加载态 | §5.2.1 / §5.4.2 |
| 次要链接(如 url / 地址) | §5.2.3 文本按钮 `#4096FF` (ZS-002-05) | §5.2.3 |
| 操作区上分割线 | `1px solid #2D4473`(弱化) | §6.1 |
#### 43.3.4 空态卡片变体
部分卡片暂无数据(总量 `0` + 占位):
| 元素 | 色值 / 规格 | 来源 |
|------|-------------|------|
| 总量数值 | `0`,`26px Bold`,`#6E88B0` (ZS-004-04,弱化非主色) | — |
| 占位文字「暂无数据」 | `14px Regular`,`#6E88B0` (ZS-004-04),居中 | §35.9 |
| 占位插画 | 灰度线性图标,`#4A6491` (ZS-004-05) | §35.9 |
### 43.4 卡片栅格(沿用 §22.4)
| 规则 | 取值 | 来源 |
|------|------|------|
| 列数 | 自适应,常用 3~4 列 | — |
| 卡片横 / 纵间距 | `--spacing-6` (32px) | §22.4 |
| 卡片内边距 | `--spacing-4` (16px) | §22.4 |
### 43.5 CSS Token 参考
```css
:root {
/* 监测任务卡片 */
--task-card-name: #FFFFFF;
--task-card-time: #6E88B0; /* ZS-004-04 */
--task-kpi-num: #4096FF; /* ZS-002-05 总量数字 */
--task-kpi-label: #6E88B0; /* ZS-004-04 */
--task-stat-name: #6E88B0; /* ZS-004-04 平台名 */
--task-stat-num: #C5CFE0; /* ZS-004-02 平台数值 */
--task-divider: #1A2F5A; /* ZS-004-07 头部分割 */
--task-divider-weak: #2D4473; /* ZS-004-06 操作区分割 */
--task-empty-text: #6E88B0; /* ZS-004-04 空态 */
}
```
---
## 44. 数据概览仪表盘(KPI 指标条 + 综合图表)
> 对应设计稿「资源使用率概览大屏」(截图 ui-27)。这是一张把 §35–§41 各图表与 §28 表格、§9.1 页签组合在一起的概览页。本节补充截图顶部出现而前文未单列的新原子——**KPI 指标卡条**(图标 + 标签 + 大数值 + 已用 + 迷你环 + 同比 / 环比箭头徽标),其余区块均引用既有章节。配色从 §1.1.3 渐蓝系(ZS-002,主色 / 数值)、§1.1.4 渐青系(ZS-003,辅色)、§1.1.5 深蓝系(ZS-004,容器 / 文字)取;涨跌语义色见 §44.2。
### 44.1 整体布局
```
┌──┬────────────────────────────────────────────────────────────┐
│三│ ▓ KPI 指标条(账号/设备/浏览器/代理IP/VPS/邮箱/手机卡/链卡服 使用率)▓│
│级│ ┌─ ▌XXX标题 ───────────────┐ ┌─ ▌XXX标题(资源列表)────────┐│
│菜│ │ [全部|账号资源|设备指标…] │ │ 🔍搜索 排序 ││
│单│ │ ◐异常率40% ╱╲折线异常点 │ │ 资源资料 关联设备 2024-… ││
│ │ │ 总量300 正常270 异常30 │ │ …(§28 表格行) ││
│ │ └───────────────────────────┘ └──────────────────────────────┘│
│ │ ┌─ ▌XXX标题 ───────────────────────┐ ┌─ ▌XXX标题 ──────────┐│
│ │ │ 异常分析 饼 + 折线(§37 / §39) │ │ ◐300/270 + 趋势饼 ││
│ │ └────────────────────────────────────┘ └─────────────────────┘│
└──┴────────────────────────────────────────────────────────────┘
```
### 44.2 涨跌语义色
| 语义 | 色值 | 来源 |
|------|------|------|
| 上升 / 正向(▲) | `#13C2C2` (ZS-003-06,辅色青) | §1.1.4 |
| 下降 / 负向(▼) | `#F5222D` (ZS-001-06,主色红) | §1.1.2 |
| 持平 | `#6E88B0` (ZS-004-04) | §1.1.5 |
> 涨跌仅用语义色,不混入图表多系列色;正向用辅色青(ZS-003)而非主色蓝,避免与 KPI 数值蓝撞色。
### 44.3 KPI 指标卡条
横向一排等宽指标卡,每张承载一个使用率指标(如「账号使用率 139 已用」+ 迷你环 + 同比箭头)。
```
┌────────────────────────┐
│ 🅰 账号使用率 │ ← 图标 + 标签
│ 139 ◐ 45% ▲ │ ← 大数值 + 迷你环 + 同比
│ 已用 同比 │
└────────────────────────┘
```
| 元素 | 色值 / 规格 | 来源 |
|------|-------------|------|
| 卡片容器 | 背景 `#122347` (ZS-004-08),边框 `1px solid #1A2F5A`,圆角 `4px`,一级阴影 | §22.1 |
| 指标图标 | `20px`,主色 `#4096FF` (ZS-002-05) 单色化 | §1.1.6 |
| 指标标签(账号使用率) | `14px Regular`,`#C5CFE0` (ZS-004-02) | — |
| 大数值(139) | `26px Bold`(D-DIN),`#FFFFFF` | §2.1.2 |
| 单位 / 说明「已用」 | `12px Regular`,`#6E88B0` (ZS-004-04) | — |
| 迷你环(mini-donut) | 直径 `32px`,环宽 `4px`,已用段 `#1677FF` (ZS-002-06),底环 `#2D4473` (ZS-004-06) | §39.2 |
| 环内百分比 | `12px Bold`(D-DIN),`#FFFFFF` | §39.3 |
| 同比 / 环比箭头徽标 | `12px`,▲ 用 `#13C2C2`、▼ 用 `#F5222D`(见 §44.2) | §44.2 |
| 卡片宽度 | 等分横排(8 张则各占 1/8),最小宽 `160px` | — |
| 卡片间距 | `--spacing-4` (16px) | §7.1 |
| 卡片内边距 | `--spacing-4` (16px) | §7.1 |
### 44.4 区块组合引用
| 区块 | 复用 | 备注 |
|------|------|------|
| 区块标题(▌XXX标题) | §35.2 图表标题(主色蓝竖条 + 文字) | — |
| 分类页签(全部 / 账号资源 / …) | §9.1.1 横向页签 | 激活 `#4096FF` + `2px` 下划线 |
| 日期筛选(右上 `2023-02`) | §17.1 日期触发器 | — |
| 异常率环形图(40% / 总量300 / 正常270 / 异常30) | §39 环形图 | 正常段主色蓝、异常段 `#F5222D`(§44.2) |
| 折线异常点图(1月10日 异常率12.9%) | §37.3 单线异常点图 + §35.6 Tooltip | 异常点用红 `#F5222D` 强调 |
| 资源列表表格 | §28 表格 + §14.2 搜索 + §13 分页 | — |
| 异常分析饼 / 趋势饼 | §39 环形图 | 多系列回落 §41 色序 |
| 容器 / 内边距 / 多图间距 | §35.1 图表容器 | `--spacing-5` / `--spacing-6` |
### 44.5 交互
| 行为 | 规则 |
|------|------|
| KPI 卡 hover | 卡片阴影升二级(§3.1.4),迷你环高亮,弹出 §35.6 Tooltip(已用 / 总量 / 同比明细) |
| 页签切换 | 切换分类后下方图表与表格联动刷新(§9.1.1) |
| 图表 hover | 沿用各图表节交互(§37.6 / §39.4 等) |
| 日期筛选变更 | 重新拉取区块数据,图表入场动画重放 |
### 44.6 CSS Token 参考
```css
:root {
/* KPI 指标卡 */
--kpi-card-bg: #122347; /* ZS-004-08 */
--kpi-card-border: #1A2F5A; /* ZS-004-07 */
--kpi-icon: #4096FF; /* ZS-002-05 */
--kpi-label: #C5CFE0; /* ZS-004-02 */
--kpi-value: #FFFFFF;
--kpi-sub: #6E88B0; /* ZS-004-04 已用 */
--kpi-ring-used: #1677FF; /* ZS-002-06 迷你环已用段 */
--kpi-ring-track: #2D4473; /* ZS-004-06 迷你环底 */
/* 涨跌语义 */
--trend-up: #13C2C2; /* ZS-003-06 上升(辅色青) */
--trend-down: #F5222D; /* ZS-001-06 下降(红) */
--trend-flat: #6E88B0; /* ZS-004-04 持平 */
}
```
@@ -1,82 +0,0 @@
# 深蓝色 UI 规范(拆分版)
面向开发的深蓝主题设计规范。原单文件 `../ui修改-深蓝.md`(约 5200 行)已按模块拆分到本目录,便于按需查阅、减少 diff 冲突,也方便配合 AI 只加载相关分册。
> **颜色唯一来源**:所有色值以 **§1 色彩**(见 [`00-基础-色彩字体间距.md`](./00-基础-色彩字体间距.md))为准,可执行变量见 [`tokens.css`](./tokens.css)。业务代码 **import `tokens.css` 并用 `var(--token)`**,不要硬编码 HEX。
## 分册
| 文件 | 章节 | 内容 |
|------|------|------|
| [`00-基础-色彩字体间距.md`](./00-基础-色彩字体间距.md) | §1–§7 | 色彩 / 字体 / 阴影 / 圆角 / 按钮 / 分割线 / 间距 |
| [`01-导航.md`](./01-导航.md) | §8–§13 | 导航组件 / 锚点页签 / 步骤条 / 面包屑 / 页头 / 分页 |
| [`02-表单.md`](./02-表单.md) | §14–§21 | 输入框 / 选择器 / 单复选 / 日期 / 穿梭框 / 树选择 / 上传 / 弹窗 |
| [`03-数据展示.md`](./03-数据展示.md) | §22–§34 | 卡片 / 折叠 / 描述列表 / 列表 / 时间轴 / 进度条 / 表格 / 标签 / 树 / 对话框 / 提示 / 气泡确认 |
| [`04-图表.md`](./04-图表.md) | §35–§41 | 图表通用 / 柱状 / 折线 / 甘特 / 环形 / 关系散点 / 图表色值 |
| [`05-页面范例.md`](./05-页面范例.md) | §42–§44 | 表单弹窗 / 卡片列表页 / 数据概览仪表盘 |
| [`tokens.css`](./tokens.css) | — | 全量 CSS 变量(颜色 / 间距 / 圆角 / 阴影 / 字体),可直接 import |
## § 索引(编号 → 所在文件)
章节编号 `§N` 全局唯一、跨文件不变;文中 `§N` / `§N.x` 引用按下表定位。
| § | 标题 | 文件 |
|---|------|------|
| §1 | 色彩 | 00 |
| §2 | 字体 | 00 |
| §3 | 阴影 | 00 |
| §4 | 圆角 | 00 |
| §5 | 按钮 | 00 |
| §6 | 分割线 | 00 |
| §7 | 间距 | 00 |
| §8 | 导航组件 | 01 |
| §9 | 锚点与页签 | 01 |
| §10 | 步骤条 | 01 |
| §11 | 面包屑 | 01 |
| §12 | 页头 | 01 |
| §13 | 分页 | 01 |
| §14 | 输入框(搜索框) | 02 |
| §15 | 选择器 | 02 |
| §16 | 单选与多选 | 02 |
| §17 | 日期选择器 | 02 |
| §18 | 穿梭框 | 02 |
| §19 | 树选择 | 02 |
| §20 | 上传 | 02 |
| §21 | 弹出式窗口(Modal) | 02 |
| §22 | 卡片 | 03 |
| §23 | 折叠面板 | 03 |
| §24 | 描述列表 | 03 |
| §25 | 列表 | 03 |
| §26 | 时间轴(节点型) | 03 |
| §27 | 进度条 | 03 |
| §28 | 表格 | 03 |
| §29 | 标签 | 03 |
| §30 | 树形控件 | 03 |
| §31 | 时间轴(长文本) | 03 |
| §32 | 对话框(确认框) | 03 |
| §33 | 全局提示 | 03 |
| §34 | 气泡确认 | 03 |
| §35 | 图表通用规范 | 04 |
| §36 | 柱状图 | 04 |
| §37 | 折线图 | 04 |
| §38 | 甘特图 | 04 |
| §39 | 环形图 | 04 |
| §40 | 关系图 / 散点图 | 04 |
| §41 | 图表色值 | 04 |
| §42 | 表单弹窗范例 | 05 |
| §43 | 卡片列表页 | 05 |
| §44 | 数据概览仪表盘 | 05 |
## 约定
- **颜色**:以 §1 为准,代码用 `tokens.css` 的 `var(--token)`。功能语义色(成功/警告/错误/信息/处理中)见 §1.1.9 / `--func-*`。
- **间距**:仅用 §7 的 8px 阶梯(`--spacing-1..8`),禁用 5px / 13px 等非阶梯值。
- **圆角**:按钮/输入/标签 `2px`,卡片/分栏/弹窗 `4px`(`--radius-*`)。
- **跨节引用**:保留 `§N` 编号,用上方「§ 索引」定位文件。
- **名称易混点**:§26 时间轴(节点型) ↔ §31 时间轴(长文本);§21 弹出式窗口(Modal) ↔ §32 对话框(确认框)。
- **图表两套色板**:§1.1.8(高彩度,强调单值)与 §41(淡色系,多分类并列)并存,优先级见 §41.3。
## 维护
- 改色 / 改间距 → 先改 **`tokens.css` + §1/§7**,组件分册回落引用,不在分册里另立硬编码。
- 新增整页范例 → 进 `05-页面范例.md`,沿用既有组件 § 与 `tokens.css`。
@@ -1,211 +0,0 @@
/* =============================================================================
* 深蓝色 UI 规范 · 设计令牌(tokens.css)
* -----------------------------------------------------------------------------
* 这是《深蓝色 UI 规范》全站颜色 / 间距 / 圆角 / 阴影 / 字体的唯一可执行来源。
* 开发时优先 import 本文件并使用 var(--token),不要在业务代码里硬编码 HEX。
*
* 文档对应:
* §1 色彩 → 原始色阶 --zs-*、功能语义 --func-*、装饰/图表色板
* §2 字体 → --font-* / --fs-*
* §3 阴影 → --shadow-*
* §4 圆角 → --radius-*
* §7 间距 → --spacing-*
* 组件级 token(--card-* / --modal-* / --kpi-* 等)保留在各分册的「CSS Token 参考」
* 小节中,均回落到本文件的原始/语义 token。
* ========================================================================== */
:root {
/* ---------------------------------------------------------------------------
* 1. 原始色阶(§1.1.2–§1.1.5)—— 01 最浅 → 10 最深
* ------------------------------------------------------------------------ */
/* 渐红系 ZS-001 —— 警示 / 错误 / 强调 */
--zs-001-01: #FFF1F0;
--zs-001-02: #FFCCC7;
--zs-001-03: #FFA39E;
--zs-001-04: #FF7875;
--zs-001-05: #FF4D4F;
--zs-001-06: #F5222D;
--zs-001-07: #CF1322;
--zs-001-08: #A8071A;
--zs-001-09: #820014;
--zs-001-10: #5C0011;
/* 渐蓝系 ZS-002 —— 信息 / 链接 / 次级操作(含主色) */
--zs-002-01: #E6F4FF;
--zs-002-02: #BAE0FF;
--zs-002-03: #91CAFF;
--zs-002-04: #69B1FF;
--zs-002-05: #4096FF;
--zs-002-06: #1677FF;
--zs-002-07: #0958D9;
--zs-002-08: #003EB3;
--zs-002-09: #002C8C;
--zs-002-10: #001D6C;
/* 渐青系 ZS-003 —— 成功 / 在线 / 数据高亮(含辅色) */
--zs-003-01: #E6FFFB;
--zs-003-02: #B5F5EC;
--zs-003-03: #87E8DE;
--zs-003-04: #5CDBD3;
--zs-003-05: #36CFC9;
--zs-003-06: #13C2C2;
--zs-003-07: #08979C;
--zs-003-08: #006D75;
--zs-003-09: #00474F;
--zs-003-10: #002329;
/* 深蓝系 ZS-004 —— 背景 / 容器 / 导航 / 文字(主题核心) */
--zs-004-01: #E8EDF5;
--zs-004-02: #C5CFE0;
--zs-004-03: #9BADC8;
--zs-004-04: #6E88B0;
--zs-004-05: #4A6491;
--zs-004-06: #2D4473;
--zs-004-07: #1A2F5A;
--zs-004-08: #122347;
--zs-004-09: #0C1836;
--zs-004-10: #060E24;
/* ---------------------------------------------------------------------------
* 2. 功能角色色(§1.1.6)—— 从色阶提取的语义别名
* ------------------------------------------------------------------------ */
--color-primary: var(--zs-002-06); /* #1677FF 主操作 */
--color-primary-hover: var(--zs-002-07); /* #0958D9 */
--color-primary-active: var(--zs-002-08); /* #003EB3 */
--color-link: var(--zs-002-05); /* #4096FF 链接 / 文本按钮 */
--color-accent: var(--zs-003-06); /* #13C2C2 辅色 */
--color-text: var(--zs-004-02); /* #C5CFE0 正文字体 */
--color-text-secondary: var(--zs-004-04); /* #6E88B0 次级字体 */
--color-text-disabled: var(--zs-004-05); /* #4A6491 禁用字体 */
--color-text-strong: #FFFFFF; /* 标题 / 强调 */
--color-bg-page: var(--zs-004-09); /* #0C1836 页面背景 */
--color-bg-container: var(--zs-004-07); /* #1A2F5A 容器背景 */
--color-bg-card: var(--zs-004-08); /* #122347 卡片背景 */
--color-bg-deepest: var(--zs-004-10); /* #060E24 全局底色 */
--color-border: var(--zs-004-07); /* #1A2F5A 容器边框 */
--color-border-strong: var(--zs-004-04); /* #6E88B0 控件边框 */
--color-white: #FFFFFF;
/* ---------------------------------------------------------------------------
* 3. 功能语义色(§1.1.9)—— 成功/警告/错误/信息/处理中
* 成功绿、警告橙为标准色系外补充色;其余复用色阶。
* ------------------------------------------------------------------------ */
--func-success: #52C41A; /* 补充色 · 成功绿 */
--func-warning: #FAAD14; /* 补充色 · 警告橙 */
--func-error: var(--zs-001-06); /* #F5222D */
--func-info: var(--zs-002-06); /* #1677FF */
--func-processing: var(--zs-003-06); /* #13C2C2 */
/* 浅底变体(Message / Alert / Tag 背景,16% 透明度) */
--func-success-bg: rgba(82, 196, 26, 0.16);
--func-warning-bg: rgba(250, 173, 20, 0.16);
--func-error-bg: rgba(245, 34, 45, 0.16);
--func-info-bg: rgba(22, 119, 255, 0.16);
/* ---------------------------------------------------------------------------
* 4. 小面积装饰色(§1.1.7)—— 仅点缀 / 徽标,勿做大面积背景
* ------------------------------------------------------------------------ */
--deco-violet: #6388FF;
--deco-danger: #F5222D;
--deco-brand-blue: #12306C;
--deco-teal: #478880;
--deco-alert: #950404;
/* ---------------------------------------------------------------------------
* 5. 字体(§2)
* ------------------------------------------------------------------------ */
--font-zh: 'Noto Sans S Chinese', 'Noto Sans SC', system-ui, sans-serif;
--font-num: 'D-DIN', 'Noto Sans S Chinese', sans-serif; /* 数据数字 */
--font-digital:'DS-Digital', 'D-DIN', sans-serif; /* 大屏特殊数字 */
--font-logo: 'DOUYUFont', 'Noto Sans S Chinese', sans-serif;
--font-table: 'Arial', 'Noto Sans S Chinese', sans-serif; /* 表格数字 */
/* 常用字号(后台系统,§2.1.2) */
--fs-12: 12px;
--fs-14: 14px;
--fs-16: 16px;
--fs-18: 18px;
--fs-20: 20px;
--fs-24: 24px;
--fs-26: 26px;
/* ---------------------------------------------------------------------------
* 6. 间距(§7)—— 8px 基准,密(≤16) / 疏(≥24),禁用非阶梯值
* ------------------------------------------------------------------------ */
--spacing-1: 4px;
--spacing-2: 8px;
--spacing-3: 12px;
--spacing-4: 16px;
--spacing-5: 24px;
--spacing-6: 32px;
--spacing-7: 40px;
--spacing-8: 56px;
/* ---------------------------------------------------------------------------
* 7. 圆角(§4)
* ------------------------------------------------------------------------ */
--radius-button: 2px;
--radius-input: 2px;
--radius-tag: 2px;
--radius-card: 4px;
--radius-panel: 4px;
--radius-modal: 4px;
/* ---------------------------------------------------------------------------
* 8. 阴影(§3.1.4)
* ------------------------------------------------------------------------ */
--shadow-1: 0 2px 8px rgba(22, 119, 255, 0.12); /* 卡片悬浮 */
--shadow-2: 0 4px 16px rgba(22, 119, 255, 0.24); /* hover / 弹出层 */
--shadow-3: 0 8px 32px rgba(6, 14, 36, 0.6); /* Dialog / Drawer */
--shadow-modal-mask: rgba(6, 14, 36, 0.6); /* 遮罩 */
/* 常用渐变 */
--gradient-header: linear-gradient(180deg, #122347 0%, #0C1836 100%); /* 顶栏 §8.2.1 */
--gradient-title-line: linear-gradient(90deg, transparent 0%, #1677FF 50%, transparent 100%); /* 标题发光线 §8.2.4 */
}
/* =============================================================================
* 图表多系列色板
* ========================================================================== */
:root {
/* 通用图表色序(§1.1.8,面积大:柱/折线/饼,高彩度) */
--chart-g-1: #1677FF;
--chart-g-2: #13C2C2;
--chart-g-3: #4096FF;
--chart-g-4: #36CFC9;
--chart-g-5: #69B1FF;
--chart-g-6: #5CDBD3;
--chart-g-7: #91CAFF;
--chart-g-8: #87E8DE;
/* 小面积图表色序(§1.1.8,散点/迷你图/徽标) */
--chart-s-1: #4096FF;
--chart-s-2: #36CFC9;
--chart-s-3: #FF4D4F;
--chart-s-4: #FFA39E;
--chart-s-5: #6388FF;
--chart-s-6: #478880;
/* 多系列填充色板(§41,淡色系,分类并列默认色序) */
--chart-p-red: #FA9196;
--chart-p-redorange: #FCA58F;
--chart-p-orange: #FDB887;
--chart-p-amber: #FFCC80;
--chart-p-olive: #DCD69A;
--chart-p-green: #BAE1B3;
--chart-p-teal: #97EBCD;
--chart-p-cyan: #9EE4EA;
--chart-p-skyblue: #A2E1F9;
--chart-p-blue: #80BCFF;
}
/*
* 图表配色优先级(§41.3):
* 有语义 → 用 --func-*;多分类并列 → 用 --chart-p-*(§41 色板);
* 需强调单值 → 用 --chart-g-* / --chart-s-*(§1.1.8 高彩度)。
*
* 接入 ECharts 时按系列数取 option.color,见 §41.4 的 CHART_PALETTES。
*/
@@ -1,156 +0,0 @@
# DeerFlow 上游升级对比与回合建议
> 对比对象:`bytedance/deer-flow`(上游,HEAD `9f3be2a` @ 2026-05-30)vs 本仓库 fork。
> 生成日期:2026-05-31。
> 说明:按用户要求,**多语言 (i18n)** 与 **播客 / TTS (podcast)** 不在本次升级范围内,已剔除。
---
## 0. 背景与对比结论
| 项 | 结论 |
|---|---|
| fork 切出时间 | **≈ 2026-05-12**(与目录名 `offline-backend-20260512` 吻合) |
| 上游当前 HEAD | `9f3be2a` @ 2026-05-30,完整历史 2186 提交 |
| fork 之后上游新增 | **95 个提交**(7 feat / 1 perf / 60 fix / 依赖与文档若干) |
| 后端核心框架 `packages/harness/deerflow/` | **高度同源、目录结构完全一致**,差异主要来自这 95 个提交 |
| 前端 | 技术栈已分道扬镳:上游仍是 **Next.js**,本仓库已迁到 **Vite + React Router**,文件几乎全不同 |
「升级」分两类,本文都覆盖:
- **A 类**:fork 之后上游真正新增的(95 提交)——同源、可 cherry-pick,**优先回合**。
- **B 类**:fork 时就裁掉/换了路线、上游一直保留的能力——成本高、按业务取舍。
---
## 1. 后端升级(A 类:fork 后上游新增)
### 1.1 新能力(feat / perf)
| 日期 | 升级 | 提交 | 说明 |
|---|---|---|---|
| 05-29 | ToolOutputBudgetMiddleware | #3303 | 给超大工具输出做预算保护,防止单个结果撑爆上下文 |
| 05-28 | MiMo reasoning 支持 | #3298 | 兼容小米 MiMo 模型思维链回传 |
| 05-21 | 链路追踪命名优化 | #3101 | 图名改 `lead_agent`、用自定义 agent 名作 run_name |
| 05-21 | 循环检测延迟告警 | #2752 | 警告延迟注入,减少对正常多轮对话的误打断 |
| 05-20 | Sandbox 下载文件接口 | #3038 | 可把沙箱内产物取回 |
| 05-15 | Discord 渠道增强 | #2842 | 仅@提及、thread 路由、typing 指示 |
| 05-13 | 子代理 token 用量流式上报 | #2882 | subagent 消耗实时汇总到 header,利于展示/计费 |
| 05-12 | perf:线程元数据过滤下推 SQL | #2865 | 线程列表筛选走 SQL,性能提升 |
能力性增强(fix 前缀实为增强):MCP 跨调用持久化会话、支持有状态 server(#3089 / #3203);摘要触发阈值调优(#3174)。
### 1.2 重要稳定性修复
| 主题 | 提交 | 价值 |
|---|---|---|
| 摘要压缩后保留消息 | #3280 | 数据正确性关键 |
| 重启后从持久化恢复 runs / interrupted 状态 | #2932(破坏性)、#2989、#3152、#3155 | 网关重启不丢运行 |
| JWT secret 持久化、多 worker 共享网关 token | #2933、#3184 | 重启/多进程登录态不失效 |
| memory 更新按 agent 隔离 + 包裹 JSON 解析健壮性 | #2941、#3252 | 记忆串号/解析修复 |
| MCP 配置接口脱敏 | #2667 | 防密钥泄露 |
| 阻塞 IO 移出事件循环 | #3311、#2822(+检测门禁 #3229) | 消除事件循环阻塞 |
### 1.3 依赖升级
- Python:`langsmith 0.7.36 → 0.8.0`、`idna 3.13 → 3.15`
### 1.4 怎么做(回合 A 类后端升级)
> 前提:本仓库 `packages/harness/deerflow/` 与上游高度同源,cherry-pick 冲突面小。
1. **加上游为远程并拉取历史**(在后端所在 git 仓库根执行):
```bash
git remote add upstream https://github.com/bytedance/deer-flow.git
git fetch upstream
```
2. **逐条预览某个提交的改动范围**:
```bash
git show <commit_hash> --stat
git show <commit_hash> -- backend/packages/harness/deerflow/...
```
3. **单条回合**(推荐用 `-x` 记录来源;如路径前缀不一致需先确认):
```bash
git cherry-pick -x <commit_hash>
# 冲突时:解决后
git cherry-pick --continue
```
> 注意:上游后端在 `backend/` 子目录,本仓库后端在 `offline-backend-20260512/backend/`。若 cherry-pick 因路径不匹配失败,改用按文件打补丁:
> ```bash
> git show <hash> -- backend/<path> | git apply --directory=offline-backend-20260512/backend -p2 -3
> ```
4. **回合后必跑校验**(在 `offline-backend-20260512/backend/`):
```bash
make test # PYTHONPATH=. uv run pytest tests/ -v
make lint
```
重点确认 `tests/test_harness_boundary.py`(harness→app 防火墙)与中间件链顺序未被破坏。
5. **依赖升级**:同步上游 `pyproject.toml` 对应行后执行 `uv lock && make install`;离线环境需同步更新 `requirements-offline.txt` / `wheelhouse`。
---
## 2. 前端升级(B 类:上游有、本仓库缺)
> 已剔除多语言、播客。下表为关键词核验结果(本仓库 vs 上游命中文件数)。
| 功能 | 本仓库 | 上游 | 状态 |
|---|---|---|---|
| Prose 文本润色 | 0 | 8 | 上游独有 |
| Replay 研究回放 | 2 | 29 | 上游完整,本仓库近乎无 |
| PPT / PPTX 生成 | 2 | 14 | 上游完整,本仓库近乎无 |
| Landing 落地页 | 0 | 11 | 上游独有(营销/介绍页,按需) |
| 静态 demo 模式 | 无 | #3170 | fork 后上游新增 |
前端体验类修复(可参考实现):流式渲染、消息去重、历史消息 Mermaid 预览、新建会话即时入侧栏。
前端依赖升级:`brace-expansion 1.x → 5.0.5`(安全)、`uuid 10 → 14`。
### 2.1 怎么做(前端能力移植)
> 前端技术栈已不同(Next.js → Vite + React Router),**不能直接 cherry-pick**,需按"读上游实现 → 适配到本仓库"的方式重写。
通用步骤:
1. 在上游 `frontend/src/` 定位目标功能的页面/组件/hook 与对应的后端 API 调用。
2. 后端能力是否具备:Prose / PPT / Replay 依赖后端相应接口,先确认本仓库后端有无对应路由(多为 deep_research/skills 相关),**没有则前端无法独立完成**,需连带回合后端。
3. 适配差异点:
- 路由:上游 app router (`src/app/...`) → 本仓库 `src/pages/` + `PageRoutes.tsx` / `WorkspaceRoutes.tsx`。
- 导航 API:用本仓库 `src/shims/`(`next/link`、`next/navigation` 已 polyfill)。
- 数据流:沿用本仓库 TanStack Query + `@langchain/langgraph-sdk` 流式。
4. 验证:`pnpm typecheck` + `pnpm dev` 实测。
---
## 3. 推荐升级清单(按优先级)
### P0 — 强烈建议(低风险、高价值,A 类)
- **摘要压缩后保留消息 #3280** —— 数据正确性,直接影响长对话可用性。
- **重启后恢复 runs / interrupted #2932 #2989 #3152 #3155** —— 生产稳定性核心。
- **JWT secret 持久化 / 多 worker 共享 token #2933 #3184** —— 否则重启或多进程部署会掉登录态。
- **阻塞 IO 移出事件循环 #3311 #2822** —— 并发吞吐与响应延迟改善。
### P1 — 建议(实用增强)
- **ToolOutputBudgetMiddleware #3303** —— 防超大工具输出拖垮上下文,对 agent 稳定性有实际帮助。
- **MCP 持久化会话 / 有状态 server #3089 #3203** —— 若用到有状态 MCP server 则必需。
- **MCP 配置接口脱敏 #2667** —— 安全加固。
- **子代理 token 用量流式上报 #2882** —— 计费/可观测。
- **线程元数据过滤下推 SQL #2865(perf)** —— 列表性能。
### P2 — 按需(看是否用到对应模型/渠道)
- MiMo reasoning #3298(用小米模型才需要)
- Discord 渠道增强 #2842(用 Discord 才需要)
- Sandbox 下载文件接口 #3038、追踪命名 #3101、循环检测延迟告警 #2752
### 前端(按业务价值)
- **Replay 研究回放** —— 对"深研/写作"类产品体验提升明显,建议优先(需后端配合)。
- **Prose 文本润色** —— 与写作工作台契合,建议评估接入。
- **PPT 生成** —— 若有汇报/导出诉求再做。
- **Landing 落地页** —— 仅当面向外部用户时考虑。
### 暂不做
- 多语言 i18n(本次明确排除)
- 播客 / TTS(本次明确排除)
---
## 4. 建议执行节奏
1. 先做 **P0 后端 4 项** + 跑全量测试,单独提交一个"上游稳定性回合"分支。
2. 再做 **P1**,逐条 cherry-pick、逐条验证。
3. 前端 **Replay / Prose** 立项评估(确认后端依赖),单独排期重写适配。
4. 依赖升级随回合同步,离线包 (`wheelhouse` / `requirements-offline.txt`) 一并更新。
File diff suppressed because it is too large. Load diff
@@ -1,514 +0,0 @@
# 浅红色 UI 规范 · 一、基础样式
> 返回 [规范目录](README.md) | 本篇:色彩 · 字体 · 阴影 · 圆角 · 间距 · 语义状态色
---
## 1. 色彩
### 1.1 标准色
色彩体系分为**主色**与**辅色**两大类。
- **主色**:渐红系(ZS-001)、渐蓝系(ZS-002),用于品牌表达与核心交互。
- **辅色**:渐橙系(FS-001)、渐绿系(FS-002)、渐青系(FS-003)、中性灰系(FS-004),用于状态提示、数据可视化与文字 / 背景 / 分割线。
每个色系按明度从浅到深编号(`-01` 最浅,数字越大越深),并附两种渐变色(`-14` 线性渐变、`-15` 径向渐变)。本主题为**浅色主题**:以白 / 浅灰作大面积背景,以深色作正文,红色为品牌强调主色。
---
#### 1.1.1 色系总览
| 类别 | 色系 | 用途定位 | 代码前缀 |
|------|------|----------|----------|
| 主色 | 渐红系 | 品牌主色、强调操作、警示与错误 | ZS-001 |
| 主色 | 渐蓝系 | 信息、链接、次级操作 | ZS-002 |
| 辅色 | 渐橙系 | 警告、进行中、提示标签 | FS-001 |
| 辅色 | 渐绿系 | 成功、在线、通过状态 | FS-002 |
| 辅色 | 渐青系 | 数据高亮、图表、辅助信息 | FS-003 |
| 辅色 | 中性灰系 | 背景、文字、图标、分割线 | FS-004 |
> 每个色系内:`-01 ~ -03` 为**浅色**(用于大面积底色);`-04 ~ -09` 为**中亮色**(用于标签、图表、提示等);`-10` 及以后为**深色**(用于小面积按钮、图标等);`-14 / -15` 为渐变色(用于特殊装饰性小面积底色)。
---
#### 1.1.2 主色 · 渐红系(ZS-001)
品牌主色系。用于主操作按钮、重要强调、错误与危险告警。
| 编号 | 色值 | 明度分组 | 说明 |
|------|------|----------|------|
| ZS-001 | `#CF1322` | 深色 | 品牌代表红(默认主色基准) |
| ZS-001-01 | `#FEFAFA` | 浅色 | 极浅红,大面积底色 |
| ZS-001-02 | `#FEF6F6` | 浅色 | 浅红底色 |
| ZS-001-03 | `#FEEDED` | 浅色 | 浅红底色 / 错误背景 |
| ZS-001-04 | `#FDDADC` | 中亮色 | — |
| ZS-001-05 | `#FCB5B9` | 中亮色 | — |
| ZS-001-06 | `#FA9196` | 中亮色 | — |
| ZS-001-07 | `#F86C73` | 中亮色 | 浅红 Tag / 图表 |
| ZS-001-08 | `#F74750` | 中亮色 | — |
| ZS-001-09 | `#F5222D` | 中亮色 | **标准红**,主按钮 / 图标 |
| ZS-001-10 | `#BC0D1E` | 深色 | 深红,Hover |
| ZS-001-11 | `#A8071A` | 深色 | 深红,Active / Press |
| ZS-001-12 | `#820014` | 深色 | 极深红 |
| ZS-001-13 | `#5C0011` | 深色 | 最深红 |
| ZS-001-14 | `#F5222D → #A8071A` | 线性渐变 | 装饰底色 |
| ZS-001-15 | `#F86C73 → #F5222D` | 径向渐变 | 装饰底色 |
---
#### 1.1.3 主色 · 渐蓝系(ZS-002)
用于信息提示、链接、次级按钮与图表。
| 编号 | 色值 | 明度分组 | 说明 |
|------|------|----------|------|
| ZS-002 | `#0062D9` | 深色 | 蓝色代表色(基准) |
| ZS-002-01 | `#F9FCFF` | 浅色 | 极浅蓝,大面积底色 |
| ZS-002-02 | `#F4F9FF` | 浅色 | 浅蓝底色 |
| ZS-002-03 | `#EAF4FF` | 浅色 | 浅蓝底色 / 信息背景 |
| ZS-002-04 | `#D4E9FF` | 中亮色 | — |
| ZS-002-05 | `#AAD2FF` | 中亮色 | — |
| ZS-002-06 | `#80BCFF` | 中亮色 | — |
| ZS-002-07 | `#55A6FE` | 中亮色 | 浅蓝 Tag / 图表 |
| ZS-002-08 | `#2B8FFE` | 中亮色 | — |
| ZS-002-09 | `#0079FE` | 中亮色 | **标准蓝**,链接 / 信息主色 |
| ZS-002-10 | `#0056C6` | 深色 | 深蓝,Hover |
| ZS-002-11 | `#004AB3` | 深色 | 深蓝,Active |
| ZS-002-12 | `#00368C` | 深色 | 极深蓝 |
| ZS-002-13 | `#002466` | 深色 | 最深蓝 |
| ZS-002-14 | `#0079FE → #004AB3` | 线性渐变 | 装饰底色 |
| ZS-002-15 | `#55A6FE → #0079FE` | 径向渐变 | 装饰底色 |
---
#### 1.1.4 辅色 · 渐橙系(FS-001)
用于警告、进行中、待处理等中性偏提醒状态。
| 编号 | 色值 | 明度分组 | 说明 |
|------|------|----------|------|
| FS-001 | `#D97B00` | 深色 | 橙色代表色(基准) |
| FS-001-01 | `#FFFCF9` | 浅色 | 极浅橙,大面积底色 |
| FS-001-02 | `#FFFAF4` | 浅色 | 浅橙底色 |
| FS-001-03 | `#FFF6EA` | 浅色 | 浅橙底色 / 警告背景 |
| FS-001-04 | `#FFEED4` | 中亮色 | — |
| FS-001-05 | `#FFDDAA` | 中亮色 | — |
| FS-001-06 | `#FFCC80` | 中亮色 | — |
| FS-001-07 | `#FFBB55` | 中亮色 | 浅橙 Tag / 图表 |
| FS-001-08 | `#FFAA2B` | 中亮色 | — |
| FS-001-09 | `#FF9900` | 中亮色 | **标准橙**,警告主色 |
| FS-001-10 | `#C66D00` | 深色 | 深橙,Hover |
| FS-001-11 | `#B35F00` | 深色 | 深橙,Active |
| FS-001-12 | `#8C4600` | 深色 | 极深橙 |
| FS-001-13 | `#663000` | 深色 | 最深橙 |
| FS-001-14 | `#FF9900 → #B35F00` | 线性渐变 | 装饰底色 |
| FS-001-15 | `#FFBB55 → #FF9900` | 径向渐变 | 装饰底色 |
---
#### 1.1.5 辅色 · 渐绿系(FS-002)
用于成功、在线、通过、已完成等正向状态。
| 编号 | 色值 | 明度分组 | 说明 |
|------|------|----------|------|
| FS-002 | `#1EB07F` | 深色 | 绿色代表色(基准) |
| FS-002-01 | `#FAFEFD` | 浅色 | 极浅绿,大面积底色 |
| FS-002-02 | `#F6FDFB` | 浅色 | 浅绿底色 |
| FS-002-03 | `#EEFCF7` | 浅色 | 浅绿底色 / 成功背景 |
| FS-002-04 | `#DCF8EE` | 中亮色 | — |
| FS-002-05 | `#BAF1DD` | 中亮色 | — |
| FS-002-06 | `#97EBCD` | 中亮色 | — |
| FS-002-07 | `#74E4BC` | 中亮色 | 浅绿 Tag / 图表 |
| FS-002-08 | `#52DDAB` | 中亮色 | — |
| FS-002-09 | `#2FD69A` | 中亮色 | **标准绿**,成功主色 |
| FS-002-10 | `#189D72` | 深色 | 深绿,Hover |
| FS-002-11 | `#118A65` | 深色 | 深绿,Active |
| FS-002-12 | `#07634B` | 深色 | 极深绿 |
| FS-002-13 | `#043D30` | 深色 | 最深绿 |
| FS-002-14 | `#2FD69A → #118A65` | 线性渐变 | 装饰底色 |
| FS-002-15 | `#74E4BC → #2FD69A` | 径向渐变 | 装饰底色 |
---
#### 1.1.6 辅色 · 渐青系(FS-003)
用于数据高亮、图表配色、辅助信息标识。
| 编号 | 色值 | 明度分组 | 说明 |
|------|------|----------|------|
| FS-003 | `#2F9ACC` | 深色 | 青色代表色(基准) |
| FS-003-01 | `#FBFDFE` | 浅色 | 极浅青,大面积底色 |
| FS-003-02 | `#F7FCFE` | 浅色 | 浅青底色 |
| FS-003-03 | `#EFFAFE` | 浅色 | 浅青底色 |
| FS-003-04 | `#E0F5FD` | 中亮色 | — |
| FS-003-05 | `#C1EBFB` | 中亮色 | — |
| FS-003-06 | `#A2E1F9` | 中亮色 | — |
| FS-003-07 | `#83D7F7` | 中亮色 | 浅青 Tag / 图表 |
| FS-003-08 | `#64CDF5` | 中亮色 | — |
| FS-003-09 | `#45C3F3` | 中亮色 | **标准青**,数据高亮主色 |
| FS-003-10 | `#2788B9` | 深色 | 深青,Hover |
| FS-003-11 | `#1E76A6` | 深色 | 深青,Active |
| FS-003-12 | `#115580` | 深色 | 极深青 |
| FS-003-13 | `#0B3959` | 深色 | 最深青 |
| FS-003-14 | `#45C3F3 → #1E76A6` | 线性渐变 | 装饰底色 |
| FS-003-15 | `#83D7F7 → #45C3F3` | 径向渐变 | 装饰底色 |
---
#### 1.1.7 辅色 · 中性灰系(FS-004)
承载页面背景、正文、备注文字、图标与分割线。浅色主题的核心承载色系。
| 编号 | 色值 | 明度分组 | 说明 |
|------|------|----------|------|
| FS-004 | `#222222` | 深色 | 中性代表色(基准) |
| FS-004-01 | `#FFFFFF` | 浅色 | 纯白,页面 / 容器背景、白色文字 |
| FS-004-02 | `#FAFAFA` | 浅色 | 浅灰背景 |
| FS-004-03 | `#F5F5F5` | 浅色 | 浅灰背景 / 分割区底 |
| FS-004-04 | `#E8E8E8` | 中亮色 | 分割线 / 边框 |
| FS-004-05 | `#DBDBDB` | 中亮色 | 边框 / 禁用底 |
| FS-004-06 | `#C1C1C1` | 中亮色 | 禁用文字 / 占位符 |
| FS-004-07 | `#A6A6A6` | 中亮色 | 备注文字 / 图标 |
| FS-004-08 | `#7F7F7F` | 中亮色 | 次级文字 |
| FS-004-09 | `#575757` | 中亮色 | 次要正文 |
| FS-004-10 | `#3D3D3D` | 深色 | 正文加强 |
> 灰系用途分层:`01 ~ 03` 背景 / 白色文字 / 分割线;`04 ~ 09` 备注文字及图标;`10` 与基准 `#222222` 用于正文。
---
#### 1.1.8 功能色(主色 / 字体色 / 辅色 / 背景色)
从标准色系中提取,用于具体 UI 角色指定。
| 角色 | 色值 | 来源 |
|------|------|------|
| 主色(Primary) | `#F5222D` | ZS-001-09 |
| 主色 Hover | `#CF1322` | ZS-001 |
| 主色 Active | `#A8071A` | ZS-001-11 |
| 信息 / 链接色 | `#0079FE` | ZS-002-09 |
| 成功色 | `#2FD69A` | FS-002-09 |
| 警告色 | `#FF9900` | FS-001-09 |
| 错误色 | `#F5222D` | ZS-001-09 |
| 正文字体色 | `#222222` | FS-004 |
| 次级字体色 | `#575757` | FS-004-09 |
| 备注 / 占位字体色 | `#A6A6A6` | FS-004-07 |
| 禁用字体色 | `#C1C1C1` | FS-004-06 |
| 页面背景色 | `#FAFAFA` | FS-004-02 |
| 容器背景色 | `#FFFFFF` | FS-004-01 |
| 分割线 | `#E8E8E8` | FS-004-04 |
---
#### 1.1.9 小面积装饰色
用于图标点缀、徽标、少量强调,不用于大面积背景。
| 色值 | 来源 | 说明 |
|------|------|------|
| `#F5222D` | ZS-001-09 | 红色,危险 / 品牌徽标 |
| `#0079FE` | ZS-002-09 | 蓝色,信息点缀 |
| `#FF9900` | FS-001-09 | 橙色,提醒点缀 |
| `#2FD69A` | FS-002-09 | 绿色,在线点 |
| `#45C3F3` | FS-003-09 | 青色,数据点缀 |
---
#### 1.1.10 图表配色
> **以 §25.1 图表通用色序为准**。本小节给出的是从主 / 辅色提取的强调型色序,适用于需要突出对比的少量序列;柱状 / 饼 / 环 / 面积等多分类图表的标准柔和色序见 [05-图表](05-图表.md)(§25)。
**通用图表色序**(面积较大,如柱状图、折线图、饼图):
| 序号 | 色值 | 来源 |
|------|------|------|
| 1 | `#F5222D` | ZS-001-09 |
| 2 | `#0079FE` | ZS-002-09 |
| 3 | `#FF9900` | FS-001-09 |
| 4 | `#2FD69A` | FS-002-09 |
| 5 | `#45C3F3` | FS-003-09 |
| 6 | `#FA9196` | ZS-001-06 |
| 7 | `#55A6FE` | ZS-002-07 |
| 8 | `#FFBB55` | FS-001-07 |
**小面积图表色序**(面积较小,如散点、小徽章、迷你图):
| 序号 | 色值 | 来源 |
|------|------|------|
| 1 | `#F86C73` | ZS-001-07 |
| 2 | `#55A6FE` | ZS-002-07 |
| 3 | `#74E4BC` | FS-002-07 |
| 4 | `#FFBB55` | FS-001-07 |
| 5 | `#83D7F7` | FS-003-07 |
| 6 | `#A6A6A6` | FS-004-07 |
---
#### 1.1.11 CSS Token 建议
```css
:root {
/* 主色 · 渐红系 ZS-001 */
--zs-001: #CF1322;
--zs-001-01: #FEFAFA; --zs-001-02: #FEF6F6; --zs-001-03: #FEEDED;
--zs-001-04: #FDDADC; --zs-001-05: #FCB5B9; --zs-001-06: #FA9196;
--zs-001-07: #F86C73; --zs-001-08: #F74750; --zs-001-09: #F5222D;
--zs-001-10: #BC0D1E; --zs-001-11: #A8071A; --zs-001-12: #820014;
--zs-001-13: #5C0011;
/* 主色 · 渐蓝系 ZS-002 */
--zs-002: #0062D9;
--zs-002-01: #F9FCFF; --zs-002-02: #F4F9FF; --zs-002-03: #EAF4FF;
--zs-002-04: #D4E9FF; --zs-002-05: #AAD2FF; --zs-002-06: #80BCFF;
--zs-002-07: #55A6FE; --zs-002-08: #2B8FFE; --zs-002-09: #0079FE;
--zs-002-10: #0056C6; --zs-002-11: #004AB3; --zs-002-12: #00368C;
--zs-002-13: #002466;
/* 辅色 · 渐橙系 FS-001 */
--fs-001: #D97B00;
--fs-001-01: #FFFCF9; --fs-001-02: #FFFAF4; --fs-001-03: #FFF6EA;
--fs-001-04: #FFEED4; --fs-001-05: #FFDDAA; --fs-001-06: #FFCC80;
--fs-001-07: #FFBB55; --fs-001-08: #FFAA2B; --fs-001-09: #FF9900;
--fs-001-10: #C66D00; --fs-001-11: #B35F00; --fs-001-12: #8C4600;
--fs-001-13: #663000;
/* 辅色 · 渐绿系 FS-002 */
--fs-002: #1EB07F;
--fs-002-01: #FAFEFD; --fs-002-02: #F6FDFB; --fs-002-03: #EEFCF7;
--fs-002-04: #DCF8EE; --fs-002-05: #BAF1DD; --fs-002-06: #97EBCD;
--fs-002-07: #74E4BC; --fs-002-08: #52DDAB; --fs-002-09: #2FD69A;
--fs-002-10: #189D72; --fs-002-11: #118A65; --fs-002-12: #07634B;
--fs-002-13: #043D30;
/* 辅色 · 渐青系 FS-003 */
--fs-003: #2F9ACC;
--fs-003-01: #FBFDFE; --fs-003-02: #F7FCFE; --fs-003-03: #EFFAFE;
--fs-003-04: #E0F5FD; --fs-003-05: #C1EBFB; --fs-003-06: #A2E1F9;
--fs-003-07: #83D7F7; --fs-003-08: #64CDF5; --fs-003-09: #45C3F3;
--fs-003-10: #2788B9; --fs-003-11: #1E76A6; --fs-003-12: #115580;
--fs-003-13: #0B3959;
/* 辅色 · 中性灰系 FS-004 */
--fs-004: #222222;
--fs-004-01: #FFFFFF; --fs-004-02: #FAFAFA; --fs-004-03: #F5F5F5;
--fs-004-04: #E8E8E8; --fs-004-05: #DBDBDB; --fs-004-06: #C1C1C1;
--fs-004-07: #A6A6A6; --fs-004-08: #7F7F7F; --fs-004-09: #575757;
--fs-004-10: #3D3D3D;
/* 功能色(语义别名) */
--color-primary: var(--zs-001-09); /* #F5222D */
--color-primary-hover: var(--zs-001); /* #CF1322 */
--color-primary-active: var(--zs-001-11); /* #A8071A */
--color-link: var(--zs-002-09); /* #0079FE */
--color-success: var(--fs-002-09); /* #2FD69A */
--color-warning: var(--fs-001-09); /* #FF9900 */
--color-error: var(--zs-001-09); /* #F5222D */
--color-text: var(--fs-004); /* #222222 */
--color-text-secondary: var(--fs-004-09); /* #575757 */
--color-text-tertiary: var(--fs-004-07); /* #A6A6A6 */
--color-text-disabled: var(--fs-004-06); /* #C1C1C1 */
--color-bg-page: var(--fs-004-02); /* #FAFAFA */
--color-bg-container: var(--fs-004-01); /* #FFFFFF */
--color-border: var(--fs-004-04); /* #E8E8E8 */
}
```
---
## 2. 字体
字体规范分**大屏页面**与**后台系统**两套,分别对应可视化大屏与管理后台两类场景。
### 2.1 大屏页面字体规范
#### 2.1.1 中文字体:Noto Sans S Chinese
| 样式 | 使用场景 |
|------|----------|
| 24px Bold | 一级标题 |
| 24px Medium | 次要一级标题 |
| 22px Medium | 二级标题 |
| 22px Regular | 正文 |
| 20px Regular | 排版空间有限的正文 |
#### 2.1.2 数字字体:D-DIN
| 样式 | 使用场景 |
|------|----------|
| 32px Bold | 重要数据展示 |
| 32px Regular | 普通数据展示 |
| 26px Bold | 常规数据展示 |
| 26px Regular | 常规数据展示 |
#### 2.1.3 特殊数字:DS-Digital
| 样式 | 使用场景 |
|------|----------|
| 50px Bold | 大屏主数据展示 |
#### 2.1.4 表格数字:Arial
| 样式 | 使用场景 |
|------|----------|
| 20px Regular | 表格横纵轴数字 |
---
### 2.2 后台系统字体规范
#### 2.2.1 中文字体:Noto Sans S Chinese
| 样式 | 使用场景 |
|------|----------|
| 18px Bold | 导航、一级标题 |
| 18px Medium | 次要一级标题 |
| 16px Bold | 二级标题 |
| 16px Medium | 次要二级标题 |
| 16px Regular | 正文、按钮内部文字等 |
| 16px Light | 次要正文 |
#### 2.2.2 数字字体:D-DIN
| 样式 | 使用场景 |
|------|----------|
| 26px Bold | 重要数据展示 |
| 26px Regular | 普通数据展示 |
| 24px Bold | 常规数据展示(小) |
| 24px Regular | 常规数据展示(小) |
#### 2.2.3 LOGO 字体:DOUYUFont
| 样式 | 使用场景 |
|------|----------|
| 20px 文字样式 | 页面顶部系统名称 |
#### 2.2.4 表格数字:Arial
| 样式 | 使用场景 |
|------|----------|
| 16px Regular | 表格横纵轴数字 |
| 14px Regular | 表格横纵轴的数字(空间有限) |
---
## 3. 阴影
> 浅色主题采用**中性投影**(基于 FS-004 `#222222`)营造层级,避免使用彩色外发光。卡片以纯白 `#FFFFFF` 浮于浅灰页面背景 `#FAFAFA` 之上,靠投影与边框区分。
### 3.1 容器与卡片配色
#### 3.1.1 卡片(标题 + 操作)
白底卡片,标题在左、操作区在右(文字操作或图标操作)。
| 角色 | 色值 | 来源 |
|------|------|------|
| 卡片背景 | `#FFFFFF` | FS-004-01 |
| 卡片边框 | `#E8E8E8` | FS-004-04 |
| 卡片阴影 | `0 2px 8px rgba(34, 34, 34, 0.08)` | 基于 FS-004 中性投影 |
| 标题文字(16px Bold) | `#222222` | FS-004(正文色) |
| 操作按钮文字 | `#F5222D` | ZS-001-09(主色红,链接型操作) |
| 次级操作图标(✏️ / ⋯) | `#A6A6A6` | FS-004-07(备注图标色) |
#### 3.1.2 卡片新增(占位卡片)
虚线描边的"新增入口"卡片,用于引导用户添加内容。
| 角色 | 色值 | 来源 |
|------|------|------|
| 卡片背景 | `#FFFFFF` | FS-004-01 |
| 虚线边框 | `#DBDBDB` | FS-004-05 |
| 加号图标 | `#A6A6A6` | FS-004-07 |
| Hover 边框 | `#F5222D` | ZS-001-09(悬停时提示可点击) |
| Hover 加号 | `#F5222D` | ZS-001-09 |
#### 3.1.3 阴影投影规则
| 层级 | 阴影值 | 使用场景 |
|------|--------|----------|
| 一级阴影(卡片浮起) | `0 2px 8px rgba(34, 34, 34, 0.08)` | 普通卡片 |
| 二级阴影(hover / 强调) | `0 4px 16px rgba(34, 34, 34, 0.12)` | 卡片 hover、悬浮按钮、弹出层 |
| 三级阴影(弹窗 / Modal) | `0 8px 32px rgba(34, 34, 34, 0.16)` | Dialog、Drawer |
---
## 4. 圆角
### 4.1 圆角统一规范
| 元素 | 圆角值 | 说明 |
|------|--------|------|
| 按钮 | `2px` | 所有按钮(主 / 次要 / 默认 / 文本)统一使用 |
| 卡片 | `4px` | 内容卡片、标题+操作卡片、卡片新增占位 |
| 页面分栏框 | `4px` | 外层容器、分栏区域 |
| 输入框 / 选择器 | `2px` | 与按钮一致,保持表单内对齐 |
| Tag / Badge | `2px` | 与按钮一致 |
| Modal / Drawer | `4px` | 与卡片一致 |
---
## 6. 间距
> 全站以 `8px (0.5rem)` 为对照基准单位,划分"密 / 疏"两个层次。所有间距取值均来自下列阶梯,禁止使用非阶梯值(如 5px、13px)。
### 6.1 间距阶梯
| 阶 | px | rem | 分组 | 典型场景 |
|----|------|--------|------|----------|
| 1 | `4px` | `0.25rem` | 密 | 图标与文字间距、Tag 内边距、紧凑徽标 |
| 2 | `8px` | `0.5rem` | 密 | 行内元素相邻、按钮组间距、表单 label 与控件 |
| 3 | `12px` | `0.75rem` | 密 | 表单项垂直间距、列表项内边距 |
| 4 | `16px` | `1rem` | 密 | 卡片内容内边距、模块内分隔 |
| 5 | `24px` | `1.5rem` | 疏 | 模块之间、Section 内边距 |
| 6 | `32px` | `2rem` | 疏 | 卡片之间、表单组之间 |
| 7 | `40px` | `2.5rem` | 疏 | 主区块之间、页面 Header / Footer 内边距 |
| 8 | `56px` | `3.5rem` | 疏 | 页面段落级隔断、Banner 区域 |
> "密"对应紧凑信息密度(表格、表单、卡片内部);"疏"对应视觉呼吸(模块之间、页面整体留白)。**16px 是"密"的上限,24px 是"疏"的下限**,两者之间不应再插入自定义值。
### 6.2 使用原则
- **同层级一致**:同一布局层级内的间距必须使用同一阶值;多种取值会破坏节奏。
- **就近取阶**:相邻区块的间距尽量从相邻阶梯取值(如 `16px → 24px`),跨越过多阶(如 `8px → 56px`)会让视觉断裂。
- **密疏不混用**:一个组件内部全部用"密",外部用"疏",避免内 / 外间距颠倒。
- **优先 8 的倍数**:除最小阶 `4px` 外,所有阶皆为 8 的倍数,方便 4/8 网格对齐。
- **响应式按比例衰减**:移动端可整体降一阶(如 `24px → 16px`),但仍在阶梯内。
### 6.3 CSS Token 建议
```css
:root {
--spacing-1: 4px; /* 0.25rem */
--spacing-2: 8px; /* 0.5rem */
--spacing-3: 12px; /* 0.75rem */
--spacing-4: 16px; /* 1rem */
--spacing-5: 24px; /* 1.5rem */
--spacing-6: 32px; /* 2rem */
--spacing-7: 40px; /* 2.5rem */
--spacing-8: 56px; /* 3.5rem */
}
```
---
## 20. 语义状态色(统一约定)
> 进度、状态标签、表格状态、时间轴等大量组件共用同一套语义色。后续章节直接引用本表。
| 语义 | 文字色 | 浅底背景 | 实填背景 | 来源 |
|------|--------|----------|----------|------|
| 成功 / 完成(绿) | `#189D72` (FS-002-10) | `#EEFCF7` (FS-002-03) | `#2FD69A` (FS-002-09) | 渐绿系 |
| 警告 / 进行中(橙) | `#FF9900` (FS-001-09) | `#FFF6EA` (FS-001-03) | `#FF9900` (FS-001-09) | 渐橙系 |
| 错误 / 失败(红) | `#F5222D` (ZS-001-09) | `#FEEDED` (ZS-001-03) | `#F5222D` (ZS-001-09) | 渐红系 |
| 信息 / 等待(蓝) | `#0079FE` (ZS-002-09) | `#EAF4FF` (ZS-002-03) | `#0079FE` (ZS-002-09) | 渐蓝系 |
| 待更新(青) | `#45C3F3` (FS-003-09) | `#EFFAFE` (FS-003-03) | `#45C3F3` (FS-003-09) | 渐青系 |
| 未开始 / 中性(灰) | `#7F7F7F` (FS-004-08) | `#F5F5F5` (FS-004-03) | `#A6A6A6` (FS-004-07) | 中性灰系 |
---
---
> 返回 [规范目录](README.md)
@@ -1,314 +0,0 @@
# 浅红色 UI 规范 · 二、按钮与表单输入
> 返回 [规范目录](README.md) | 本篇:按钮 · 表单 · 输入框 · 选择按钮 · 标签输入框 · 上传
---
## 5. 按钮
> 全部圆角 `2px`,文字字号 `14px`,字体 Noto Sans S Chinese Regular。配色取自 ZS-001 主色红与 FS-004 中性灰。
### 5.1 按钮尺寸
| 规格 | 高度 | 内边距 | 使用场景 |
|------|------|--------|----------|
| 小 | `24px` | `0 8px` | 空间小的板块内部,如表格中、卡片标题等 |
| 正常 | `32px` | `0 14px` | 默认尺寸,所有常用按钮 |
### 5.2 按钮类型与配色
#### 5.2.1 主按钮(Primary)
> 一个操作区域只能有一个主按钮。
| 状态 | 背景 | 边框 | 文字 |
|------|------|------|------|
| 默认 | `#F5222D` (ZS-001-09) | — | `#FFFFFF` |
| Hover | `#CF1322` (ZS-001) | — | `#FFFFFF` |
| Active | `#A8071A` (ZS-001-11) | — | `#FFFFFF` |
| 失效 | `#A6A6A6` (FS-004-07) | — | `#FFFFFF` |
#### 5.2.2 次要按钮(Secondary / Outline-Primary)
重要度低于主按钮、高于默认按钮。红色描边 + 红色文字。
| 状态 | 背景 | 边框 | 文字 |
|------|------|------|------|
| 默认 | `#FFFFFF` | `#F5222D` (ZS-001-09) | `#F5222D` (ZS-001-09) |
| Hover | `#FEEDED` (ZS-001-03) | `#CF1322` (ZS-001) | `#CF1322` (ZS-001) |
| Active | `#FDDADC` (ZS-001-04) | `#A8071A` (ZS-001-11) | `#A8071A` (ZS-001-11) |
| 失效 | `#FFFFFF` | `#FCB5B9` (ZS-001-05) | `#FCB5B9` (ZS-001-05) |
#### 5.2.3 默认按钮(Default)
用于"取消 / 返回"等次要操作,与主按钮成对出现时主按钮在右。中性描边。
| 状态 | 背景 | 边框 | 文字 |
|------|------|------|------|
| 默认 | `#FFFFFF` | `#DBDBDB` (FS-004-05) | `#222222` (FS-004) |
| Hover | `#FAFAFA` (FS-004-02) | `#F5222D` (ZS-001-09) | `#F5222D` (ZS-001-09) |
| Active | `#F5F5F5` (FS-004-03) | `#CF1322` (ZS-001) | `#CF1322` (ZS-001) |
| 禁用 | `#F5F5F5` (FS-004-03) | `#E8E8E8` (FS-004-04) | `#C1C1C1` (FS-004-06) |
#### 5.2.4 文本按钮(Text)
用于表格操作、文件下载等。无背景与边框,靠文字色识别。
| 状态 | 文字 | 装饰 |
|------|------|------|
| 默认 | `#F5222D` (ZS-001-09) | 无下划线 |
| Hover | `#CF1322` (ZS-001) | 显示下划线(与文字同色) |
| Active | `#A8071A` (ZS-001-11) | 下划线 |
| 失效 | `#C1C1C1` (FS-004-06) | 无装饰 |
#### 5.2.5 图标按钮(Icon-only)
用于增强辨识度或节省空间。仅图标,无文字。
| 变体 | 背景 | 边框 | 图标颜色 |
|------|------|------|----------|
| 描边(默认) | `#FFFFFF` | `#DBDBDB` (FS-004-05) | `#575757` (FS-004-09) |
| 描边 Hover | `#FAFAFA` | `#F5222D` (ZS-001-09) | `#F5222D` (ZS-001-09) |
| 主色(填充) | `#F5222D` (ZS-001-09) | — | `#FFFFFF` |
| 主色 Hover | `#CF1322` (ZS-001) | — | `#FFFFFF` |
| 浅色(弱填充) | `#FEEDED` (ZS-001-03) | — | `#F5222D` (ZS-001-09) |
### 5.3 按钮组合
#### 5.3.1 文字 + 下拉按钮(含 ⌄)
| 形态 | 配色规则 |
|------|----------|
| 默认(Outline) | 沿用 §5.2.3 默认按钮,箭头 ⌄ 与文字同色 `#222222` |
| 主色(Filled) | 沿用 §5.2.1 主按钮,箭头 ⌄ 同步白色 `#FFFFFF` |
#### 5.3.2 图标 + 文字按钮(如 `+ 正常`)
| 形态 | 配色规则 |
|------|----------|
| 默认(Outline) | 沿用 §5.2.3,左侧图标颜色与文字一致 `#222222` |
| 主色(Filled) | 沿用 §5.2.1,左侧图标与文字一致 `#FFFFFF` |
#### 5.3.3 文本按钮 + 图标("更多 »"、"展开 ⌄"、"下一页 »"、"« 上一页"、"收起 ^")
| 角色 | 色值 |
|------|------|
| 文字 + 图标(默认) | `#F5222D` (ZS-001-09) |
| 文字 + 图标(Hover) | `#CF1322` (ZS-001) |
| 失效 | `#C1C1C1` (FS-004-06) |
> 图标可在文字前或后;多个文本按钮排成一行时,间距 `16px`,无分隔符。
#### 5.3.4 主按钮 + 默认按钮组合(如"确定 / 取消 / 返回")
- **排列顺序**:默认按钮在左,主按钮在右(最右侧为主操作)
- **按钮间距**:`8px`
- **示例**:`[ 返回 ] [ 取消 ] [ 确定 ]`
- **配色**:主按钮沿用 §5.2.1,其余沿用 §5.2.3
### 5.4 其他按钮
#### 5.4.1 悬浮状态(Floating)
用于 Banner / 登录 / 视觉冲击强等展示场景。配色与主按钮一致,叠加阴影抬升。
| 状态 | 阴影 |
|------|------|
| 默认 | `0 2px 8px rgba(34, 34, 34, 0.08)`(§3.1.3 一级阴影) |
| Hover | `0 4px 16px rgba(34, 34, 34, 0.12)`(§3.1.3 二级阴影) |
| Active | 阴影消失,按下感 |
#### 5.4.2 加载中(Loading)
用于异步操作等待反馈。点击瞬间将按钮锁定,文字前显示旋转图标。
| 角色 | 色值 |
|------|------|
| 背景 | `#F5222D` (ZS-001-09) |
| 文字 | `#FFFFFF` |
| 加载图标 | `#FFFFFF`,旋转动画 `1.2s linear infinite` |
| 不可点击 | 鼠标 `cursor: not-allowed` |
> 与失效态的区别:失效是"不可操作",加载是"操作进行中"——保留品牌主色,只是不可再次触发。
---
## 12. 表单(Form)
> 页面级表单可参照弹窗表单,**操作按钮置于左下角**。容器为白底卡片,标题下叠 `1px solid #E8E8E8` 分割线。
### 12.1 基础表单结构
| 元素 | 规格 / 配色 |
|------|-------------|
| 容器背景 | `#FFFFFF` (FS-004-01),圆角 `4px` |
| 表单标题 | `16px Bold`,`#222222` (FS-004),下叠 `1px solid #E8E8E8` |
| 字段标签(label) | `14px Regular`,`#575757` (FS-004-09),左对齐 |
| 字段垂直间距 | `--spacing-3` (12px) |
| 输入控件 | 见 §13 输入框 |
| 操作按钮区 | 置于左下角,主按钮 + 默认按钮(§5.3.4),间距 `--spacing-2` (8px) |
### 12.2 开关(Switch)
| 状态 | 轨道背景 | 滑块 | 文字 |
|------|----------|------|------|
| 开(ON) | `#F5222D` (ZS-001-09) | `#FFFFFF` 圆点 | "开" `#FFFFFF` |
| 关(OFF) | `#DBDBDB` (FS-004-05) | `#FFFFFF` 圆点 | "关" `#A6A6A6` (FS-004-07) |
| 禁用 | `#E8E8E8` (FS-004-04) | `#FFFFFF` | `#C1C1C1` |
- 轨道高 `20px`,圆角全圆;切换过渡 `transform 160ms ease-out`
### 12.3 表格内报错(Table 报错)
行内可编辑表格的校验错误态。
| 元素 | 配色 |
|------|------|
| 表头背景 | `#FEEDED` (ZS-001-03) |
| 表头文字 | `#222222` (FS-004),`14px Medium` |
| 行(默认) | 背景 `#FFFFFF`,底边 `1px solid #F5F5F5` (FS-004-03) |
| 行(hover / 选中) | 背景 `#FEF6F6` (ZS-001-02) |
| 正文文字 | `#222222` (FS-004) |
| 错误输入框边框 | `1px solid #F5222D` (ZS-001-09) |
| 单位后缀(如 `M`) | addon 背景 `#FAFAFA`,边框 `#DBDBDB`,文字 `#575757` |
| 错误提示气泡 | 背景 `#F5222D`,文字 `#FFFFFF` `12px`,圆角 `2px`,带向上箭头,定位于出错输入框下方 |
| 复选框(选中) | 勾选填充 `#F5222D`,对勾 `#FFFFFF` |
---
## 13. 输入框(Input)
> 字号 `16px`,高度 `32px`,圆角 `2px`,内边距 `0 12px`,背景 `#FFFFFF`。
### 13.1 基础状态
| 状态 | 边框 | 背景 | 文字 / 占位符 |
|------|------|------|--------------|
| 未填(默认) | `1px solid #DBDBDB` (FS-004-05) | `#FFFFFF` | 占位符 `#A6A6A6` (FS-004-07) |
| 聚焦(Focus) | `1px solid #F5222D` (ZS-001-09) | `#FFFFFF` | 文字 `#222222` |
| 已填 | `1px solid #DBDBDB` | `#FFFFFF` | 文字 `#222222` (FS-004) |
| 未填 · 禁用 | `1px solid #E8E8E8` (FS-004-04) | `#F5F5F5` (FS-004-03) | 占位符 `#C1C1C1` (FS-004-06),`cursor: not-allowed` |
| 已填 · 禁用 | `1px solid #E8E8E8` | `#F5F5F5` | 文字 `#A6A6A6` |
| 错误(Error) | `1px solid #F5222D` | `#FFFFFF` | 文字 `#222222`,下方红色提示文字 `12px #F5222D` |
### 13.2 带操作 / 特殊输入框
| 类型 | 说明 | 配色 |
|------|------|------|
| 未填 / 已填 · 操作 | 尾部带操作图标(如 ⚙) | 图标 `#A6A6A6`,hover `#F5222D` |
| 密码 · 不可见 | 尾部带可见性切换 👁 | 图标 `#A6A6A6`,点击切换明 / 密文 |
| 前置标签 | 头部下拉(如 `http:// ▾`) | addon 背景 `#FAFAFA`,边框 `#DBDBDB`,文字 `#575757` |
| 后置标签 | 尾部下拉(如 `.com ▾`) | 同前置标签 |
| 前后置标签 | 头尾各一段 addon | 同上;输入区与 addon 共用一条外边框 |
---
## 14. 选择按钮(单选 / 多选式)
> 以按钮形态替代传统单选 / 复选,提升点击区域与辨识度。每组提供三种"已选"填充强度,可按对比需要选用;布局分**相连**(segmented)与**分离**两种。
### 14.1 选择按钮(基础)
| 状态 / 变体 | 背景 | 边框 | 文字 |
|------|------|------|------|
| 未选 | `#FFFFFF` | `1px solid #DBDBDB` (FS-004-05) | `#222222` (FS-004) |
| 已选 · 描边 | `#FFFFFF` | `1px solid #F5222D` (ZS-001-09) | `#F5222D` (ZS-001-09) |
| 已选 · 浅填 | `#FEEDED` (ZS-001-03) | `1px solid #FCB5B9` (ZS-001-05) | `#F5222D` (ZS-001-09) |
| 已选 · 实填 | `#F5222D` (ZS-001-09) | — | `#FFFFFF` |
| 禁用 | `#F5F5F5` (FS-004-03) | `1px solid #E8E8E8` | `#C1C1C1` (FS-004-06) |
- 高度 `32px`,内边距 `0 14px`,圆角 `2px`,字号 `14px`
- **相连布局**:按钮紧贴,仅最外两端圆角,相邻共用边框
- **分离布局**:按钮间距 `--spacing-2` (8px),各自独立圆角
### 14.2 选择按钮(带统计)
在文案后附计数,如 `已选(230)`。配色完全沿用 §14.1,计数文字与主文字同色(实填态为 `#FFFFFF`)。
### 14.3 按钮式页签(带图标)
文案前带图标(如 `田`),用于视图 / 模式切换。
| 状态 / 变体 | 背景 | 边框 | 图标 + 文字 |
|------|------|------|-------------|
| 正常(未激活) | `#FFFFFF` | `1px solid #DBDBDB` | `#222222` |
| 激活 · 描边 | `#FFFFFF` | `1px solid #F5222D` | `#F5222D` |
| 激活 · 实填 | `#F5222D` (ZS-001-09) | — | `#FFFFFF` |
- 图标与文字间距 `--spacing-1` (4px),其余规格同 §14.1
---
## 15. 标签输入框(Tag Input)
> 在输入框内以"标签 + 自由输入"混合录入,支持单行 / 多行,含字数统计。
### 15.1 规格
| 属性 | 取值 |
|------|------|
| 容器边框 | `1px solid #DBDBDB` (FS-004-05),圆角 `2px` |
| 聚焦边框 | `1px solid #F5222D` (ZS-001-09) |
| 容器背景 | `#FFFFFF` |
| 内边距 | `--spacing-2` (8px) |
| 标签与标签间距 | `--spacing-1` (4px) |
| 右下角支持拖拽缩放(多行) | `resize: vertical` |
### 15.2 标签(Tag)配色
| 元素 | 色值 | 来源 |
|------|------|------|
| 标签背景 | `#F5F5F5` (FS-004-03) | — |
| 标签文字 | `#575757` (FS-004-09) | — |
| 关闭图标 `×` | `#A6A6A6` (FS-004-07) | hover `#F5222D` |
| 占位符(请输入) | `#A6A6A6` (FS-004-07) | — |
| 字数统计(如 `2/100`) | `#A6A6A6` (FS-004-07) | 超限时变 `#F5222D` |
> 折叠多标签时可用 `+N` 形式聚合(如 `+1 ×`),样式同普通标签。
---
## 16. 上传(Upload)
> 含单文件、批量上传、拖拽上传三种形态。操作按钮中"开始上传 / 上传"为主按钮,"取消"为默认按钮。
### 16.1 上传触发器
| 形态 | 配色 |
|------|------|
| 上传按钮(⬆ 上传文件) | 默认按钮(§5.2.3),含上传图标 |
| 选择文件输入 | 只读输入框(§13)+ 右侧"选择文件"默认按钮 |
| 提示文字 | `12px Regular`,`#A6A6A6` (FS-004-07),如"支持 DOCX/ZIP/PDF,文件大小不超过 2MB" |
| 示例文件链接(下载 / 查看示例文件) | 文本按钮 `#F5222D` (ZS-001-09),hover `#CF1322` |
### 16.2 拖拽区(Drop Zone)
| 属性 | 取值 |
|------|------|
| 背景 | `#FAFAFA` (FS-004-02) |
| 边框 | `1px dashed #DBDBDB` (FS-004-05) |
| 拖入高亮 | 边框 `1px dashed #F5222D` (ZS-001-09),背景 `#FEF6F6` (ZS-001-02) |
| 提示文字(释放鼠标) | `#A6A6A6` (FS-004-07) |
### 16.3 文件列表与状态
列表表头:文件名 / 大小 / 状态 / 操作。各状态配色:
| 状态 | 图标 | 文字色 |
|------|------|--------|
| 上传成功 | ✓ 圆形,`#2FD69A` (FS-002-09) | `#189D72` (FS-002-10) |
| 文件格式有误 / 失败 | ❗ 圆形,`#F5222D` (ZS-001-09) | `#F5222D` |
| 上传中(如 上传 40%) | ⟳ 旋转,`#F5222D` (ZS-001-09) | `#575757` (FS-004-09) |
| 待上传 | 🕐 时钟,`#A6A6A6` (FS-004-07) | `#A6A6A6` |
| 操作列「删除」 | — | 文本按钮 `#A6A6A6`,hover `#F5222D` |
### 16.4 底部操作
`[ 取消 ](默认按钮) [ 开始上传 ](主按钮 #F5222D)`,间距 `--spacing-2` (8px),右对齐。
---
---
> 返回 [规范目录](README.md)
@@ -1,495 +0,0 @@
# 浅红色 UI 规范 · 三、导航
> 返回 [规范目录](README.md) | 本篇:导航组件 · 面包屑 · 页头与分页 · 步骤条 · 页签与锚点
---
## 7. 导航组件
> 适用于"后台 / 控制台"类浅红主题界面。整体结构 = 顶部导航(红底)+ 二级导航(白底)+ 左侧分级菜单 + 多级下拉。颜色取自 ZS-001 主色红与 FS-004 中性灰。
### 7.1 顶部导航(Header · 红底)
#### 7.1.1 规格
| 属性 | 取值 |
|------|------|
| 高度 | `56px` |
| 左右 padding | `--spacing-4` (16px) |
| 背景 | `linear-gradient(90deg, #A8071A 0%, #CF1322 100%)`(ZS-001-11 → ZS-001) |
| LOGO 区切角装饰 | 叠深红斜切块 `#A8071A` (ZS-001-11),`clip-path` 斜边 |
| 底边 | 无(与二级导航白底直接相接) |
| 文字基线 | `Noto Sans S Chinese`,垂直居中 |
#### 7.1.2 系统标题(LOGO + ******系统)
- 字号 `20px Bold`,颜色 `#FFFFFF`,字体 `DOUYUFont` 或 Noto Sans Medium
- LOGO 图标 `#FFFFFF`,置于左侧切角块上
#### 7.1.3 一级导航(中部主导航,红底白字)
| 状态 | 文字 | 背景 / 装饰 |
|------|------|------------|
| 默认 | `#FFFFFF` | `transparent` |
| Hover | `#FFFFFF` | 背景 `rgba(255, 255, 255, 0.12)` |
| 激活 | `#FFFFFF` | 底部 `2px solid #FFFFFF` 高亮线,背景 `rgba(255, 255, 255, 0.16)` |
- 字号 `16px Medium`,项间距 `--spacing-5` (24px)
#### 7.1.4 右侧工具区(图标 / 用户 / 退出)
| 元素 | 字号/尺寸 | 颜色 |
|------|----------|------|
| 工具图标(消息 / 通知等) | `16px` | `#FFFFFF`,hover `rgba(255,255,255,0.7)` |
| 用户名(如"李向前") | `14px Regular` | `#FFFFFF` |
| 用户头像 | `28px` 圆形 | — |
| 退出图标 | `16px` | `#FFFFFF` |
- 元素间距 `--spacing-3` (12px),整体右 padding `--spacing-4` (16px)
#### 7.1.5 用户下拉菜单(右上角)
白底面板,规格同 §7.4 下拉面板。
| 状态 | 背景 | 文字 / 图标 |
|------|------|-------------|
| 默认 | `transparent` | `#575757` (FS-004-09) |
| Hover / 激活 | `#FEEDED` (ZS-001-03) | `#F5222D` (ZS-001-09) |
> 项示例:用户中心 / 修改密码 / 退出;每项左侧带 16px 图标,图标色与文字同步。
### 7.2 二级导航(白底 Tab 行)
紧贴顶部导航下方的白底标签行。
#### 7.2.1 规格
| 属性 | 取值 |
|------|------|
| 高度 | `48px` |
| 背景 | `#FFFFFF` (FS-004-01) |
| 底边 | `border-bottom: 1px solid #E8E8E8` (FS-004-04) |
| 项间距 | `--spacing-6` (32px) |
| 字号 | `16px Medium` |
#### 7.2.2 状态配色
| 状态 | 文字 | 下划线 / 装饰 |
|------|------|--------------|
| 默认 | `#222222` (FS-004) | 无 |
| Hover | `#F5222D` (ZS-001-09) | 无 |
| 激活 | `#F5222D` (ZS-001-09) | 底部 `2px solid #F5222D`,含下拉箭头 `▾` 同色 |
| 失效 | `#C1C1C1` (FS-004-06) | 无 |
### 7.3 侧边栏(分级菜单)
#### 7.3.1 规格
| 属性 | 取值 |
|------|------|
| 宽度 | `200px`(展开)/ `64px`(折叠仅图标) |
| 背景 | `#FFFFFF` (FS-004-01) |
| 右边界 | `border-right: 1px solid #E8E8E8` (FS-004-04) |
| 项高度 | `40px` |
| 项左 padding | 二级 `--spacing-4` (16px);三级 `--spacing-6` (32px) |
| 字号 | `16px Regular`(二级)/ `14px Regular`(三级) |
#### 7.3.2 二级菜单项配色
| 状态 | 背景 | 文字 | 图标 | 左侧指示条 |
|------|------|------|------|-----------|
| 默认 | `transparent` | `#222222` (FS-004) | `#A6A6A6` (FS-004-07) | 无 |
| Hover | `#FAFAFA` (FS-004-02) | `#F5222D` (ZS-001-09) | `#F5222D` | 无 |
| 激活 | `#FEEDED` (ZS-001-03) | `#F5222D` (ZS-001-09) | `#F5222D` | 左侧 `2px solid #F5222D` |
#### 7.3.3 三级菜单项配色(前缀 `-` 短横线)
| 状态 | 背景 | 文字 | 左侧指示条 |
|------|------|------|-----------|
| 默认 | `transparent` | `#575757` (FS-004-09) | 无 |
| Hover | `#FAFAFA` (FS-004-02) | `#F5222D` (ZS-001-09) | 无 |
| 激活 | `#FEEDED` (ZS-001-03) | `#F5222D` (ZS-001-09) | 左侧 `2px solid #F5222D` |
### 7.4 下拉菜单面板
#### 7.4.1 列表型面板(如二级导航的"浏览器容器 / 手机板卡容器…")
| 属性 | 取值 |
|------|------|
| 背景 | `#FFFFFF` (FS-004-01) |
| 边框 | `1px solid #E8E8E8` (FS-004-04) |
| 阴影 | `0 4px 16px rgba(34, 34, 34, 0.12)`(§3.1.3 二级阴影) |
| 圆角 | `4px` |
| 内边距 | 上下 `--spacing-2` (8px) |
| 最小宽度 | `150px` |
| 行高 | `36px` |
| 状态 | 背景 | 文字 |
|------|------|------|
| 默认 | `transparent` | `#575757` (FS-004-09) |
| Hover / 激活 | `#FEEDED` (ZS-001-03) | `#F5222D` (ZS-001-09) |
| 失效 | `transparent` | `#C1C1C1` (FS-004-06) |
#### 7.4.2 卡片宫格型面板(一级导航的图标宫格下拉)
白底大面板内以网格陈列"图标 + 二级导航名"卡片。
| 角色 | 背景 | 图标 / 文字 |
|------|------|-------------|
| 卡片(默认) | `#FEF6F6` (ZS-001-02) | 图标 `#F5222D`,文字 `#575757` |
| 卡片(Hover) | `#FEEDED` (ZS-001-03) | 图标 / 文字 `#F5222D` |
| 卡片(激活 / 当前) | `#F5222D` (ZS-001-09) | 图标 / 文字 `#FFFFFF` |
| 属性 | 取值 |
|------|------|
| 卡片尺寸 | `80px × 72px`(自适应等分) |
| 卡片圆角 | `4px` |
| 卡片间距 | `--spacing-2` (8px) |
| 面板内边距 | `--spacing-4` (16px) |
| 面板阴影 | `0 8px 32px rgba(34, 34, 34, 0.16)`(§3.1.3 三级阴影) |
### 7.5 交互与动画
| 行为 | 动效 |
|------|------|
| 下拉面板展开 | `transform-origin: top; scaleY(0.95) → scaleY(1); opacity 0 → 1`,`200ms ease-out` |
| 展开箭头旋转 | `rotate(0 → 180deg)`,`200ms ease-out` |
| Hover 背景过渡 | `background-color 120ms ease-out` |
| 激活底部高亮线 | `width 0% → 100%`,`240ms ease-out` |
### 7.6 侧边栏布局变体
> 侧边栏宽度通常固定,按"是否分级 / 是否展开"分为四种形态。关键区分:**叶子节点选中**用实心红填充 + 白字 + 左侧红指示条;**父级(含子项的可展开项)选中**仅用红字 + 红图标,不填充。
#### 7.6.1 上下分级 · 收起(仅图标)
| 属性 | 取值 |
|------|------|
| 宽度 | `64px`,仅图标 |
| 图标项(默认) | 图标 `#A6A6A6`,背景 `transparent` |
| 图标项(激活) | 图标 `#FFFFFF`,背景 `#F5222D` (ZS-001-09) 圆角方块 `4px` |
| Hover 弹出浮层 | **深色浮层**:背景 `#3D3D3D` (FS-004-10),圆角 `4px`,阴影 §3.1.3 三级 |
| 浮层内二级标题 | `14px Medium`,`#FFFFFF` |
| 浮层内三级项(前缀 `-`) | `14px Regular`,`#FFFFFF`;hover 背景 `rgba(255,255,255,0.1)` |
#### 7.6.2 上下分级 · 展开
完整宽度列表,二级为可展开组(`^ / ▾`),其下挂三级项。
| 层级 / 状态 | 背景 | 文字 / 图标 | 左侧指示条 |
|------|------|-------------|-----------|
| 二级(默认) | `transparent` | 文字 `#222222`、图标 `#A6A6A6` | 无 |
| 二级(展开 / 当前父级) | `transparent` | 文字 `#F5222D`、图标 `#F5222D` | 无 |
| 三级(默认,前缀 `-`) | `transparent` | `#575757` (FS-004-09) | 无 |
| 三级(选中 · 叶子) | `#F5222D` (ZS-001-09) | `#FFFFFF` | 左侧 `2px solid #F5222D` |
#### 7.6.3 只有二级标题 · 不分级
扁平列表,二级即叶子节点,无三级。
| 状态 | 背景 | 文字 / 图标 | 左侧指示条 |
|------|------|-------------|-----------|
| 默认 | `transparent` | 文字 `#222222`、图标 `#A6A6A6` | 无 |
| Hover | `#FAFAFA` (FS-004-02) | 文字 `#F5222D`、图标 `#F5222D` | 无 |
| 选中 | `#F5222D` (ZS-001-09) | `#FFFFFF` | 左侧 `2px solid #F5222D` |
#### 7.6.4 二级选中 · 分级(含四级浮层)
二级展开后选中三级,三级再向右弹出**白底四级浮层**。
| 角色 | 配色 |
|------|------|
| 三级选中(叶子) | 背景 `#F5222D`,文字 `#FFFFFF`,左侧 `2px solid #F5222D` |
| 四级浮层背景 | `#FFFFFF`,边框 `1px solid #E8E8E8`,阴影 §3.1.3 二级 |
| 四级项(默认) | `#575757` (FS-004-09) |
| 四级项(Hover) | 背景 `#FEEDED` (ZS-001-03),文字 `#F5222D` |
| 四级项(选中) | 文字 `#F5222D`,左侧 `2px solid #F5222D` |
---
## 8. 面包屑(Breadcrumb)
> 用于标识当前页面在层级结构中的位置,并提供逐级返回与跳转。建议层级**不超过四级**。
### 8.1 顶部路径式(🏠 | 概况 / 总概况)
页面顶部的简版面包屑,靠 `/` 分隔。
| 元素 | 色值 | 来源 | 说明 |
|------|------|------|------|
| 首页图标 🏠 | `#A6A6A6` (FS-004-07) | — | hover `#F5222D` |
| 竖分隔 `\|` | `#E8E8E8` | FS-004-04 | 首页图标与路径间 |
| 父级路径文字 | `#575757` (FS-004-09) | — | 可点击,hover `#F5222D` |
| 路径分隔 `/` | `#C1C1C1` | FS-004-06 | — |
| 当前页文字 | `#222222` (FS-004) | — | `Medium`,不可点击 |
### 8.2 返回 + 路径式(含 ← 返回)
左侧"← 返回"按钮 + 竖分隔 + 文字路径。按层级展开:
```
一级: ← 返回 │ 现在位置
二级: ← 返回 │ 上一级位置 / 现在位置
三级: ← 返回 │ 上二级位置 / 上一级位置 / 现在位置
四级: ← 返回 │ 上三级位置 / 上二级位置 / 上一级位置 / 现在位置
```
| 元素 | 色值 | 来源 | 说明 |
|------|------|------|------|
| 返回箭头 ← + "返回" | `#F5222D` (ZS-001-09) | — | hover `#CF1322`,文本按钮态 |
| 竖分隔 `\|` | `#E8E8E8` | FS-004-04 | 返回与路径间 |
| 历史层级文字 | `#575757` (FS-004-09) | — | 可点击,hover `#F5222D` |
| 路径分隔 `/` | `#C1C1C1` | FS-004-06 | — |
| 当前位置文字 | `#222222` (FS-004) | — | `Medium`,不可点击 |
### 8.3 行为规则
- 点击「返回」按钮逐步向上返回一级。
- 点击文字路径,快速返回到该层级页面。
- 层级尽可能不超过四级;超出时中间层折叠为 `…`。
- 字号 `14px Regular`,元素间距 `--spacing-2` (8px),竖分隔高度约 `12–14px`。
---
## 9. 页头与分页
### 9.1 页头(Page Header)
内容区顶部的标题区,底部叠一条 `1px solid #E8E8E8` (FS-004-04) 分割线。
| 变体 | 构成 | 配色 |
|------|------|------|
| 基础页头 | 页头标题 | 标题 `16px Bold #222222` (FS-004) |
| 页头 + 说明 | 标题 +(简要说明) | 说明 `14px Regular #A6A6A6` (FS-004-07) |
| 页头 + 说明 icon | 标题 + `?` 帮助图标 | 图标 `#A6A6A6`,hover `#F5222D` |
| 分 Tabs 页头 | 多个标题 Tab | 见 §9.2 |
### 9.2 分 Tabs 页头
横向页签形态,激活态红色下划线。
| 状态 | 文字 | 下划线 |
|------|------|--------|
| 默认 | `#222222` (FS-004) | 无 |
| Hover | `#F5222D` (ZS-001-09) | 无 |
| 激活 | `#F5222D` (ZS-001-09) | `2px solid #F5222D`,宽度与文字齐 |
| 失效 | `#C1C1C1` (FS-004-06) | 无 |
- 字号 `14px Medium`,项间距 `--spacing-5` (24px),下划线与文字底部间距 `--spacing-2` (8px)
### 9.3 分页(Pagination)
#### 9.3.1 正常分页
`共 240 项 [1] 2 3 4 5 … 24 > 每页 [10 ▾] 条 前往 [ ] 页`
| 元素 | 默认 | Hover | 激活 | 失效 |
|------|------|-------|------|------|
| 总数 / 辅助文字(共N项 / 每页 / 条 / 前往 / 页) | `#575757` (FS-004-09) | — | — | — |
| 页码数字 | `#222222` (FS-004) | `#F5222D` (ZS-001-09) | 背景 `#F5222D` + 文字 `#FFFFFF` | `#C1C1C1` (FS-004-06) |
| 翻页箭头 `< >` | `#575757` (FS-004-09) | `#F5222D` | — | `#C1C1C1` |
| 省略号 `…` | `#A6A6A6` (FS-004-07) | — | — | — |
| 每页选择器 / 跳转输入框 | 边框 `#DBDBDB` (FS-004-05) | 边框 `#F5222D` | — | 边框 `#E8E8E8` |
- 页码项尺寸 `28px × 28px`,圆角 `2px`,激活态为实心红方块;项间距 `--spacing-1` (4px)
#### 9.3.2 精简分页
`共 1 项 < [1] / 10 >`
| 元素 | 色值 |
|------|------|
| 总数文字 | `#575757` (FS-004-09) |
| 当前页输入框 | 边框 `#DBDBDB`,文字 `#222222` |
| 总页数 `/ N` | `#A6A6A6` (FS-004-07) |
| 翻页箭头 `< >` | `#575757`,hover `#F5222D`,失效 `#C1C1C1` |
#### 9.3.3 导航式分页
仅"下一页 » / « 上一页"文本按钮,无页码。配色沿用 §5.3.3 文本按钮 + 图标:
| 状态 | 色值 |
|------|------|
| 默认 | `#F5222D` (ZS-001-09) |
| Hover | `#CF1322` (ZS-001) |
| 失效 | `#C1C1C1` (FS-004-06) |
---
## 10. 步骤条(Steps)
> 用于多步流程的进度指引。按布局分**横向**(弹窗 / 页面)与**纵向**两类。颜色取自 ZS-001 主色红与 FS-004 中性灰。
### 10.1 步骤节点(公共)
每个步骤由「指示圆 + 标题(可选描述)」组成。指示圆按状态切换形态:
| 状态 | 圆形外观 | 圆内符号 | 来源 |
|------|----------|----------|------|
| 已完成(Completed) | 描边圆 `2px solid #F5222D`,背景 `transparent` | `✓`(对勾,`#F5222D`) | ZS-001-09 |
| 当前(Current) | 实心圆,背景 `#F5222D` | 步骤数字,`#FFFFFF` | ZS-001-09 / 白 |
| 未开始(Upcoming) | 描边圆 `1px solid #C1C1C1`,背景 `transparent` | 步骤数字,`#A6A6A6` | FS-004-06 / FS-004-07 |
| 失效(Disabled) | 描边圆 `1px solid #E8E8E8`,背景 `transparent` | 步骤数字,`#C1C1C1` | FS-004-04 / FS-004-06 |
**指示圆规格**:
| 属性 | 取值 |
|------|------|
| 直径 | `24px` |
| 圆内字号 | `12px Bold`(D-DIN 或 Noto Sans Bold) |
| 对勾图标 | `12px`,居中 |
**标题与描述文字**:
| 元素 | 状态 | 字号 / 颜色 | 来源 |
|------|------|-------------|------|
| 标题 | 已完成 / 当前 | `14px Medium`,`#222222` | FS-004 |
| 标题 | 未开始 / 失效 | `14px Medium`,`#A6A6A6` → `#C1C1C1` | FS-004-07 → FS-004-06 |
| 描述 | 已完成 / 当前 | `12px Regular`,`#575757` | FS-004-09 |
| 描述 | 未开始 / 失效 | `12px Regular`,`#A6A6A6` → `#C1C1C1` | FS-004-07 → FS-004-06 |
**连接线(步骤之间)**:
| 类型 | 色值 | 来源 | 样式 |
|------|------|------|------|
| 已完成段 | `#F5222D` | ZS-001-09 | `1px dashed`(弹窗)/ `1px solid`(页面) |
| 未完成段 | `#C1C1C1` | FS-004-06 | `1px dashed` |
> 连接线在指示圆两侧水平居中(横向)或正下方垂直(纵向);线两端与圆外缘留 `--spacing-1` (4px) 间隙。
### 10.2 弹窗步骤条(横向 · 描边圆为主)
用于 Modal / Drawer 内的紧凑步骤条。完成态用**描边 + 红色对勾**,避免在弹窗内过于抢眼。
```
( ✓ ) ┄┄┄ (●2) ┄┄┄ ( 3 ) ┄┄┄ ( 4 ) ┄┄┄ ( 5 )
标题 标题 标题 标题 标题
(描述) (描述) (描述) (描述) (描述)
```
| 属性 | 取值 |
|------|------|
| 容器水平 padding | `--spacing-5` (24px) |
| 指示圆 → 标题间距 | `--spacing-2` (8px) |
| 标题 → 描述间距 | `--spacing-1` (4px) |
| 连接线 | `1px dashed`,完成段 `#F5222D`,未完成段 `#C1C1C1` |
| 文字对齐 | 标题、描述居中于指示圆下方 |
| 两种变体 | 仅标题 / 标题 + 描述 |
### 10.3 页面步骤条(横向 · 实心圆 + 自定义首项标题)
用于页面级向导(如「新建集群」)。完成态连接线改为**实线**,首步标题可显示具体名称。
```
(✓) ──── (●2) ┄┄┄ ( 3 ) ┄┄┄ ( 4 ) ┄┄┄ ( 5 )
新建集群 标题 标题 标题 标题
(描述文本) (描述文本) (描述文本) (描述文本) (描述文本)
```
| 维度 | 弹窗步骤条 | 页面步骤条 |
|------|-----------|-----------|
| 完成态圆形 | 描边圆 + 红色对勾 | 描边圆 + 红色对勾(同) |
| 完成段连接线 | `1px dashed #F5222D` | `1px solid #F5222D` |
| 第一项 | 通用「标题」占位 | 可显示具体名称(自定义文案) |
### 10.4 纵向步骤条
用于侧栏 / 详情页内的纵向流程指引。两种变体——「仅标题」与「标题 + 描述」。
```
仅标题: 标题 + 描述:
(✓) 新建集群 (✓) 新建集群
┊ 描述文本描述
(●2) 标题 ┊
┊ (●2) 标题
( 3 ) 标题 描述文本描述
┊ ┊
( 4 ) 标题 ( 3 ) 标题
描述文本描述
```
| 属性 | 取值 |
|------|------|
| 指示圆 → 标题水平间距 | `--spacing-2` (8px) |
| 节点垂直间距(仅标题) | `--spacing-5` (24px),圆心到圆心 |
| 节点垂直间距(标题 + 描述) | `--spacing-6` (32px),圆心到圆心 |
| 标题 → 描述间距 | `--spacing-1` (4px) |
| 连接线位置 | 上下指示圆正下方垂直,居中于圆心 |
| 连接线样式 | `1px dashed`,完成段 `#F5222D`,未完成段 `#C1C1C1` |
| 文字对齐 | 标题与描述左对齐 |
### 10.5 交互与动画
| 行为 | 动效 |
|------|------|
| 步骤切换(next) | 当前圆 `background: transparent → #F5222D`,`200ms ease-out`;上一步圆 `border-color: #C1C1C1 → #F5222D`,再 `200ms` 切换为对勾图标淡入 |
| 连接线进度 | 完成段 `width 0% → 100%`,`240ms ease-out` |
---
## 11. 页签与锚点
> 共用主色红指示当前位置:页签用于同层级内容切换,锚点用于长页面内章节跳转。
### 11.1 页签(Tabs)
#### 11.1.1 横向页签(下划线 · 横向)
| 状态 | 文字 | 下划线 |
|------|------|--------|
| 默认 | `#222222` (FS-004) | 无 |
| Hover | `#F5222D` (ZS-001-09) | 无 |
| 激活 | `#F5222D` (ZS-001-09) | `2px solid #F5222D`,宽度与文字齐 |
| 失效 | `#C1C1C1` (FS-004-06) | 无 |
- 字号 `14px Medium`,项间距 `--spacing-5` (24px),下划线与文字底部间距 `--spacing-2` (8px)
- 容器底边叠一条 `1px solid #E8E8E8` (FS-004-04) 基线,激活高亮线压在其上
#### 11.1.2 纵向页签(下划线 · 纵向)
| 状态 | 文字 | 右侧指示线 |
|------|------|-----------|
| 默认 | `#222222` (FS-004) | 无 |
| Hover | `#F5222D` (ZS-001-09) | 无 |
| 激活 | `#F5222D` (ZS-001-09) | 右侧 `2px solid #F5222D`,高度与文字齐 |
| 失效 | `#C1C1C1` (FS-004-06) | 无 |
- 字号 `14px Medium`,项高度 `32px`,项间距 `--spacing-2` (8px)
- 容器右边叠一条 `1px solid #E8E8E8` 基线,激活指示线压在其上
### 11.2 锚点(Anchor)
用于长内容页的章节快速跳转,支持一 / 二级缩进。
```
│ 一级
│ 一级 (active)
│ 二级
│ 二级
│ 一级
│ 一级
```
| 属性 | 取值 |
|------|------|
| 左侧基线 | `1px solid #E8E8E8` (FS-004-04) 通栏 |
| 项高度 | `32px` |
| 一级项左 padding | `--spacing-3` (12px) |
| 二级项左 padding | `--spacing-5` (24px) |
| 字号 | `14px Regular` |
| 状态 | 一级文字 | 二级文字 | 左侧指示条 |
|------|----------|----------|-----------|
| 默认 | `#222222` (FS-004) | `#A6A6A6` (FS-004-07) | 无 |
| Hover | `#F5222D` (ZS-001-09) | `#F5222D` (ZS-001-09) | 无 |
| 激活 | `#F5222D` (ZS-001-09) | — | 左侧 `2px solid #F5222D`,覆盖容器基线 |
- 二级项不单独支持激活态:选中二级时其父级一级显示激活态,指示条按父级位置渲染
---
---
> 返回 [规范目录](README.md)
@@ -1,308 +0,0 @@
# 浅红色 UI 规范 · 四、数据展示
> 返回 [规范目录](README.md) | 本篇:弹出式窗口 · 卡片 · 折叠面板 · 进度条 · 表格 · 标签 · 时间轴
---
## 17. 弹出式窗口(Modal / Dialog)
> 用于聚焦的多步表单 / 确认操作。内部可承载页签、步骤条、表单控件。
### 17.1 容器规格
| 属性 | 取值 |
|------|------|
| 背景 | `#FFFFFF` (FS-004-01),圆角 `4px` |
| 阴影 | `0 8px 32px rgba(34, 34, 34, 0.16)`(§3.1.3 三级阴影) |
| 遮罩层 | `rgba(34, 34, 34, 0.45)`(基于 FS-004) |
| 标题区 | 左侧 `3px` 主色红竖条 `#F5222D` + 标题 `16px Bold #222222` |
| 关闭按钮 `×` | `#A6A6A6` (FS-004-07),hover `#F5222D` |
| 内边距 | `--spacing-5` (24px) |
| 底部操作区 | 右对齐,主按钮在最右(§5.3.4) |
### 17.2 内部页签与步骤条
- 顶部页签沿用 §11.1 横向页签(Tab 激活红字 + 红下划线,基线 `#E8E8E8`)。
- 步骤条沿用 §10.2 弹窗步骤条(完成态红描边对勾、当前态红实心、未开始灰描边,连接线 `1px dashed`)。
### 17.3 表单控件补充(Modal 内常用)
| 控件 | 配色 |
|------|------|
| 必填星号 `*` | `#F5222D` (ZS-001-09),置于 label 前 |
| 下拉选择(Select,`请选择 ▾`) | 同输入框(§13),箭头 `#A6A6A6`,聚焦边框 `#F5222D` |
| 日期 / 时间选择器(📅) | 输入框 + 日历图标 `#A6A6A6`;区间用"起 至 止"两段 |
| 文本区域(Textarea) | 同输入框,`resize: vertical`,最小高度约 `80px` |
| 数值 + 单位(如 `100 天`) | 输入框 + 尾部 addon(背景 `#FAFAFA`,边框 `#DBDBDB`,文字 `#575757`) |
| 复选框(Checkbox) | 未选边框 `#DBDBDB`;选中背景 `#F5222D` + 对勾 `#FFFFFF` |
| 错误态 | 控件边框 `1px solid #F5222D`,下方红色提示文字 `12px #F5222D`(如"输入表单内容报错") |
---
## 18. 数据展示 · 卡片(Card)
> 用于陈列结构化信息(人物 / 媒体 / 群组等档案)。在 §3 卡片基础上扩展"分级信息卡片"。
### 18.1 卡片头部
| 元素 | 配色 |
|------|------|
| 类目竖条(标题前) | `3px` 主色红 `#F5222D` (ZS-001-09) |
| 标题(如「个体」「媒体」) | `16px Bold`,`#222222` (FS-004) |
| 关闭 `×` | `#A6A6A6` (FS-004-07),hover `#F5222D` |
| 右上操作(查看档案) | 小尺寸主按钮 `#F5222D` + 白字(§5.1 小,含图标) |
### 18.2 标签 / 属性 Tag
| 类型 | 背景 | 文字 | 来源 |
|------|------|------|------|
| 人物属性 / 红色标签 | `#FEEDED` (ZS-001-03) | `#F5222D` (ZS-001-09) | 主色红 |
| 立场 / 成功类(绿) | `#EEFCF7` (FS-002-03) | `#189D72` (FS-002-10) | 渐绿系 |
| 状态签(右上角强调) | `transparent` | `#F5222D` (ZS-001-09) | 主色红 |
| 话题标签(#话题) | `transparent` | `#F5222D` (ZS-001-09) | 主色红,`#` 前缀 |
- Tag 圆角 `2px`,内边距 `0 8px`,字号 `12px`
### 18.3 卡片内页签与信息行
| 元素 | 配色 |
|------|------|
| 页签(基本信息 / 线下活动 / 线上发声) | 激活 `#F5222D` + 红下划线;默认 `#222222`(§11.1.1) |
| 页签红点提示(•) | `#F5222D` (ZS-001-09),标识有更新 |
| 信息项标签(国家/地区、职务…) | `#A6A6A6` (FS-004-07) |
| 信息项取值 | `#222222` (FS-004) |
| 来源平台图标(如 Facebook) | 平台原色(蓝 `#0079FE` 近似),不纳入主题强调色 |
---
## 19. 数据展示 · 折叠面板(Collapse)
> 用于长内容的收起 / 展开。含列表概览折叠、带图标折叠、手风琴折叠、信息流折叠四类。
### 19.1 列表概览折叠(含 + 图标变体)
| 状态 | 构成 | 配色 |
|------|------|------|
| 收起 | 标题 + 描述 + 右侧 `▾` | 标题 `#222222` (FS-004)、描述 `#A6A6A6` (FS-004-07)、箭头 `#A6A6A6` |
| 展开 | 上述 + 下方内容区 | 内容区背景 `#FAFAFA` (FS-004-02),箭头旋转为 `^` |
| 属性 | 取值 |
|------|------|
| 容器边框 | `1px solid #DBDBDB` (FS-004-05),圆角 `4px` |
| 头部内边距 | `--spacing-4` (16px) |
| 左侧图标(带图标变体) | 占位 `40px`,图标 `#A6A6A6` |
### 19.2 手风琴折叠(Accordion)
多面板同列堆叠,同一时刻通常仅展开一项。
| 元素 | 配色 |
|------|------|
| 项分隔 | `1px solid #E8E8E8` (FS-004-04) |
| 标题 | `#222222` (FS-004),前置 `▾ / >` 箭头 `#A6A6A6` |
| 收起 / 展开 链接 | 文本按钮 `#F5222D` (ZS-001-09) |
| 展开正文 | `#575757` (FS-004-09),背景 `#FFFFFF` |
### 19.3 信息流折叠(Feed)
用于资讯流条目的展开详情。
| 元素 | 配色 |
|------|------|
| 前置「标签」Tag | 背景 `#FEEDED` (ZS-001-03),文字 `#F5222D` (ZS-001-09) |
| 条目标题 | `#222222` (FS-004),`14px Medium` |
| 热度计数(🔥 6405) | 火苗图标 `#FF9900` (FS-001-09),数字 `#575757` (FS-004-09) |
| 收起 / 展开 链接 + 箭头 | `#F5222D` (ZS-001-09) |
| 分节标题(【热点首发】/【应对建议】) | `#F5222D` (ZS-001-09),`Medium` |
| 来源 / 作者行(社交媒体 \| 平台 \| @用户) | `#A6A6A6` (FS-004-07) |
| 外链 URL | `#0079FE` (ZS-002-09),hover 下划线 |
| 日期 | `#A6A6A6` (FS-004-07) |
| 正文 | `#575757` (FS-004-09) |
| 缩略图 | 圆角 `2px`,间距 `--spacing-1` (4px) |
---
## 21. 数据展示 · 进度条(Progress)
### 21.1 标准进度条
| 属性 | 取值 |
|------|------|
| 轨道背景 | `#F5F5F5` (FS-004-03) |
| 轨道高度 | `8px`,圆角全圆 |
| 已完成段 | 按语义取色(§20):成功绿 / 失败红 / 警告橙 |
| 百分比文字 | `#222222` (FS-004),置于条右侧 |
| 末端状态图标 | 成功 ✓ `#2FD69A`;失败 ✗ `#F5222D`(橙色进行态可不带图标) |
### 21.2 小型进度条
放在较窄区域内(如表格单元格)。规格同 §21.1,仅尺寸缩小:
| 属性 | 取值 |
|------|------|
| 轨道高度 | `4px` |
| 百分比 / 图标 | 字号缩至 `12px`,图标 `12px` |
### 21.3 环形进度条
| 属性 | 取值 |
|------|------|
| 轨道环 | `#F5F5F5` (FS-004-03) |
| 进度环 | 按语义取色(§20),线宽 `6px`,圆头端点 |
| 中心主数字(如 `82.0%`) | `#222222` (FS-004),D-DIN |
| 中心名称 | `12px Regular`,`#A6A6A6` (FS-004-07) |
> 进度环颜色按阈值映射(如低→绿、中→橙、高→红)由业务自定义,但必须取自 §20 语义色。
---
## 22. 数据展示 · 表格(Table)
### 22.1 结构规格
| 属性 | 取值 |
|------|------|
| 表头背景 | `#FFFFFF`(或浅红 `#FEF6F6` ZS-001-02 强调首行) |
| 表头文字 | `14px Medium`,`#222222` (FS-004) |
| 单元格文字 | `14px Regular`,`#222222` |
| 行高 | `48px` |
| 行分隔 | `1px solid #F5F5F5` (FS-004-03) |
| 行 Hover | 背景 `#FAFAFA` (FS-004-02) |
| 斑马纹(可选) | 偶数行 `#FAFAFA` (FS-004-02) |
| 复选框(选中) | 填充 `#F5222D` + 对勾 `#FFFFFF` |
### 22.2 状态列(圆点 + 文字)
| 文字 | 圆点色 | 来源 |
|------|--------|------|
| 普通 | `#2FD69A`(成功绿) | FS-002-09 |
| 异常 | `#F5222D`(错误红) | ZS-001-09 |
| 特殊 | `#FF9900`(警告橙) | FS-001-09 |
- 文字统一 `#222222` (FS-004),圆点直径 `6px`,与文字间距 `--spacing-1` (4px)
### 22.3 整体状态列(状态标签)
使用 §23 浅底状态标签:进行中(橙)/ 已完成(绿)/ 已失败(红)。
### 22.4 操作列
文本按钮 + 图标,沿用 §5.3.3:查看 / 配置 等均为 `#F5222D` (ZS-001-09),hover `#CF1322`,按钮间距 `--spacing-3` (12px)。
### 22.5 单元格内编辑报错
可编辑单元格(如 `100 [M]`)校验失败:输入框边框 `1px solid #F5222D`,红色气泡提示"Table 内输入报错"(背景 `#F5222D`、白字 `12px`,§12.3 同款)。
---
## 23. 数据展示 · 标签(Tag / Badge)
### 23.1 标签形式
| 形式 | 用途 | 配色 |
|------|------|------|
| 文字按钮标签 | 重点突出标记属性 | 三种强度:灰底 `#F5F5F5`/`#575757`;浅红底 `#FEEDED`/`#F5222D`;实红 `#F5222D`/`#FFFFFF` |
| 图标 + 文字按钮标签 | 增强辨识度 | 灰底 + 平台原色图标(如 Twitter 蓝);或实红 `#F5222D` + 白字 + ✓ |
| 圆点 + 文字标签 | 属性多 / 复杂、无需突出 | 圆点按语义色(普通绿 / 特殊橙 / 异常红),文字 `#222222` |
| 动态标签 | 可增删(筛选列表、关键词) | 已加:灰底 `#F5F5F5` + `×`;新增入口:虚线框 `#DBDBDB` + "+ 标签" |
- 标签圆角 `2px`,高度 `24px`,内边距 `0 8px`,字号 `12px`
### 23.2 状态标签(Status Badge)
四种呈现风格,颜色全部取自 §20 语义状态色:
| 风格 | 说明 |
|------|------|
| ① 浅底彩字 | 浅底背景 + 语义文字色(默认推荐,弱干扰) |
| ② 实填白字 | 语义实填背景 + 白字(强调态,用于重点状态) |
| ③ 图标 + 浅底彩字 | ① 基础上前置语义图标 |
| ④ 图标 + 彩字(无底) | 仅图标 + 文字,最轻量 |
常见状态与语义映射:
| 状态 | 语义 / 取色 |
|------|-------------|
| 未开始 / 就绪 | 中性灰 |
| 等待中 / 等待 | 信息蓝 |
| 进行中 / 进行 | 警告橙 |
| 已完成 / 成功 | 成功绿 |
| 已失败 / 失败 | 错误红 |
| 待更新 / 待更 | 青 |
- 图标(②④ 风格):就绪 ⊖、等待 ⊗、进行 🕐、成功 ✓、失败 ✗、待更 🕐,颜色与文字同步
---
## 24. 数据展示 · 时间轴(Timeline)
> 用于按时间顺序陈列事件。分纵向(基础 / 卡片 / 长文本)与横向两种主轴方向,另含全局缩略时间轴。
### 24.1 纵向时间轴点(基础)
| 元素 | 配色 |
|------|------|
| 轴线 | `1px solid #E8E8E8` (FS-004-04) 或虚线 |
| 最新 / 当前节点 | 空心红圆环 `2px solid #F5222D` (ZS-001-09),背景 `#FFFFFF` |
| 历史节点 | 实心红点 `#F5222D` (ZS-001-09),直径 `8px` |
| 事件标题 | `14px Medium`,`#222222` (FS-004) |
| 时间文字 | `12px Regular`,`#A6A6A6` (FS-004-07) |
### 24.2 卡片式时间轴点
节点内容置于浅灰卡片中。
| 元素 | 配色 |
|------|------|
| 卡片背景 | `#FAFAFA` (FS-004-02),圆角 `4px` |
| 卡片内分隔 | `1px solid #E8E8E8` |
| 正文标签(如"安全状况:") | `#575757` (FS-004-09) |
| 正向取值(如"今日无安全风险") | `#189D72`(成功绿,FS-002-10) |
### 24.3 横向长文本时间轴
横轴双向箭头,节点上下交错分布。
| 元素 | 配色 |
|------|------|
| 主轴线 + 双向箭头 | `#222222` (FS-004) |
| 上方节点标记 + 日期 | 红色 `#F5222D` (ZS-001-09),`▼` 标记 |
| 下方节点标记 + 日期 | 橙色 `#FF9900` (FS-001-09),`▲` 标记 |
| 节点正文 | `#575757` (FS-004-09),`14px Regular` |
> 上 / 下用红 / 橙双色区分两类事件或交替排布,避免拥挤。
### 24.4 纵向长文本时间轴
带头像、状态标签、富文本与附件的纵向时间流。
| 元素 | 配色 |
|------|------|
| 轴线 | `1px dashed #DBDBDB` (FS-004-05) |
| 日期分组吸顶标签 | 背景 `#A6A6A6`(FS-004-07) 或 `#F5F5F5`,文字 `#FFFFFF` / `#575757`,圆角 `2px` |
| 节点圆点 | 红 `#F5222D`(重点)/ 浅红 `#FCB5B9`(次要) |
| 状态标签 | 浅底状态标签:计划(橙 `#FFF6EA`/`#FF9900`)、已成行(红 `#FEEDED`/`#F5222D`)(§23.2 ①) |
| 人物名 | `14px Regular`,`#222222` |
| 时间 | `12px`,`#A6A6A6` (FS-004-07) |
| 正文 | `#575757` (FS-004-09) |
| 地址(📍 地址) | 图标 + 文字 `#A6A6A6` |
| 缩略图 | 圆角 `2px`,间距 `--spacing-1` (4px) |
### 24.5 全局时间轴(缩略 / 范围选择)
页面顶部用于框选时间范围的缩略轴。
| 元素 | 配色 |
|------|------|
| 刻度轴背景 | 灰阶渐变 `linear-gradient(90deg, #7F7F7F, #DBDBDB)`(FS-004-08 → FS-004-05) |
| 刻度文字(如 04-03) | `#FFFFFF`,`12px` |
| 拖拽游标(handle) | `#FFFFFF` 竖条,带轻微阴影 |
| 选区刷选框(brush) | 边框 `1px solid #F5222D`,填充 `rgba(245, 34, 45, 0.08)`(浅红 ZS-001-09 8%) |
| 选区内迷你面积图 | 描边 `#F5222D`,填充浅红渐变 |
---
---
> 返回 [规范目录](README.md)
@@ -1,90 +0,0 @@
# 浅红色 UI 规范 · 五、图表
> 返回 [规范目录](README.md) | 本篇:图表通用色序 · 公共元素 · 柱状/折线/甘特/饼环
---
## 25. 图表(Charts)
> 图表配色以**柔和色系**为主(保证大面积色块不刺眼),强调线 / 排行榜可叠加主色红渐变。多分类图表的色序按"用几色取几色",从下表对应行从左到右取用。
### 25.1 图表通用色序(权威)
> 如需更多色值,请 UI 设计师按系统整体风格在同色系内扩展。
| 分类数 | 色序(从左到右) |
|--------|------------------|
| 三色 | `#FA9196` `#97EBCD` `#80BCFF` |
| 四色 | `#FA9196` `#FFCC80` `#97EBCD` `#A2E1F9` |
| 五色 | `#FA9196` `#FFCC80` `#97EBCD` `#A2E1F9` `#80BCFF` |
| 六色 | `#FA9196` `#FFCC80` `#BAE1B3` `#97EBCD` `#A2E1F9` `#80BCFF` |
| 七色 | `#FA9196` `#FCA58F` `#FFCC80` `#BAE1B3` `#97EBCD` `#A2E1F9` `#80BCFF` |
| 八色 | `#FA9196` `#FCA58F` `#FDB887` `#FFCC80` `#BAE1B3` `#97EBCD` `#A2E1F9` `#80BCFF` |
| 九色 | `#FA9196` `#FCA58F` `#FDB887` `#FFCC80` `#DCD69A` `#BAE1B3` `#97EBCD` `#A2E1F9` `#80BCFF` |
| 十色 | `#FA9196` `#FCA58F` `#FDB887` `#FFCC80` `#DCD69A` `#BAE1B3` `#97EBCD` `#9EE4EA` `#A2E1F9` `#80BCFF` |
- 色值映射:`#FA9196`(ZS-001-06 浅红) / `#FCA58F`·`#FDB887`(橙红过渡) / `#FFCC80`(FS-001-06 浅橙) / `#DCD69A`(黄绿过渡) / `#BAE1B3`·`#97EBCD`(FS-002-06 浅绿) / `#9EE4EA`·`#A2E1F9`(FS-003-06 浅青) / `#80BCFF`(ZS-002-06 浅蓝)
- 取色原则:序列从暖(红)到冷(蓝)渐变排布,相邻类别对比清晰
### 25.2 图表公共元素
| 元素 | 配色 |
|------|------|
| 坐标轴线 | `1px solid #E8E8E8` (FS-004-04) |
| 网格线 | `1px dashed #F5F5F5` (FS-004-03) |
| 轴刻度文字 | `12px`,`#A6A6A6` (FS-004-07) |
| 图例文字 | `12px`,`#575757` (FS-004-09),前置色块圆角 `2px` |
| Tooltip 浮层 | 背景 `rgba(34,34,34,0.85)`(FS-004),文字 `#FFFFFF` `12px`,圆角 `4px` |
| 数值标注气泡 | 背景 `#7F7F7F` (FS-004-08),白字(如悬停值 `59.32`) |
| 高亮选区(band) | 填充 `rgba(245,34,45,0.08)`,中线 `1px dashed #F5222D` (ZS-001-09) |
### 25.3 柱状图(Bar)
| 形态 | 配色 |
|------|------|
| 堆叠柱 / 分组柱 | 各序列按 §25.1 取色(如 Paper 绿 `#97EBCD`、Project 橙 `#FFCC80`、Book 红 `#FA9196`) |
| 单序列柱 / 条形 | 主序列 `#FA9196`(浅红) |
| 排行榜(TOP 渐变条) | 进度条填充 `linear-gradient(90deg, #F5222D 0%, #FF9900 100%)`(红→橙),轨道 `#F5F5F5`,名称 `#222222`、数值 `#222222` 右对齐 |
- 柱体圆角 `2px`,柱间距按类目自适应
### 25.4 折线图(Line / Area)
| 元素 | 配色 |
|------|------|
| 主折线 | `#F5222D` (ZS-001-09),线宽 `2px` |
| 面积填充 | `linear-gradient(180deg, rgba(245,34,45,0.15), rgba(245,34,45,0))` |
| 数据点 | 描边 `#F5222D` + 白色填充,悬停放大并实心 |
| 多序列线 | 用饱和主色保证细线可辨:红 `#F5222D` / 蓝 `#0079FE` / 橙 `#FF9900` / 绿 `#2FD69A` |
| 涨跌双色线 | 上行 `#F5222D`(红)/ 下行 `#2FD69A`(绿) |
| 堆叠面积 | 各层按 §25.1 柔和色 + 各自浅色渐变填充 |
| 悬停辅助线 + tooltip | 竖虚线 `#F5222D`,tooltip 见 §25.2(如"异常率 12.3%") |
### 25.5 甘特图(Gantt)
| 元素 | 配色 |
|------|------|
| 行背景斑马纹 | 浅红 `#FEF6F6` (ZS-001-02) 与 `#FFFFFF` 交替 |
| 任务条 | 按 §25.1 取色(每类任务一色),圆角 `2px`,末端可带箭头 |
| 网格线 | `1px solid #F5F5F5` (FS-004-03) |
| 时间轴 / 月份标签 | `12px`,`#A6A6A6` (FS-004-07) |
| 图例 | 圆点 + 文字,圆点取对应任务色,文字 `#575757` |
### 25.6 饼图 / 环形图(Pie / Donut)
支持三环至八环(即 3~8 个分类),扇区按 §25.1 对应行取色。
| 元素 | 配色 |
|------|------|
| 扇区 | §25.1 通用色序 |
| 中心主数字(如 `34801`) | `#222222` (FS-004),D-DIN |
| 中心说明(XXX总数) | `12px`,`#A6A6A6` (FS-004-07) |
| 引导线 + 标签 | 引导线同扇区色,标签文字 `#575757` (FS-004-09),百分比同行 |
| 扇区间隙 | `2px` 白色描边分隔,环宽适中(甜甜圈留中心空 ≥ 50%) |
---
---
> 返回 [规范目录](README.md)
@@ -1,121 +0,0 @@
# 浅红色 UI 规范 · 六、页面与布局
> 返回 [规范目录](README.md) | 本篇:统计与汇总卡 · 实体卡片 · 综合页面布局示例
---
## 26. 统计与汇总卡(Dashboard)
> 仪表盘顶部的指标卡与汇总卡,用于一屏概览关键数据。
### 26.1 指标卡(已用 / 可用 + 迷你环)
横向排列的使用率卡,左侧数值、右侧迷你环形进度。
| 元素 | 配色 |
|------|------|
| 卡标题(如"账号使用率") | `14px Regular`,`#575757` (FS-004-09) |
| 主数值(已用,如 `139`) | D-DIN `26px Bold`,`#222222` (FS-004) |
| 次数值(可用,如 `2`) | D-DIN,`#FF9900`(警告橙,FS-001-09) |
| 数值下标签(已用 / 可用) | `12px`,`#A6A6A6` (FS-004-07) |
| 迷你环 + 中心百分比 | 按使用率阈值取语义色(§20):低→绿、中→橙、高→红;轨道 `#F5F5F5` |
| 卡间竖分隔 | `1px solid #E8E8E8` (FS-004-04) |
### 26.2 汇总卡(总量 + 正常 / 异常 + 环形)
| 元素 | 配色 |
|------|------|
| 总量数值(如 `300`) | D-DIN,`#222222` (FS-004) |
| 正常数值(如 `270`) | `#FF9900`(橙)或成功绿,按业务定 |
| 异常数值(如 `30`) | `#F5222D`(错误红,ZS-001-09) |
| 标签(总量 / 正常 / 异常) | `#575757` (FS-004-09) |
| 环形图 | 正常段绿 `#97EBCD`、异常段红 `#FA9196`(§25.1 色序);中心主数(如 `40%`)`#222222`、说明(异常率)`#A6A6A6` |
### 26.3 时间范围切换(今日 / 近7日)
分段选择,沿用 §14.3 按钮式页签:
| 状态 | 配色 |
|------|------|
| 选中(今日) | 背景 `#F5222D` (ZS-001-09),文字 `#FFFFFF` |
| 未选(近7日) | 背景 `#FFFFFF`,边框 `#DBDBDB`,文字 `#222222` |
### 26.4 资源检索条
`[资源类型 ▾] [请输入关键词] [搜索]`:下拉 + 输入框沿用 §13;搜索为主按钮(§5.2.1 红)。配合右侧表格(§22),表头含排序箭头(默认 `#A6A6A6`,激活 `#F5222D`)。
---
## 27. 实体卡片(列表卡 / 任务集卡)
> 卡片网格陈列实体(任务集 / 人物 / 项目等),含主数据、属性表、负责人操作按钮与空状态。
### 27.1 卡片结构
| 元素 | 配色 |
|------|------|
| 卡片背景 / 边框 / 阴影 | `#FFFFFF` / `1px solid #E8E8E8` / §3.1.3 一级阴影,圆角 `4px` |
| 主标题(如人名) | `16px Bold`,`#222222` (FS-004) |
| 副标题(项目名) | `14px Regular`,`#575757` (FS-004-09) |
| 创建时间 | `12px`,`#A6A6A6` (FS-004-07) |
| 主指标(目标数量,如 `10247`) | D-DIN `26px Bold`,`#F5222D` (ZS-001-09);标签"目标数量" `#A6A6A6` |
| 属性面板(社交媒体(52)) | 背景 `#FEF6F6` (ZS-001-02);条目名 `#575757`、数值 `#F5222D` (ZS-001-09) |
| 联系/备注行 | `#575757` (FS-004-09),时间后缀 `#A6A6A6` |
| 底部负责人按钮 | 通栏主按钮:背景 `#F5222D`、文字 `#FFFFFF`(§5.2.1) |
### 27.2 卡片 Hover 操作菜单
卡片右上 `⋯` 触发**深色下拉**:
| 元素 | 配色 |
|------|------|
| 菜单背景 | `#3D3D3D` (FS-004-10),圆角 `4px`,阴影 §3.1.3 二级 |
| 菜单项(修改 / 删除) | 文字 + 图标 `#FFFFFF`;hover 背景 `rgba(255,255,255,0.1)` |
### 27.3 空状态(暂无任务)
| 元素 | 配色 |
|------|------|
| 空状态插图 / 图标 | 中性灰 `#C1C1C1` (FS-004-06) |
| 空状态文字(暂无任务) | `14px`,`#A6A6A6` (FS-004-07) |
| 主指标归零 | 数值显示 `0`,颜色同 §27.1(红) |
---
## 28. 综合页面布局示例
> 标准后台页面 = 顶部红底导航(§7.1)+ 左侧分级侧边栏(§7.3 / §7.6)+ 右侧内容区。
### 28.1 仪表盘页
```
┌ Header(红底,§7.1)────────────────────────────────────────────┐
├──────┬───────────────────────────────────────────────────────────┤
│ 侧栏 │ 面包屑(§8.1) │
│(§7.3)│ ┌ 指标卡 × N(§26.1,横向等分,竖线分隔)───────────────┐ │
│ │ ├ 汇总卡(§26.2) │ 折线图(§25.4) │ 资源表(§22 + 检索§26.4)│ │
│ │ └ 异常分布图 … │ │
└──────┴───────────────────────────────────────────────────────────┘
```
- 内容区背景 `#FAFAFA` (FS-004-02),卡片白底浮起;模块间距 `--spacing-5` (24px),卡内边距 `--spacing-4` (16px)
### 28.2 列表卡片页
```
┌ Header(红底)─────────────────────────────────────────────────┐
├──────┬───────────────────────────────────────────────────────────┤
│ 侧栏 │ [搜索框] [搜索] [+ 添加任务集] 排序 [创建时间 ▾]│
│ │ ┌实体卡┐ ┌实体卡┐ ┌实体卡┐ ┌实体卡┐ (§27,等宽网格) │
│ │ └─────┘ └─────┘ └─────┘ └─────┘ │
│ │ ┌实体卡┐ ┌空状态┐ … │
└──────┴───────────────────────────────────────────────────────────┘
```
- 卡片网格列间距 / 行间距 `--spacing-5` (24px)
- 顶部工具条:搜索输入(§13)+ 搜索主按钮(§5.2.1)+ "添加"主按钮;右侧排序下拉(§13)
- "+ 添加任务集"为主按钮(红 `#F5222D` + 白字 + 加号图标,§5.3.2)
---
> 返回 [规范目录](README.md)
@@ -1,85 +0,0 @@
# 浅红色 UI 规范
浅色主题设计规范:以白 / 浅灰作大面积背景、深色作正文、**红色(`#F5222D`)为品牌强调主色**。
> 全文按主题拆分为 6 篇,章节号(§1 ~ §28)全局唯一,跨篇引用(如"沿用 §5.2.1")按下表定位即可。
## 篇目导航
| 篇 | 文件 | 内容 |
|----|------|------|
| 一、基础样式 | [01-基础样式.md](01-基础样式.md) | §1 色彩 · §2 字体 · §3 阴影 · §4 圆角 · §6 间距 · §20 语义状态色 |
| 二、按钮与表单 | [02-按钮与表单.md](02-按钮与表单.md) | §5 按钮 · §12 表单 · §13 输入框 · §14 选择按钮 · §15 标签输入框 · §16 上传 |
| 三、导航 | [03-导航.md](03-导航.md) | §7 导航组件 · §8 面包屑 · §9 页头与分页 · §10 步骤条 · §11 页签与锚点 |
| 四、数据展示 | [04-数据展示.md](04-数据展示.md) | §17 弹窗 · §18 卡片 · §19 折叠面板 · §21 进度条 · §22 表格 · §23 标签 · §24 时间轴 |
| 五、图表 | [05-图表.md](05-图表.md) | §25 图表(通用色序 / 柱 / 折线 / 甘特 / 饼环) |
| 六、页面与布局 | [06-页面与布局.md](06-页面与布局.md) | §26 统计与汇总卡 · §27 实体卡片 · §28 综合页面布局示例 |
## 章节速查(§ → 篇)
| § | 章节 | 所在篇 |
|---|------|--------|
| 1 | 色彩 | [一](01-基础样式.md) |
| 2 | 字体 | [一](01-基础样式.md) |
| 3 | 阴影 | [一](01-基础样式.md) |
| 4 | 圆角 | [一](01-基础样式.md) |
| 5 | 按钮 | [二](02-按钮与表单.md) |
| 6 | 间距 | [一](01-基础样式.md) |
| 7 | 导航组件 | [三](03-导航.md) |
| 8 | 面包屑 | [三](03-导航.md) |
| 9 | 页头与分页 | [三](03-导航.md) |
| 10 | 步骤条 | [三](03-导航.md) |
| 11 | 页签与锚点 | [三](03-导航.md) |
| 12 | 表单 | [二](02-按钮与表单.md) |
| 13 | 输入框 | [二](02-按钮与表单.md) |
| 14 | 选择按钮 | [二](02-按钮与表单.md) |
| 15 | 标签输入框 | [二](02-按钮与表单.md) |
| 16 | 上传 | [二](02-按钮与表单.md) |
| 17 | 弹出式窗口 | [四](04-数据展示.md) |
| 18 | 卡片 | [四](04-数据展示.md) |
| 19 | 折叠面板 | [四](04-数据展示.md) |
| 20 | 语义状态色 | [一](01-基础样式.md) |
| 21 | 进度条 | [四](04-数据展示.md) |
| 22 | 表格 | [四](04-数据展示.md) |
| 23 | 标签 | [四](04-数据展示.md) |
| 24 | 时间轴 | [四](04-数据展示.md) |
| 25 | 图表 | [五](05-图表.md) |
| 26 | 统计与汇总卡 | [六](06-页面与布局.md) |
| 27 | 实体卡片 | [六](06-页面与布局.md) |
| 28 | 综合页面布局示例 | [六](06-页面与布局.md) |
## 颜色速查
完整色板与编号见 [§1 色彩](01-基础样式.md)。最常用的功能色:
| 角色 | 色值 | 编号 |
|------|------|------|
| 主色 Primary | `#F5222D` | ZS-001-09 |
| 主色 Hover | `#CF1322` | ZS-001 |
| 主色 Active | `#A8071A` | ZS-001-11 |
| 链接 / 信息 | `#0079FE` | ZS-002-09 |
| 正文 | `#222222` | FS-004 |
| 次级文字 | `#575757` | FS-004-09 |
| 备注 / 占位 | `#A6A6A6` | FS-004-07 |
| 禁用文字 | `#C1C1C1` | FS-004-06 |
| 页面背景 | `#FAFAFA` | FS-004-02 |
| 容器背景 | `#FFFFFF` | FS-004-01 |
| 分割线 | `#E8E8E8` | FS-004-04 |
**语义状态色**(成功 / 警告 / 错误 / 信息 / 待更新 / 中性,详见 [§20](01-基础样式.md)):
| 语义 | 文字 | 浅底 | 实填 |
|------|------|------|------|
| 成功(绿) | `#189D72` | `#EEFCF7` | `#2FD69A` |
| 警告(橙) | `#FF9900` | `#FFF6EA` | `#FF9900` |
| 错误(红) | `#F5222D` | `#FEEDED` | `#F5222D` |
| 信息(蓝) | `#0079FE` | `#EAF4FF` | `#0079FE` |
| 待更新(青) | `#45C3F3` | `#EFFAFE` | `#45C3F3` |
| 中性(灰) | `#7F7F7F` | `#F5F5F5` | `#A6A6A6` |
## 通用约定速记
- **圆角**:按钮 / 输入框 / Tag `2px`;卡片 / 分栏 / Modal `4px`(§4)
- **间距阶梯**:4 / 8 / 12 / 16(密)· 24 / 32 / 40 / 56(疏),16 是密的上限、24 是疏的下限(§6)
- **按钮尺寸**:小 `24px` / 正常 `32px`(§5.1)
- **字体**:中文 Noto Sans S Chinese,数字 D-DIN(§2)
-14
View File
@@ -1,14 +0,0 @@
# 浅红色 UI 规范(已拆分)
本规范已按主题拆分到文件夹 **[`浅红色UI规范/`](浅红色UI规范/README.md)**,请从目录进入:
👉 **[浅红色UI规范 / README.md](浅红色UI规范/README.md)**
| 篇 | 文件 |
|----|------|
| 一、基础样式(色彩 / 字体 / 阴影 / 圆角 / 间距 / 语义色) | [01-基础样式.md](浅红色UI规范/01-基础样式.md) |
| 二、按钮与表单 | [02-按钮与表单.md](浅红色UI规范/02-按钮与表单.md) |
| 三、导航 | [03-导航.md](浅红色UI规范/03-导航.md) |
| 四、数据展示 | [04-数据展示.md](浅红色UI规范/04-数据展示.md) |
| 五、图表 | [05-图表.md](浅红色UI规范/05-图表.md) |
| 六、页面与布局 | [06-页面与布局.md](浅红色UI规范/06-页面与布局.md) |
-109
View File
@@ -1,109 +0,0 @@
# AI 智能写作 — 手工测试清单
> 测试环境:后端 `http://localhost:8001`,前端 `http://localhost:5174`
> 每项测试前确认后端已启动(`make dev`)、前端已启动(`pnpm dev`)
---
## T1 — 启动写作任务
| # | 步骤 | 预期结果 |
|---|------|----------|
| 1.1 | 打开 Canvas 页面,点击右上角「AI 写作」按钮 | AIWritingPanel 在右侧展开,显示写作配置表单 |
| 1.2 | 填写:意图「新能源汽车市场趋势分析」,类型「分析报告」,目标读者「行业研究人员」,字数「1200」 | 表单验证通过,提交按钮可点击 |
| 1.3 | 点击「开始写作」 | 按钮变为 loading 状态;时间轴出现第一条「素材收集专家 · 开始检索素材」进度条目 |
| 1.4 | 意图字段留空后点击提交 | 表单校验阻止提交,提示「请填写写作意图」 |
---
## T2 — SSE 进度流(时间轴)
| # | 步骤 | 预期结果 |
|---|------|----------|
| 2.1 | 任务启动后观察时间轴 | 每隔几秒新增进度条目,agent_name 标签分别出现「素材收集专家」「作家」「编辑」 |
| 2.2 | 打开浏览器 DevTools → Network,过滤 `stream` | 看到一个 `text/event-stream` 请求长连接,持续收到 `data:` 帧 |
| 2.3 | 断网 5 秒后恢复 | 面板显示「连接中断」提示(或 error 事件),不崩溃 |
---
## T3 — PAUSE-1 素材确认
| # | 步骤 | 预期结果 |
|---|------|----------|
| 3.1 | 等待素材收集专家完成检索,出现 PAUSE-1 干预卡 | 卡片标题「素材确认」,列出 ≥1 条素材,每条有复选框、标题、来源、相关度分数色条 |
| 3.2 | 全选所有素材,点击「确认素材,继续写作」 | 干预卡禁用,时间轴新增「作家 · 开始规划大纲」 |
| 3.3 | 重新发起任务,PAUSE-1 出现后取消勾选全部素材,点击「重新检索」 | 向后端发送 `action: re_search`;时间轴回到素材收集专家节点重新检索 |
| 3.4 | PAUSE-1 出现后填写「追加关键词」输入框(如「锂电池」)并按 Enter,再点「追加搜索」 | 标签出现「锂电池」;后端收到 `action: append_search, extraKeywords: ["锂电池"]` |
| 3.5 | 验证相关度色条颜色:分数 ≥0.8 绿色 / 0.5–0.8 橙色 / <0.5 红色 | 三种颜色均可在不同素材中观察到 |
---
## T4 — PAUSE-2 大纲确认
| # | 步骤 | 预期结果 |
|---|------|----------|
| 4.1 | PAUSE-2 出现后检查大纲卡 | 显示文章标题 + ≥2 个章节,每节有节标题与要点列表 |
| 4.2 | 点击文章标题,内联编辑改为「2025 新能源趋势报告」,按 Enter | 标题实时变更,不刷新页面 |
| 4.3 | 展开某章节,点击一条要点,修改文字,按 Blur 失焦 | 要点文字更新 |
| 4.4 | 点击「+ 新增章节」,填写章节标题 | 大纲末尾追加新章节 |
| 4.5 | 点击「确认大纲,开始写作」 | 编辑后的大纲随 `action: confirm_outline` 提交;时间轴出现「作家 · 开始撰写草稿」 |
| 4.6 | PAUSE-2 出现后点击「重新规划大纲」 | 显示意见输入框;填写「请增加政策分析章节」后提交;后端收到 `action: re_outline, outlineFeedback: ...` |
---
## T5 — PAUSE-3 草稿确认
| # | 步骤 | 预期结果 |
|---|------|----------|
| 5.1 | 作家完成草稿,PAUSE-3 出现 | 干预卡显示修改次数(如「第 1 稿」);右侧 Artifact 面板同步显示 Markdown 草稿内容 |
| 5.2 | 点击「定稿,提交编辑审核」 | 进入编辑节点;时间轴出现「编辑 · 开始审核」 |
| 5.3 | 点击「提出修改意见」,填写意见后提交 | 作家节点重新生成草稿;修改次数 +1 |
| 5.4 | 连续打回 3 次,观察第 4 次 PAUSE-3 | revisionCount 正确递增显示 |
---
## T6 — PAUSE-4 审核结果确认
| # | 步骤 | 预期结果 |
|---|------|----------|
| 6.1 | 编辑审核完成,PAUSE-4 出现 | ReviewCard 显示:综合得分、事实/逻辑/语言三项分数、及格线(默认 75)、verdict 徽章(通过=绿 / 打回=红)|
| 6.2 | verdict=pass 时,点击「接受审核,定稿」 | 流程结束;时间轴出现「完成」;右侧面板保留最终草稿 |
| 6.3 | verdict=reject 时,查看问题列表 | major 问题排在 minor 前;每条显示类别徽章、位置、问题描述、建议 |
| 6.4 | verdict=reject 时,点击「强制定稿」 | 向后端发送 `action: force_finalize`;流程结束 |
| 6.5 | 添加「额外说明」文字后点击「接受审核,定稿」 | extra_notes 包含在提交的 intervention payload 中(DevTools → Network 验证) |
---
## T7 — 错误与边界场景
| # | 步骤 | 预期结果 |
|---|------|----------|
| 7.1 | 后端未启动时点击「开始写作」 | 面板显示错误提示「无法连接到服务器」,不白屏 |
| 7.2 | 写作进行中点击「停止/重置」 | 时间轴清空,表单恢复可编辑,后端流被中断(Network 连接关闭)|
| 7.3 | 素材列表为空(后端返回 0 条)时 PAUSE-1 | 干预卡显示「暂无素材」提示,「确认」按钮仍可点击 |
| 7.4 | 大纲只有 1 个章节时,点击删除该章节 | 删除按钮禁用或弹出「至少保留一个章节」提示 |
| 7.5 | 弱模型返回格式错误的 JSON(手动 mock 后端) | 前端收到 fallback 结构,不崩溃;时间轴正常继续 |
---
## T8 — 跨阶段状态一致性
| # | 步骤 | 预期结果 |
|---|------|----------|
| 8.1 | PAUSE-2 时展开大纲卡,刷新页面(F5) | 页面恢复;如写作任务 ID 持久化,状态可恢复(否则回到初始状态,均可接受)|
| 8.2 | 同时打开两个浏览器标签,各自启动独立写作任务 | 两个任务互不干扰,各自有独立 task_id |
| 8.3 | 完整跑通一次全流程(T1→T2→T3→T4→T5→T6 pass) | 右侧 Artifact 面板显示完整定稿文章,时间轴所有节点状态为「完成」 |
---
## 后端单元测试(补充确认)
```bash
# 在 offline-backend-20260512/backend/ 下运行
PYTHONPATH=. uv run pytest tests/test_ai_writing_json_parse.py -v # 16 tests
PYTHONPATH=. uv run pytest tests/test_ai_writing_graph.py -v # 19 tests
```
两个测试文件均应全部通过(0 failed)。
npm install @tiptap/core @tiptap/starter-kit @tiptap/extension-underline @tiptap/extension-placeholder
+11 -23
View File
@@ -1,24 +1,12 @@
<!doctype html> <!doctype html>
<html lang="en-US" suppressHydrationWarning> <html lang="en-US">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<script>
(function () {
try {
var t = localStorage.getItem("strategy-theme");
var root = document.documentElement;
root.classList.remove("dark", "light");
if (t === "light") return;
root.classList.add("dark");
} catch (e) {}
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <title>DeerFlow Vite</title>
<title>CM 助手</title> </head>
</head> <body>
<body> <div id="root"></div>
<div id="root"></div> <script type="module" src="/src/main.tsx"></script>
<script type="module" src="/src/main.tsx"></script> </body>
</body> </html>
</html>
+10 -1630
View File
File diff suppressed because it is too large. Load diff
+2 -26
View File
@@ -5,9 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build", "build": "vite build",
"build:sit": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode sit",
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
@@ -19,10 +17,6 @@
"@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1", "@codemirror/lang-python": "^6.2.1",
"@codemirror/language-data": "^6.5.2", "@codemirror/language-data": "^6.5.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/noto-sans-sc": "^5.2.9",
"@langchain/core": "^1.1.15", "@langchain/core": "^1.1.15",
"@langchain/langgraph-sdk": "^1.5.3", "@langchain/langgraph-sdk": "^1.5.3",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
@@ -43,20 +37,12 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2", "@radix-ui/react-use-controllable-state": "^1.2.2",
"@tanstack/react-query": "^5.90.17", "@tanstack/react-query": "^5.90.17",
"@tiptap/core": "^3.23.4",
"@tiptap/extension-bubble-menu": "^3.23.4",
"@tiptap/extension-placeholder": "^3.23.4",
"@tiptap/extension-underline": "^3.23.4",
"@tiptap/pm": "^3.23.4",
"@tiptap/react": "^3.23.4",
"@tiptap/starter-kit": "^3.23.4",
"@types/hast": "^3.0.4", "@types/hast": "^3.0.4",
"@uiw/codemirror-theme-basic": "^4.25.4", "@uiw/codemirror-theme-basic": "^4.25.4",
"@uiw/codemirror-theme-monokai": "^4.25.4", "@uiw/codemirror-theme-monokai": "^4.25.4",
"@uiw/react-codemirror": "^4.25.4", "@uiw/react-codemirror": "^4.25.4",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"ai": "^6.0.33", "ai": "^6.0.33",
"axios": "1.16.1",
"best-effort-json-parser": "^1.2.1", "best-effort-json-parser": "^1.2.1",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -64,12 +50,8 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dayjs": "^1.11.21",
"dnd-kit": "^0.0.2",
"docx": "^9.5.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"file-saver": "^2.0.5",
"gsap": "^3.13.0", "gsap": "^3.13.0",
"hast": "^1.0.0", "hast": "^1.0.0",
"katex": "^0.16.28", "katex": "^0.16.28",
@@ -80,10 +62,8 @@
"ogl": "^1.0.11", "ogl": "^1.0.11",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.4.1", "react-resizable-panels": "^4.4.1",
"react-router-dom": "^7.9.4", "react-router-dom": "^7.9.4",
"react-syntax-highlighter": "^16.1.1",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
@@ -92,8 +72,6 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"streamdown": "1.4.0", "streamdown": "1.4.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tdesign-react": "^1.12.3",
"tiptap-markdown": "^0.9.0",
"tokenlens": "^1.3.1", "tokenlens": "^1.3.1",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"use-stick-to-bottom": "^1.1.1", "use-stick-to-bottom": "^1.1.1",
@@ -102,7 +80,6 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.15", "@tailwindcss/postcss": "^4.0.15",
"@types/file-saver": "^2.0.7",
"@types/gsap": "^3.0.0", "@types/gsap": "^3.0.0",
"@types/node": "^20.14.10", "@types/node": "^20.14.10",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
@@ -112,8 +89,7 @@
"tailwindcss": "^4.0.15", "tailwindcss": "^4.0.15",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"vite": "^6.0.0", "vite": "^6.0.0"
"vite-plugin-source-identifier": "^1.1.2"
}, },
"packageManager": "pnpm@10.26.2" "packageManager": "pnpm@10.26.2"
} }
+5 -5
View File
@@ -1,5 +1,5 @@
export default { export default {
plugins: { plugins: {
"@tailwindcss/postcss": {}, "@tailwindcss/postcss": {},
}, },
}; };
-29
View File
@@ -1,29 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="智能助手中心">
<defs>
<!-- 品牌玫红渐变填充的圆角磁贴 -->
<linearGradient id="tile" x1="6" y1="4" x2="58" y2="60" gradientUnits="userSpaceOnUse">
<stop stop-color="#FB7185"/>
<stop offset="1" stop-color="#E11D48"/>
</linearGradient>
<!-- 气泡内 AI 闪光星:玫红渐变,与白色气泡形成对比 -->
<linearGradient id="spark" x1="24" y1="18" x2="40" y2="36" gradientUnits="userSpaceOnUse">
<stop stop-color="#FB7185"/>
<stop offset="1" stop-color="#E11D48"/>
</linearGradient>
</defs>
<!-- 磁贴背景 -->
<rect width="64" height="64" rx="15" fill="url(#tile)"/>
<!-- 辅助小闪光,点出「多种智能工具」 -->
<path fill="#FFFFFF" fill-opacity="0.85"
d="M47 43 Q47.6 46.4 50.5 47 Q47.6 47.6 47 51 Q46.4 47.6 43.5 47 Q46.4 46.4 47 43 Z"/>
<!-- 助手对话气泡(白色)+ 尾巴 -->
<rect x="12" y="13" width="40" height="28" rx="10" fill="#FFFFFF"/>
<path fill="#FFFFFF" d="M21 40 H31 L19 49 Z"/>
<!-- 气泡内的 AI 闪光星 -->
<path fill="url(#spark)"
d="M32 17.5 Q33.2 25.8 41.5 27 Q33.2 28.2 32 36.5 Q30.8 28.2 22.5 27 Q30.8 25.8 32 17.5 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

+106 -47
View File
@@ -2,57 +2,70 @@ import { useMemo } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { ThemeProvider as NextThemesProvider } from "next-themes"; import { ThemeProvider as NextThemesProvider } from "next-themes";
import { FontScaleApplier } from "@/components/font-scale";
import { QueryClientProvider } from "@/components/query-client-provider"; import { QueryClientProvider } from "@/components/query-client-provider";
import { AuthSyncWatcher } from "@/core/auth/sync"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { CommandPalette } from "@/components/workspace/command-palette";
import { Toaster } from "sonner";
import { I18nProvider } from "@/core/i18n/context"; import { I18nProvider } from "@/core/i18n/context";
import { detectLocale } from "@/core/i18n/locale"; import { detectLocale } from "@/core/i18n/locale";
import { ChatProvider } from "@/contexts/ChatContext"; import { SubtasksProvider } from "@/core/tasks/context";
import { ThemeProvider } from "@/contexts/ThemeContext"; import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { HashRouter, useLocation } from "react-router-dom"; import { HashRouter, useLocation } from "react-router-dom";
import LandingPage from "./pages/LandingPage"; import LandingPage from "./pages/LandingPage";
import ChatsPage from "./pages/ChatsPage";
import ChatPage from "./pages/ChatPage";
import AgentsPage from "./pages/AgentsPage";
import AgentChatPage from "./pages/AgentChatPage";
import NewAgentPage from "./pages/NewAgentPage";
import LoginPage from "./pages/LoginPage"; import LoginPage from "./pages/LoginPage";
import PageRoutes from "./pages/PageRoutes"; import ScheduledTaskRunDetailPage from "./pages/ScheduledTaskRunDetailPage";
import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage"; import ScheduledTasksPage from "./pages/ScheduledTasksPage";
import PublicSharePage from "./pages/PublicSharePage";
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
import EmbedChatPage from "./pages/EmbedChatPage";
import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage"; import { NotFoundPage } from "./pages/NotFoundPage";
function AppProviders({ children }: { children: React.ReactNode }) { function AppProviders({ children }: { children: React.ReactNode }) {
const locale = useMemo(() => detectLocale(), []); const locale = useMemo(() => detectLocale(), []);
const location = useLocation(); const location = useLocation();
const forcedTheme = location.pathname === "/landing" ? "light" : undefined; const forcedTheme = location.pathname === "/landing" ? "dark" : undefined;
return ( return (
<NextThemesProvider <NextThemesProvider
attribute="class" attribute="class"
storageKey="strategy-theme" enableSystem
defaultTheme="light"
enableSystem={false}
disableTransitionOnChange disableTransitionOnChange
forcedTheme={forcedTheme} forcedTheme={forcedTheme}
> >
{/* QueryClientProvider must wrap ThemeProvider because the custom <I18nProvider initialLocale={locale}>
ThemeProvider reads the admin-configured default via useQuery. */} <QueryClientProvider>{children}</QueryClientProvider>
<QueryClientProvider> </I18nProvider>
<I18nProvider initialLocale={locale}>
<ThemeProvider>
<ChatProvider>
<AuthSyncWatcher />
<FontScaleApplier />
{children}
</ChatProvider>
</ThemeProvider>
</I18nProvider>
</QueryClientProvider>
</NextThemesProvider> </NextThemesProvider>
); );
} }
function WorkspaceLayout({ children }: { children: React.ReactNode }) {
return (
<SidebarProvider className="h-screen" defaultOpen>
<WorkspaceSidebar />
<SidebarInset className="min-w-0">{children}</SidebarInset>
<CommandPalette />
<Toaster position="top-center" />
</SidebarProvider>
);
}
function ChatRuntime({ children }: { children: React.ReactNode }) {
return (
<SubtasksProvider>
<ArtifactsProvider>
<PromptInputProvider>{children}</PromptInputProvider>
</ArtifactsProvider>
</SubtasksProvider>
);
}
export function App() { export function App() {
return ( return (
<HashRouter> <HashRouter>
@@ -62,32 +75,78 @@ export function App() {
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/login/:username" element={<LoginPage />} /> <Route path="/login/:username" element={<LoginPage />} />
<Route path="/landing" element={<LandingPage />} /> <Route path="/landing" element={<LandingPage />} />
{/* Public read-only conversation viewer — no authentication. */}
<Route path="/share/:shareCode" element={<PublicSharePage />} />
{/* Public read-only roundtable share — landing + full result report. */}
<Route <Route
path="/roundtable-share/:shareCode" path="/workspace"
element={<PublicRoundtableSharePage />} element={<Navigate to="/workspace/chats/new" replace />}
/> />
<Route <Route
path="/roundtable-share/:shareCode/report" path="/workspace/chats"
element={<PublicRoundtableSharePage report />}
/>
{/* Public read-only scheduled-task runs viewer — no authentication. */}
<Route
path="/public/scheduled-tasks/:taskId"
element={<PublicScheduledTaskPage />}
/>
{/* Embedded workspace Q&A — no page/workspace sidebars; parent passes message/username/theme */}
<Route
path="/embed/chats/:thread_id"
element={ element={
<ChatRuntime> <WorkspaceLayout>
<EmbedChatPage /> <ChatsPage />
</ChatRuntime> </WorkspaceLayout>
}
/>
<Route
path="/workspace/chats/:thread_id"
element={
<WorkspaceLayout>
<ChatRuntime>
<ChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="/workspace/agents"
element={
<WorkspaceLayout>
<AgentsPage />
</WorkspaceLayout>
}
/>
<Route
path="/workspace/scheduled"
element={<Navigate to="/workspace/scheduled/tasks" replace />}
/>
<Route
path="/workspace/scheduled/tasks"
element={
<WorkspaceLayout>
<ScheduledTasksPage />
</WorkspaceLayout>
}
/>
<Route
path="/workspace/scheduled/runs/:run_id"
element={
<WorkspaceLayout>
<ScheduledTaskRunDetailPage />
</WorkspaceLayout>
}
/>
<Route
path="/workspace/agents/new"
element={
<WorkspaceLayout>
<NewAgentPage />
</WorkspaceLayout>
}
/>
<Route
path="/workspace/agents/:agent_id/chats"
element={<Navigate to="/workspace/agents" replace />}
/>
<Route
path="/workspace/agents/:agent_id/chats/:thread_id"
element={
<WorkspaceLayout>
<ChatRuntime>
<AgentChatPage />
</ChatRuntime>
</WorkspaceLayout>
} }
/> />
<Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
</AppProviders> </AppProviders>
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:url(#a);}</style><linearGradient id="a" x1="0.277" y1="0.03" x2="0.725" y2="1.083" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#92eeff"/><stop offset="1" stop-color="#00c7ff"/></linearGradient></defs><g transform="translate(-128.5 -117)"><rect class="a" width="16" height="16" transform="translate(128.5 117)"/><path class="b" d="M4,0,8,5H0Z" transform="translate(140.5 128) rotate(180)"/></g></svg>

Before

Width:  |  Height:  |  Size: 589 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="84.499" height="70" viewBox="0 0 84.499 70"><defs><style>.a{fill:url(#a);}</style><linearGradient id="a" x1="0.043" x2="0.978" y2="1.016" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#0062d9" stop-opacity="0.6"/><stop offset="1" stop-color="#0062d9" stop-opacity="0.302"/></linearGradient></defs><path class="a" d="M8855.277-1940.39l-.054-70h78v31.183l6.5,5.257-6.5,5.259v28.3Z" transform="translate(-8855.224 2010.389)"/></svg>

Before

Width:  |  Height:  |  Size: 536 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="77" height="45" viewBox="0 0 77 45"><defs><style>.a{fill:url(#a);}</style><pattern id="a" preserveAspectRatio="none" width="100%" height="100%" viewBox="0 0 340 45"><image width="340" height="45" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAVQAAAAtCAYAAADxyJMIAAAABHNCSVQICAgIfAhkiAAAIABJREFUeF7tnd2O5MZ1x5vdPTO7WkmGhMiQDQS5NODbvEBeIgbygcRwjOQur+D1K+QiQC4CBMld8hJ+CgO+DpAIcixblvZjZnqG+f/PqcM6Vawii+zuda+0XLSaXawvFos//s85xVHX9/3mXFvXdeeq+htSb/9mB4iX+vlmfZu/PKLs1BX7/ET1fndznsn8w5X1Pkc5jvfPV5ZfPcu784zD6v683QWXMLJbknnpsHy7gXpmWP5sIYRaYHgqsC2dKG97/haQL4Xy2SH8Drqt024JI98BtXVUi/nOBM0pWLaAkX09JRx/sBDe+Vj99sjyR10jV/ijI5Xir44s78+jBcLMPwXis0H3HWz9pXoH1FPdgFLPCaF5DCjXAvLrALM/rQzKuWH36QnH7xTX9bMzw2IO2muhPAfgNwbeM4/fKa7xiet4B9RVAzq4ndb7GL3nquSrnFKXrcBcqhbngHlO4P3+Qp3oH54xcDAH7Dng5nO3FcBTwC3Blv5d29bPeNfbby5o3wF1EVBH/vtl0+tn0ti4TA2ex4JzDpD5uc8B81TQe3FhSnTRHEDmZycCwhys54Cb97sG4GNBW1O0R7sRTjSOS6/fGfO/A2pxcJsDnykcFZjlLYdmGZZj2JZUZisof4iufJHBaykUW+D38QJ1+fIth6ld3fcWwOCLTOUSyPm4zkHa4MvrVwNxK4BL4F0L3bO4DxaM7RnhuKbqd0BdEzuo+TdLSnNOZY6B2W3mgFlSki2grMFxCoinAOBtBbg3ATS14zajP3FT+/WJgPzE3bS/XnPruDJ2HsdUUwL0/6DC78tDsfyEb4Vw3q858K4B7tiN0CNI5rd4Dj+fHajC+S4zBmdbOFOGbxlQm5VnHO4SPH85HNar/Pno6kwrzTZgah05KKlO8rQSKEuQXArHGugIuBawEVot+fzw3U5E+W/w9Js67utZkrd0c7H8kq31XJmvBvClYC5BuATfHLylOWSq92P0L87v8gh44I6V7XjcctiWVG0K2caxvzzIfguA2nht8qkzDVLNXVef3eYHSYVUnbaNZ0GuOBWY5dmSw/M18lLF+C0Hp4GxdMPWoPnBCZRgK/xmwPXRswUuhSUQDHl/+2JF4KkE7KUQLvX1q4q5O3Xt8mM5aKl0nxTcDrWxyl0KU4o2wlVvtCnXQTtYV9y0lwHXbyhQF16PHJ7+Ca3qs361vMk+pTw9NKPCTOtNYanHcqU5pTJPBccZELYC7vFEQF3ByDdSZBsAaudpv+canwX4UjAvgbB1Lodui7K1sh64Ebbjm25ayVptWu67btRyBbs4+PWHg+s3AKiL4ZnOd+/3HJvuqao01VkDZ6o0O5jmaXn75cGpwNSMY1imM6MEzDklWYNaTfUh/3fQFcLh5EC8v5BF+3PEaz1+tdAt0Fjvl1NAranpOQiXoDvnYvDQ9cBNXQjpDViGLc885jPQ/koGpH4De0Wb+mM3y1/RfTOQfQuBugCgpah7C0BT1elvA70qn2brUBWcZbX5x6H4i2C2KjTHV/e22yb3WwRleSaUQFmA5HeOUYmNAOyfnsA90Aibty7bl7jY141R6yMAXYRwCb5LwVuDrsH2Wf8o1+S/8RkHyfRGmXch9JuP3JWtuQ2mAEswzwe7XCPnAexbBNRGkFq25xi7sXO9KwSQNhP+TsJzPPI+KGRqk+A0aD5DJ14EyHrV6RXmJw6qMXAT24og1LQMlgZKb2bOKsoaIClJC1t/dxpQ9nfjB0h3rcqkdEyeTuH4VJ5TwbWlran++vLH9KkKXkC5uM0AuDQ3RuDNoVsCbm1FxE2AKTsXAYslYWEaP4PflqDV+Rtv4AjYmOb9tPkqA1WyMhWGPe8isES6Cp6HH828bM7YdGkvHKiNEOWp5mrUYDq1bGnKhDcVOl6O1GENoQ1uBx+n7kdwlsx0TUvN81Z4Sr4mpTmjKE+pJGsgbJp1LlN/OG/AaWl/avm7/YrAVaGyU8GXVXevZpRvg+IdAZaK9Ius4zVV+1Wn6pRbVLLpTWugVZeBHist9zLIlgJg0UUw5R7Qfoz9rwumwPFwvVCgNoLUQ3QcSJI5N4xmhOd4gE2FlnyeCk8LEOV+Tk3PfZs1c11VZ644c2DmVzX+rgDzGFC2gHE19J4UFO5XGIEPFszxS83K87DNzud1o2mfnVMLrI8BcRW8a4DrFe3NJgJVz0lv3NRf2zvY2pn3ULS2vnYtZLW9qF7jqNYCXM0ugfVgvSCgNtw5BtDclC8Fk8wPGpcraQPe/xkBqsfSYBFTOihP65jBU9O5jddjhjwO5GqqT0EzB+bwO4By8dWdguQiOJaA2HCZzpWlb/TptrbfNQClta6j8xHQV21AngPwWviOwDs9Pn2ibhW0erNERRuVUVx/G/Ikil8By/JfqCdI6nkW0mxwP3THPstuizTQlV6O8XItPX4GwF42UOtm/Hj++nWfPgofAcpF8ikczXS3pUlT8PzAKVWrxfyc0b85D84IBZ0tGtCpq1J3rAbKWUieAYwnhdvNhUf/b08XzT8LxGeUcQ3AS8BbgG1ZnWIyB9BGmBpsU9fBWM3mboMcsgrYuNVWFKQBLl9C2zQFO145MOZKkjKvbS4XqK0wTU35eMbRB5oO0liFjpcrmQk/XpLU4U0dbonZnvk3/ah3m1RVdc48T/OlvewyeEbV2uJzPAFAVwHz0sE4c7uc/PAKEJ8EuA2uh2MhC8CmQL1KzP/EZwfAat7oLoAbYPSQ6gdXQe1FhXTpluiR4Zqpet1sch+swTVforUaqvH2L82XywNqbtaXzXk9F2/Opwvn47nWVaiOzG1gVWk9p19yFFRogOdYUdbBmV8BVaYa+U6g6pRmWbEeCcpmSP4BwDirsk9Ou7YK58zrtloW5KKZ/XVb/qPhu0LZOlWbBzrUhNNAWTxWAe2gYvX+rLsKzB9bh6yW9+o1Va5xMCNcx0GaHLAr3QGXA9Tn4bxjdD6dVbWgEpXo+N12vmGk5cevYarvkzB77ca1Dk/rh1a4AJwllRnAYcCM4FRYjtO19aqtMQvJM8BxNfyusyCV+Qzvj1ue1T9Muwy63elM9mRS3rX5PHM8ngXSM2p4NXwr0MU5jEz+AmxjACIFbb9J/bOSb/DJpko2AteWb/FvIUwBtuR7bfW5rlKu8fa8HKD+WT7t8JsQrQeVIkjjMiYFafSFxgi8+kB1K8NzDC7n74Spnh8f4Adw+mOqQKNpHiEZFWbN3E+A6mA5BuqJQNkExxyEhUuVJ81BrqGKtzrLIog3gPlkEJ4A70Lo4g/fDA+UCM4UtHrT6Jpirwptv098swrZJF8Bsj7oFVcU3AzFbAVBBLGpV3ML8IgCNrZF9Tq1YsD7WyfU65LFdef9f0oZUPn9v+FeSk16TfSRefpDoxKNNyDNeKrQX0O5GEjp+/yYMO0677MeFsN75akL3eHHTCE6Y6ZzOVAEYqq6cuU5AmqAZ0x/H3VlAaylhKnC8pmrqUEdrobjIVOOT9HuHpM4T6+dWK48qTRn1OjG1OhcvrzNNSr2VVYJz23F1gRfvwLgRbmRo6FbcDdMQLYESK5UMOUKn+jQz971LcLUvbxBuA0vNnyZuAwkv3tJoRcs2HrZG2mD/le757lMKw6QD2ylbgHNk75QoGnfw+cX2RinS7H0YAGslwPUf3AnYCAtReipRhWiCh9To+YLZZpF5DWApBsGs7A4ntCSrRIsGvk6R8ozNdVjvzL/qLRxv/Gvl6aQVcU5p1yzq5wo4dGxTYOyXA1L35oHJKGZb0vhFsr3rt4uwMqnFVratObLy1q5Up3FtDkAe9iuBK1vtwm6E0p3NWzrirZ3sC2qT7uM8q1qNsI0fVECMI3H3HlHBVtzEUTrlIEu3fw6WINr+ncJbEkWX4uVW1PKmVugtkqgAaqXA9QfuWFUv2g6jw2kClA9GANKMa9F4fk08kC1MgGgUoea8VLfhxzV7BXJzN/JP4FWU5oJCAumuh5PoVmC51i5qutgbPLPwPJoUJYgOQHGOdClF3PBr+s39CbV3ZJbob3/k6A2KJ9I6cpEngN9BborgMu/TTsAMoyI3pyp6V6HbR2yg6vg91qx1uHfDIuAjfVz5YAB1q8ioHqV+89dY32xIILV2iBgvb81XSWg9UwEsJbMovOa/P84AVGeRFSi0Yy3eU1wKkBT8JThKdec//nA+T4BzyHdgTOmlRWnh11s+2ZCiWaBp2qQ6rovqVmdXUsV35yJfT0G9ipAtsDv0BiAemIX9w1/v25sb98QkFoB6SKA72ZcCQsV8CR024FbB6WCdgTbgqItAtmrWe8q+OrawVUvUw/I6je3q+w4FSsDXKZcrQwBG5WrpipcU5eNh2tJtRYCWP3zxvlDCC2JYLVXqzm7H4cScZlTHlyK78Kb8jSfiFei5nd06pM1B3hKUwGesg94WleL5nbm2/RKU8uH7le+hzxoMwWkgqXbvKen78arC8Csmf/joa0B81hQ1gC5EIr9Q6PKfFgIW6t3h5uG+/y2jWC8Cmk+H4+XoLnDzZS3z7SGrbN2j4XxAgAvAu8pgPsyDER8kKiKVNO9DMZxOouYsk3KzKpap2a9mgZk07YNsApX3Uyt5urVg5X54uuwXrmqWyBLine truncated

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,158 +1,158 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { type LucideIcon, XIcon } from "lucide-react"; import { type LucideIcon, XIcon } from "lucide-react";
import { forwardRef, type ComponentProps, type HTMLAttributes } from "react"; import { forwardRef, type ComponentProps, type HTMLAttributes } from "react";
export type ArtifactProps = HTMLAttributes<HTMLDivElement>; export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
export const Artifact = ({ className, ...props }: ArtifactProps) => ( export const Artifact = ({ className, ...props }: ArtifactProps) => (
<div <div
className={cn( className={cn(
"flex flex-col overflow-hidden rounded-[1.35rem] border border-border bg-background shadow-[0_24px_60px_-28px_rgba(15,23,42,0.16)] ring-1 ring-black/3", "flex flex-col overflow-hidden rounded-[1.35rem] border border-[#ece7dd] bg-white shadow-[0_24px_60px_-28px_rgba(15,23,42,0.16)] ring-1 ring-black/3",
className, className,
)} )}
{...props} {...props}
/> />
); );
export type ArtifactHeaderProps = HTMLAttributes<HTMLDivElement>; export type ArtifactHeaderProps = HTMLAttributes<HTMLDivElement>;
export const ArtifactHeader = ({ export const ArtifactHeader = ({
className, className,
...props ...props
}: ArtifactHeaderProps) => ( }: ArtifactHeaderProps) => (
<div <div
className={cn( className={cn(
"flex items-center justify-between border-b border-border bg-background px-4 py-3", "flex items-center justify-between border-b border-[#efe9de] bg-white px-4 py-3",
className, className,
)} )}
{...props} {...props}
/> />
); );
export type ArtifactCloseProps = ComponentProps<typeof Button>; export type ArtifactCloseProps = ComponentProps<typeof Button>;
export const ArtifactClose = ({ export const ArtifactClose = ({
className, className,
children, children,
size = "sm", size = "sm",
variant = "ghost", variant = "ghost",
...props ...props
}: ArtifactCloseProps) => ( }: ArtifactCloseProps) => (
<Button <Button
className={cn( className={cn(
"text-muted-foreground hover:text-foreground size-8 p-0", "text-muted-foreground hover:text-foreground size-8 p-0",
className, className,
)} )}
size={size} size={size}
type="button" type="button"
variant={variant} variant={variant}
{...props} {...props}
> >
{children ?? <XIcon className="size-4" />} {children ?? <XIcon className="size-4" />}
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</Button> </Button>
); );
export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>; export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>;
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => ( export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
<div <div
className={cn( className={cn(
"text-foreground text-sm font-semibold tracking-tight", "text-foreground text-sm font-semibold tracking-tight",
className, className,
)} )}
{...props} {...props}
/> />
); );
export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>; export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
export const ArtifactDescription = ({ export const ArtifactDescription = ({
className, className,
...props ...props
}: ArtifactDescriptionProps) => ( }: ArtifactDescriptionProps) => (
<p className={cn("text-muted-foreground text-sm", className)} {...props} /> <p className={cn("text-muted-foreground text-sm", className)} {...props} />
); );
export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>; export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>;
export const ArtifactActions = ({ export const ArtifactActions = ({
className, className,
...props ...props
}: ArtifactActionsProps) => ( }: ArtifactActionsProps) => (
<div className={cn("flex items-center gap-1.5", className)} {...props} /> <div className={cn("flex items-center gap-1.5", className)} {...props} />
); );
export type ArtifactActionProps = ComponentProps<typeof Button> & { export type ArtifactActionProps = ComponentProps<typeof Button> & {
tooltip?: string; tooltip?: string;
label?: string; label?: string;
icon?: LucideIcon; icon?: LucideIcon;
}; };
export const ArtifactAction = ({ export const ArtifactAction = ({
tooltip, tooltip,
label, label,
icon: Icon, icon: Icon,
children, children,
className, className,
size = "sm", size = "sm",
variant = "ghost", variant = "ghost",
...props ...props
}: ArtifactActionProps) => { }: ArtifactActionProps) => {
const button = ( const button = (
<Button <Button
className={cn( className={cn(
"text-muted-foreground hover:text-foreground hover:bg-accent/70 size-8 rounded-full p-0 transition-all duration-200 hover:scale-[1.03]", "text-muted-foreground hover:text-foreground hover:bg-accent/70 size-8 rounded-full p-0 transition-all duration-200 hover:scale-[1.03]",
className, className,
)} )}
size={size} size={size}
type="button" type="button"
variant={variant} variant={variant}
{...props} {...props}
> >
{Icon ? <Icon className="size-4" /> : children} {Icon ? <Icon className="size-4" /> : children}
<span className="sr-only">{label || tooltip}</span> <span className="sr-only">{label || tooltip}</span>
</Button> </Button>
); );
if (tooltip) { if (tooltip) {
return ( return (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger> <TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{tooltip}</p> <p>{tooltip}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
); );
} }
return button; return button;
}; };
export type ArtifactContentProps = HTMLAttributes<HTMLDivElement>; export type ArtifactContentProps = HTMLAttributes<HTMLDivElement>;
export const ArtifactContent = forwardRef<HTMLDivElement, ArtifactContentProps>( export const ArtifactContent = forwardRef<HTMLDivElement, ArtifactContentProps>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"min-h-0 flex-1 overflow-auto bg-background p-4", "min-h-0 flex-1 overflow-auto bg-white p-4",
className, className,
)} )}
{...props} {...props}
/> />
), ),
); );
ArtifactContent.displayName = "ArtifactContent"; ArtifactContent.displayName = "ArtifactContent";
@@ -1,22 +1,22 @@
import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react"; import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
type CanvasProps = ReactFlowProps & { type CanvasProps = ReactFlowProps & {
children?: ReactNode; children?: ReactNode;
}; };
export const Canvas = ({ children, ...props }: CanvasProps) => ( export const Canvas = ({ children, ...props }: CanvasProps) => (
<ReactFlow <ReactFlow
deleteKeyCode={["Backspace", "Delete"]} deleteKeyCode={["Backspace", "Delete"]}
fitView fitView
panOnDrag={false} panOnDrag={false}
panOnScroll panOnScroll
selectionOnDrag={true} selectionOnDrag={true}
zoomOnDoubleClick={false} zoomOnDoubleClick={false}
{...props} {...props}
> >
<Background bgColor="var(--sidebar)" /> <Background bgColor="var(--sidebar)" />
{children} {children}
</ReactFlow> </ReactFlow>
); );
@@ -1,239 +1,239 @@
"use client"; "use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state"; import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
BrainIcon, BrainIcon,
ChevronDownIcon, ChevronDownIcon,
DotIcon, DotIcon,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import type { ComponentProps, ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
import { import {
createContext, createContext,
isValidElement, isValidElement,
memo, memo,
useContext, useContext,
useMemo, useMemo,
} from "react"; } from "react";
type ChainOfThoughtContextValue = { type ChainOfThoughtContextValue = {
isOpen: boolean; isOpen: boolean;
setIsOpen: (open: boolean) => void; setIsOpen: (open: boolean) => void;
}; };
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>( const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
null, null,
); );
const useChainOfThought = () => { const useChainOfThought = () => {
const context = useContext(ChainOfThoughtContext); const context = useContext(ChainOfThoughtContext);
if (!context) { if (!context) {
throw new Error( throw new Error(
"ChainOfThought components must be used within ChainOfThought", "ChainOfThought components must be used within ChainOfThought",
); );
} }
return context; return context;
}; };
export type ChainOfThoughtProps = ComponentProps<"div"> & { export type ChainOfThoughtProps = ComponentProps<"div"> & {
open?: boolean; open?: boolean;
defaultOpen?: boolean; defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
}; };
export const ChainOfThought = memo( export const ChainOfThought = memo(
({ ({
className, className,
open, open,
defaultOpen = false, defaultOpen = false,
onOpenChange, onOpenChange,
children, children,
...props ...props
}: ChainOfThoughtProps) => { }: ChainOfThoughtProps) => {
const [isOpen, setIsOpen] = useControllableState({ const [isOpen, setIsOpen] = useControllableState({
prop: open, prop: open,
defaultProp: defaultOpen, defaultProp: defaultOpen,
onChange: onOpenChange, onChange: onOpenChange,
}); });
const chainOfThoughtContext = useMemo( const chainOfThoughtContext = useMemo(
() => ({ isOpen, setIsOpen }), () => ({ isOpen, setIsOpen }),
[isOpen, setIsOpen], [isOpen, setIsOpen],
); );
return ( return (
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}> <ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
<div className={cn("not-prose", className)} {...props}> <div className={cn("not-prose", className)} {...props}>
{children} {children}
</div> </div>
</ChainOfThoughtContext.Provider> </ChainOfThoughtContext.Provider>
); );
}, },
); );
export type ChainOfThoughtHeaderProps = ComponentProps< export type ChainOfThoughtHeaderProps = ComponentProps<
typeof CollapsibleTrigger typeof CollapsibleTrigger
> & { > & {
icon?: React.ReactElement; icon?: React.ReactElement;
}; };
export const ChainOfThoughtHeader = memo( export const ChainOfThoughtHeader = memo(
({ className, children, icon, ...props }: ChainOfThoughtHeaderProps) => { ({ className, children, icon, ...props }: ChainOfThoughtHeaderProps) => {
const { isOpen, setIsOpen } = useChainOfThought(); const { isOpen, setIsOpen } = useChainOfThought();
return ( return (
<Collapsible onOpenChange={setIsOpen} open={isOpen}> <Collapsible onOpenChange={setIsOpen} open={isOpen}>
<CollapsibleTrigger <CollapsibleTrigger
className={cn( className={cn(
"text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors", "text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors",
className, className,
)} )}
{...props} {...props}
> >
{icon ?? <BrainIcon className="size-4" />} {icon ?? <BrainIcon className="size-4" />}
<span className="flex-1 text-left"> <span className="flex-1 text-left">
{children ?? "Chain of Thought"} {children ?? "Chain of Thought"}
</span> </span>
<ChevronDownIcon <ChevronDownIcon
className={cn( className={cn(
"size-4 transition-transform", "size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0", isOpen ? "rotate-180" : "rotate-0",
)} )}
/> />
</CollapsibleTrigger> </CollapsibleTrigger>
</Collapsible> </Collapsible>
); );
}, },
); );
export type ChainOfThoughtStepProps = ComponentProps<"div"> & { export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
icon?: LucideIcon | React.ReactElement; icon?: LucideIcon | React.ReactElement;
label: ReactNode; label: ReactNode;
description?: ReactNode; description?: ReactNode;
status?: "complete" | "active" | "pending"; status?: "complete" | "active" | "pending";
}; };
export const ChainOfThoughtStep = memo( export const ChainOfThoughtStep = memo(
({ ({
className, className,
icon: Icon = DotIcon, icon: Icon = DotIcon,
label, label,
description, description,
status = "complete", status = "complete",
children, children,
...props ...props
}: ChainOfThoughtStepProps) => { }: ChainOfThoughtStepProps) => {
const statusStyles = { const statusStyles = {
complete: "text-muted-foreground", complete: "text-muted-foreground",
active: "text-foreground", active: "text-foreground",
pending: "text-muted-foreground/50", pending: "text-muted-foreground/50",
}; };
return ( return (
<div <div
className={cn( className={cn(
"flex gap-2 text-sm", "flex gap-2 text-sm",
statusStyles[status], statusStyles[status],
"fade-in-0 slide-in-from-top-2 animate-in", "fade-in-0 slide-in-from-top-2 animate-in",
className, className,
)} )}
{...props} {...props}
> >
<div className="relative mt-0.5"> <div className="relative mt-0.5">
{isValidElement(Icon) ? Icon : <Icon className="size-4" />} {isValidElement(Icon) ? Icon : <Icon className="size-4" />}
<div className="bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px" /> <div className="bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px" />
</div> </div>
<div className="flex-1 space-y-2 overflow-hidden"> <div className="flex-1 space-y-2 overflow-hidden">
<div>{label}</div> <div>{label}</div>
{description && ( {description && (
<div className="text-muted-foreground text-xs">{description}</div> <div className="text-muted-foreground text-xs">{description}</div>
)} )}
{children} {children}
</div> </div>
</div> </div>
); );
}, },
); );
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">; export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
export const ChainOfThoughtSearchResults = memo( export const ChainOfThoughtSearchResults = memo(
({ className, ...props }: ChainOfThoughtSearchResultsProps) => ( ({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
<div <div
className={cn( className={cn(
"flex flex-wrap items-center gap-2 overflow-x-hidden", "flex flex-wrap items-center gap-2 overflow-x-hidden",
className, className,
)} )}
{...props} {...props}
/> />
), ),
); );
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>; export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
export const ChainOfThoughtSearchResult = memo( export const ChainOfThoughtSearchResult = memo(
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => ( ({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
<Badge <Badge
className={cn("gap-1 px-2 py-0.5 text-xs font-normal", className)} className={cn("gap-1 px-2 py-0.5 text-xs font-normal", className)}
variant="secondary" variant="secondary"
{...props} {...props}
> >
{children} {children}
</Badge> </Badge>
), ),
); );
export type ChainOfThoughtContentProps = ComponentProps< export type ChainOfThoughtContentProps = ComponentProps<
typeof CollapsibleContent typeof CollapsibleContent
>; >;
export const ChainOfThoughtContent = memo( export const ChainOfThoughtContent = memo(
({ className, children, ...props }: ChainOfThoughtContentProps) => { ({ className, children, ...props }: ChainOfThoughtContentProps) => {
const { isOpen } = useChainOfThought(); const { isOpen } = useChainOfThought();
return ( return (
<Collapsible open={isOpen}> <Collapsible open={isOpen}>
<CollapsibleContent <CollapsibleContent
className={cn( className={cn(
"mt-2 space-y-3", "mt-2 space-y-3",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none", "data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
); );
}, },
); );
export type ChainOfThoughtImageProps = ComponentProps<"div"> & { export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
caption?: string; caption?: string;
}; };
export const ChainOfThoughtImage = memo( export const ChainOfThoughtImage = memo(
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => ( ({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
<div className={cn("mt-2 space-y-2", className)} {...props}> <div className={cn("mt-2 space-y-2", className)} {...props}>
<div className="bg-muted relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg p-3"> <div className="bg-muted relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg p-3">
{children} {children}
</div> </div>
{caption && <p className="text-muted-foreground text-xs">{caption}</p>} {caption && <p className="text-muted-foreground text-xs">{caption}</p>}
</div> </div>
), ),
); );
ChainOfThought.displayName = "ChainOfThought"; ChainOfThought.displayName = "ChainOfThought";
ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader"; ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader";
ChainOfThoughtStep.displayName = "ChainOfThoughtStep"; ChainOfThoughtStep.displayName = "ChainOfThoughtStep";
ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults"; ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults";
ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult"; ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult";
ChainOfThoughtContent.displayName = "ChainOfThoughtContent"; ChainOfThoughtContent.displayName = "ChainOfThoughtContent";
ChainOfThoughtImage.displayName = "ChainOfThoughtImage"; ChainOfThoughtImage.displayName = "ChainOfThoughtImage";
@@ -1,71 +1,71 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BookmarkIcon, type LucideProps } from "lucide-react"; import { BookmarkIcon, type LucideProps } from "lucide-react";
import type { ComponentProps, HTMLAttributes } from "react"; import type { ComponentProps, HTMLAttributes } from "react";
export type CheckpointProps = HTMLAttributes<HTMLDivElement>; export type CheckpointProps = HTMLAttributes<HTMLDivElement>;
export const Checkpoint = ({ export const Checkpoint = ({
className, className,
children, children,
...props ...props
}: CheckpointProps) => ( }: CheckpointProps) => (
<div <div
className={cn( className={cn(
"text-muted-foreground flex items-center gap-0.5 overflow-hidden", "text-muted-foreground flex items-center gap-0.5 overflow-hidden",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
<Separator /> <Separator />
</div> </div>
); );
export type CheckpointIconProps = LucideProps; export type CheckpointIconProps = LucideProps;
export const CheckpointIcon = ({ export const CheckpointIcon = ({
className, className,
children, children,
...props ...props
}: CheckpointIconProps) => }: CheckpointIconProps) =>
children ?? ( children ?? (
<BookmarkIcon className={cn("size-4 shrink-0", className)} {...props} /> <BookmarkIcon className={cn("size-4 shrink-0", className)} {...props} />
); );
export type CheckpointTriggerProps = ComponentProps<typeof Button> & { export type CheckpointTriggerProps = ComponentProps<typeof Button> & {
tooltip?: string; tooltip?: string;
}; };
export const CheckpointTrigger = ({ export const CheckpointTrigger = ({
children, children,
className, className,
variant = "ghost", variant = "ghost",
size = "sm", size = "sm",
tooltip, tooltip,
...props ...props
}: CheckpointTriggerProps) => }: CheckpointTriggerProps) =>
tooltip ? ( tooltip ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button size={size} type="button" variant={variant} {...props}> <Button size={size} type="button" variant={variant} {...props}>
{children} {children}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent align="start" side="bottom"> <TooltipContent align="start" side="bottom">
{tooltip} {tooltip}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : (
<Button size={size} type="button" variant={variant} {...props}> <Button size={size} type="button" variant={variant} {...props}>
{children} {children}
</Button> </Button>
); );
@@ -1,178 +1,178 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CheckIcon, CopyIcon } from "lucide-react"; import { CheckIcon, CopyIcon } from "lucide-react";
import { import {
type ComponentProps, type ComponentProps,
createContext, createContext,
type HTMLAttributes, type HTMLAttributes,
useContext, useContext,
useEffect, useEffect,
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki"; import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki";
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & { type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string; code: string;
language: BundledLanguage; language: BundledLanguage;
showLineNumbers?: boolean; showLineNumbers?: boolean;
}; };
type CodeBlockContextType = { type CodeBlockContextType = {
code: string; code: string;
}; };
const CodeBlockContext = createContext<CodeBlockContextType>({ const CodeBlockContext = createContext<CodeBlockContextType>({
code: "", code: "",
}); });
const lineNumberTransformer: ShikiTransformer = { const lineNumberTransformer: ShikiTransformer = {
name: "line-numbers", name: "line-numbers",
line(node, line) { line(node, line) {
node.children.unshift({ node.children.unshift({
type: "element", type: "element",
tagName: "span", tagName: "span",
properties: { properties: {
className: [ className: [
"inline-block", "inline-block",
"min-w-10", "min-w-10",
"mr-4", "mr-4",
"text-right", "text-right",
"select-none", "select-none",
"text-muted-foreground", "text-muted-foreground",
], ],
}, },
children: [{ type: "text", value: String(line) }], children: [{ type: "text", value: String(line) }],
}); });
}, },
}; };
export async function highlightCode( export async function highlightCode(
code: string, code: string,
language: BundledLanguage, language: BundledLanguage,
showLineNumbers = false, showLineNumbers = false,
) { ) {
const transformers: ShikiTransformer[] = showLineNumbers const transformers: ShikiTransformer[] = showLineNumbers
? [lineNumberTransformer] ? [lineNumberTransformer]
: []; : [];
return await Promise.all([ return await Promise.all([
codeToHtml(code, { codeToHtml(code, {
lang: language, lang: language,
theme: "one-light", theme: "one-light",
transformers, transformers,
}), }),
codeToHtml(code, { codeToHtml(code, {
lang: language, lang: language,
theme: "one-dark-pro", theme: "one-dark-pro",
transformers, transformers,
}), }),
]); ]);
} }
export const CodeBlock = ({ export const CodeBlock = ({
code, code,
language, language,
showLineNumbers = false, showLineNumbers = false,
className, className,
children, children,
...props ...props
}: CodeBlockProps) => { }: CodeBlockProps) => {
const [html, setHtml] = useState<string>(""); const [html, setHtml] = useState<string>("");
const [darkHtml, setDarkHtml] = useState<string>(""); const [darkHtml, setDarkHtml] = useState<string>("");
const mounted = useRef(false); const mounted = useRef(false);
useEffect(() => { useEffect(() => {
highlightCode(code, language, showLineNumbers).then(([light, dark]) => { highlightCode(code, language, showLineNumbers).then(([light, dark]) => {
if (!mounted.current) { if (!mounted.current) {
setHtml(light); setHtml(light);
setDarkHtml(dark); setDarkHtml(dark);
mounted.current = true; mounted.current = true;
} }
}); });
return () => { return () => {
mounted.current = false; mounted.current = false;
}; };
}, [code, language, showLineNumbers]); }, [code, language, showLineNumbers]);
return ( return (
<CodeBlockContext.Provider value={{ code }}> <CodeBlockContext.Provider value={{ code }}>
<div <div
className={cn( className={cn(
"group bg-background text-foreground relative size-full overflow-hidden rounded-md border", "group bg-background text-foreground relative size-full overflow-hidden rounded-md border",
className, className,
)} )}
{...props} {...props}
> >
<div className="relative size-full"> <div className="relative size-full">
<div <div
className="[&>pre]:bg-background! [&>pre]:text-foreground! size-full overflow-auto dark:hidden [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:text-sm [&>pre]:whitespace-pre-wrap" className="[&>pre]:bg-background! [&>pre]:text-foreground! size-full overflow-auto dark:hidden [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:text-sm [&>pre]:whitespace-pre-wrap"
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed." // biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: html }} dangerouslySetInnerHTML={{ __html: html }}
/> />
<div <div
className="[&>pre]:bg-background! [&>pre]:text-foreground! hidden size-full overflow-auto dark:block [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:text-sm [&>pre]:whitespace-pre-wrap" className="[&>pre]:bg-background! [&>pre]:text-foreground! hidden size-full overflow-auto dark:block [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:text-sm [&>pre]:whitespace-pre-wrap"
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed." // biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: darkHtml }} dangerouslySetInnerHTML={{ __html: darkHtml }}
/> />
{children && ( {children && (
<div className="absolute top-2 right-2 flex items-center gap-2"> <div className="absolute top-2 right-2 flex items-center gap-2">
{children} {children}
</div> </div>
)} )}
</div> </div>
</div> </div>
</CodeBlockContext.Provider> </CodeBlockContext.Provider>
); );
}; };
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & { export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void; onCopy?: () => void;
onError?: (error: Error) => void; onError?: (error: Error) => void;
timeout?: number; timeout?: number;
}; };
export const CodeBlockCopyButton = ({ export const CodeBlockCopyButton = ({
onCopy, onCopy,
onError, onError,
timeout = 2000, timeout = 2000,
children, children,
className, className,
...props ...props
}: CodeBlockCopyButtonProps) => { }: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
const { code } = useContext(CodeBlockContext); const { code } = useContext(CodeBlockContext);
const copyToClipboard = async () => { const copyToClipboard = async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available")); onError?.(new Error("Clipboard API not available"));
return; return;
} }
try { try {
await navigator.clipboard.writeText(code); await navigator.clipboard.writeText(code);
setIsCopied(true); setIsCopied(true);
onCopy?.(); onCopy?.();
setTimeout(() => setIsCopied(false), timeout); setTimeout(() => setIsCopied(false), timeout);
} catch (error) { } catch (error) {
onError?.(error as Error); onError?.(error as Error);
} }
}; };
const Icon = isCopied ? CheckIcon : CopyIcon; const Icon = isCopied ? CheckIcon : CopyIcon;
return ( return (
<Button <Button
className={cn("shrink-0", className)} className={cn("shrink-0", className)}
onClick={copyToClipboard} onClick={copyToClipboard}
size="icon" size="icon"
variant="ghost" variant="ghost"
{...props} {...props}
> >
{children ?? <Icon size={14} />} {children ?? <Icon size={14} />}
</Button> </Button>
); );
}; };
@@ -1,28 +1,28 @@
import type { ConnectionLineComponent } from "@xyflow/react"; import type { ConnectionLineComponent } from "@xyflow/react";
const HALF = 0.5; const HALF = 0.5;
export const Connection: ConnectionLineComponent = ({ export const Connection: ConnectionLineComponent = ({
fromX, fromX,
fromY, fromY,
toX, toX,
toY, toY,
}) => ( }) => (
<g> <g>
<path <path
className="animated" className="animated"
d={`M${fromX},${fromY} C ${fromX + (toX - fromX) * HALF},${fromY} ${fromX + (toX - fromX) * HALF},${toY} ${toX},${toY}`} d={`M${fromX},${fromY} C ${fromX + (toX - fromX) * HALF},${fromY} ${fromX + (toX - fromX) * HALF},${toY} ${toX},${toY}`}
fill="none" fill="none"
stroke="var(--color-ring)" stroke="var(--color-ring)"
strokeWidth={1} strokeWidth={1}
/> />
<circle <circle
cx={toX} cx={toX}
cy={toY} cy={toY}
fill="#fff" fill="#fff"
r={3} r={3}
stroke="var(--color-ring)" stroke="var(--color-ring)"
strokeWidth={1} strokeWidth={1}
/> />
</g> </g>
); );
@@ -1,408 +1,408 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
HoverCard, HoverCard,
HoverCardContent, HoverCardContent,
HoverCardTrigger, HoverCardTrigger,
} from "@/components/ui/hover-card"; } from "@/components/ui/hover-card";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { LanguageModelUsage } from "ai"; import type { LanguageModelUsage } from "ai";
import { type ComponentProps, createContext, useContext } from "react"; import { type ComponentProps, createContext, useContext } from "react";
import { getUsage } from "tokenlens"; import { getUsage } from "tokenlens";
const PERCENT_MAX = 100; const PERCENT_MAX = 100;
const ICON_RADIUS = 10; const ICON_RADIUS = 10;
const ICON_VIEWBOX = 24; const ICON_VIEWBOX = 24;
const ICON_CENTER = 12; const ICON_CENTER = 12;
const ICON_STROKE_WIDTH = 2; const ICON_STROKE_WIDTH = 2;
type ModelId = string; type ModelId = string;
type ContextSchema = { type ContextSchema = {
usedTokens: number; usedTokens: number;
maxTokens: number; maxTokens: number;
usage?: LanguageModelUsage; usage?: LanguageModelUsage;
modelId?: ModelId; modelId?: ModelId;
}; };
const ContextContext = createContext<ContextSchema | null>(null); const ContextContext = createContext<ContextSchema | null>(null);
const useContextValue = () => { const useContextValue = () => {
const context = useContext(ContextContext); const context = useContext(ContextContext);
if (!context) { if (!context) {
throw new Error("Context components must be used within Context"); throw new Error("Context components must be used within Context");
} }
return context; return context;
}; };
export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema; export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema;
export const Context = ({ export const Context = ({
usedTokens, usedTokens,
maxTokens, maxTokens,
usage, usage,
modelId, modelId,
...props ...props
}: ContextProps) => ( }: ContextProps) => (
<ContextContext.Provider <ContextContext.Provider
value={{ value={{
usedTokens, usedTokens,
maxTokens, maxTokens,
usage, usage,
modelId, modelId,
}} }}
> >
<HoverCard closeDelay={0} openDelay={0} {...props} /> <HoverCard closeDelay={0} openDelay={0} {...props} />
</ContextContext.Provider> </ContextContext.Provider>
); );
const ContextIcon = () => { const ContextIcon = () => {
const { usedTokens, maxTokens } = useContextValue(); const { usedTokens, maxTokens } = useContextValue();
const circumference = 2 * Math.PI * ICON_RADIUS; const circumference = 2 * Math.PI * ICON_RADIUS;
const usedPercent = usedTokens / maxTokens; const usedPercent = usedTokens / maxTokens;
const dashOffset = circumference * (1 - usedPercent); const dashOffset = circumference * (1 - usedPercent);
return ( return (
<svg <svg
aria-label="Model context usage" aria-label="Model context usage"
height="20" height="20"
role="img" role="img"
style={{ color: "currentcolor" }} style={{ color: "currentcolor" }}
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`} viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
width="20" width="20"
> >
<circle <circle
cx={ICON_CENTER} cx={ICON_CENTER}
cy={ICON_CENTER} cy={ICON_CENTER}
fill="none" fill="none"
opacity="0.25" opacity="0.25"
r={ICON_RADIUS} r={ICON_RADIUS}
stroke="currentColor" stroke="currentColor"
strokeWidth={ICON_STROKE_WIDTH} strokeWidth={ICON_STROKE_WIDTH}
/> />
<circle <circle
cx={ICON_CENTER} cx={ICON_CENTER}
cy={ICON_CENTER} cy={ICON_CENTER}
fill="none" fill="none"
opacity="0.7" opacity="0.7"
r={ICON_RADIUS} r={ICON_RADIUS}
stroke="currentColor" stroke="currentColor"
strokeDasharray={`${circumference} ${circumference}`} strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={dashOffset} strokeDashoffset={dashOffset}
strokeLinecap="round" strokeLinecap="round"
strokeWidth={ICON_STROKE_WIDTH} strokeWidth={ICON_STROKE_WIDTH}
style={{ transformOrigin: "center", transform: "rotate(-90deg)" }} style={{ transformOrigin: "center", transform: "rotate(-90deg)" }}
/> />
</svg> </svg>
); );
}; };
export type ContextTriggerProps = ComponentProps<typeof Button>; export type ContextTriggerProps = ComponentProps<typeof Button>;
export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => { export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
const { usedTokens, maxTokens } = useContextValue(); const { usedTokens, maxTokens } = useContextValue();
const usedPercent = usedTokens / maxTokens; const usedPercent = usedTokens / maxTokens;
const renderedPercent = new Intl.NumberFormat("en-US", { const renderedPercent = new Intl.NumberFormat("en-US", {
style: "percent", style: "percent",
maximumFractionDigits: 1, maximumFractionDigits: 1,
}).format(usedPercent); }).format(usedPercent);
return ( return (
<HoverCardTrigger asChild> <HoverCardTrigger asChild>
{children ?? ( {children ?? (
<Button type="button" variant="ghost" {...props}> <Button type="button" variant="ghost" {...props}>
<span className="text-muted-foreground font-medium"> <span className="text-muted-foreground font-medium">
{renderedPercent} {renderedPercent}
</span> </span>
<ContextIcon /> <ContextIcon />
</Button> </Button>
)} )}
</HoverCardTrigger> </HoverCardTrigger>
); );
}; };
export type ContextContentProps = ComponentProps<typeof HoverCardContent>; export type ContextContentProps = ComponentProps<typeof HoverCardContent>;
export const ContextContent = ({ export const ContextContent = ({
className, className,
...props ...props
}: ContextContentProps) => ( }: ContextContentProps) => (
<HoverCardContent <HoverCardContent
className={cn("min-w-60 divide-y overflow-hidden p-0", className)} className={cn("min-w-60 divide-y overflow-hidden p-0", className)}
{...props} {...props}
/> />
); );
export type ContextContentHeaderProps = ComponentProps<"div">; export type ContextContentHeaderProps = ComponentProps<"div">;
export const ContextContentHeader = ({ export const ContextContentHeader = ({
children, children,
className, className,
...props ...props
}: ContextContentHeaderProps) => { }: ContextContentHeaderProps) => {
const { usedTokens, maxTokens } = useContextValue(); const { usedTokens, maxTokens } = useContextValue();
const usedPercent = usedTokens / maxTokens; const usedPercent = usedTokens / maxTokens;
const displayPct = new Intl.NumberFormat("en-US", { const displayPct = new Intl.NumberFormat("en-US", {
style: "percent", style: "percent",
maximumFractionDigits: 1, maximumFractionDigits: 1,
}).format(usedPercent); }).format(usedPercent);
const used = new Intl.NumberFormat("en-US", { const used = new Intl.NumberFormat("en-US", {
notation: "compact", notation: "compact",
}).format(usedTokens); }).format(usedTokens);
const total = new Intl.NumberFormat("en-US", { const total = new Intl.NumberFormat("en-US", {
notation: "compact", notation: "compact",
}).format(maxTokens); }).format(maxTokens);
return ( return (
<div className={cn("w-full space-y-2 p-3", className)} {...props}> <div className={cn("w-full space-y-2 p-3", className)} {...props}>
{children ?? ( {children ?? (
<> <>
<div className="flex items-center justify-between gap-3 text-xs"> <div className="flex items-center justify-between gap-3 text-xs">
<p>{displayPct}</p> <p>{displayPct}</p>
<p className="text-muted-foreground font-mono"> <p className="text-muted-foreground font-mono">
{used} / {total} {used} / {total}
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Progress className="bg-muted" value={usedPercent * PERCENT_MAX} /> <Progress className="bg-muted" value={usedPercent * PERCENT_MAX} />
</div> </div>
</> </>
)} )}
</div> </div>
); );
}; };
export type ContextContentBodyProps = ComponentProps<"div">; export type ContextContentBodyProps = ComponentProps<"div">;
export const ContextContentBody = ({ export const ContextContentBody = ({
children, children,
className, className,
...props ...props
}: ContextContentBodyProps) => ( }: ContextContentBodyProps) => (
<div className={cn("w-full p-3", className)} {...props}> <div className={cn("w-full p-3", className)} {...props}>
{children} {children}
</div> </div>
); );
export type ContextContentFooterProps = ComponentProps<"div">; export type ContextContentFooterProps = ComponentProps<"div">;
export const ContextContentFooter = ({ export const ContextContentFooter = ({
children, children,
className, className,
...props ...props
}: ContextContentFooterProps) => { }: ContextContentFooterProps) => {
const { modelId, usage } = useContextValue(); const { modelId, usage } = useContextValue();
const costUSD = modelId const costUSD = modelId
? getUsage({ ? getUsage({
modelId, modelId,
usage: { usage: {
input: usage?.inputTokens ?? 0, input: usage?.inputTokens ?? 0,
output: usage?.outputTokens ?? 0, output: usage?.outputTokens ?? 0,
}, },
}).costUSD?.totalUSD }).costUSD?.totalUSD
: undefined; : undefined;
const totalCost = new Intl.NumberFormat("en-US", { const totalCost = new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: "USD", currency: "USD",
}).format(costUSD ?? 0); }).format(costUSD ?? 0);
return ( return (
<div <div
className={cn( className={cn(
"bg-secondary flex w-full items-center justify-between gap-3 p-3 text-xs", "bg-secondary flex w-full items-center justify-between gap-3 p-3 text-xs",
className, className,
)} )}
{...props} {...props}
> >
{children ?? ( {children ?? (
<> <>
<span className="text-muted-foreground">Total cost</span> <span className="text-muted-foreground">Total cost</span>
<span>{totalCost}</span> <span>{totalCost}</span>
</> </>
)} )}
</div> </div>
); );
}; };
export type ContextInputUsageProps = ComponentProps<"div">; export type ContextInputUsageProps = ComponentProps<"div">;
export const ContextInputUsage = ({ export const ContextInputUsage = ({
className, className,
children, children,
...props ...props
}: ContextInputUsageProps) => { }: ContextInputUsageProps) => {
const { usage, modelId } = useContextValue(); const { usage, modelId } = useContextValue();
const inputTokens = usage?.inputTokens ?? 0; const inputTokens = usage?.inputTokens ?? 0;
if (children) { if (children) {
return children; return children;
} }
if (!inputTokens) { if (!inputTokens) {
return null; return null;
} }
const inputCost = modelId const inputCost = modelId
? getUsage({ ? getUsage({
modelId, modelId,
usage: { input: inputTokens, output: 0 }, usage: { input: inputTokens, output: 0 },
}).costUSD?.totalUSD }).costUSD?.totalUSD
: undefined; : undefined;
const inputCostText = new Intl.NumberFormat("en-US", { const inputCostText = new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: "USD", currency: "USD",
}).format(inputCost ?? 0); }).format(inputCost ?? 0);
return ( return (
<div <div
className={cn("flex items-center justify-between text-xs", className)} className={cn("flex items-center justify-between text-xs", className)}
{...props} {...props}
> >
<span className="text-muted-foreground">Input</span> <span className="text-muted-foreground">Input</span>
<TokensWithCost costText={inputCostText} tokens={inputTokens} /> <TokensWithCost costText={inputCostText} tokens={inputTokens} />
</div> </div>
); );
}; };
export type ContextOutputUsageProps = ComponentProps<"div">; export type ContextOutputUsageProps = ComponentProps<"div">;
export const ContextOutputUsage = ({ export const ContextOutputUsage = ({
className, className,
children, children,
...props ...props
}: ContextOutputUsageProps) => { }: ContextOutputUsageProps) => {
const { usage, modelId } = useContextValue(); const { usage, modelId } = useContextValue();
const outputTokens = usage?.outputTokens ?? 0; const outputTokens = usage?.outputTokens ?? 0;
if (children) { if (children) {
return children; return children;
} }
if (!outputTokens) { if (!outputTokens) {
return null; return null;
} }
const outputCost = modelId const outputCost = modelId
? getUsage({ ? getUsage({
modelId, modelId,
usage: { input: 0, output: outputTokens }, usage: { input: 0, output: outputTokens },
}).costUSD?.totalUSD }).costUSD?.totalUSD
: undefined; : undefined;
const outputCostText = new Intl.NumberFormat("en-US", { const outputCostText = new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: "USD", currency: "USD",
}).format(outputCost ?? 0); }).format(outputCost ?? 0);
return ( return (
<div <div
className={cn("flex items-center justify-between text-xs", className)} className={cn("flex items-center justify-between text-xs", className)}
{...props} {...props}
> >
<span className="text-muted-foreground">Output</span> <span className="text-muted-foreground">Output</span>
<TokensWithCost costText={outputCostText} tokens={outputTokens} /> <TokensWithCost costText={outputCostText} tokens={outputTokens} />
</div> </div>
); );
}; };
export type ContextReasoningUsageProps = ComponentProps<"div">; export type ContextReasoningUsageProps = ComponentProps<"div">;
export const ContextReasoningUsage = ({ export const ContextReasoningUsage = ({
className, className,
children, children,
...props ...props
}: ContextReasoningUsageProps) => { }: ContextReasoningUsageProps) => {
const { usage, modelId } = useContextValue(); const { usage, modelId } = useContextValue();
const reasoningTokens = usage?.reasoningTokens ?? 0; const reasoningTokens = usage?.reasoningTokens ?? 0;
if (children) { if (children) {
return children; return children;
} }
if (!reasoningTokens) { if (!reasoningTokens) {
return null; return null;
} }
const reasoningCost = modelId const reasoningCost = modelId
? getUsage({ ? getUsage({
modelId, modelId,
usage: { reasoningTokens }, usage: { reasoningTokens },
}).costUSD?.totalUSD }).costUSD?.totalUSD
: undefined; : undefined;
const reasoningCostText = new Intl.NumberFormat("en-US", { const reasoningCostText = new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: "USD", currency: "USD",
}).format(reasoningCost ?? 0); }).format(reasoningCost ?? 0);
return ( return (
<div <div
className={cn("flex items-center justify-between text-xs", className)} className={cn("flex items-center justify-between text-xs", className)}
{...props} {...props}
> >
<span className="text-muted-foreground">Reasoning</span> <span className="text-muted-foreground">Reasoning</span>
<TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} /> <TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
</div> </div>
); );
}; };
export type ContextCacheUsageProps = ComponentProps<"div">; export type ContextCacheUsageProps = ComponentProps<"div">;
export const ContextCacheUsage = ({ export const ContextCacheUsage = ({
className, className,
children, children,
...props ...props
}: ContextCacheUsageProps) => { }: ContextCacheUsageProps) => {
const { usage, modelId } = useContextValue(); const { usage, modelId } = useContextValue();
const cacheTokens = usage?.cachedInputTokens ?? 0; const cacheTokens = usage?.cachedInputTokens ?? 0;
if (children) { if (children) {
return children; return children;
} }
if (!cacheTokens) { if (!cacheTokens) {
return null; return null;
} }
const cacheCost = modelId const cacheCost = modelId
? getUsage({ ? getUsage({
modelId, modelId,
usage: { cacheReads: cacheTokens, input: 0, output: 0 }, usage: { cacheReads: cacheTokens, input: 0, output: 0 },
}).costUSD?.totalUSD }).costUSD?.totalUSD
: undefined; : undefined;
const cacheCostText = new Intl.NumberFormat("en-US", { const cacheCostText = new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: "USD", currency: "USD",
}).format(cacheCost ?? 0); }).format(cacheCost ?? 0);
return ( return (
<div <div
className={cn("flex items-center justify-between text-xs", className)} className={cn("flex items-center justify-between text-xs", className)}
{...props} {...props}
> >
<span className="text-muted-foreground">Cache</span> <span className="text-muted-foreground">Cache</span>
<TokensWithCost costText={cacheCostText} tokens={cacheTokens} /> <TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
</div> </div>
); );
}; };
const TokensWithCost = ({ const TokensWithCost = ({
tokens, tokens,
costText, costText,
}: { }: {
tokens?: number; tokens?: number;
costText?: string; costText?: string;
}) => ( }) => (
<span> <span>
{tokens === undefined {tokens === undefined
? "—" ? "—"
: new Intl.NumberFormat("en-US", { : new Intl.NumberFormat("en-US", {
notation: "compact", notation: "compact",
}).format(tokens)} }).format(tokens)}
{costText ? ( {costText ? (
<span className="text-muted-foreground ml-2">• {costText}</span> <span className="text-muted-foreground ml-2">• {costText}</span>
) : null} ) : null}
</span> </span>
); );
@@ -1,18 +1,18 @@
"use client"; "use client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Controls as ControlsPrimitive } from "@xyflow/react"; import { Controls as ControlsPrimitive } from "@xyflow/react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
export type ControlsProps = ComponentProps<typeof ControlsPrimitive>; export type ControlsProps = ComponentProps<typeof ControlsPrimitive>;
export const Controls = ({ className, ...props }: ControlsProps) => ( export const Controls = ({ className, ...props }: ControlsProps) => (
<ControlsPrimitive <ControlsPrimitive
className={cn( className={cn(
"bg-card gap-px overflow-hidden rounded-md border p-1 shadow-none!", "bg-card gap-px overflow-hidden rounded-md border p-1 shadow-none!",
"[&>button]:hover:bg-secondary! [&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent!", "[&>button]:hover:bg-secondary! [&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent!",
className, className,
)} )}
{...props} {...props}
/> />
); );
@@ -1,100 +1,100 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ArrowDownIcon } from "lucide-react"; import { ArrowDownIcon } from "lucide-react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { useCallback } from "react"; import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>; export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => ( export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom <StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)} className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth" initial="smooth"
resize="smooth" resize="smooth"
role="log" role="log"
{...props} {...props}
/> />
); );
export type ConversationContentProps = ComponentProps< export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content typeof StickToBottom.Content
>; >;
export const ConversationContent = ({ export const ConversationContent = ({
className, className,
...props ...props
}: ConversationContentProps) => ( }: ConversationContentProps) => (
<StickToBottom.Content <StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)} className={cn("flex flex-col gap-8 p-4", className)}
{...props} {...props}
/> />
); );
export type ConversationEmptyStateProps = ComponentProps<"div"> & { export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string; title?: string;
description?: string; description?: string;
icon?: React.ReactNode; icon?: React.ReactNode;
}; };
export const ConversationEmptyState = ({ export const ConversationEmptyState = ({
className, className,
title = "No messages yet", title = "No messages yet",
description = "Start a conversation to see messages here", description = "Start a conversation to see messages here",
icon, icon,
children, children,
...props ...props
}: ConversationEmptyStateProps) => ( }: ConversationEmptyStateProps) => (
<div <div
className={cn( className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center", "flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className, className,
)} )}
{...props} {...props}
> >
{children ?? ( {children ?? (
<> <>
{icon && <div className="text-muted-foreground">{icon}</div>} {icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium">{title}</h3> <h3 className="text-sm font-medium">{title}</h3>
{description && ( {description && (
<p className="text-muted-foreground text-sm">{description}</p> <p className="text-muted-foreground text-sm">{description}</p>
)} )}
</div> </div>
</> </>
)} )}
</div> </div>
); );
export type ConversationScrollButtonProps = ComponentProps<typeof Button>; export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({ export const ConversationScrollButton = ({
className, className,
...props ...props
}: ConversationScrollButtonProps) => { }: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext(); const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => { const handleScrollToBottom = useCallback(() => {
scrollToBottom(); scrollToBottom();
}, [scrollToBottom]); }, [scrollToBottom]);
return ( return (
!isAtBottom && ( !isAtBottom && (
<Button <Button
className={cn( className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full", "absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
className, className,
)} )}
onClick={handleScrollToBottom} onClick={handleScrollToBottom}
size="icon" size="icon"
type="button" type="button"
variant="outline" variant="outline"
{...props} {...props}
> >
<ArrowDownIcon className="size-4" /> <ArrowDownIcon className="size-4" />
</Button> </Button>
) )
); );
}; };
+140 -140
View File
@@ -1,140 +1,140 @@
import { import {
BaseEdge, BaseEdge,
type EdgeProps, type EdgeProps,
getBezierPath, getBezierPath,
getSimpleBezierPath, getSimpleBezierPath,
type InternalNode, type InternalNode,
type Node, type Node,
Position, Position,
useInternalNode, useInternalNode,
} from "@xyflow/react"; } from "@xyflow/react";
const Temporary = ({ const Temporary = ({
id, id,
sourceX, sourceX,
sourceY, sourceY,
targetX, targetX,
targetY, targetY,
sourcePosition, sourcePosition,
targetPosition, targetPosition,
}: EdgeProps) => { }: EdgeProps) => {
const [edgePath] = getSimpleBezierPath({ const [edgePath] = getSimpleBezierPath({
sourceX, sourceX,
sourceY, sourceY,
sourcePosition, sourcePosition,
targetX, targetX,
targetY, targetY,
targetPosition, targetPosition,
}); });
return ( return (
<BaseEdge <BaseEdge
className="stroke-ring stroke-1" className="stroke-ring stroke-1"
id={id} id={id}
path={edgePath} path={edgePath}
style={{ style={{
strokeDasharray: "5, 5", strokeDasharray: "5, 5",
}} }}
/> />
); );
}; };
const getHandleCoordsByPosition = ( const getHandleCoordsByPosition = (
node: InternalNode<Node>, node: InternalNode<Node>,
handlePosition: Position, handlePosition: Position,
) => { ) => {
// Choose the handle type based on position - Left is for target, Right is for source // Choose the handle type based on position - Left is for target, Right is for source
const handleType = handlePosition === Position.Left ? "target" : "source"; const handleType = handlePosition === Position.Left ? "target" : "source";
const handle = node.internals.handleBounds?.[handleType]?.find( const handle = node.internals.handleBounds?.[handleType]?.find(
(h) => h.position === handlePosition, (h) => h.position === handlePosition,
); );
if (!handle) { if (!handle) {
return [0, 0] as const; return [0, 0] as const;
} }
let offsetX = handle.width / 2; let offsetX = handle.width / 2;
let offsetY = handle.height / 2; let offsetY = handle.height / 2;
// this is a tiny detail to make the markerEnd of an edge visible. // this is a tiny detail to make the markerEnd of an edge visible.
// The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset // The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset
// when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position // when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position
switch (handlePosition) { switch (handlePosition) {
case Position.Left: case Position.Left:
offsetX = 0; offsetX = 0;
break; break;
case Position.Right: case Position.Right:
offsetX = handle.width; offsetX = handle.width;
break; break;
case Position.Top: case Position.Top:
offsetY = 0; offsetY = 0;
break; break;
case Position.Bottom: case Position.Bottom:
offsetY = handle.height; offsetY = handle.height;
break; break;
default: default:
throw new Error(`Invalid handle position: ${handlePosition}`); throw new Error(`Invalid handle position: ${handlePosition}`);
} }
const x = node.internals.positionAbsolute.x + handle.x + offsetX; const x = node.internals.positionAbsolute.x + handle.x + offsetX;
const y = node.internals.positionAbsolute.y + handle.y + offsetY; const y = node.internals.positionAbsolute.y + handle.y + offsetY;
return [x, y] as const; return [x, y] as const;
}; };
const getEdgeParams = ( const getEdgeParams = (
source: InternalNode<Node>, source: InternalNode<Node>,
target: InternalNode<Node>, target: InternalNode<Node>,
) => { ) => {
const sourcePos = Position.Right; const sourcePos = Position.Right;
const [sx, sy] = getHandleCoordsByPosition(source, sourcePos); const [sx, sy] = getHandleCoordsByPosition(source, sourcePos);
const targetPos = Position.Left; const targetPos = Position.Left;
const [tx, ty] = getHandleCoordsByPosition(target, targetPos); const [tx, ty] = getHandleCoordsByPosition(target, targetPos);
return { return {
sx, sx,
sy, sy,
tx, tx,
ty, ty,
sourcePos, sourcePos,
targetPos, targetPos,
}; };
}; };
const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => { const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => {
const sourceNode = useInternalNode(source); const sourceNode = useInternalNode(source);
const targetNode = useInternalNode(target); const targetNode = useInternalNode(target);
if (!(sourceNode && targetNode)) { if (!(sourceNode && targetNode)) {
return null; return null;
} }
const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
sourceNode, sourceNode,
targetNode, targetNode,
); );
const [edgePath] = getBezierPath({ const [edgePath] = getBezierPath({
sourceX: sx, sourceX: sx,
sourceY: sy, sourceY: sy,
sourcePosition: sourcePos, sourcePosition: sourcePos,
targetX: tx, targetX: tx,
targetY: ty, targetY: ty,
targetPosition: targetPos, targetPosition: targetPos,
}); });
return ( return (
<> <>
<BaseEdge id={id} markerEnd={markerEnd} path={edgePath} style={style} /> <BaseEdge id={id} markerEnd={markerEnd} path={edgePath} style={style} />
<circle fill="var(--primary)" r="4"> <circle fill="var(--primary)" r="4">
<animateMotion dur="2s" path={edgePath} repeatCount="indefinite" /> <animateMotion dur="2s" path={edgePath} repeatCount="indefinite" />
</circle> </circle>
</> </>
); );
}; };
export const Edge = { export const Edge = {
Temporary, Temporary,
Animated, Animated,
}; };
@@ -1,24 +1,24 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Experimental_GeneratedImage } from "ai"; import type { Experimental_GeneratedImage } from "ai";
export type ImageProps = Experimental_GeneratedImage & { export type ImageProps = Experimental_GeneratedImage & {
className?: string; className?: string;
alt?: string; alt?: string;
}; };
export const Image = ({ export const Image = ({
base64, base64,
uint8Array, uint8Array,
mediaType, mediaType,
...props ...props
}: ImageProps) => ( }: ImageProps) => (
<img <img
{...props} {...props}
alt={props.alt} alt={props.alt}
className={cn( className={cn(
"h-auto max-w-full overflow-hidden rounded-md", "h-auto max-w-full overflow-hidden rounded-md",
props.className, props.className,
)} )}
src={`data:${mediaType};base64,${base64}`} src={`data:${mediaType};base64,${base64}`}
/> />
); );
@@ -1,96 +1,96 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "react"; import type { HTMLAttributes } from "react";
type LoaderIconProps = { type LoaderIconProps = {
size?: number; size?: number;
}; };
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => ( const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
<svg <svg
height={size} height={size}
strokeLinejoin="round" strokeLinejoin="round"
style={{ color: "currentcolor" }} style={{ color: "currentcolor" }}
viewBox="0 0 16 16" viewBox="0 0 16 16"
width={size} width={size}
> >
<title>Loader</title> <title>Loader</title>
<g clipPath="url(#clip0_2393_1490)"> <g clipPath="url(#clip0_2393_1490)">
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" /> <path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
<path <path
d="M8 16V12" d="M8 16V12"
opacity="0.5" opacity="0.5"
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.5"
/> />
<path <path
d="M3.29773 1.52783L5.64887 4.7639" d="M3.29773 1.52783L5.64887 4.7639"
opacity="0.9" opacity="0.9"
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.5"
/> />
<path <path
d="M12.7023 1.52783L10.3511 4.7639" d="M12.7023 1.52783L10.3511 4.7639"
opacity="0.1" opacity="0.1"
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.5"
/> />
<path <path
d="M12.7023 14.472L10.3511 11.236" d="M12.7023 14.472L10.3511 11.236"
opacity="0.4" opacity="0.4"
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.5"
/> />
<path <path
d="M3.29773 14.472L5.64887 11.236" d="M3.29773 14.472L5.64887 11.236"
opacity="0.6" opacity="0.6"
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.5"
/> />
<path <path
d="M15.6085 5.52783L11.8043 6.7639" d="M15.6085 5.52783L11.8043 6.7639"
opacity="0.2" opacity="0.2"
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.5"
/> />
<path <path
d="M0.391602 10.472L4.19583 9.23598" d="M0.391602 10.472L4.19583 9.23598"
opacity="0.7" opacity="0.7"
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.5"
/> />
<path <path
d="M15.6085 10.4722L11.8043 9.2361" d="M15.6085 10.4722L11.8043 9.2361"
opacity="0.3" opacity="0.3"
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.5"
/> />
<path <path
d="M0.391602 5.52783L4.19583 6.7639" d="M0.391602 5.52783L4.19583 6.7639"
opacity="0.8" opacity="0.8"
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.5"
/> />
</g> </g>
<defs> <defs>
<clipPath id="clip0_2393_1490"> <clipPath id="clip0_2393_1490">
<rect fill="white" height="16" width="16" /> <rect fill="white" height="16" width="16" />
</clipPath> </clipPath>
</defs> </defs>
</svg> </svg>
); );
export type LoaderProps = HTMLAttributes<HTMLDivElement> & { export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
size?: number; size?: number;
}; };
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => ( export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
<div <div
className={cn( className={cn(
"inline-flex animate-spin items-center justify-center", "inline-flex animate-spin items-center justify-center",
className, className,
)} )}
{...props} {...props}
> >
<LoaderIcon size={size} /> <LoaderIcon size={size} />
</div> </div>
); );
@@ -1,446 +1,446 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"; import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { FileUIPart, UIMessage } from "ai"; import type { FileUIPart, UIMessage } from "ai";
import { import {
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
PaperclipIcon, PaperclipIcon,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react"; import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react"; import { createContext, memo, useContext, useEffect, useState } from "react";
import { Streamdown } from "streamdown"; import { Streamdown } from "streamdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & { export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"]; from: UIMessage["role"];
}; };
export const Message = ({ className, from, ...props }: MessageProps) => ( export const Message = ({ className, from, ...props }: MessageProps) => (
<div <div
className={cn( className={cn(
"group flex w-full flex-col gap-2", "group flex w-full flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant", from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className, className,
)} )}
{...props} {...props}
/> />
); );
export type MessageContentProps = HTMLAttributes<HTMLDivElement>; export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({ export const MessageContent = ({
children, children,
className, className,
...props ...props
}: MessageContentProps) => ( }: MessageContentProps) => (
<div <div
className={cn( className={cn(
"is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-visible", "is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-visible",
"group-[.is-user]:overflow-hidden", "group-[.is-user]:overflow-hidden",
"group-[.is-user]:bg-secondary group-[.is-user]:text-foreground group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:px-4 group-[.is-user]:py-3", "group-[.is-user]:bg-secondary group-[.is-user]:text-foreground group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:px-4 group-[.is-user]:py-3",
"group-[.is-assistant]:text-foreground", "group-[.is-assistant]:text-foreground",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
</div> </div>
); );
export type MessageActionsProps = ComponentProps<"div">; export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({ export const MessageActions = ({
className, className,
children, children,
...props ...props
}: MessageActionsProps) => ( }: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}> <div className={cn("flex items-center gap-1", className)} {...props}>
{children} {children}
</div> </div>
); );
export type MessageActionProps = ComponentProps<typeof Button> & { export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string; tooltip?: string;
label?: string; label?: string;
}; };
export const MessageAction = ({ export const MessageAction = ({
tooltip, tooltip,
children, children,
label, label,
variant = "ghost", variant = "ghost",
size = "icon-sm", size = "icon-sm",
...props ...props
}: MessageActionProps) => { }: MessageActionProps) => {
const button = ( const button = (
<Button size={size} type="button" variant={variant} {...props}> <Button size={size} type="button" variant={variant} {...props}>
{children} {children}
<span className="sr-only">{label || tooltip}</span> <span className="sr-only">{label || tooltip}</span>
</Button> </Button>
); );
if (tooltip) { if (tooltip) {
return ( return (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger> <TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{tooltip}</p> <p>{tooltip}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
); );
} }
return button; return button;
}; };
type MessageBranchContextType = { type MessageBranchContextType = {
currentBranch: number; currentBranch: number;
totalBranches: number; totalBranches: number;
goToPrevious: () => void; goToPrevious: () => void;
goToNext: () => void; goToNext: () => void;
branches: ReactElement[]; branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void; setBranches: (branches: ReactElement[]) => void;
}; };
const MessageBranchContext = createContext<MessageBranchContextType | null>( const MessageBranchContext = createContext<MessageBranchContextType | null>(
null, null,
); );
const useMessageBranch = () => { const useMessageBranch = () => {
const context = useContext(MessageBranchContext); const context = useContext(MessageBranchContext);
if (!context) { if (!context) {
throw new Error( throw new Error(
"MessageBranch components must be used within MessageBranch", "MessageBranch components must be used within MessageBranch",
); );
} }
return context; return context;
}; };
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & { export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number; defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void; onBranchChange?: (branchIndex: number) => void;
}; };
export const MessageBranch = ({ export const MessageBranch = ({
defaultBranch = 0, defaultBranch = 0,
onBranchChange, onBranchChange,
className, className,
...props ...props
}: MessageBranchProps) => { }: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch); const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]); const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = (newBranch: number) => { const handleBranchChange = (newBranch: number) => {
setCurrentBranch(newBranch); setCurrentBranch(newBranch);
onBranchChange?.(newBranch); onBranchChange?.(newBranch);
}; };
const goToPrevious = () => { const goToPrevious = () => {
const newBranch = const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1; currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch); handleBranchChange(newBranch);
}; };
const goToNext = () => { const goToNext = () => {
const newBranch = const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0; currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch); handleBranchChange(newBranch);
}; };
const contextValue: MessageBranchContextType = { const contextValue: MessageBranchContextType = {
currentBranch, currentBranch,
totalBranches: branches.length, totalBranches: branches.length,
goToPrevious, goToPrevious,
goToNext, goToNext,
branches, branches,
setBranches, setBranches,
}; };
return ( return (
<MessageBranchContext.Provider value={contextValue}> <MessageBranchContext.Provider value={contextValue}>
<div <div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)} className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props} {...props}
/> />
</MessageBranchContext.Provider> </MessageBranchContext.Provider>
); );
}; };
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>; export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({ export const MessageBranchContent = ({
children, children,
...props ...props
}: MessageBranchContentProps) => { }: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch(); const { currentBranch, setBranches, branches } = useMessageBranch();
const childrenArray = Array.isArray(children) ? children : [children]; const childrenArray = Array.isArray(children) ? children : [children];
// Use useEffect to update branches when they change // Use useEffect to update branches when they change
useEffect(() => { useEffect(() => {
if (branches.length !== childrenArray.length) { if (branches.length !== childrenArray.length) {
setBranches(childrenArray); setBranches(childrenArray);
} }
}, [childrenArray, branches, setBranches]); }, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => ( return childrenArray.map((branch, index) => (
<div <div
className={cn( className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0", "grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden", index === currentBranch ? "block" : "hidden",
)} )}
key={branch.key} key={branch.key}
{...props} {...props}
> >
{branch} {branch}
</div> </div>
)); ));
}; };
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & { export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"]; from: UIMessage["role"];
}; };
export const MessageBranchSelector = ({ export const MessageBranchSelector = ({
className, className,
from, from,
...props ...props
}: MessageBranchSelectorProps) => { }: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch(); const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch // Don't render if there's only one branch
if (totalBranches <= 1) { if (totalBranches <= 1) {
return null; return null;
} }
return ( return (
<ButtonGroup <ButtonGroup
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md" className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
orientation="horizontal" orientation="horizontal"
{...props} {...props}
/> />
); );
}; };
export type MessageBranchPreviousProps = ComponentProps<typeof Button>; export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({ export const MessageBranchPrevious = ({
children, children,
...props ...props
}: MessageBranchPreviousProps) => { }: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch(); const { goToPrevious, totalBranches } = useMessageBranch();
return ( return (
<Button <Button
aria-label="Previous branch" aria-label="Previous branch"
disabled={totalBranches <= 1} disabled={totalBranches <= 1}
onClick={goToPrevious} onClick={goToPrevious}
size="icon-sm" size="icon-sm"
type="button" type="button"
variant="ghost" variant="ghost"
{...props} {...props}
> >
{children ?? <ChevronLeftIcon size={14} />} {children ?? <ChevronLeftIcon size={14} />}
</Button> </Button>
); );
}; };
export type MessageBranchNextProps = ComponentProps<typeof Button>; export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({ export const MessageBranchNext = ({
children, children,
className, className,
...props ...props
}: MessageBranchNextProps) => { }: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch(); const { goToNext, totalBranches } = useMessageBranch();
return ( return (
<Button <Button
aria-label="Next branch" aria-label="Next branch"
disabled={totalBranches <= 1} disabled={totalBranches <= 1}
onClick={goToNext} onClick={goToNext}
size="icon-sm" size="icon-sm"
type="button" type="button"
variant="ghost" variant="ghost"
{...props} {...props}
> >
{children ?? <ChevronRightIcon size={14} />} {children ?? <ChevronRightIcon size={14} />}
</Button> </Button>
); );
}; };
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>; export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({ export const MessageBranchPage = ({
className, className,
...props ...props
}: MessageBranchPageProps) => { }: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch(); const { currentBranch, totalBranches } = useMessageBranch();
return ( return (
<ButtonGroupText <ButtonGroupText
className={cn( className={cn(
"text-muted-foreground border-none bg-transparent shadow-none", "text-muted-foreground border-none bg-transparent shadow-none",
className, className,
)} )}
{...props} {...props}
> >
{currentBranch + 1} of {totalBranches} {currentBranch + 1} of {totalBranches}
</ButtonGroupText> </ButtonGroupText>
); );
}; };
export type MessageResponseProps = ComponentProps<typeof Streamdown>; export type MessageResponseProps = ComponentProps<typeof Streamdown>;
export const MessageResponse = memo( export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => ( ({ className, ...props }: MessageResponseProps) => (
<Streamdown <Streamdown
className={cn( className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0", "size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className, className,
)} )}
{...props} {...props}
/> />
), ),
(prevProps, nextProps) => prevProps.children === nextProps.children, (prevProps, nextProps) => prevProps.children === nextProps.children,
); );
MessageResponse.displayName = "MessageResponse"; MessageResponse.displayName = "MessageResponse";
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & { export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: FileUIPart; data: FileUIPart;
className?: string; className?: string;
onRemove?: () => void; onRemove?: () => void;
}; };
export function MessageAttachment({ export function MessageAttachment({
data, data,
className, className,
onRemove, onRemove,
...props ...props
}: MessageAttachmentProps) { }: MessageAttachmentProps) {
const filename = data.filename || ""; const filename = data.filename || "";
const mediaType = const mediaType =
data.mediaType?.startsWith("image/") && data.url ? "image" : "file"; data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
const isImage = mediaType === "image"; const isImage = mediaType === "image";
const attachmentLabel = filename || (isImage ? "Image" : "Attachment"); const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
return ( return (
<div <div
className={cn( className={cn(
"group relative size-24 overflow-hidden rounded-lg", "group relative size-24 overflow-hidden rounded-lg",
className, className,
)} )}
{...props} {...props}
> >
{isImage ? ( {isImage ? (
<> <>
<img <img
alt={filename || "attachment"} alt={filename || "attachment"}
className="size-full object-cover" className="size-full object-cover"
height={100} height={100}
src={data.url} src={data.url}
width={100} width={100}
/> />
{onRemove && ( {onRemove && (
<Button <Button
aria-label="Remove attachment" aria-label="Remove attachment"
className="bg-background/80 hover:bg-background absolute top-2 right-2 size-6 rounded-full p-0 opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100 [&>svg]:size-3" className="bg-background/80 hover:bg-background absolute top-2 right-2 size-6 rounded-full p-0 opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onRemove(); onRemove();
}} }}
type="button" type="button"
variant="ghost" variant="ghost"
> >
<XIcon /> <XIcon />
<span className="sr-only">Remove</span> <span className="sr-only">Remove</span>
</Button> </Button>
)} )}
</> </>
) : ( ) : (
<> <>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="bg-muted text-muted-foreground flex size-full shrink-0 items-center justify-center rounded-lg"> <div className="bg-muted text-muted-foreground flex size-full shrink-0 items-center justify-center rounded-lg">
<PaperclipIcon className="size-4" /> <PaperclipIcon className="size-4" />
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{attachmentLabel}</p> <p>{attachmentLabel}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{onRemove && ( {onRemove && (
<Button <Button
aria-label="Remove attachment" aria-label="Remove attachment"
className="hover:bg-accent size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity group-hover:opacity-100 [&>svg]:size-3" className="hover:bg-accent size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onRemove(); onRemove();
}} }}
type="button" type="button"
variant="ghost" variant="ghost"
> >
<XIcon /> <XIcon />
<span className="sr-only">Remove</span> <span className="sr-only">Remove</span>
</Button> </Button>
)} )}
</> </>
)} )}
</div> </div>
); );
} }
export type MessageAttachmentsProps = ComponentProps<"div">; export type MessageAttachmentsProps = ComponentProps<"div">;
export function MessageAttachments({ export function MessageAttachments({
children, children,
className, className,
...props ...props
}: MessageAttachmentsProps) { }: MessageAttachmentsProps) {
if (!children) { if (!children) {
return null; return null;
} }
return ( return (
<div <div
className={cn( className={cn(
"ml-auto flex w-fit flex-wrap items-start gap-2", "ml-auto flex w-fit flex-wrap items-start gap-2",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
</div> </div>
); );
} }
export type MessageToolbarProps = ComponentProps<"div">; export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({ export const MessageToolbar = ({
className, className,
children, children,
...props ...props
}: MessageToolbarProps) => ( }: MessageToolbarProps) => (
<div <div
className={cn( className={cn(
"mt-4 flex w-full items-center justify-between gap-4", "mt-4 flex w-full items-center justify-between gap-4",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
</div> </div>
); );
@@ -1,208 +1,208 @@
import { import {
Command, Command,
CommandDialog, CommandDialog,
CommandEmpty, CommandEmpty,
CommandGroup, CommandGroup,
CommandInput, CommandInput,
CommandItem, CommandItem,
CommandList, CommandList,
CommandSeparator, CommandSeparator,
CommandShortcut, CommandShortcut,
} from "@/components/ui/command"; } from "@/components/ui/command";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ComponentProps, ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
export type ModelSelectorProps = ComponentProps<typeof Dialog>; export type ModelSelectorProps = ComponentProps<typeof Dialog>;
export const ModelSelector = (props: ModelSelectorProps) => ( export const ModelSelector = (props: ModelSelectorProps) => (
<Dialog {...props} /> <Dialog {...props} />
); );
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>; export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => ( export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
<DialogTrigger {...props} /> <DialogTrigger {...props} />
); );
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & { export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
title?: ReactNode; title?: ReactNode;
}; };
export const ModelSelectorContent = ({ export const ModelSelectorContent = ({
className, className,
children, children,
title = "Model Selector", title = "Model Selector",
...props ...props
}: ModelSelectorContentProps) => ( }: ModelSelectorContentProps) => (
<DialogContent className={cn("p-0", className)} {...props}> <DialogContent className={cn("p-0", className)} {...props}>
<DialogTitle className="sr-only">{title}</DialogTitle> <DialogTitle className="sr-only">{title}</DialogTitle>
<Command className="**:data-[slot=command-input-wrapper]:h-auto"> <Command className="**:data-[slot=command-input-wrapper]:h-auto">
{children} {children}
</Command> </Command>
</DialogContent> </DialogContent>
); );
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>; export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => ( export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
<CommandDialog {...props} /> <CommandDialog {...props} />
); );
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>; export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
export const ModelSelectorInput = ({ export const ModelSelectorInput = ({
className, className,
...props ...props
}: ModelSelectorInputProps) => ( }: ModelSelectorInputProps) => (
<CommandInput className={cn("h-auto py-3.5", className)} {...props} /> <CommandInput className={cn("h-auto py-3.5", className)} {...props} />
); );
export type ModelSelectorListProps = ComponentProps<typeof CommandList>; export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
export const ModelSelectorList = (props: ModelSelectorListProps) => ( export const ModelSelectorList = (props: ModelSelectorListProps) => (
<CommandList {...props} /> <CommandList {...props} />
); );
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>; export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => ( export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
<CommandEmpty {...props} /> <CommandEmpty {...props} />
); );
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>; export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>;
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => ( export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
<CommandGroup {...props} /> <CommandGroup {...props} />
); );
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>; export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
export const ModelSelectorItem = (props: ModelSelectorItemProps) => ( export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
<CommandItem {...props} /> <CommandItem {...props} />
); );
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>; export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => ( export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
<CommandShortcut {...props} /> <CommandShortcut {...props} />
); );
export type ModelSelectorSeparatorProps = ComponentProps< export type ModelSelectorSeparatorProps = ComponentProps<
typeof CommandSeparator typeof CommandSeparator
>; >;
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => ( export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
<CommandSeparator {...props} /> <CommandSeparator {...props} />
); );
export type ModelSelectorLogoProps = Omit< export type ModelSelectorLogoProps = Omit<
ComponentProps<"img">, ComponentProps<"img">,
"src" | "alt" "src" | "alt"
> & { > & {
provider: provider:
| "moonshotai-cn" | "moonshotai-cn"
| "lucidquery" | "lucidquery"
| "moonshotai" | "moonshotai"
| "zai-coding-plan" | "zai-coding-plan"
| "alibaba" | "alibaba"
| "xai" | "xai"
| "vultr" | "vultr"
| "nvidia" | "nvidia"
| "upstage" | "upstage"
| "groq" | "groq"
| "github-copilot" | "github-copilot"
| "mistral" | "mistral"
| "vercel" | "vercel"
| "nebius" | "nebius"
| "deepseek" | "deepseek"
| "alibaba-cn" | "alibaba-cn"
| "google-vertex-anthropic" | "google-vertex-anthropic"
| "venice" | "venice"
| "chutes" | "chutes"
| "cortecs" | "cortecs"
| "github-models" | "github-models"
| "togetherai" | "togetherai"
| "azure" | "azure"
| "baseten" | "baseten"
| "huggingface" | "huggingface"
| "opencode" | "opencode"
| "fastrouter" | "fastrouter"
| "google" | "google"
| "google-vertex" | "google-vertex"
| "cloudflare-workers-ai" | "cloudflare-workers-ai"
| "inception" | "inception"
| "wandb" | "wandb"
| "openai" | "openai"
| "zhipuai-coding-plan" | "zhipuai-coding-plan"
| "perplexity" | "perplexity"
| "openrouter" | "openrouter"
| "zenmux" | "zenmux"
| "v0" | "v0"
| "iflowcn" | "iflowcn"
| "synthetic" | "synthetic"
| "deepinfra" | "deepinfra"
| "zhipuai" | "zhipuai"
| "submodel" | "submodel"
| "zai" | "zai"
| "inference" | "inference"
| "requesty" | "requesty"
| "morph" | "morph"
| "lmstudio" | "lmstudio"
| "anthropic" | "anthropic"
| "aihubmix" | "aihubmix"
| "fireworks-ai" | "fireworks-ai"
| "modelscope" | "modelscope"
| "llama" | "llama"
| "scaleway" | "scaleway"
| "amazon-bedrock" | "amazon-bedrock"
| "cerebras" | "cerebras"
| (string & {}); | (string & {});
}; };
export const ModelSelectorLogo = ({ export const ModelSelectorLogo = ({
provider, provider,
className, className,
...props ...props
}: ModelSelectorLogoProps) => ( }: ModelSelectorLogoProps) => (
<img <img
{...props} {...props}
alt={`${provider} logo`} alt={`${provider} logo`}
className={cn("size-3 dark:invert", className)} className={cn("size-3 dark:invert", className)}
height={12} height={12}
src={`https://models.dev/logos/${provider}.svg`} src={`https://models.dev/logos/${provider}.svg`}
width={12} width={12}
/> />
); );
export type ModelSelectorLogoGroupProps = ComponentProps<"div">; export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
export const ModelSelectorLogoGroup = ({ export const ModelSelectorLogoGroup = ({
className, className,
...props ...props
}: ModelSelectorLogoGroupProps) => ( }: ModelSelectorLogoGroupProps) => (
<div <div
className={cn( className={cn(
"[&>img]:bg-background dark:[&>img]:bg-foreground flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:p-px [&>img]:ring-1", "[&>img]:bg-background dark:[&>img]:bg-foreground flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:p-px [&>img]:ring-1",
className, className,
)} )}
{...props} {...props}
/> />
); );
export type ModelSelectorNameProps = ComponentProps<"span">; export type ModelSelectorNameProps = ComponentProps<"span">;
export const ModelSelectorName = ({ export const ModelSelectorName = ({
className, className,
...props ...props
}: ModelSelectorNameProps) => ( }: ModelSelectorNameProps) => (
<span <span
className={cn("flex-1 truncate text-left text-xs", className)} className={cn("flex-1 truncate text-left text-xs", className)}
{...props} {...props}
/> />
); );
@@ -1,71 +1,71 @@
import { import {
Card, Card,
CardAction, CardAction,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Handle, Position } from "@xyflow/react"; import { Handle, Position } from "@xyflow/react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
export type NodeProps = ComponentProps<typeof Card> & { export type NodeProps = ComponentProps<typeof Card> & {
handles: { handles: {
target: boolean; target: boolean;
source: boolean; source: boolean;
}; };
}; };
export const Node = ({ handles, className, ...props }: NodeProps) => ( export const Node = ({ handles, className, ...props }: NodeProps) => (
<Card <Card
className={cn( className={cn(
"node-container relative size-full h-auto w-sm gap-0 rounded-md p-0", "node-container relative size-full h-auto w-sm gap-0 rounded-md p-0",
className, className,
)} )}
{...props} {...props}
> >
{handles.target && <Handle position={Position.Left} type="target" />} {handles.target && <Handle position={Position.Left} type="target" />}
{handles.source && <Handle position={Position.Right} type="source" />} {handles.source && <Handle position={Position.Right} type="source" />}
{props.children} {props.children}
</Card> </Card>
); );
export type NodeHeaderProps = ComponentProps<typeof CardHeader>; export type NodeHeaderProps = ComponentProps<typeof CardHeader>;
export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => ( export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
<CardHeader <CardHeader
className={cn("bg-secondary gap-0.5 rounded-t-md border-b p-3!", className)} className={cn("bg-secondary gap-0.5 rounded-t-md border-b p-3!", className)}
{...props} {...props}
/> />
); );
export type NodeTitleProps = ComponentProps<typeof CardTitle>; export type NodeTitleProps = ComponentProps<typeof CardTitle>;
export const NodeTitle = (props: NodeTitleProps) => <CardTitle {...props} />; export const NodeTitle = (props: NodeTitleProps) => <CardTitle {...props} />;
export type NodeDescriptionProps = ComponentProps<typeof CardDescription>; export type NodeDescriptionProps = ComponentProps<typeof CardDescription>;
export const NodeDescription = (props: NodeDescriptionProps) => ( export const NodeDescription = (props: NodeDescriptionProps) => (
<CardDescription {...props} /> <CardDescription {...props} />
); );
export type NodeActionProps = ComponentProps<typeof CardAction>; export type NodeActionProps = ComponentProps<typeof CardAction>;
export const NodeAction = (props: NodeActionProps) => <CardAction {...props} />; export const NodeAction = (props: NodeActionProps) => <CardAction {...props} />;
export type NodeContentProps = ComponentProps<typeof CardContent>; export type NodeContentProps = ComponentProps<typeof CardContent>;
export const NodeContent = ({ className, ...props }: NodeContentProps) => ( export const NodeContent = ({ className, ...props }: NodeContentProps) => (
<CardContent className={cn("p-3", className)} {...props} /> <CardContent className={cn("p-3", className)} {...props} />
); );
export type NodeFooterProps = ComponentProps<typeof CardFooter>; export type NodeFooterProps = ComponentProps<typeof CardFooter>;
export const NodeFooter = ({ className, ...props }: NodeFooterProps) => ( export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
<CardFooter <CardFooter
className={cn("bg-secondary rounded-b-md border-t p-3!", className)} className={cn("bg-secondary rounded-b-md border-t p-3!", className)}
{...props} {...props}
/> />
); );
@@ -1,365 +1,365 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
ChevronDownIcon, ChevronDownIcon,
ExternalLinkIcon, ExternalLinkIcon,
MessageCircleIcon, MessageCircleIcon,
} from "lucide-react"; } from "lucide-react";
import { type ComponentProps, createContext, useContext } from "react"; import { type ComponentProps, createContext, useContext } from "react";
const providers = { const providers = {
github: { github: {
title: "Open in GitHub", title: "Open in GitHub",
createUrl: (url: string) => url, createUrl: (url: string) => url,
icon: ( icon: (
<svg fill="currentColor" role="img" viewBox="0 0 24 24"> <svg fill="currentColor" role="img" viewBox="0 0 24 24">
<title>GitHub</title> <title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" /> <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg> </svg>
), ),
}, },
scira: { scira: {
title: "Open in Scira", title: "Open in Scira",
createUrl: (q: string) => createUrl: (q: string) =>
`https://scira.ai/?${new URLSearchParams({ `https://scira.ai/?${new URLSearchParams({
q, q,
})}`, })}`,
icon: ( icon: (
<svg <svg
fill="none" fill="none"
height="934" height="934"
viewBox="0 0 910 934" viewBox="0 0 910 934"
width="910" width="910"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<title>Scira AI</title> <title>Scira AI</title>
<path <path
d="M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z" d="M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z"
fill="currentColor" fill="currentColor"
stroke="currentColor" stroke="currentColor"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="8" strokeWidth="8"
/> />
<path <path
d="M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z" d="M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z"
fill="currentColor" fill="currentColor"
stroke="currentColor" stroke="currentColor"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="8" strokeWidth="8"
/> />
<path <path
d="M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z" d="M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z"
stroke="currentColor" stroke="currentColor"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="20" strokeWidth="20"
/> />
<path <path
d="M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z" d="M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z"
stroke="currentColor" stroke="currentColor"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="20" strokeWidth="20"
/> />
<path <path
d="M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z" d="M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z"
fill="currentColor" fill="currentColor"
stroke="currentColor" stroke="currentColor"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="8" strokeWidth="8"
/> />
<path <path
d="M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z" d="M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z"
fill="currentColor" fill="currentColor"
stroke="currentColor" stroke="currentColor"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="8" strokeWidth="8"
/> />
<path <path
d="M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442" d="M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442"
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="30" strokeWidth="30"
/> />
</svg> </svg>
), ),
}, },
chatgpt: { chatgpt: {
title: "Open in ChatGPT", title: "Open in ChatGPT",
createUrl: (prompt: string) => createUrl: (prompt: string) =>
`https://chatgpt.com/?${new URLSearchParams({ `https://chatgpt.com/?${new URLSearchParams({
hints: "search", hints: "search",
prompt, prompt,
})}`, })}`,
icon: ( icon: (
<svg <svg
fill="currentColor" fill="currentColor"
role="img" role="img"
viewBox="0 0 24 24" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<title>OpenAI</title> <title>OpenAI</title>
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" /> <path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
</svg> </svg>
), ),
}, },
claude: { claude: {
title: "Open in Claude", title: "Open in Claude",
createUrl: (q: string) => createUrl: (q: string) =>
`https://claude.ai/new?${new URLSearchParams({ `https://claude.ai/new?${new URLSearchParams({
q, q,
})}`, })}`,
icon: ( icon: (
<svg <svg
fill="currentColor" fill="currentColor"
role="img" role="img"
viewBox="0 0 12 12" viewBox="0 0 12 12"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<title>Claude</title> <title>Claude</title>
<path <path
clipRule="evenodd" clipRule="evenodd"
d="M2.3545 7.9775L4.7145 6.654L4.7545 6.539L4.7145 6.475H4.6L4.205 6.451L2.856 6.4145L1.6865 6.366L0.5535 6.305L0.268 6.2445L0 5.892L0.0275 5.716L0.2675 5.5555L0.6105 5.5855L1.3705 5.637L2.5095 5.716L3.3355 5.7645L4.56 5.892H4.7545L4.782 5.8135L4.715 5.7645L4.6635 5.716L3.4845 4.918L2.2085 4.074L1.5405 3.588L1.1785 3.3425L0.9965 3.1115L0.9175 2.6075L1.2455 2.2465L1.686 2.2765L1.7985 2.307L2.245 2.65L3.199 3.388L4.4445 4.3045L4.627 4.4565L4.6995 4.405L4.709 4.3685L4.627 4.2315L3.9495 3.0085L3.2265 1.7635L2.9045 1.2475L2.8195 0.938C2.78711 0.819128 2.76965 0.696687 2.7675 0.5735L3.1415 0.067L3.348 0L3.846 0.067L4.056 0.249L4.366 0.956L4.867 2.0705L5.6445 3.5855L5.8725 4.0345L5.994 4.4505L6.0395 4.578H6.1185V4.505L6.1825 3.652L6.301 2.6045L6.416 1.257L6.456 0.877L6.644 0.422L7.0175 0.176L7.3095 0.316L7.5495 0.6585L7.516 0.8805L7.373 1.806L7.0935 3.2575L6.9115 4.2285H7.0175L7.139 4.1075L7.6315 3.4545L8.4575 2.4225L8.8225 2.0125L9.2475 1.5605L9.521 1.345H10.0375L10.4175 1.9095L10.2475 2.4925L9.7155 3.166L9.275 3.737L8.643 4.587L8.248 5.267L8.2845 5.322L8.3785 5.312L9.8065 5.009L10.578 4.869L11.4985 4.7115L11.915 4.9055L11.9605 5.103L11.7965 5.5065L10.812 5.7495L9.6575 5.9805L7.938 6.387L7.917 6.402L7.9415 6.4325L8.716 6.5055L9.047 6.5235H9.858L11.368 6.636L11.763 6.897L12 7.216L11.9605 7.4585L11.353 7.7685L10.533 7.574L8.6185 7.119L7.9625 6.9545H7.8715V7.0095L8.418 7.5435L9.421 8.4485L10.6755 9.6135L10.739 9.9025L10.578 10.13L10.408 10.1055L9.3055 9.277L8.88 8.9035L7.917 8.0935H7.853V8.1785L8.075 8.503L9.2475 10.2635L9.3085 10.8035L9.2235 10.98L8.9195 11.0865L8.5855 11.0255L7.8985 10.063L7.191 8.9795L6.6195 8.008L6.5495 8.048L6.2125 11.675L6.0545 11.86L5.69 12L5.3865 11.7695L5.2255 11.396L5.3865 10.658L5.581 9.696L5.7385 8.931L5.8815 7.981L5.9665 7.665L5.9605 7.644L5.8905 7.653L5.1735 8.6365L4.0835 10.109L3.2205 11.0315L3.0135 11.1135L2.655 10.9285L2.6885 10.5975L2.889 10.303L4.083 8.785L4.803 7.844L5.268 7.301L5.265 7.222H5.2375L2.066 9.28L1.501 9.353L1.2575 9.125L1.288 8.752L1.4035 8.6305L2.3575 7.9745L2.3545 7.9775Z" d="M2.3545 7.9775L4.7145 6.654L4.7545 6.539L4.7145 6.475H4.6L4.205 6.451L2.856 6.4145L1.6865 6.366L0.5535 6.305L0.268 6.2445L0 5.892L0.0275 5.716L0.2675 5.5555L0.6105 5.5855L1.3705 5.637L2.5095 5.716L3.3355 5.7645L4.56 5.892H4.7545L4.782 5.8135L4.715 5.7645L4.6635 5.716L3.4845 4.918L2.2085 4.074L1.5405 3.588L1.1785 3.3425L0.9965 3.1115L0.9175 2.6075L1.2455 2.2465L1.686 2.2765L1.7985 2.307L2.245 2.65L3.199 3.388L4.4445 4.3045L4.627 4.4565L4.6995 4.405L4.709 4.3685L4.627 4.2315L3.9495 3.0085L3.2265 1.7635L2.9045 1.2475L2.8195 0.938C2.78711 0.819128 2.76965 0.696687 2.7675 0.5735L3.1415 0.067L3.348 0L3.846 0.067L4.056 0.249L4.366 0.956L4.867 2.0705L5.6445 3.5855L5.8725 4.0345L5.994 4.4505L6.0395 4.578H6.1185V4.505L6.1825 3.652L6.301 2.6045L6.416 1.257L6.456 0.877L6.644 0.422L7.0175 0.176L7.3095 0.316L7.5495 0.6585L7.516 0.8805L7.373 1.806L7.0935 3.2575L6.9115 4.2285H7.0175L7.139 4.1075L7.6315 3.4545L8.4575 2.4225L8.8225 2.0125L9.2475 1.5605L9.521 1.345H10.0375L10.4175 1.9095L10.2475 2.4925L9.7155 3.166L9.275 3.737L8.643 4.587L8.248 5.267L8.2845 5.322L8.3785 5.312L9.8065 5.009L10.578 4.869L11.4985 4.7115L11.915 4.9055L11.9605 5.103L11.7965 5.5065L10.812 5.7495L9.6575 5.9805L7.938 6.387L7.917 6.402L7.9415 6.4325L8.716 6.5055L9.047 6.5235H9.858L11.368 6.636L11.763 6.897L12 7.216L11.9605 7.4585L11.353 7.7685L10.533 7.574L8.6185 7.119L7.9625 6.9545H7.8715V7.0095L8.418 7.5435L9.421 8.4485L10.6755 9.6135L10.739 9.9025L10.578 10.13L10.408 10.1055L9.3055 9.277L8.88 8.9035L7.917 8.0935H7.853V8.1785L8.075 8.503L9.2475 10.2635L9.3085 10.8035L9.2235 10.98L8.9195 11.0865L8.5855 11.0255L7.8985 10.063L7.191 8.9795L6.6195 8.008L6.5495 8.048L6.2125 11.675L6.0545 11.86L5.69 12L5.3865 11.7695L5.2255 11.396L5.3865 10.658L5.581 9.696L5.7385 8.931L5.8815 7.981L5.9665 7.665L5.9605 7.644L5.8905 7.653L5.1735 8.6365L4.0835 10.109L3.2205 11.0315L3.0135 11.1135L2.655 10.9285L2.6885 10.5975L2.889 10.303L4.083 8.785L4.803 7.844L5.268 7.301L5.265 7.222H5.2375L2.066 9.28L1.501 9.353L1.2575 9.125L1.288 8.752L1.4035 8.6305L2.3575 7.9745L2.3545 7.9775Z"
fillRule="evenodd" fillRule="evenodd"
/> />
</svg> </svg>
), ),
}, },
t3: { t3: {
title: "Open in T3 Chat", title: "Open in T3 Chat",
createUrl: (q: string) => createUrl: (q: string) =>
`https://t3.chat/new?${new URLSearchParams({ `https://t3.chat/new?${new URLSearchParams({
q, q,
})}`, })}`,
icon: <MessageCircleIcon />, icon: <MessageCircleIcon />,
}, },
v0: { v0: {
title: "Open in v0", title: "Open in v0",
createUrl: (q: string) => createUrl: (q: string) =>
`https://v0.app?${new URLSearchParams({ `https://v0.app?${new URLSearchParams({
q, q,
})}`, })}`,
icon: ( icon: (
<svg <svg
fill="currentColor" fill="currentColor"
viewBox="0 0 147 70" viewBox="0 0 147 70"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<title>v0</title> <title>v0</title>
<path d="M56 50.2031V14H70V60.1562C70 65.5928 65.5928 70 60.1562 70C57.5605 70 54.9982 68.9992 53.1562 67.1573L0 14H19.7969L56 50.2031Z" /> <path d="M56 50.2031V14H70V60.1562C70 65.5928 65.5928 70 60.1562 70C57.5605 70 54.9982 68.9992 53.1562 67.1573L0 14H19.7969L56 50.2031Z" />
<path d="M147 56H133V23.9531L100.953 56H133V70H96.6875C85.8144 70 77 61.1856 77 50.3125V14H91V46.1562L123.156 14H91V0H127.312C138.186 0 147 8.81439 147 19.6875V56Z" /> <path d="M147 56H133V23.9531L100.953 56H133V70H96.6875C85.8144 70 77 61.1856 77 50.3125V14H91V46.1562L123.156 14H91V0H127.312C138.186 0 147 8.81439 147 19.6875V56Z" />
</svg> </svg>
), ),
}, },
cursor: { cursor: {
title: "Open in Cursor", title: "Open in Cursor",
createUrl: (text: string) => { createUrl: (text: string) => {
const url = new URL("https://cursor.com/link/prompt"); const url = new URL("https://cursor.com/link/prompt");
url.searchParams.set("text", text); url.searchParams.set("text", text);
return url.toString(); return url.toString();
}, },
icon: ( icon: (
<svg <svg
version="1.1" version="1.1"
viewBox="0 0 466.73 532.09" viewBox="0 0 466.73 532.09"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<title>Cursor</title> <title>Cursor</title>
<path <path
d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"
fill="currentColor" fill="currentColor"
/> />
</svg> </svg>
), ),
}, },
}; };
const OpenInContext = createContext<{ query: string } | undefined>(undefined); const OpenInContext = createContext<{ query: string } | undefined>(undefined);
const useOpenInContext = () => { const useOpenInContext = () => {
const context = useContext(OpenInContext); const context = useContext(OpenInContext);
if (!context) { if (!context) {
throw new Error("OpenIn components must be used within an OpenIn provider"); throw new Error("OpenIn components must be used within an OpenIn provider");
} }
return context; return context;
}; };
export type OpenInProps = ComponentProps<typeof DropdownMenu> & { export type OpenInProps = ComponentProps<typeof DropdownMenu> & {
query: string; query: string;
}; };
export const OpenIn = ({ query, ...props }: OpenInProps) => ( export const OpenIn = ({ query, ...props }: OpenInProps) => (
<OpenInContext.Provider value={{ query }}> <OpenInContext.Provider value={{ query }}>
<DropdownMenu {...props} /> <DropdownMenu {...props} />
</OpenInContext.Provider> </OpenInContext.Provider>
); );
export type OpenInContentProps = ComponentProps<typeof DropdownMenuContent>; export type OpenInContentProps = ComponentProps<typeof DropdownMenuContent>;
export const OpenInContent = ({ className, ...props }: OpenInContentProps) => ( export const OpenInContent = ({ className, ...props }: OpenInContentProps) => (
<DropdownMenuContent <DropdownMenuContent
align="start" align="start"
className={cn("w-[240px]", className)} className={cn("w-[240px]", className)}
{...props} {...props}
/> />
); );
export type OpenInItemProps = ComponentProps<typeof DropdownMenuItem>; export type OpenInItemProps = ComponentProps<typeof DropdownMenuItem>;
export const OpenInItem = (props: OpenInItemProps) => ( export const OpenInItem = (props: OpenInItemProps) => (
<DropdownMenuItem {...props} /> <DropdownMenuItem {...props} />
); );
export type OpenInLabelProps = ComponentProps<typeof DropdownMenuLabel>; export type OpenInLabelProps = ComponentProps<typeof DropdownMenuLabel>;
export const OpenInLabel = (props: OpenInLabelProps) => ( export const OpenInLabel = (props: OpenInLabelProps) => (
<DropdownMenuLabel {...props} /> <DropdownMenuLabel {...props} />
); );
export type OpenInSeparatorProps = ComponentProps<typeof DropdownMenuSeparator>; export type OpenInSeparatorProps = ComponentProps<typeof DropdownMenuSeparator>;
export const OpenInSeparator = (props: OpenInSeparatorProps) => ( export const OpenInSeparator = (props: OpenInSeparatorProps) => (
<DropdownMenuSeparator {...props} /> <DropdownMenuSeparator {...props} />
); );
export type OpenInTriggerProps = ComponentProps<typeof DropdownMenuTrigger>; export type OpenInTriggerProps = ComponentProps<typeof DropdownMenuTrigger>;
export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => ( export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => (
<DropdownMenuTrigger {...props} asChild> <DropdownMenuTrigger {...props} asChild>
{children ?? ( {children ?? (
<Button type="button" variant="outline"> <Button type="button" variant="outline">
Open in chat Open in chat
<ChevronDownIcon className="size-4" /> <ChevronDownIcon className="size-4" />
</Button> </Button>
)} )}
</DropdownMenuTrigger> </DropdownMenuTrigger>
); );
export type OpenInChatGPTProps = ComponentProps<typeof DropdownMenuItem>; export type OpenInChatGPTProps = ComponentProps<typeof DropdownMenuItem>;
export const OpenInChatGPT = (props: OpenInChatGPTProps) => { export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
const { query } = useOpenInContext(); const { query } = useOpenInContext();
return ( return (
<DropdownMenuItem asChild {...props}> <DropdownMenuItem asChild {...props}>
<a <a
className="flex items-center gap-2" className="flex items-center gap-2"
href={providers.chatgpt.createUrl(query)} href={providers.chatgpt.createUrl(query)}
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
> >
<span className="shrink-0">{providers.chatgpt.icon}</span> <span className="shrink-0">{providers.chatgpt.icon}</span>
<span className="flex-1">{providers.chatgpt.title}</span> <span className="flex-1">{providers.chatgpt.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" /> <ExternalLinkIcon className="size-4 shrink-0" />
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>
); );
}; };
export type OpenInClaudeProps = ComponentProps<typeof DropdownMenuItem>; export type OpenInClaudeProps = ComponentProps<typeof DropdownMenuItem>;
export const OpenInClaude = (props: OpenInClaudeProps) => { export const OpenInClaude = (props: OpenInClaudeProps) => {
const { query } = useOpenInContext(); const { query } = useOpenInContext();
return ( return (
<DropdownMenuItem asChild {...props}> <DropdownMenuItem asChild {...props}>
<a <a
className="flex items-center gap-2" className="flex items-center gap-2"
href={providers.claude.createUrl(query)} href={providers.claude.createUrl(query)}
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
> >
<span className="shrink-0">{providers.claude.icon}</span> <span className="shrink-0">{providers.claude.icon}</span>
<span className="flex-1">{providers.claude.title}</span> <span className="flex-1">{providers.claude.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" /> <ExternalLinkIcon className="size-4 shrink-0" />
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>
); );
}; };
export type OpenInT3Props = ComponentProps<typeof DropdownMenuItem>; export type OpenInT3Props = ComponentProps<typeof DropdownMenuItem>;
export const OpenInT3 = (props: OpenInT3Props) => { export const OpenInT3 = (props: OpenInT3Props) => {
const { query } = useOpenInContext(); const { query } = useOpenInContext();
return ( return (
<DropdownMenuItem asChild {...props}> <DropdownMenuItem asChild {...props}>
<a <a
className="flex items-center gap-2" className="flex items-center gap-2"
href={providers.t3.createUrl(query)} href={providers.t3.createUrl(query)}
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
> >
<span className="shrink-0">{providers.t3.icon}</span> <span className="shrink-0">{providers.t3.icon}</span>
<span className="flex-1">{providers.t3.title}</span> <span className="flex-1">{providers.t3.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" /> <ExternalLinkIcon className="size-4 shrink-0" />
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>
); );
}; };
export type OpenInSciraProps = ComponentProps<typeof DropdownMenuItem>; export type OpenInSciraProps = ComponentProps<typeof DropdownMenuItem>;
export const OpenInScira = (props: OpenInSciraProps) => { export const OpenInScira = (props: OpenInSciraProps) => {
const { query } = useOpenInContext(); const { query } = useOpenInContext();
return ( return (
<DropdownMenuItem asChild {...props}> <DropdownMenuItem asChild {...props}>
<a <a
className="flex items-center gap-2" className="flex items-center gap-2"
href={providers.scira.createUrl(query)} href={providers.scira.createUrl(query)}
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
> >
<span className="shrink-0">{providers.scira.icon}</span> <span className="shrink-0">{providers.scira.icon}</span>
<span className="flex-1">{providers.scira.title}</span> <span className="flex-1">{providers.scira.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" /> <ExternalLinkIcon className="size-4 shrink-0" />
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>
); );
}; };
export type OpenInv0Props = ComponentProps<typeof DropdownMenuItem>; export type OpenInv0Props = ComponentProps<typeof DropdownMenuItem>;
export const OpenInv0 = (props: OpenInv0Props) => { export const OpenInv0 = (props: OpenInv0Props) => {
const { query } = useOpenInContext(); const { query } = useOpenInContext();
return ( return (
<DropdownMenuItem asChild {...props}> <DropdownMenuItem asChild {...props}>
<a <a
className="flex items-center gap-2" className="flex items-center gap-2"
href={providers.v0.createUrl(query)} href={providers.v0.createUrl(query)}
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
> >
<span className="shrink-0">{providers.v0.icon}</span> <span className="shrink-0">{providers.v0.icon}</span>
<span className="flex-1">{providers.v0.title}</span> <span className="flex-1">{providers.v0.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" /> <ExternalLinkIcon className="size-4 shrink-0" />
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>
); );
}; };
export type OpenInCursorProps = ComponentProps<typeof DropdownMenuItem>; export type OpenInCursorProps = ComponentProps<typeof DropdownMenuItem>;
export const OpenInCursor = (props: OpenInCursorProps) => { export const OpenInCursor = (props: OpenInCursorProps) => {
const { query } = useOpenInContext(); const { query } = useOpenInContext();
return ( return (
<DropdownMenuItem asChild {...props}> <DropdownMenuItem asChild {...props}>
<a <a
className="flex items-center gap-2" className="flex items-center gap-2"
href={providers.cursor.createUrl(query)} href={providers.cursor.createUrl(query)}
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
> >
<span className="shrink-0">{providers.cursor.icon}</span> <span className="shrink-0">{providers.cursor.icon}</span>
<span className="flex-1">{providers.cursor.title}</span> <span className="flex-1">{providers.cursor.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" /> <ExternalLinkIcon className="size-4 shrink-0" />
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>
); );
}; };
@@ -1,15 +1,15 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Panel as PanelPrimitive } from "@xyflow/react"; import { Panel as PanelPrimitive } from "@xyflow/react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
type PanelProps = ComponentProps<typeof PanelPrimitive>; type PanelProps = ComponentProps<typeof PanelPrimitive>;
export const Panel = ({ className, ...props }: PanelProps) => ( export const Panel = ({ className, ...props }: PanelProps) => (
<PanelPrimitive <PanelPrimitive
className={cn( className={cn(
"bg-card m-4 overflow-hidden rounded-md border p-1", "bg-card m-4 overflow-hidden rounded-md border p-1",
className, className,
)} )}
{...props} {...props}
/> />
); );
+142 -142
View File
@@ -1,142 +1,142 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardAction, CardAction,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronsUpDownIcon } from "lucide-react"; import { ChevronsUpDownIcon } from "lucide-react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import { Shimmer } from "./shimmer"; import { Shimmer } from "./shimmer";
type PlanContextValue = { type PlanContextValue = {
isStreaming: boolean; isStreaming: boolean;
}; };
const PlanContext = createContext<PlanContextValue | null>(null); const PlanContext = createContext<PlanContextValue | null>(null);
const usePlan = () => { const usePlan = () => {
const context = useContext(PlanContext); const context = useContext(PlanContext);
if (!context) { if (!context) {
throw new Error("Plan components must be used within Plan"); throw new Error("Plan components must be used within Plan");
} }
return context; return context;
}; };
export type PlanProps = ComponentProps<typeof Collapsible> & { export type PlanProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean; isStreaming?: boolean;
}; };
export const Plan = ({ export const Plan = ({
className, className,
isStreaming = false, isStreaming = false,
children, children,
...props ...props
}: PlanProps) => ( }: PlanProps) => (
<PlanContext.Provider value={{ isStreaming }}> <PlanContext.Provider value={{ isStreaming }}>
<Collapsible asChild data-slot="plan" {...props}> <Collapsible asChild data-slot="plan" {...props}>
<Card className={cn("shadow-none", className)}>{children}</Card> <Card className={cn("shadow-none", className)}>{children}</Card>
</Collapsible> </Collapsible>
</PlanContext.Provider> </PlanContext.Provider>
); );
export type PlanHeaderProps = ComponentProps<typeof CardHeader>; export type PlanHeaderProps = ComponentProps<typeof CardHeader>;
export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => ( export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
<CardHeader <CardHeader
className={cn("flex items-start justify-between", className)} className={cn("flex items-start justify-between", className)}
data-slot="plan-header" data-slot="plan-header"
{...props} {...props}
/> />
); );
export type PlanTitleProps = Omit< export type PlanTitleProps = Omit<
ComponentProps<typeof CardTitle>, ComponentProps<typeof CardTitle>,
"children" "children"
> & { > & {
children: string; children: string;
}; };
export const PlanTitle = ({ children, ...props }: PlanTitleProps) => { export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
const { isStreaming } = usePlan(); const { isStreaming } = usePlan();
return ( return (
<CardTitle data-slot="plan-title" {...props}> <CardTitle data-slot="plan-title" {...props}>
{isStreaming ? <Shimmer>{children}</Shimmer> : children} {isStreaming ? <Shimmer>{children}</Shimmer> : children}
</CardTitle> </CardTitle>
); );
}; };
export type PlanDescriptionProps = Omit< export type PlanDescriptionProps = Omit<
ComponentProps<typeof CardDescription>, ComponentProps<typeof CardDescription>,
"children" "children"
> & { > & {
children: string; children: string;
}; };
export const PlanDescription = ({ export const PlanDescription = ({
className, className,
children, children,
...props ...props
}: PlanDescriptionProps) => { }: PlanDescriptionProps) => {
const { isStreaming } = usePlan(); const { isStreaming } = usePlan();
return ( return (
<CardDescription <CardDescription
className={cn("text-balance", className)} className={cn("text-balance", className)}
data-slot="plan-description" data-slot="plan-description"
{...props} {...props}
> >
{isStreaming ? <Shimmer>{children}</Shimmer> : children} {isStreaming ? <Shimmer>{children}</Shimmer> : children}
</CardDescription> </CardDescription>
); );
}; };
export type PlanActionProps = ComponentProps<typeof CardAction>; export type PlanActionProps = ComponentProps<typeof CardAction>;
export const PlanAction = (props: PlanActionProps) => ( export const PlanAction = (props: PlanActionProps) => (
<CardAction data-slot="plan-action" {...props} /> <CardAction data-slot="plan-action" {...props} />
); );
export type PlanContentProps = ComponentProps<typeof CardContent>; export type PlanContentProps = ComponentProps<typeof CardContent>;
export const PlanContent = (props: PlanContentProps) => ( export const PlanContent = (props: PlanContentProps) => (
<CollapsibleContent asChild> <CollapsibleContent asChild>
<CardContent data-slot="plan-content" {...props} /> <CardContent data-slot="plan-content" {...props} />
</CollapsibleContent> </CollapsibleContent>
); );
export type PlanFooterProps = ComponentProps<"div">; export type PlanFooterProps = ComponentProps<"div">;
export const PlanFooter = (props: PlanFooterProps) => ( export const PlanFooter = (props: PlanFooterProps) => (
<CardFooter data-slot="plan-footer" {...props} /> <CardFooter data-slot="plan-footer" {...props} />
); );
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>; export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => ( export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button <Button
className={cn("size-8", className)} className={cn("size-8", className)}
data-slot="plan-trigger" data-slot="plan-trigger"
size="icon" size="icon"
variant="ghost" variant="ghost"
{...props} {...props}
> >
<ChevronsUpDownIcon className="size-4" /> <ChevronsUpDownIcon className="size-4" />
<span className="sr-only">Toggle plan</span> <span className="sr-only">Toggle plan</span>
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
); );
File diff suppressed because it is too large. Load diff
+274 -274
View File
@@ -1,274 +1,274 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronDownIcon, PaperclipIcon } from "lucide-react"; import { ChevronDownIcon, PaperclipIcon } from "lucide-react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
export type QueueMessagePart = { export type QueueMessagePart = {
type: string; type: string;
text?: string; text?: string;
url?: string; url?: string;
filename?: string; filename?: string;
mediaType?: string; mediaType?: string;
}; };
export type QueueMessage = { export type QueueMessage = {
id: string; id: string;
parts: QueueMessagePart[]; parts: QueueMessagePart[];
}; };
export type QueueTodo = { export type QueueTodo = {
id: string; id: string;
title: string; title: string;
description?: string; description?: string;
status?: "pending" | "completed"; status?: "pending" | "completed";
}; };
export type QueueItemProps = ComponentProps<"li">; export type QueueItemProps = ComponentProps<"li">;
export const QueueItem = ({ className, ...props }: QueueItemProps) => ( export const QueueItem = ({ className, ...props }: QueueItemProps) => (
<li <li
className={cn( className={cn(
"group hover:bg-muted flex flex-col gap-1 rounded-md px-3 py-1 text-sm transition-colors", "group hover:bg-muted flex flex-col gap-1 rounded-md px-3 py-1 text-sm transition-colors",
className, className,
)} )}
{...props} {...props}
/> />
); );
export type QueueItemIndicatorProps = ComponentProps<"span"> & { export type QueueItemIndicatorProps = ComponentProps<"span"> & {
completed?: boolean; completed?: boolean;
}; };
export const QueueItemIndicator = ({ export const QueueItemIndicator = ({
completed = false, completed = false,
className, className,
...props ...props
}: QueueItemIndicatorProps) => ( }: QueueItemIndicatorProps) => (
<span <span
className={cn( className={cn(
"mt-0.5 inline-block size-2.5 rounded-full border", "mt-0.5 inline-block size-2.5 rounded-full border",
completed completed
? "border-muted-foreground/20 bg-muted-foreground/10" ? "border-muted-foreground/20 bg-muted-foreground/10"
: "border-muted-foreground/50", : "border-muted-foreground/50",
className, className,
)} )}
{...props} {...props}
/> />
); );
export type QueueItemContentProps = ComponentProps<"span"> & { export type QueueItemContentProps = ComponentProps<"span"> & {
completed?: boolean; completed?: boolean;
}; };
export const QueueItemContent = ({ export const QueueItemContent = ({
completed = false, completed = false,
className, className,
...props ...props
}: QueueItemContentProps) => ( }: QueueItemContentProps) => (
<span <span
className={cn( className={cn(
"line-clamp-1 grow break-words", "line-clamp-1 grow break-words",
completed completed
? "text-muted-foreground/50 line-through" ? "text-muted-foreground/50 line-through"
: "text-muted-foreground", : "text-muted-foreground",
className, className,
)} )}
{...props} {...props}
/> />
); );
export type QueueItemDescriptionProps = ComponentProps<"div"> & { export type QueueItemDescriptionProps = ComponentProps<"div"> & {
completed?: boolean; completed?: boolean;
}; };
export const QueueItemDescription = ({ export const QueueItemDescription = ({
completed = false, completed = false,
className, className,
...props ...props
}: QueueItemDescriptionProps) => ( }: QueueItemDescriptionProps) => (
<div <div
className={cn( className={cn(
"ml-6 text-xs", "ml-6 text-xs",
completed completed
? "text-muted-foreground/40 line-through" ? "text-muted-foreground/40 line-through"
: "text-muted-foreground", : "text-muted-foreground",
className, className,
)} )}
{...props} {...props}
/> />
); );
export type QueueItemActionsProps = ComponentProps<"div">; export type QueueItemActionsProps = ComponentProps<"div">;
export const QueueItemActions = ({ export const QueueItemActions = ({
className, className,
...props ...props
}: QueueItemActionsProps) => ( }: QueueItemActionsProps) => (
<div className={cn("flex gap-1", className)} {...props} /> <div className={cn("flex gap-1", className)} {...props} />
); );
export type QueueItemActionProps = Omit< export type QueueItemActionProps = Omit<
ComponentProps<typeof Button>, ComponentProps<typeof Button>,
"variant" | "size" "variant" | "size"
>; >;
export const QueueItemAction = ({ export const QueueItemAction = ({
className, className,
...props ...props
}: QueueItemActionProps) => ( }: QueueItemActionProps) => (
<Button <Button
className={cn( className={cn(
"text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground size-auto rounded p-1 opacity-0 transition-opacity group-hover:opacity-100", "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground size-auto rounded p-1 opacity-0 transition-opacity group-hover:opacity-100",
className, className,
)} )}
size="icon" size="icon"
type="button" type="button"
variant="ghost" variant="ghost"
{...props} {...props}
/> />
); );
export type QueueItemAttachmentProps = ComponentProps<"div">; export type QueueItemAttachmentProps = ComponentProps<"div">;
export const QueueItemAttachment = ({ export const QueueItemAttachment = ({
className, className,
...props ...props
}: QueueItemAttachmentProps) => ( }: QueueItemAttachmentProps) => (
<div className={cn("mt-1 flex flex-wrap gap-2", className)} {...props} /> <div className={cn("mt-1 flex flex-wrap gap-2", className)} {...props} />
); );
export type QueueItemImageProps = ComponentProps<"img">; export type QueueItemImageProps = ComponentProps<"img">;
export const QueueItemImage = ({ export const QueueItemImage = ({
className, className,
...props ...props
}: QueueItemImageProps) => ( }: QueueItemImageProps) => (
<img <img
alt="" alt=""
className={cn("h-8 w-8 rounded border object-cover", className)} className={cn("h-8 w-8 rounded border object-cover", className)}
height={32} height={32}
width={32} width={32}
{...props} {...props}
/> />
); );
export type QueueItemFileProps = ComponentProps<"span">; export type QueueItemFileProps = ComponentProps<"span">;
export const QueueItemFile = ({ export const QueueItemFile = ({
children, children,
className, className,
...props ...props
}: QueueItemFileProps) => ( }: QueueItemFileProps) => (
<span <span
className={cn( className={cn(
"bg-muted flex items-center gap-1 rounded border px-2 py-1 text-xs", "bg-muted flex items-center gap-1 rounded border px-2 py-1 text-xs",
className, className,
)} )}
{...props} {...props}
> >
<PaperclipIcon size={12} /> <PaperclipIcon size={12} />
<span className="max-w-[100px] truncate">{children}</span> <span className="max-w-[100px] truncate">{children}</span>
</span> </span>
); );
export type QueueListProps = ComponentProps<typeof ScrollArea>; export type QueueListProps = ComponentProps<typeof ScrollArea>;
export const QueueList = ({ export const QueueList = ({
children, children,
className, className,
...props ...props
}: QueueListProps) => ( }: QueueListProps) => (
<ScrollArea className={cn("mt-2 -mb-1", className)} {...props}> <ScrollArea className={cn("mt-2 -mb-1", className)} {...props}>
<div className="max-h-40 pr-4"> <div className="max-h-40 pr-4">
<ul>{children}</ul> <ul>{children}</ul>
</div> </div>
</ScrollArea> </ScrollArea>
); );
// QueueSection - collapsible section container // QueueSection - collapsible section container
export type QueueSectionProps = ComponentProps<typeof Collapsible>; export type QueueSectionProps = ComponentProps<typeof Collapsible>;
export const QueueSection = ({ export const QueueSection = ({
className, className,
defaultOpen = true, defaultOpen = true,
...props ...props
}: QueueSectionProps) => ( }: QueueSectionProps) => (
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} /> <Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
); );
// QueueSectionTrigger - section header/trigger // QueueSectionTrigger - section header/trigger
export type QueueSectionTriggerProps = ComponentProps<"button">; export type QueueSectionTriggerProps = ComponentProps<"button">;
export const QueueSectionTrigger = ({ export const QueueSectionTrigger = ({
children, children,
className, className,
...props ...props
}: QueueSectionTriggerProps) => ( }: QueueSectionTriggerProps) => (
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button <button
className={cn( className={cn(
"group bg-muted/40 text-muted-foreground hover:bg-muted flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm font-medium transition-colors", "group bg-muted/40 text-muted-foreground hover:bg-muted flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm font-medium transition-colors",
className, className,
)} )}
type="button" type="button"
{...props} {...props}
> >
{children} {children}
</button> </button>
</CollapsibleTrigger> </CollapsibleTrigger>
); );
// QueueSectionLabel - label content with icon and count // QueueSectionLabel - label content with icon and count
export type QueueSectionLabelProps = ComponentProps<"span"> & { export type QueueSectionLabelProps = ComponentProps<"span"> & {
count?: number; count?: number;
label: string; label: string;
icon?: React.ReactNode; icon?: React.ReactNode;
}; };
export const QueueSectionLabel = ({ export const QueueSectionLabel = ({
count, count,
label, label,
icon, icon,
className, className,
...props ...props
}: QueueSectionLabelProps) => ( }: QueueSectionLabelProps) => (
<span className={cn("flex items-center gap-2", className)} {...props}> <span className={cn("flex items-center gap-2", className)} {...props}>
<ChevronDownIcon className="size-4 transition-transform group-data-[state=closed]:-rotate-90" /> <ChevronDownIcon className="size-4 transition-transform group-data-[state=closed]:-rotate-90" />
{icon} {icon}
<span> <span>
{count} {label} {count} {label}
</span> </span>
</span> </span>
); );
// QueueSectionContent - collapsible content area // QueueSectionContent - collapsible content area
export type QueueSectionContentProps = ComponentProps< export type QueueSectionContentProps = ComponentProps<
typeof CollapsibleContent typeof CollapsibleContent
>; >;
export const QueueSectionContent = ({ export const QueueSectionContent = ({
className, className,
...props ...props
}: QueueSectionContentProps) => ( }: QueueSectionContentProps) => (
<CollapsibleContent className={cn(className)} {...props} /> <CollapsibleContent className={cn(className)} {...props} />
); );
export type QueueProps = ComponentProps<"div">; export type QueueProps = ComponentProps<"div">;
export const Queue = ({ className, ...props }: QueueProps) => ( export const Queue = ({ className, ...props }: QueueProps) => (
<div <div
className={cn( className={cn(
"border-border bg-background flex flex-col gap-2 rounded-xl border px-3 pt-2 pb-2 shadow-xs", "border-border bg-background flex flex-col gap-2 rounded-xl border px-3 pt-2 pb-2 shadow-xs",
className, className,
)} )}
{...props} {...props}
/> />
); );
@@ -1,188 +1,188 @@
"use client"; "use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state"; import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BrainIcon, ChevronDownIcon } from "lucide-react"; import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react"; import { createContext, memo, useContext, useEffect, useState } from "react";
import { Streamdown } from "streamdown"; import { Streamdown } from "streamdown";
import { reasoningPlugins } from "@/core/streamdown/plugins"; import { reasoningPlugins } from "@/core/streamdown/plugins";
import { Shimmer } from "./shimmer"; import { Shimmer } from "./shimmer";
type ReasoningContextValue = { type ReasoningContextValue = {
isStreaming: boolean; isStreaming: boolean;
isOpen: boolean; isOpen: boolean;
setIsOpen: (open: boolean) => void; setIsOpen: (open: boolean) => void;
duration: number | undefined; duration: number | undefined;
}; };
const ReasoningContext = createContext<ReasoningContextValue | null>(null); const ReasoningContext = createContext<ReasoningContextValue | null>(null);
export const useReasoning = () => { export const useReasoning = () => {
const context = useContext(ReasoningContext); const context = useContext(ReasoningContext);
if (!context) { if (!context) {
throw new Error("Reasoning components must be used within Reasoning"); throw new Error("Reasoning components must be used within Reasoning");
} }
return context; return context;
}; };
export type ReasoningProps = ComponentProps<typeof Collapsible> & { export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean; isStreaming?: boolean;
open?: boolean; open?: boolean;
defaultOpen?: boolean; defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
duration?: number; duration?: number;
}; };
const AUTO_CLOSE_DELAY = 1000; const AUTO_CLOSE_DELAY = 1000;
const MS_IN_S = 1000; const MS_IN_S = 1000;
export const Reasoning = memo( export const Reasoning = memo(
({ ({
className, className,
isStreaming = false, isStreaming = false,
open, open,
defaultOpen = true, defaultOpen = true,
onOpenChange, onOpenChange,
duration: durationProp, duration: durationProp,
children, children,
...props ...props
}: ReasoningProps) => { }: ReasoningProps) => {
const [isOpen, setIsOpen] = useControllableState({ const [isOpen, setIsOpen] = useControllableState({
prop: open, prop: open,
defaultProp: defaultOpen, defaultProp: defaultOpen,
onChange: onOpenChange, onChange: onOpenChange,
}); });
const [duration, setDuration] = useControllableState({ const [duration, setDuration] = useControllableState({
prop: durationProp, prop: durationProp,
defaultProp: undefined, defaultProp: undefined,
}); });
const [hasAutoClosed, setHasAutoClosed] = useState(false); const [hasAutoClosed, setHasAutoClosed] = useState(false);
const [startTime, setStartTime] = useState<number | null>(null); const [startTime, setStartTime] = useState<number | null>(null);
// Track duration when streaming starts and ends // Track duration when streaming starts and ends
useEffect(() => { useEffect(() => {
if (isStreaming) { if (isStreaming) {
if (startTime === null) { if (startTime === null) {
setStartTime(Date.now()); setStartTime(Date.now());
} }
} else if (startTime !== null) { } else if (startTime !== null) {
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S)); setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
setStartTime(null); setStartTime(null);
} }
}, [isStreaming, startTime, setDuration]); }, [isStreaming, startTime, setDuration]);
// Auto-open when streaming starts, auto-close when streaming ends (once only) // Auto-open when streaming starts, auto-close when streaming ends (once only)
useEffect(() => { useEffect(() => {
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) { if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
// Add a small delay before closing to allow user to see the content // Add a small delay before closing to allow user to see the content
const timer = setTimeout(() => { const timer = setTimeout(() => {
setIsOpen(false); setIsOpen(false);
setHasAutoClosed(true); setHasAutoClosed(true);
}, AUTO_CLOSE_DELAY); }, AUTO_CLOSE_DELAY);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed]); }, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed]);
const handleOpenChange = (newOpen: boolean) => { const handleOpenChange = (newOpen: boolean) => {
setIsOpen(newOpen); setIsOpen(newOpen);
}; };
return ( return (
<ReasoningContext.Provider <ReasoningContext.Provider
value={{ isStreaming, isOpen, setIsOpen, duration }} value={{ isStreaming, isOpen, setIsOpen, duration }}
> >
<Collapsible <Collapsible
className={cn("not-prose mb-4", className)} className={cn("not-prose mb-4", className)}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
open={isOpen} open={isOpen}
{...props} {...props}
> >
{children} {children}
</Collapsible> </Collapsible>
</ReasoningContext.Provider> </ReasoningContext.Provider>
); );
}, },
); );
export type ReasoningTriggerProps = ComponentProps< export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger typeof CollapsibleTrigger
> & { > & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode; getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
}; };
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => { const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) { if (isStreaming || duration === 0) {
return <Shimmer duration={1}>Thinking...</Shimmer>; return <Shimmer duration={1}>Thinking...</Shimmer>;
} }
if (duration === undefined) { if (duration === undefined) {
return <span>Thought for a few seconds</span>; return <span>Thought for a few seconds</span>;
} }
return <span>Thought for {duration} seconds</span>; return <span>Thought for {duration} seconds</span>;
}; };
export const ReasoningTrigger = memo( export const ReasoningTrigger = memo(
({ ({
className, className,
children, children,
getThinkingMessage = defaultGetThinkingMessage, getThinkingMessage = defaultGetThinkingMessage,
...props ...props
}: ReasoningTriggerProps) => { }: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning(); const { isStreaming, isOpen, duration } = useReasoning();
return ( return (
<CollapsibleTrigger <CollapsibleTrigger
className={cn( className={cn(
"text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors", "text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors",
className, className,
)} )}
{...props} {...props}
> >
{children ?? ( {children ?? (
<> <>
<BrainIcon className="size-4" /> <BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)} {getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon <ChevronDownIcon
className={cn( className={cn(
"size-4 transition-transform", "size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0", isOpen ? "rotate-180" : "rotate-0",
)} )}
/> />
</> </>
)} )}
</CollapsibleTrigger> </CollapsibleTrigger>
); );
}, },
); );
export type ReasoningContentProps = ComponentProps< export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent typeof CollapsibleContent
> & { > & {
children: string; children: string;
}; };
export const ReasoningContent = memo( export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => ( ({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent <CollapsibleContent
className={cn( className={cn(
"mt-4 text-sm", "mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none", "data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none",
className, className,
)} )}
{...props} {...props}
> >
<Streamdown {...reasoningPlugins}>{children}</Streamdown> <Streamdown {...reasoningPlugins}>{children}</Streamdown>
</CollapsibleContent> </CollapsibleContent>
), ),
); );
Reasoning.displayName = "Reasoning"; Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger"; ReasoningTrigger.displayName = "ReasoningTrigger";
ReasoningContent.displayName = "ReasoningContent"; ReasoningContent.displayName = "ReasoningContent";
@@ -1,64 +1,64 @@
"use client"; "use client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { import {
type CSSProperties, type CSSProperties,
type ElementType, type ElementType,
type JSX, type JSX,
memo, memo,
useMemo, useMemo,
} from "react"; } from "react";
export type TextShimmerProps = { export type TextShimmerProps = {
children: string; children: string;
as?: ElementType; as?: ElementType;
className?: string; className?: string;
duration?: number; duration?: number;
spread?: number; spread?: number;
}; };
const ShimmerComponent = ({ const ShimmerComponent = ({
children, children,
as: Component = "p", as: Component = "p",
className, className,
duration = 2, duration = 2,
spread = 2, spread = 2,
}: TextShimmerProps) => { }: TextShimmerProps) => {
const MotionComponent = motion.create( const MotionComponent = motion.create(
Component as keyof JSX.IntrinsicElements, Component as keyof JSX.IntrinsicElements,
); );
const dynamicSpread = useMemo( const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread, () => (children?.length ?? 0) * spread,
[children, spread], [children, spread],
); );
return ( return (
<MotionComponent <MotionComponent
animate={{ backgroundPosition: "0% center" }} animate={{ backgroundPosition: "0% center" }}
className={cn( className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent", "relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[background-repeat:no-repeat,padding-box] [--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))]", "[background-repeat:no-repeat,padding-box] [--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))]",
className, className,
)} )}
initial={{ backgroundPosition: "100% center" }} initial={{ backgroundPosition: "100% center" }}
style={ style={
{ {
"--spread": `${dynamicSpread}px`, "--spread": `${dynamicSpread}px`,
backgroundImage: backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))", "var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties } as CSSProperties
} }
transition={{ transition={{
repeat: Number.POSITIVE_INFINITY, repeat: Number.POSITIVE_INFINITY,
duration, duration,
ease: "linear", ease: "linear",
}} }}
> >
{children} {children}
</MotionComponent> </MotionComponent>
); );
}; };
export const Shimmer = memo(ShimmerComponent); export const Shimmer = memo(ShimmerComponent);
@@ -1,77 +1,77 @@
"use client"; "use client";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BookIcon, ChevronDownIcon } from "lucide-react"; import { BookIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
export type SourcesProps = ComponentProps<"div">; export type SourcesProps = ComponentProps<"div">;
export const Sources = ({ className, ...props }: SourcesProps) => ( export const Sources = ({ className, ...props }: SourcesProps) => (
<Collapsible <Collapsible
className={cn("not-prose text-primary mb-4 text-xs", className)} className={cn("not-prose text-primary mb-4 text-xs", className)}
{...props} {...props}
/> />
); );
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & { export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
count: number; count: number;
}; };
export const SourcesTrigger = ({ export const SourcesTrigger = ({
className, className,
count, count,
children, children,
...props ...props
}: SourcesTriggerProps) => ( }: SourcesTriggerProps) => (
<CollapsibleTrigger <CollapsibleTrigger
className={cn("flex items-center gap-2", className)} className={cn("flex items-center gap-2", className)}
{...props} {...props}
> >
{children ?? ( {children ?? (
<> <>
<p className="font-medium">Used {count} sources</p> <p className="font-medium">Used {count} sources</p>
<ChevronDownIcon className="h-4 w-4" /> <ChevronDownIcon className="h-4 w-4" />
</> </>
)} )}
</CollapsibleTrigger> </CollapsibleTrigger>
); );
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>; export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
export const SourcesContent = ({ export const SourcesContent = ({
className, className,
...props ...props
}: SourcesContentProps) => ( }: SourcesContentProps) => (
<CollapsibleContent <CollapsibleContent
className={cn( className={cn(
"mt-3 flex w-fit flex-col gap-2", "mt-3 flex w-fit flex-col gap-2",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 data-[state=closed]:animate-out data-[state=open]:animate-in outline-none", "data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 data-[state=closed]:animate-out data-[state=open]:animate-in outline-none",
className, className,
)} )}
{...props} {...props}
/> />
); );
export type SourceProps = ComponentProps<"a">; export type SourceProps = ComponentProps<"a">;
export const Source = ({ href, title, children, ...props }: SourceProps) => ( export const Source = ({ href, title, children, ...props }: SourceProps) => (
<a <a
className="flex items-center gap-2" className="flex items-center gap-2"
href={href} href={href}
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
{...props} {...props}
> >
{children ?? ( {children ?? (
<> <>
<BookIcon className="h-4 w-4" /> <BookIcon className="h-4 w-4" />
<span className="block font-medium">{title}</span> <span className="block font-medium">{title}</span>
</> </>
)} )}
</a> </a>
); );
@@ -1,80 +1,80 @@
"use client"; "use client";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Children, type ComponentProps } from "react"; import { Children, type ComponentProps } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const STAGGER_DELAY_MS = 60; const STAGGER_DELAY_MS = 60;
const STAGGER_DELAY_MS_OFFSET = 250; const STAGGER_DELAY_MS_OFFSET = 250;
export type SuggestionsProps = ComponentProps<typeof ScrollArea>; export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
export const Suggestions = ({ export const Suggestions = ({
className, className,
children, children,
...props ...props
}: SuggestionsProps) => ( }: SuggestionsProps) => (
<ScrollArea className="overflow-x-auto whitespace-normal" {...props}> <ScrollArea className="overflow-x-auto whitespace-normal" {...props}>
<div <div
className={cn("flex w-full flex-wrap items-center gap-2", className)} className={cn("flex w-full flex-wrap items-center gap-2", className)}
data-slot="suggestions-list" data-slot="suggestions-list"
> >
{Children.map(children, (child, index) => {Children.map(children, (child, index) =>
child != null ? ( child != null ? (
<span <span
className="animate-fade-in-up max-w-full opacity-0" className="animate-fade-in-up max-w-full opacity-0"
style={{ style={{
animationDelay: `${STAGGER_DELAY_MS_OFFSET + index * STAGGER_DELAY_MS}ms`, animationDelay: `${STAGGER_DELAY_MS_OFFSET + index * STAGGER_DELAY_MS}ms`,
}} }}
> >
{child} {child}
</span> </span>
) : ( ) : (
child child
), ),
)} )}
</div> </div>
<ScrollBar className="hidden" orientation="horizontal" /> <ScrollBar className="hidden" orientation="horizontal" />
</ScrollArea> </ScrollArea>
); );
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & { export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
suggestion: React.ReactNode; suggestion: React.ReactNode;
icon?: LucideIcon; icon?: LucideIcon;
onClick?: () => void; onClick?: () => void;
}; };
export const Suggestion = ({ export const Suggestion = ({
suggestion, suggestion,
onClick, onClick,
className, className,
icon: Icon, icon: Icon,
variant = "outline", variant = "outline",
size = "sm", size = "sm",
children, children,
...props ...props
}: SuggestionProps) => { }: SuggestionProps) => {
const handleClick = () => { const handleClick = () => {
onClick?.(); onClick?.();
}; };
return ( return (
<Button <Button
className={cn( className={cn(
"text-muted-foreground dark:bg-background h-auto max-w-full cursor-pointer rounded-full px-4 py-2 text-center text-xs font-normal whitespace-normal", "text-muted-foreground dark:bg-background h-auto max-w-full cursor-pointer rounded-full px-4 py-2 text-center text-xs font-normal whitespace-normal",
className, className,
)} )}
onClick={handleClick} onClick={handleClick}
size={size} size={size}
type="button" type="button"
variant={variant} variant={variant}
{...props} {...props}
> >
{Icon && <Icon className="size-4" />} {Icon && <Icon className="size-4" />}
{children ?? suggestion} {children ?? suggestion}
</Button> </Button>
); );
}; };
@@ -1,87 +1,87 @@
"use client"; "use client";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronDownIcon, SearchIcon } from "lucide-react"; import { ChevronDownIcon, SearchIcon } from "lucide-react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
export type TaskItemFileProps = ComponentProps<"div">; export type TaskItemFileProps = ComponentProps<"div">;
export const TaskItemFile = ({ export const TaskItemFile = ({
children, children,
className, className,
...props ...props
}: TaskItemFileProps) => ( }: TaskItemFileProps) => (
<div <div
className={cn( className={cn(
"bg-secondary text-foreground inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs", "bg-secondary text-foreground inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
</div> </div>
); );
export type TaskItemProps = ComponentProps<"div">; export type TaskItemProps = ComponentProps<"div">;
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => ( export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
<div className={cn("text-muted-foreground text-sm", className)} {...props}> <div className={cn("text-muted-foreground text-sm", className)} {...props}>
{children} {children}
</div> </div>
); );
export type TaskProps = ComponentProps<typeof Collapsible>; export type TaskProps = ComponentProps<typeof Collapsible>;
export const Task = ({ export const Task = ({
defaultOpen = true, defaultOpen = true,
className, className,
...props ...props
}: TaskProps) => ( }: TaskProps) => (
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} /> <Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
); );
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & { export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
title: string; title: string;
}; };
export const TaskTrigger = ({ export const TaskTrigger = ({
children, children,
className, className,
title, title,
...props ...props
}: TaskTriggerProps) => ( }: TaskTriggerProps) => (
<CollapsibleTrigger asChild className={cn("group", className)} {...props}> <CollapsibleTrigger asChild className={cn("group", className)} {...props}>
{children ?? ( {children ?? (
<div className="text-muted-foreground hover:text-foreground flex w-full cursor-pointer items-center gap-2 text-sm transition-colors"> <div className="text-muted-foreground hover:text-foreground flex w-full cursor-pointer items-center gap-2 text-sm transition-colors">
<SearchIcon className="size-4" /> <SearchIcon className="size-4" />
<p className="text-sm">{title}</p> <p className="text-sm">{title}</p>
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" /> <ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
</div> </div>
)} )}
</CollapsibleTrigger> </CollapsibleTrigger>
); );
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>; export type TaskContentProps = ComponentProps<typeof CollapsibleContent>;
export const TaskContent = ({ export const TaskContent = ({
children, children,
className, className,
...props ...props
}: TaskContentProps) => ( }: TaskContentProps) => (
<CollapsibleContent <CollapsibleContent
className={cn( className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none", "data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground data-[state=closed]:animate-out data-[state=open]:animate-in outline-none",
className, className,
)} )}
{...props} {...props}
> >
<div className="border-muted mt-4 space-y-2 border-l-2 pl-4"> <div className="border-muted mt-4 space-y-2 border-l-2 pl-4">
{children} {children}
</div> </div>
</CollapsibleContent> </CollapsibleContent>
); );
@@ -1,16 +1,16 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { NodeToolbar, Position } from "@xyflow/react"; import { NodeToolbar, Position } from "@xyflow/react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
type ToolbarProps = ComponentProps<typeof NodeToolbar>; type ToolbarProps = ComponentProps<typeof NodeToolbar>;
export const Toolbar = ({ className, ...props }: ToolbarProps) => ( export const Toolbar = ({ className, ...props }: ToolbarProps) => (
<NodeToolbar <NodeToolbar
className={cn( className={cn(
"bg-background flex items-center gap-1 rounded-sm border p-1.5", "bg-background flex items-center gap-1 rounded-sm border p-1.5",
className, className,
)} )}
position={Position.Bottom} position={Position.Bottom}
{...props} {...props}
/> />
); );
@@ -1,263 +1,263 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronDownIcon } from "lucide-react"; import { ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
import { createContext, useContext, useEffect, useState } from "react"; import { createContext, useContext, useEffect, useState } from "react";
export type WebPreviewContextValue = { export type WebPreviewContextValue = {
url: string; url: string;
setUrl: (url: string) => void; setUrl: (url: string) => void;
consoleOpen: boolean; consoleOpen: boolean;
setConsoleOpen: (open: boolean) => void; setConsoleOpen: (open: boolean) => void;
}; };
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null); const WebPreviewContext = createContext<WebPreviewContextValue | null>(null);
const useWebPreview = () => { const useWebPreview = () => {
const context = useContext(WebPreviewContext); const context = useContext(WebPreviewContext);
if (!context) { if (!context) {
throw new Error("WebPreview components must be used within a WebPreview"); throw new Error("WebPreview components must be used within a WebPreview");
} }
return context; return context;
}; };
export type WebPreviewProps = ComponentProps<"div"> & { export type WebPreviewProps = ComponentProps<"div"> & {
defaultUrl?: string; defaultUrl?: string;
onUrlChange?: (url: string) => void; onUrlChange?: (url: string) => void;
}; };
export const WebPreview = ({ export const WebPreview = ({
className, className,
children, children,
defaultUrl = "", defaultUrl = "",
onUrlChange, onUrlChange,
...props ...props
}: WebPreviewProps) => { }: WebPreviewProps) => {
const [url, setUrl] = useState(defaultUrl); const [url, setUrl] = useState(defaultUrl);
const [consoleOpen, setConsoleOpen] = useState(false); const [consoleOpen, setConsoleOpen] = useState(false);
const handleUrlChange = (newUrl: string) => { const handleUrlChange = (newUrl: string) => {
setUrl(newUrl); setUrl(newUrl);
onUrlChange?.(newUrl); onUrlChange?.(newUrl);
}; };
const contextValue: WebPreviewContextValue = { const contextValue: WebPreviewContextValue = {
url, url,
setUrl: handleUrlChange, setUrl: handleUrlChange,
consoleOpen, consoleOpen,
setConsoleOpen, setConsoleOpen,
}; };
return ( return (
<WebPreviewContext.Provider value={contextValue}> <WebPreviewContext.Provider value={contextValue}>
<div <div
className={cn( className={cn(
"bg-card flex size-full flex-col rounded-lg border", "bg-card flex size-full flex-col rounded-lg border",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
</div> </div>
</WebPreviewContext.Provider> </WebPreviewContext.Provider>
); );
}; };
export type WebPreviewNavigationProps = ComponentProps<"div">; export type WebPreviewNavigationProps = ComponentProps<"div">;
export const WebPreviewNavigation = ({ export const WebPreviewNavigation = ({
className, className,
children, children,
...props ...props
}: WebPreviewNavigationProps) => ( }: WebPreviewNavigationProps) => (
<div <div
className={cn("flex items-center gap-1 border-b p-2", className)} className={cn("flex items-center gap-1 border-b p-2", className)}
{...props} {...props}
> >
{children} {children}
</div> </div>
); );
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & { export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
tooltip?: string; tooltip?: string;
}; };
export const WebPreviewNavigationButton = ({ export const WebPreviewNavigationButton = ({
onClick, onClick,
disabled, disabled,
tooltip, tooltip,
children, children,
...props ...props
}: WebPreviewNavigationButtonProps) => ( }: WebPreviewNavigationButtonProps) => (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
className="hover:text-foreground h-8 w-8 p-0" className="hover:text-foreground h-8 w-8 p-0"
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
size="sm" size="sm"
variant="ghost" variant="ghost"
{...props} {...props}
> >
{children} {children}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{tooltip}</p> <p>{tooltip}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
); );
export type WebPreviewUrlProps = ComponentProps<typeof Input>; export type WebPreviewUrlProps = ComponentProps<typeof Input>;
export const WebPreviewUrl = ({ export const WebPreviewUrl = ({
value, value,
onChange, onChange,
onKeyDown, onKeyDown,
...props ...props
}: WebPreviewUrlProps) => { }: WebPreviewUrlProps) => {
const { url, setUrl } = useWebPreview(); const { url, setUrl } = useWebPreview();
const [inputValue, setInputValue] = useState(url); const [inputValue, setInputValue] = useState(url);
// Sync input value with context URL when it changes externally // Sync input value with context URL when it changes externally
useEffect(() => { useEffect(() => {
setInputValue(url); setInputValue(url);
}, [url]); }, [url]);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value); setInputValue(event.target.value);
onChange?.(event); onChange?.(event);
}; };
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") { if (event.key === "Enter") {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
setUrl(target.value); setUrl(target.value);
} }
onKeyDown?.(event); onKeyDown?.(event);
}; };
return ( return (
<Input <Input
className="h-8 flex-1 text-sm" className="h-8 flex-1 text-sm"
onChange={onChange ?? handleChange} onChange={onChange ?? handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Enter URL..." placeholder="Enter URL..."
value={value ?? inputValue} value={value ?? inputValue}
{...props} {...props}
/> />
); );
}; };
export type WebPreviewBodyProps = ComponentProps<"iframe"> & { export type WebPreviewBodyProps = ComponentProps<"iframe"> & {
loading?: ReactNode; loading?: ReactNode;
}; };
export const WebPreviewBody = ({ export const WebPreviewBody = ({
className, className,
loading, loading,
src, src,
...props ...props
}: WebPreviewBodyProps) => { }: WebPreviewBodyProps) => {
const { url } = useWebPreview(); const { url } = useWebPreview();
return ( return (
<div className="flex-1"> <div className="flex-1">
<iframe <iframe
className={cn("size-full", className)} className={cn("size-full", className)}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation" sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
src={(src ?? url) || undefined} src={(src ?? url) || undefined}
title="Preview" title="Preview"
{...props} {...props}
/> />
{loading} {loading}
</div> </div>
); );
}; };
export type WebPreviewConsoleProps = ComponentProps<"div"> & { export type WebPreviewConsoleProps = ComponentProps<"div"> & {
logs?: Array<{ logs?: Array<{
level: "log" | "warn" | "error"; level: "log" | "warn" | "error";
message: string; message: string;
timestamp: Date; timestamp: Date;
}>; }>;
}; };
export const WebPreviewConsole = ({ export const WebPreviewConsole = ({
className, className,
logs = [], logs = [],
children, children,
...props ...props
}: WebPreviewConsoleProps) => { }: WebPreviewConsoleProps) => {
const { consoleOpen, setConsoleOpen } = useWebPreview(); const { consoleOpen, setConsoleOpen } = useWebPreview();
return ( return (
<Collapsible <Collapsible
className={cn("bg-muted/50 border-t font-mono text-sm", className)} className={cn("bg-muted/50 border-t font-mono text-sm", className)}
onOpenChange={setConsoleOpen} onOpenChange={setConsoleOpen}
open={consoleOpen} open={consoleOpen}
{...props} {...props}
> >
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button <Button
className="hover:bg-muted/50 flex w-full items-center justify-between p-4 text-left font-medium" className="hover:bg-muted/50 flex w-full items-center justify-between p-4 text-left font-medium"
variant="ghost" variant="ghost"
> >
Console Console
<ChevronDownIcon <ChevronDownIcon
className={cn( className={cn(
"h-4 w-4 transition-transform duration-200", "h-4 w-4 transition-transform duration-200",
consoleOpen && "rotate-180", consoleOpen && "rotate-180",
)} )}
/> />
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent <CollapsibleContent
className={cn( className={cn(
"px-4 pb-4", "px-4 pb-4",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=open]:animate-in outline-none", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=open]:animate-in outline-none",
)} )}
> >
<div className="max-h-48 space-y-1 overflow-y-auto"> <div className="max-h-48 space-y-1 overflow-y-auto">
{logs.length === 0 ? ( {logs.length === 0 ? (
<p className="text-muted-foreground">No console output</p> <p className="text-muted-foreground">No console output</p>
) : ( ) : (
logs.map((log, index) => ( logs.map((log, index) => (
<div <div
className={cn( className={cn(
"text-xs", "text-xs",
log.level === "error" && "text-destructive", log.level === "error" && "text-destructive",
log.level === "warn" && "text-yellow-600", log.level === "warn" && "text-yellow-600",
log.level === "log" && "text-foreground", log.level === "log" && "text-foreground",
)} )}
key={`${log.timestamp.getTime()}-${index}`} key={`${log.timestamp.getTime()}-${index}`}
> >
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{log.timestamp.toLocaleTimeString()} {log.timestamp.toLocaleTimeString()}
</span>{" "} </span>{" "}
{log.message} {log.message}
</div> </div>
)) ))
)} )}
{children} {children}
</div> </div>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
); );
}; };
@@ -1,45 +0,0 @@
"use client";
import { useEffect } from "react";
import { useLocalSettings } from "@/core/settings";
/**
* 基准字号 = 16px,对应《深蓝色 UI 规范》§2.1.2 后台系统正文字号(16px Regular)。
* 用户在「外观 → 字号」里选择的倍数通过 CSS 变量 `--ui-font-scale` 生效:globals.css
* 只把 Tailwind 的 `--text-*` 字号 token 乘以该倍数,**不动间距 token**,所以放大 / 缩小
* 的只有文字本身,padding / margin / 宽高等布局尺寸保持不变,不会把页面挤错位。
* `fontScale === 1` 即默认 16px。
*/
export const BASE_FONT_PX = 16;
export const FONT_SCALE_OPTIONS: { value: number; label: string; px: number }[] =
[
{ value: 0.875, label: "小", px: 14 },
{ value: 1, label: "标准", px: 16 },
{ value: 1.125, label: "大", px: 18 },
{ value: 1.25, label: "特大", px: 20 },
];
/**
* 把用户选择的字号倍数写到 <html> 的 `--ui-font-scale` CSS 变量。挂在应用根部,
* 保证刷新后即时生效、全局统一。只设变量、不直接改 font-size,因此布局不受影响。
* 不渲染任何 DOM。
*/
export function FontScaleApplier() {
const [settings] = useLocalSettings();
const { fontScale } = settings.display;
useEffect(() => {
const scale =
typeof fontScale === "number" && Number.isFinite(fontScale) && fontScale > 0
? fontScale
: 1;
const root = document.documentElement;
root.style.setProperty("--ui-font-scale", String(scale));
// 清掉旧实现可能残留的根级 font-size 内联样式(旧版用它做整页缩放)。
root.style.removeProperty("font-size");
}, [fontScale]);
return null;
}
@@ -1,45 +0,0 @@
import { useEffect, useState } from "react";
import { injectInPageAnchorHandler } from "@/core/utils/markdown";
/**
* Sandboxed preview of a generated HTML document.
*
* Renders via a `blob:` URL rather than `srcDoc`. A srcDoc document's base URL
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`)
* navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is
* a real document URL, so fragment links scroll within the frame as expected —
* all without enabling scripts (the sandbox stays script-free for security; the
* server already sanitizes script vectors and this is defense-in-depth).
*
* The `charset=utf-8` on the blob type is required: a blob: URL whose
* Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on
* Chinese Windows), garbling CJK text when the HTML lacks a `<meta charset>`.
*/
export function HtmlPreviewFrame({
html,
className = "h-[70vh] w-full rounded-md border bg-white",
}: {
html: string;
className?: string;
}) {
const [url, setUrl] = useState<string>();
useEffect(() => {
const blob = new Blob([injectInPageAnchorHandler(html ?? "")], {
type: "text/html;charset=utf-8",
});
const objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [html]);
return (
<iframe
title="HTML 预览"
sandbox="allow-scripts allow-forms"
src={url}
className={className}
/>
);
}
+19 -19
View File
@@ -1,19 +1,19 @@
import { useMemo } from "react"; import { useMemo } from "react";
export function Footer() { export function Footer() {
const year = useMemo(() => new Date().getFullYear(), []); const year = useMemo(() => new Date().getFullYear(), []);
return ( return (
<footer className="container-md mx-auto mt-32 flex flex-col items-center justify-center"> <footer className="container-md mx-auto mt-32 flex flex-col items-center justify-center">
<hr className="from-border/0 to-border/0 m-0 h-px w-full border-none bg-linear-to-r via-white/20" /> <hr className="from-border/0 to-border/0 m-0 h-px w-full border-none bg-linear-to-r via-white/20" />
<div className="text-muted-foreground container flex h-20 flex-col items-center justify-center text-sm"> <div className="text-muted-foreground container flex h-20 flex-col items-center justify-center text-sm">
<p className="text-center font-serif text-lg md:text-xl"> <p className="text-center font-serif text-lg md:text-xl">
&quot;Originated from Open Source, give back to Open Source.&quot; &quot;Originated from Open Source, give back to Open Source.&quot;
</p> </p>
</div> </div>
<div className="text-muted-foreground container mb-8 flex flex-col items-center justify-center text-xs"> <div className="text-muted-foreground container mb-8 flex flex-col items-center justify-center text-xs">
<p>Licensed under MIT License</p> <p>Licensed under MIT License</p>
<p>&copy; {year} CM 助手</p> <p>&copy; {year} CM业务中心-问答管理</p>
</div> </div>
</footer> </footer>
); );
} }
+127 -52
View File
@@ -1,52 +1,127 @@
"use client"; "use client";
import Link from "next/link"; import { StarFilledIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import type { Locale } from "@/core/i18n/locale"; import { useEffect, useState } from "react";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button";
import { NumberTicker } from "@/components/ui/number-ticker";
export type HeaderProps = { import type { Locale } from "@/core/i18n/locale";
className?: string; import { useI18n } from "@/core/i18n/hooks";
homeURL?: string; import { env } from "@/env";
locale?: Locale; import { cn } from "@/lib/utils";
};
export type HeaderProps = {
export function Header({ className, homeURL }: HeaderProps) { className?: string;
const isExternalHome = !homeURL; homeURL?: string;
const { locale: resolvedLocale, t } = useI18n(); locale?: Locale;
const lang = resolvedLocale.substring(0, 2); };
return (
<header export function Header({ className, homeURL }: HeaderProps) {
className={cn( const isExternalHome = !homeURL;
"container-md fixed top-0 right-0 left-0 z-20 mx-auto flex h-16 items-center justify-between backdrop-blur-xs", const { locale: resolvedLocale, t } = useI18n();
className, const lang = resolvedLocale.substring(0, 2);
)} return (
> <header
<div className="flex items-center gap-6"> className={cn(
<a "container-md fixed top-0 right-0 left-0 z-20 mx-auto flex h-16 items-center justify-between backdrop-blur-xs",
href={homeURL ?? "/"} className,
target={isExternalHome ? "_blank" : "_self"} )}
rel={isExternalHome ? "noopener noreferrer" : undefined} >
> <div className="flex items-center gap-6">
<h1 className="font-serif text-xl">CM 助手</h1> <a
</a> href={homeURL ?? "https://github.com/bytedance/deer-flow"}
</div> target={isExternalHome ? "_blank" : "_self"}
<nav className="mr-8 ml-auto flex items-center gap-8 text-sm font-medium"> rel={isExternalHome ? "noopener noreferrer" : undefined}
<Link >
href={`/${lang}/docs`} <h1 className="font-serif text-xl">CM业务中心-问答管理</h1>
className="text-secondary-foreground hover:text-foreground transition-colors" </a>
> </div>
{t.home.docs} <nav className="mr-8 ml-auto flex items-center gap-8 text-sm font-medium">
</Link> <Link
<Link href={`/${lang}/docs`}
href="/blog/posts" className="text-secondary-foreground hover:text-foreground transition-colors"
className="text-secondary-foreground hover:text-foreground transition-colors" >
> {t.home.docs}
{t.home.blog} </Link>
</Link> <Link
</nav> href="/blog/posts"
<hr className="from-border/0 via-border/70 to-border/0 absolute top-16 right-0 left-0 z-10 m-0 h-px w-full border-none bg-linear-to-r" /> className="text-secondary-foreground hover:text-foreground transition-colors"
</header> >
); {t.home.blog}
} </Link>
</nav>
<div className="relative">
<div
className="pointer-events-none absolute inset-0 z-0 h-full w-full rounded-full opacity-30 blur-2xl"
style={{
background: "linear-gradient(90deg, #ff80b5 0%, #9089fc 100%)",
filter: "blur(16px)",
}}
/>
<Button
variant="outline"
size="sm"
asChild
className="group relative z-10"
>
<a
href="https://github.com/bytedance/deer-flow"
target="_blank"
rel="noopener noreferrer"
>
<GitHubLogoIcon className="size-4" />
Star on GitHub
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
env.GITHUB_OAUTH_TOKEN && <StarCounter />}
</a>
</Button>
</div>
<hr className="from-border/0 via-border/70 to-border/0 absolute top-16 right-0 left-0 z-10 m-0 h-px w-full border-none bg-linear-to-r" />
</header>
);
}
function StarCounter() {
const [stars, setStars] = useState(10000);
useEffect(() => {
let cancelled = false;
async function loadStars() {
try {
const response = await fetch(
"https://api.github.com/repos/bytedance/deer-flow",
{
headers: env.GITHUB_OAUTH_TOKEN
? {
Authorization: `Bearer ${env.GITHUB_OAUTH_TOKEN}`,
"Content-Type": "application/json",
}
: {},
},
);
if (!response.ok) return;
const data = await response.json();
if (!cancelled && typeof data?.stargazers_count === "number") {
setStars(data.stargazers_count);
}
} catch (error) {
console.error("Error fetching GitHub stars:", error);
}
}
void loadStars();
return () => {
cancelled = true;
};
}, []);
return (
<>
<StarFilledIcon className="size-4 transition-colors duration-300 group-hover:text-yellow-500" />
<NumberTicker className="font-mono tabular-nums" value={stars} />
</>
);
}
+136 -136
View File
@@ -1,136 +1,136 @@
"use client"; "use client";
import { ChevronRightIcon } from "lucide-react"; import { ChevronRightIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { FlickeringGrid } from "@/components/ui/flickering-grid"; import { FlickeringGrid } from "@/components/ui/flickering-grid";
import Galaxy from "@/components/ui/galaxy"; import Galaxy from "@/components/ui/galaxy";
import { WordRotate } from "@/components/ui/word-rotate"; import { WordRotate } from "@/components/ui/word-rotate";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function Hero({ className }: { className?: string }) { export function Hero({ className }: { className?: string }) {
return ( return (
<div <div
className={cn( className={cn(
"flex size-full flex-col items-center justify-center", "flex size-full flex-col items-center justify-center",
className, className,
)} )}
> >
<div className="absolute inset-0 z-0 bg-black/40"> <div className="absolute inset-0 z-0 bg-black/40">
<Galaxy <Galaxy
mouseRepulsion={false} mouseRepulsion={false}
starSpeed={0.2} starSpeed={0.2}
density={0.6} density={0.6}
glowIntensity={0.35} glowIntensity={0.35}
twinkleIntensity={0.3} twinkleIntensity={0.3}
speed={0.5} speed={0.5}
/> />
</div> </div>
<FlickeringGrid <FlickeringGrid
className="absolute inset-0 z-0 translate-y-8 mask-[url(/images/deer.svg)] mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]" className="absolute inset-0 z-0 translate-y-8 mask-[url(/images/deer.svg)] mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"
squareSize={4} squareSize={4}
gridGap={4} gridGap={4}
color={"white"} color={"white"}
maxOpacity={0.3} maxOpacity={0.3}
flickerChance={0.25} flickerChance={0.25}
/> />
<div className="container-md relative z-10 mx-auto flex h-screen flex-col items-center justify-center"> <div className="container-md relative z-10 mx-auto flex h-screen flex-col items-center justify-center">
<h1 className="flex items-center gap-2 text-4xl font-bold md:text-6xl"> <h1 className="flex items-center gap-2 text-4xl font-bold md:text-6xl">
<WordRotate <WordRotate
words={[ words={[
"Deep Research", "Deep Research",
"Collect Data", "Collect Data",
"Analyze Data", "Analyze Data",
"Generate Webpages", "Generate Webpages",
"Vibe Coding", "Vibe Coding",
"Generate Slides", "Generate Slides",
"Generate Images", "Generate Images",
"Generate Podcasts", "Generate Podcasts",
"Generate Videos", "Generate Videos",
"Generate Songs", "Generate Songs",
"Organize Emails", "Organize Emails",
"Do Anything", "Do Anything",
"Learn Anything", "Learn Anything",
]} ]}
/>{" "} />{" "}
<div>CM 助手</div> <div>CM业务中心-问答管理</div>
</h1> </h1>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && ( {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && (
<a <a
href="https://byteplus.com" href="https://byteplus.com"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<div className="mt-4 flex size-fit items-center rounded-lg border border-white px-3 text-center text-shadow-sm"> <div className="mt-4 flex size-fit items-center rounded-lg border border-white px-3 text-center text-shadow-sm">
<span>In partnership with</span>&nbsp; <span>In partnership with</span>&nbsp;
<BytePlusIcon className="h-4" /> <BytePlusIcon className="h-4" />
</div> </div>
</a> </a>
)} )}
<p className="text-muted-foreground mt-8 scale-105 text-center text-2xl text-shadow-sm"> <p className="text-muted-foreground mt-8 scale-105 text-center text-2xl text-shadow-sm">
An open-source SuperAgent harness that researches, codes, and creates. An open-source SuperAgent harness that researches, codes, and creates.
With With
<br /> <br />
the help of sandboxes, memories, tools, skills and subagents, it the help of sandboxes, memories, tools, skills and subagents, it
handles handles
<br /> <br />
different levels of tasks that could take minutes to hours. different levels of tasks that could take minutes to hours.
</p> </p>
<Link href="/workspace"> <Link href="/workspace">
<Button className="size-lg mt-8 scale-108" size="lg"> <Button className="size-lg mt-8 scale-108" size="lg">
<span className="text-md">Get Started with 2.0</span> <span className="text-md">Get Started with 2.0</span>
<ChevronRightIcon className="size-4" /> <ChevronRightIcon className="size-4" />
</Button> </Button>
</Link> </Link>
</div> </div>
</div> </div>
); );
} }
function BytePlusIcon(props: React.SVGProps<SVGSVGElement>) { function BytePlusIcon(props: React.SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
width={80} width={80}
height={24} height={24}
viewBox="0 0 120 24" viewBox="0 0 120 24"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
{...props} {...props}
> >
<path <path
d="M30.8027 3.64648H37.9333C38.8894 3.64648 39.7259 3.81911 40.4297 4.16435C41.1334 4.50959 41.6911 4.98761 42.0762 5.59843C42.4613 6.20924 42.6472 6.88644 42.6472 7.6566C42.6472 8.34708 42.4746 8.99773 42.1426 9.58198C41.7973 10.1795 41.3326 10.631 40.7483 10.9629C41.5583 11.3082 42.1691 11.8128 42.5808 12.49C42.9924 13.1672 43.1916 13.9108 43.1916 14.734C43.1916 15.5175 42.9924 16.208 42.5941 16.8188C42.1957 17.4296 41.638 17.9209 40.921 18.2661C40.2039 18.6246 39.3408 18.7973 38.3449 18.7973H30.8027V3.64648ZM37.8005 9.99361C38.4113 9.99361 38.9026 9.82099 39.2877 9.47575C39.6728 9.13051 39.8587 8.69232 39.8587 8.1479C39.8587 7.5902 39.6728 7.12546 39.301 6.76694C38.9292 6.40842 38.4379 6.22252 37.8138 6.22252H33.5248V9.99361H37.8005ZM38.1458 16.2345C38.7831 16.2345 39.301 16.0619 39.6993 15.7034C40.0977 15.3449 40.2969 14.8934 40.2969 14.349C40.2969 13.9772 40.2039 13.6319 40.018 13.3398C39.8321 13.0477 39.5798 12.8087 39.2479 12.636C38.9292 12.4634 38.5574 12.3705 38.1325 12.3705H33.5381V16.2212H38.1458V16.2345Z" d="M30.8027 3.64648H37.9333C38.8894 3.64648 39.7259 3.81911 40.4297 4.16435C41.1334 4.50959 41.6911 4.98761 42.0762 5.59843C42.4613 6.20924 42.6472 6.88644 42.6472 7.6566C42.6472 8.34708 42.4746 8.99773 42.1426 9.58198C41.7973 10.1795 41.3326 10.631 40.7483 10.9629C41.5583 11.3082 42.1691 11.8128 42.5808 12.49C42.9924 13.1672 43.1916 13.9108 43.1916 14.734C43.1916 15.5175 42.9924 16.208 42.5941 16.8188C42.1957 17.4296 41.638 17.9209 40.921 18.2661C40.2039 18.6246 39.3408 18.7973 38.3449 18.7973H30.8027V3.64648ZM37.8005 9.99361C38.4113 9.99361 38.9026 9.82099 39.2877 9.47575C39.6728 9.13051 39.8587 8.69232 39.8587 8.1479C39.8587 7.5902 39.6728 7.12546 39.301 6.76694C38.9292 6.40842 38.4379 6.22252 37.8138 6.22252H33.5248V9.99361H37.8005ZM38.1458 16.2345C38.7831 16.2345 39.301 16.0619 39.6993 15.7034C40.0977 15.3449 40.2969 14.8934 40.2969 14.349C40.2969 13.9772 40.2039 13.6319 40.018 13.3398C39.8321 13.0477 39.5798 12.8087 39.2479 12.636C38.9292 12.4634 38.5574 12.3705 38.1325 12.3705H33.5381V16.2212H38.1458V16.2345Z"
fill="currentColor" fill="currentColor"
/> />
<path <path
d="M49.2392 18.2385L44.127 7.3236H46.7295L50.567 15.4899L54.0062 7.3236H57.6445V5.09281L60.2073 4.33594V7.3236H63.0887V9.75357H60.2073V15.304C60.2073 15.7023 60.3135 16.021 60.5259 16.2202C60.7384 16.4327 61.0438 16.5655 61.4422 16.6318C61.8405 16.6982 62.3982 16.7115 63.0887 16.6584V18.9556C61.7741 19.1016 60.7251 19.0618 59.9417 18.8626C59.1582 18.6502 58.574 18.2518 58.2022 17.6676C57.8304 17.0833 57.6445 16.2866 57.6445 15.2907V9.75357H55.5863L49.4782 24.0014H46.7959L49.2392 18.2385Z" d="M49.2392 18.2385L44.127 7.3236H46.7295L50.567 15.4899L54.0062 7.3236H57.6445V5.09281L60.2073 4.33594V7.3236H63.0887V9.75357H60.2073V15.304C60.2073 15.7023 60.3135 16.021 60.5259 16.2202C60.7384 16.4327 61.0438 16.5655 61.4422 16.6318C61.8405 16.6982 62.3982 16.7115 63.0887 16.6584V18.9556C61.7741 19.1016 60.7251 19.0618 59.9417 18.8626C59.1582 18.6502 58.574 18.2518 58.2022 17.6676C57.8304 17.0833 57.6445 16.2866 57.6445 15.2907V9.75357H55.5863L49.4782 24.0014H46.7959L49.2392 18.2385Z"
fill="currentColor" fill="currentColor"
/> />
<path <path
d="M70.2461 19.1147C69.1572 19.1147 68.1746 18.8491 67.2849 18.318C66.3953 17.7869 65.6782 17.0565 65.1604 16.1138C64.6425 15.1843 64.377 14.1485 64.377 13.0066C64.377 11.8912 64.6425 10.882 65.1737 9.97908C65.7048 9.06286 66.4218 8.34582 67.3248 7.82796C68.2277 7.29682 69.2236 7.03125 70.2992 7.03125C71.6536 7.03125 72.7822 7.33666 73.6719 7.93419C74.5616 8.53172 75.2122 9.36827 75.584 10.4438C75.9691 11.5194 76.0753 12.7676 75.916 14.1751L67.0061 14.1884C67.1389 14.7062 67.3779 15.1577 67.7364 15.5295C68.0949 15.9013 68.5066 16.1802 68.958 16.366C69.4095 16.5519 69.8477 16.6316 70.2461 16.6316C70.6975 16.6316 71.1092 16.5918 71.5075 16.4988C71.8926 16.4059 72.2378 16.2731 72.5432 16.0739C72.8486 15.8747 73.1142 15.649 73.3665 15.3967L73.4727 15.2639L75.5043 16.6051C74.9998 17.4416 74.296 18.0657 73.4063 18.4773C72.5167 18.9022 71.4677 19.1147 70.2461 19.1147ZM73.486 11.9576C73.3665 11.4663 73.1673 11.0281 72.8619 10.6563C72.5565 10.2845 72.1847 9.99236 71.7332 9.79318C71.2818 9.594 70.8037 9.48778 70.2992 9.48778C69.8211 9.48778 69.3564 9.594 68.8916 9.79318C68.4269 9.99236 68.0285 10.2845 67.6833 10.6563C67.3381 11.0281 67.1256 11.4663 67.0459 11.9576H73.486Z" d="M70.2461 19.1147C69.1572 19.1147 68.1746 18.8491 67.2849 18.318C66.3953 17.7869 65.6782 17.0565 65.1604 16.1138C64.6425 15.1843 64.377 14.1485 64.377 13.0066C64.377 11.8912 64.6425 10.882 65.1737 9.97908C65.7048 9.06286 66.4218 8.34582 67.3248 7.82796C68.2277 7.29682 69.2236 7.03125 70.2992 7.03125C71.6536 7.03125 72.7822 7.33666 73.6719 7.93419C74.5616 8.53172 75.2122 9.36827 75.584 10.4438C75.9691 11.5194 76.0753 12.7676 75.916 14.1751L67.0061 14.1884C67.1389 14.7062 67.3779 15.1577 67.7364 15.5295C68.0949 15.9013 68.5066 16.1802 68.958 16.366C69.4095 16.5519 69.8477 16.6316 70.2461 16.6316C70.6975 16.6316 71.1092 16.5918 71.5075 16.4988C71.8926 16.4059 72.2378 16.2731 72.5432 16.0739C72.8486 15.8747 73.1142 15.649 73.3665 15.3967L73.4727 15.2639L75.5043 16.6051C74.9998 17.4416 74.296 18.0657 73.4063 18.4773C72.5167 18.9022 71.4677 19.1147 70.2461 19.1147ZM73.486 11.9576C73.3665 11.4663 73.1673 11.0281 72.8619 10.6563C72.5565 10.2845 72.1847 9.99236 71.7332 9.79318C71.2818 9.594 70.8037 9.48778 70.2992 9.48778C69.8211 9.48778 69.3564 9.594 68.8916 9.79318C68.4269 9.99236 68.0285 10.2845 67.6833 10.6563C67.3381 11.0281 67.1256 11.4663 67.0459 11.9576H73.486Z"
fill="currentColor" fill="currentColor"
/> />
<path <path
d="M78 3.64648H84.3206C86.0733 3.64648 87.4278 4.0714 88.3838 4.92122C89.3399 5.77105 89.8179 6.93956 89.8179 8.42675C89.8179 9.39608 89.6054 10.2459 89.1805 10.9762C88.7556 11.7065 88.1315 12.2642 87.3215 12.6493C86.5115 13.0344 85.5156 13.2336 84.3471 13.2336H80.7354V18.7973H78V3.64648ZM84.3737 10.7505C84.9314 10.7505 85.4227 10.6575 85.8078 10.4849C86.2061 10.3123 86.5115 10.0467 86.724 9.70149C86.9364 9.35625 87.0427 8.93133 87.0427 8.42675C87.0427 7.6566 86.8169 7.08562 86.3655 6.71382C85.914 6.34202 85.2501 6.15612 84.387 6.15612H80.7487V10.7505H84.3737Z" d="M78 3.64648H84.3206C86.0733 3.64648 87.4278 4.0714 88.3838 4.92122C89.3399 5.77105 89.8179 6.93956 89.8179 8.42675C89.8179 9.39608 89.6054 10.2459 89.1805 10.9762C88.7556 11.7065 88.1315 12.2642 87.3215 12.6493C86.5115 13.0344 85.5156 13.2336 84.3471 13.2336H80.7354V18.7973H78V3.64648ZM84.3737 10.7505C84.9314 10.7505 85.4227 10.6575 85.8078 10.4849C86.2061 10.3123 86.5115 10.0467 86.724 9.70149C86.9364 9.35625 87.0427 8.93133 87.0427 8.42675C87.0427 7.6566 86.8169 7.08562 86.3655 6.71382C85.914 6.34202 85.2501 6.15612 84.387 6.15612H80.7487V10.7505H84.3737Z"
fill="currentColor" fill="currentColor"
/> />
<path <path
d="M92.168 3.36719H94.8104V18.8101H92.168V3.36719Z" d="M92.168 3.36719H94.8104V18.8101H92.168V3.36719Z"
fill="currentColor" fill="currentColor"
/> />
<path <path
d="M101.982 19.1155C101.158 19.1155 100.415 18.9164 99.764 18.5313C99.1134 18.1462 98.5955 17.6018 98.2237 16.9113C97.8519 16.2208 97.666 15.4507 97.666 14.6008V7.32422H100.282V13.6979C100.282 14.2822 100.375 14.7735 100.574 15.1984C100.773 15.6233 101.052 15.9553 101.424 16.1943C101.796 16.4333 102.247 16.5528 102.765 16.5528C103.256 16.5528 103.708 16.4333 104.106 16.181C104.504 15.942 104.836 15.5835 105.075 15.1187C105.314 14.654 105.434 14.1228 105.434 13.512V7.32422H108.076V18.8101H105.527V17.3628C105.155 17.947 104.651 18.3719 104.04 18.6774C103.442 18.9562 102.752 19.1155 101.982 19.1155Z" d="M101.982 19.1155C101.158 19.1155 100.415 18.9164 99.764 18.5313C99.1134 18.1462 98.5955 17.6018 98.2237 16.9113C97.8519 16.2208 97.666 15.4507 97.666 14.6008V7.32422H100.282V13.6979C100.282 14.2822 100.375 14.7735 100.574 15.1984C100.773 15.6233 101.052 15.9553 101.424 16.1943C101.796 16.4333 102.247 16.5528 102.765 16.5528C103.256 16.5528 103.708 16.4333 104.106 16.181C104.504 15.942 104.836 15.5835 105.075 15.1187C105.314 14.654 105.434 14.1228 105.434 13.512V7.32422H108.076V18.8101H105.527V17.3628C105.155 17.947 104.651 18.3719 104.04 18.6774C103.442 18.9562 102.752 19.1155 101.982 19.1155Z"
fill="currentColor" fill="currentColor"
/> />
<path <path
d="M115.287 19.1163C114.424 19.1163 113.535 18.9304 112.645 18.5586C111.755 18.1868 111.012 17.6424 110.414 16.9386L112.1 15.5045C112.525 15.8763 112.95 16.1817 113.375 16.4075C113.813 16.6332 114.358 16.766 115.048 16.8058C115.752 16.8457 116.35 16.766 116.828 16.5535C117.306 16.3411 117.558 16.0357 117.584 15.6107C117.598 15.3186 117.518 15.0929 117.345 14.9335C117.173 14.7742 116.907 14.6281 116.535 14.5219C116.164 14.4157 115.593 14.2962 114.809 14.1634L114.265 14.0572C113.123 13.8713 112.246 13.5127 111.622 12.9949C110.998 12.477 110.693 11.7334 110.693 10.7906C110.693 10.0072 110.905 9.34329 111.317 8.77231C111.742 8.21461 112.286 7.7897 112.99 7.51085C113.681 7.232 114.451 7.08594 115.314 7.08594C116.19 7.08594 117.053 7.24528 117.863 7.56396C118.687 7.88265 119.364 8.40051 119.882 9.10427L118.209 10.5118C117.903 10.1267 117.505 9.83459 117 9.63541C116.496 9.43624 115.951 9.34329 115.354 9.34329C114.969 9.34329 114.597 9.38312 114.252 9.47607C113.906 9.56902 113.614 9.70181 113.388 9.90098C113.163 10.1002 113.043 10.3392 113.043 10.6446C113.043 10.9102 113.123 11.1093 113.269 11.2554C113.415 11.4015 113.694 11.5342 114.079 11.6405C114.464 11.7467 115.128 11.8928 116.044 12.0654H116.084C117.226 12.2778 118.169 12.6762 118.899 13.2339C119.629 13.7916 120.001 14.5617 120.001 15.5045C120.001 16.2348 119.775 16.8855 119.324 17.4299C118.872 17.9743 118.288 18.3992 117.558 18.6914C116.841 18.9702 116.084 19.1163 115.287 19.1163Z" d="M115.287 19.1163C114.424 19.1163 113.535 18.9304 112.645 18.5586C111.755 18.1868 111.012 17.6424 110.414 16.9386L112.1 15.5045C112.525 15.8763 112.95 16.1817 113.375 16.4075C113.813 16.6332 114.358 16.766 115.048 16.8058C115.752 16.8457 116.35 16.766 116.828 16.5535C117.306 16.3411 117.558 16.0357 117.584 15.6107C117.598 15.3186 117.518 15.0929 117.345 14.9335C117.173 14.7742 116.907 14.6281 116.535 14.5219C116.164 14.4157 115.593 14.2962 114.809 14.1634L114.265 14.0572C113.123 13.8713 112.246 13.5127 111.622 12.9949C110.998 12.477 110.693 11.7334 110.693 10.7906C110.693 10.0072 110.905 9.34329 111.317 8.77231C111.742 8.21461 112.286 7.7897 112.99 7.51085C113.681 7.232 114.451 7.08594 115.314 7.08594C116.19 7.08594 117.053 7.24528 117.863 7.56396C118.687 7.88265 119.364 8.40051 119.882 9.10427L118.209 10.5118C117.903 10.1267 117.505 9.83459 117 9.63541C116.496 9.43624 115.951 9.34329 115.354 9.34329C114.969 9.34329 114.597 9.38312 114.252 9.47607C113.906 9.56902 113.614 9.70181 113.388 9.90098C113.163 10.1002 113.043 10.3392 113.043 10.6446C113.043 10.9102 113.123 11.1093 113.269 11.2554C113.415 11.4015 113.694 11.5342 114.079 11.6405C114.464 11.7467 115.128 11.8928 116.044 12.0654H116.084C117.226 12.2778 118.169 12.6762 118.899 13.2339C119.629 13.7916 120.001 14.5617 120.001 15.5045C120.001 16.2348 119.775 16.8855 119.324 17.4299C118.872 17.9743 118.288 18.3992 117.558 18.6914C116.841 18.9702 116.084 19.1163 115.287 19.1163Z"
fill="currentColor" fill="currentColor"
/> />
<path <path
d="M14.7392 9.80759C14.5931 9.92709 14.3806 9.83414 14.3806 9.63497V8.58596V2.23883C14.3806 2.05293 14.1549 1.9467 14.0221 2.06621L5.17862 9.56857C5.03256 9.68808 4.8201 9.59513 4.8201 9.39595V4.27044C4.8201 4.12438 4.70059 4.00487 4.55453 4.00487H0.26557C0.119507 4.00487 0 4.12438 0 4.27044V20.3242C0 20.5101 0.225735 20.6163 0.35852 20.4968L9.18873 12.9944C9.33479 12.8749 9.54725 12.9679 9.54725 13.1671V20.5765C9.54725 20.7624 9.77299 20.8686 9.90577 20.7491L18.736 13.2467C18.882 13.1272 19.0945 13.2202 19.0945 13.4193V18.5449C19.0945 18.6909 19.214 18.8104 19.3601 18.8104H23.649C23.7951 18.8104 23.9146 18.6909 23.9146 18.5449V2.47784C23.9146 2.29194 23.6889 2.18572 23.5561 2.30522L14.7392 9.80759Z" d="M14.7392 9.80759C14.5931 9.92709 14.3806 9.83414 14.3806 9.63497V8.58596V2.23883C14.3806 2.05293 14.1549 1.9467 14.0221 2.06621L5.17862 9.56857C5.03256 9.68808 4.8201 9.59513 4.8201 9.39595V4.27044C4.8201 4.12438 4.70059 4.00487 4.55453 4.00487H0.26557C0.119507 4.00487 0 4.12438 0 4.27044V20.3242C0 20.5101 0.225735 20.6163 0.35852 20.4968L9.18873 12.9944C9.33479 12.8749 9.54725 12.9679 9.54725 13.1671V20.5765C9.54725 20.7624 9.77299 20.8686 9.90577 20.7491L18.736 13.2467C18.882 13.1272 19.0945 13.2202 19.0945 13.4193V18.5449C19.0945 18.6909 19.214 18.8104 19.3601 18.8104H23.649C23.7951 18.8104 23.9146 18.6909 23.9146 18.5449V2.47784C23.9146 2.29194 23.6889 2.18572 23.5561 2.30522L14.7392 9.80759Z"
fill="currentColor" fill="currentColor"
/> />
</svg> </svg>
); );
} }
+163 -163
View File
@@ -1,163 +1,163 @@
import Link from "next/link"; import Link from "next/link";
import { getBlogRoute, normalizeTagSlug, type BlogPost } from "@/core/blog"; import { getBlogRoute, normalizeTagSlug, type BlogPost } from "@/core/blog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type PostListProps = { type PostListProps = {
description?: string; description?: string;
posts: BlogPost[]; posts: BlogPost[];
title: string; title: string;
}; };
type PostMetaProps = { type PostMetaProps = {
currentLang?: string; currentLang?: string;
date?: string | null; date?: string | null;
languages?: string[]; languages?: string[];
pathname?: string; pathname?: string;
}; };
function formatDate(date?: string): string | null { function formatDate(date?: string): string | null {
if (!date) { if (!date) {
return null; return null;
} }
const value = new Date(date); const value = new Date(date);
if (Number.isNaN(value.getTime())) { if (Number.isNaN(value.getTime())) {
return date; return date;
} }
return new Intl.DateTimeFormat("en-US", { return new Intl.DateTimeFormat("en-US", {
day: "numeric", day: "numeric",
month: "short", month: "short",
year: "numeric", year: "numeric",
}).format(value); }).format(value);
} }
export function PostMeta({ export function PostMeta({
currentLang, currentLang,
date, date,
languages, languages,
pathname, pathname,
}: PostMetaProps) { }: PostMetaProps) {
const formattedDate = formatDate(date ?? undefined); const formattedDate = formatDate(date ?? undefined);
const availableLanguages = Array.isArray(languages) const availableLanguages = Array.isArray(languages)
? languages.filter((lang): lang is string => typeof lang === "string") ? languages.filter((lang): lang is string => typeof lang === "string")
: []; : [];
if (!formattedDate && availableLanguages.length <= 1) { if (!formattedDate && availableLanguages.length <= 1) {
return null; return null;
} }
return ( return (
<div className="flex flex-wrap items-center gap-8 text-sm"> <div className="flex flex-wrap items-center gap-8 text-sm">
{formattedDate ? ( {formattedDate ? (
<p className="text-muted-foreground">{formattedDate}</p> <p className="text-muted-foreground">{formattedDate}</p>
) : null} ) : null}
{pathname && availableLanguages.length > 1 ? ( {pathname && availableLanguages.length > 1 ? (
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<span className="text-secondary-foreground text-sm">Language:</span> <span className="text-secondary-foreground text-sm">Language:</span>
{availableLanguages.map((lang) => { {availableLanguages.map((lang) => {
const isActive = lang === currentLang; const isActive = lang === currentLang;
return ( return (
<Link <Link
key={lang} key={lang}
href={`${pathname}?lang=${lang}`} href={`${pathname}?lang=${lang}`}
className={ className={
isActive isActive
? "text-foreground text-sm font-medium" ? "text-foreground text-sm font-medium"
: "text-muted-foreground hover:text-foreground text-sm transition-colors" : "text-muted-foreground hover:text-foreground text-sm transition-colors"
} }
> >
{lang.toUpperCase()} {lang.toUpperCase()}
</Link> </Link>
); );
})} })}
</div> </div>
) : null} ) : null}
</div> </div>
); );
} }
export function PostTags({ export function PostTags({
tags, tags,
className, className,
}: { }: {
tags?: unknown; tags?: unknown;
className?: string; className?: string;
}) { }) {
if (!Array.isArray(tags)) { if (!Array.isArray(tags)) {
return null; return null;
} }
const validTags = tags.filter( const validTags = tags.filter(
(tag): tag is string => typeof tag === "string" && tag.length > 0, (tag): tag is string => typeof tag === "string" && tag.length > 0,
); );
if (validTags.length === 0) { if (validTags.length === 0) {
return null; return null;
} }
return ( return (
<div className={cn("flex flex-wrap items-center gap-3", className)}> <div className={cn("flex flex-wrap items-center gap-3", className)}>
<span className="text-secondary-foreground text-sm">Tags:</span> <span className="text-secondary-foreground text-sm">Tags:</span>
{validTags.map((tag) => ( {validTags.map((tag) => (
<Link <Link
key={tag} key={tag}
href={`/blog/tags/${normalizeTagSlug(tag)}`} href={`/blog/tags/${normalizeTagSlug(tag)}`}
className="border-border text-secondary-foreground hover:text-foreground rounded-xl border px-2 py-1 text-sm transition-colors" className="border-border text-secondary-foreground hover:text-foreground rounded-xl border px-2 py-1 text-sm transition-colors"
> >
{tag} {tag}
</Link> </Link>
))} ))}
</div> </div>
); );
} }
export function PostList({ description, posts, title }: PostListProps) { export function PostList({ description, posts, title }: PostListProps) {
return ( return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-12 px-6"> <div className="mx-auto flex w-full max-w-5xl flex-col gap-12 px-6">
<header className="space-y-4"> <header className="space-y-4">
<h2 className="text-foreground text-4xl font-semibold tracking-tight"> <h2 className="text-foreground text-4xl font-semibold tracking-tight">
{title} {title}
</h2> </h2>
{description ? ( {description ? (
<p className="text-secondary-foreground">{description}</p> <p className="text-secondary-foreground">{description}</p>
) : null} ) : null}
</header> </header>
<div className="space-y-12"> <div className="space-y-12">
{posts.map((post) => { {posts.map((post) => {
return ( return (
<article <article
key={post.slug.join("/")} key={post.slug.join("/")}
className="border-border space-y-5 border-b pb-12 last:border-b-0 last:pb-0" className="border-border space-y-5 border-b pb-12 last:border-b-0 last:pb-0"
> >
<div className="space-y-3"> <div className="space-y-3">
<PostMeta <PostMeta
currentLang={post.lang} currentLang={post.lang}
date={post.metadata.date} date={post.metadata.date}
languages={post.languages} languages={post.languages}
pathname={getBlogRoute(post.slug)} pathname={getBlogRoute(post.slug)}
/> />
<Link <Link
href={getBlogRoute(post.slug)} href={getBlogRoute(post.slug)}
className="text-foreground hover:text-primary block text-2xl font-semibold tracking-tight transition-colors" className="text-foreground hover:text-primary block text-2xl font-semibold tracking-tight transition-colors"
> >
{post.title} {post.title}
</Link> </Link>
</div> </div>
{post.metadata.description ? ( {post.metadata.description ? (
<p className="text-secondary-foreground leading-10"> <p className="text-secondary-foreground leading-10">
{post.metadata.description} {post.metadata.description}
</p> </p>
) : null} ) : null}
<PostTags tags={post.metadata.tags} /> <PostTags tags={post.metadata.tags} />
</article> </article>
); );
})} })}
</div> </div>
</div> </div>
); );
} }
File diff suppressed because it is too large. Load diff
+29 -29
View File
@@ -1,29 +1,29 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function Section({ export function Section({
className, className,
title, title,
subtitle, subtitle,
children, children,
}: { }: {
className?: string; className?: string;
title: React.ReactNode; title: React.ReactNode;
subtitle?: React.ReactNode; subtitle?: React.ReactNode;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className={cn("mx-auto flex flex-col py-16", className)}> <section className={cn("mx-auto flex flex-col py-16", className)}>
<header className="flex flex-col items-center justify-between"> <header className="flex flex-col items-center justify-between">
<div className="mb-4 bg-linear-to-r from-white via-gray-200 to-gray-400 bg-clip-text text-center text-5xl font-bold text-transparent"> <div className="mb-4 bg-linear-to-r from-white via-gray-200 to-gray-400 bg-clip-text text-center text-5xl font-bold text-transparent">
{title} {title}
</div> </div>
{subtitle && ( {subtitle && (
<div className="text-muted-foreground text-center text-xl"> <div className="text-muted-foreground text-center text-xl">
{subtitle} {subtitle}
</div> </div>
)} )}
</header> </header>
<main className="mt-4">{children}</main> <main className="mt-4">{children}</main>
</section> </section>
); );
} }
@@ -1,99 +1,99 @@
import Link from "next/link"; import Link from "next/link";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { pathOfThread } from "@/core/threads/utils"; import { pathOfThread } from "@/core/threads/utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Section } from "../section"; import { Section } from "../section";
export function CaseStudySection({ className }: { className?: string }) { export function CaseStudySection({ className }: { className?: string }) {
const caseStudies = [ const caseStudies = [
{ {
threadId: "7cfa5f8f-a2f8-47ad-acbd-da7137baf990", threadId: "7cfa5f8f-a2f8-47ad-acbd-da7137baf990",
title: "Forecast 2026 Agent Trends and Opportunities", title: "Forecast 2026 Agent Trends and Opportunities",
description: description:
"Create a webpage with a Deep Research report forecasting the agent technology trends and opportunities in 2026.", "Create a webpage with a Deep Research report forecasting the agent technology trends and opportunities in 2026.",
}, },
{ {
threadId: "4f3e55ee-f853-43db-bfb3-7d1a411f03cb", threadId: "4f3e55ee-f853-43db-bfb3-7d1a411f03cb",
title: 'Generate a Video Based On the Novel "Pride and Prejudice"', title: 'Generate a Video Based On the Novel "Pride and Prejudice"',
description: description:
'Search the specific scene from the novel "Pride and Prejudice", then generate a video as well as a reference image based on the scenes.', 'Search the specific scene from the novel "Pride and Prejudice", then generate a video as well as a reference image based on the scenes.',
}, },
{ {
threadId: "21cfea46-34bd-4aa6-9e1f-3009452fbeb9", threadId: "21cfea46-34bd-4aa6-9e1f-3009452fbeb9",
title: "Doraemon Explains the MOE Architecture", title: "Doraemon Explains the MOE Architecture",
description: description:
"Generate a Doraemon comic strip explaining the MOE architecture to the teenagers who are interested in AI.", "Generate a Doraemon comic strip explaining the MOE architecture to the teenagers who are interested in AI.",
}, },
{ {
threadId: "ad76c455-5bf9-4335-8517-fc03834ab828", threadId: "ad76c455-5bf9-4335-8517-fc03834ab828",
title: "An Exploratory Data Analysis of the Titanic Dataset", title: "An Exploratory Data Analysis of the Titanic Dataset",
description: description:
"Explore the Titanic dataset and identify the key factors that influenced survival rates with visualizations and insights.", "Explore the Titanic dataset and identify the key factors that influenced survival rates with visualizations and insights.",
}, },
{ {
threadId: "d3e5adaf-084c-4dd5-9d29-94f1d6bccd98", threadId: "d3e5adaf-084c-4dd5-9d29-94f1d6bccd98",
title: "Watch Y Combinator's Video then Conduct a Deep Research", title: "Watch Y Combinator's Video then Conduct a Deep Research",
description: description:
"Watch the given Y Combinator's YouTube video and conduct a deep research on the YC's tips for technical startup founders.", "Watch the given Y Combinator's YouTube video and conduct a deep research on the YC's tips for technical startup founders.",
}, },
{ {
threadId: "3823e443-4e2b-4679-b496-a9506eae462b", threadId: "3823e443-4e2b-4679-b496-a9506eae462b",
title: "Collect and Summarize Dr. Fei Fei Li's Podcasts", title: "Collect and Summarize Dr. Fei Fei Li's Podcasts",
description: description:
"Collect all the podcast appearances of Dr. Fei Fei Li in the last 6 months, then summarize them into a comprehensive report.", "Collect all the podcast appearances of Dr. Fei Fei Li in the last 6 months, then summarize them into a comprehensive report.",
}, },
]; ];
return ( return (
<Section <Section
className={className} className={className}
title="Case Studies" title="Case Studies"
subtitle="CM 助手" subtitle="CM业务中心-问答管理"
> >
<div className="container-md mt-8 grid grid-cols-1 gap-4 px-4 md:grid-cols-2 md:px-20 lg:grid-cols-3"> <div className="container-md mt-8 grid grid-cols-1 gap-4 px-4 md:grid-cols-2 md:px-20 lg:grid-cols-3">
{caseStudies.map((caseStudy) => ( {caseStudies.map((caseStudy) => (
<Link <Link
key={caseStudy.title} key={caseStudy.title}
href={pathOfThread(caseStudy.threadId) + "?mock=true"} href={pathOfThread(caseStudy.threadId) + "?mock=true"}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<Card className="group/card relative h-64 overflow-hidden"> <Card className="group/card relative h-64 overflow-hidden">
<div <div
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat transition-all duration-300 group-hover/card:scale-110 group-hover/card:brightness-90" className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat transition-all duration-300 group-hover/card:scale-110 group-hover/card:brightness-90"
style={{ style={{
backgroundImage: `url(/images/${caseStudy.threadId}.jpg)`, backgroundImage: `url(/images/${caseStudy.threadId}.jpg)`,
}} }}
></div> ></div>
<div <div
className={cn( className={cn(
"flex h-full w-full translate-y-[calc(100%-60px)] flex-col items-center", "flex h-full w-full translate-y-[calc(100%-60px)] flex-col items-center",
"transition-all duration-300", "transition-all duration-300",
"group-hover/card:translate-y-[calc(100%-128px)]", "group-hover/card:translate-y-[calc(100%-128px)]",
)} )}
> >
<div <div
className="flex w-full flex-col p-4" className="flex w-full flex-col p-4"
style={{ style={{
background: background:
"linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 100%)", "linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 100%)",
}} }}
> >
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h3 className="flex h-14 items-center text-xl font-bold text-shadow-black"> <h3 className="flex h-14 items-center text-xl font-bold text-shadow-black">
{caseStudy.title} {caseStudy.title}
</h3> </h3>
<p className="box-shadow-black overflow-hidden text-sm text-white/85 text-shadow-black"> <p className="box-shadow-black overflow-hidden text-sm text-white/85 text-shadow-black">
{caseStudy.description} {caseStudy.description}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</Card> </Card>
</Link> </Link>
))} ))}
</div> </div>
</Section> </Section>
); );
} }
@@ -1,20 +1,35 @@
"use client"; "use client";
import { AuroraText } from "@/components/ui/aurora-text"; import { GitHubLogoIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { Section } from "../section";
import { AuroraText } from "@/components/ui/aurora-text";
export function CommunitySection() { import { Button } from "@/components/ui/button";
return (
<Section import { Section } from "../section";
title={
<AuroraText colors={["#60A5FA", "#A5FA60", "#A560FA"]}> export function CommunitySection() {
Join the Community return (
</AuroraText> <Section
} title={
subtitle="Contribute brilliant ideas to shape the future of CM Assistant. Collaborate, innovate, and make impacts." <AuroraText colors={["#60A5FA", "#A5FA60", "#A560FA"]}>
> Join the Community
<div /> </AuroraText>
</Section> }
); subtitle="Contribute brilliant ideas to shape the future of DeerFlow. Collaborate, innovate, and make impacts."
} >
<div className="flex justify-center">
<Button className="text-xl" size="lg" asChild>
<Link
href="https://github.com/bytedance/deer-flow"
target="_blank"
rel="noopener noreferrer"
>
<GitHubLogoIcon />
Contribute Now
</Link>
</Button>
</div>
</Section>
);
}
@@ -1,126 +1,126 @@
"use client"; "use client";
import { import {
AnimatedSpan, AnimatedSpan,
Terminal, Terminal,
TypingAnimation, TypingAnimation,
} from "@/components/ui/terminal"; } from "@/components/ui/terminal";
import { Section } from "../section"; import { Section } from "../section";
export function SandboxSection({ className }: { className?: string }) { export function SandboxSection({ className }: { className?: string }) {
return ( return (
<Section <Section
className={className} className={className}
title="Agent Runtime Environment" title="Agent Runtime Environment"
subtitle={ subtitle={
<p> <p>
We give CM Assistant a &quot;computer&quot;, which can execute commands, We give DeerFlow a &quot;computer&quot;, which can execute commands,
manage files, and run long tasks — all in a secure Docker-based manage files, and run long tasks — all in a secure Docker-based
sandbox sandbox
</p> </p>
} }
> >
<div className="mt-8 flex w-full max-w-6xl flex-col items-center gap-12 lg:flex-row lg:gap-16"> <div className="mt-8 flex w-full max-w-6xl flex-col items-center gap-12 lg:flex-row lg:gap-16">
{/* Left: Terminal */} {/* Left: Terminal */}
<div className="w-full flex-1"> <div className="w-full flex-1">
<Terminal className="h-[360px] w-full"> <Terminal className="h-[360px] w-full">
{/* Scene 1: Build a Game */} {/* Scene 1: Build a Game */}
<TypingAnimation>$ cat requirements.txt</TypingAnimation> <TypingAnimation>$ cat requirements.txt</TypingAnimation>
<AnimatedSpan delay={800} className="text-zinc-400"> <AnimatedSpan delay={800} className="text-zinc-400">
pygame==2.5.0 pygame==2.5.0
</AnimatedSpan> </AnimatedSpan>
<TypingAnimation delay={1200}> <TypingAnimation delay={1200}>
$ pip install -r requirements.txt $ pip install -r requirements.txt
</TypingAnimation> </TypingAnimation>
<AnimatedSpan delay={2000} className="text-green-500"> <AnimatedSpan delay={2000} className="text-green-500">
✔ Installed pygame ✔ Installed pygame
</AnimatedSpan> </AnimatedSpan>
<TypingAnimation delay={2400}> <TypingAnimation delay={2400}>
$ write game.py --lines 156 $ write game.py --lines 156
</TypingAnimation> </TypingAnimation>
<AnimatedSpan delay={3200} className="text-blue-500"> <AnimatedSpan delay={3200} className="text-blue-500">
✔ Written 156 lines ✔ Written 156 lines
</AnimatedSpan> </AnimatedSpan>
<TypingAnimation delay={3600}> <TypingAnimation delay={3600}>
$ python game.py --test $ python game.py --test
</TypingAnimation> </TypingAnimation>
<AnimatedSpan delay={4200} className="text-green-500"> <AnimatedSpan delay={4200} className="text-green-500">
✔ All sprites loaded ✔ All sprites loaded
</AnimatedSpan> </AnimatedSpan>
<AnimatedSpan delay={4500} className="text-green-500"> <AnimatedSpan delay={4500} className="text-green-500">
✔ Physics engine OK ✔ Physics engine OK
</AnimatedSpan> </AnimatedSpan>
<AnimatedSpan delay={4800} className="text-green-500"> <AnimatedSpan delay={4800} className="text-green-500">
✔ 60 FPS stable ✔ 60 FPS stable
</AnimatedSpan> </AnimatedSpan>
{/* Scene 2: Data Analysis */} {/* Scene 2: Data Analysis */}
<TypingAnimation delay={5400}> <TypingAnimation delay={5400}>
$ curl -O sales-2024.csv $ curl -O sales-2024.csv
</TypingAnimation> </TypingAnimation>
<AnimatedSpan delay={6200} className="text-zinc-400"> <AnimatedSpan delay={6200} className="text-zinc-400">
Downloaded 12.4 MB Downloaded 12.4 MB
</AnimatedSpan> </AnimatedSpan>
</Terminal> </Terminal>
</div> </div>
{/* Right: Description */} {/* Right: Description */}
<div className="w-full flex-1 space-y-6"> <div className="w-full flex-1 space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm font-medium tracking-wider text-purple-400 uppercase"> <p className="text-sm font-medium tracking-wider text-purple-400 uppercase">
Open-source Open-source
</p> </p>
<h2 className="text-4xl font-bold tracking-tight lg:text-5xl"> <h2 className="text-4xl font-bold tracking-tight lg:text-5xl">
<a <a
href="https://github.com/agent-infra/sandbox" href="https://github.com/agent-infra/sandbox"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
AIO Sandbox AIO Sandbox
</a> </a>
</h2> </h2>
</div> </div>
<div className="space-y-4 text-lg text-zinc-400"> <div className="space-y-4 text-lg text-zinc-400">
<p> <p>
We recommend using{" "} We recommend using{" "}
<a <a
href="https://github.com/agent-infra/sandbox" href="https://github.com/agent-infra/sandbox"
className="underline" className="underline"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
All-in-One Sandbox All-in-One Sandbox
</a>{" "} </a>{" "}
that combines Browser, Shell, File, MCP and VSCode Server in a that combines Browser, Shell, File, MCP and VSCode Server in a
single Docker container. single Docker container.
</p> </p>
</div> </div>
{/* Feature Tags */} {/* Feature Tags */}
<div className="flex flex-wrap gap-3 pt-4"> <div className="flex flex-wrap gap-3 pt-4">
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300"> <span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300">
Isolated Isolated
</span> </span>
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300"> <span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300">
Safe Safe
</span> </span>
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300"> <span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300">
Persistent Persistent
</span> </span>
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300"> <span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300">
Mountable FS Mountable FS
</span> </span>
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300"> <span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300">
Long-running Long-running
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</Section> </Section>
); );
} }
@@ -1,28 +1,28 @@
"use client"; "use client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import ProgressiveSkillsAnimation from "../progressive-skills-animation"; import ProgressiveSkillsAnimation from "../progressive-skills-animation";
import { Section } from "../section"; import { Section } from "../section";
export function SkillsSection({ className }: { className?: string }) { export function SkillsSection({ className }: { className?: string }) {
return ( return (
<Section <Section
className={cn("h-[calc(100vh-64px)] w-full bg-white/2", className)} className={cn("h-[calc(100vh-64px)] w-full bg-white/2", className)}
title="Agent Skills" title="Agent Skills"
subtitle={ subtitle={
<div> <div>
Agent Skills are loaded progressively — only what&apos;s needed, when Agent Skills are loaded progressively — only what&apos;s needed, when
it&apos;s needed. it&apos;s needed.
<br /> <br />
Extend CM Assistant with your own skill files, or use our built-in Extend DeerFlow with your own skill files, or use our built-in
library. library.
</div> </div>
} }
> >
<div className="relative overflow-hidden"> <div className="relative overflow-hidden">
<ProgressiveSkillsAnimation /> <ProgressiveSkillsAnimation />
</div> </div>
</Section> </Section>
); );
} }
@@ -1,63 +1,63 @@
"use client"; "use client";
import MagicBento, { type BentoCardProps } from "@/components/ui/magic-bento"; import MagicBento, { type BentoCardProps } from "@/components/ui/magic-bento";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Section } from "../section"; import { Section } from "../section";
const COLOR = "#0a0a0a"; const COLOR = "#0a0a0a";
const features: BentoCardProps[] = [ const features: BentoCardProps[] = [
{ {
color: COLOR, color: COLOR,
label: "Context Engineering", label: "Context Engineering",
title: "Long/Short-term Memory", title: "Long/Short-term Memory",
description: "Now the agent can better understand you", description: "Now the agent can better understand you",
}, },
{ {
color: COLOR, color: COLOR,
label: "Long Task Running", label: "Long Task Running",
title: "Planning and Sub-tasking", title: "Planning and Sub-tasking",
description: description:
"Plans ahead, reasons through complexity, then executes sequentially or in parallel", "Plans ahead, reasons through complexity, then executes sequentially or in parallel",
}, },
{ {
color: COLOR, color: COLOR,
label: "Extensible", label: "Extensible",
title: "Skills and Tools", title: "Skills and Tools",
description: description:
"Plug, play, or even swap built-in tools. Build the agent you want.", "Plug, play, or even swap built-in tools. Build the agent you want.",
}, },
{ {
color: COLOR, color: COLOR,
label: "Persistent", label: "Persistent",
title: "Sandbox with File System", title: "Sandbox with File System",
description: "Read, write, run — like a real computer", description: "Read, write, run — like a real computer",
}, },
{ {
color: COLOR, color: COLOR,
label: "Flexible", label: "Flexible",
title: "Multi-Model Support", title: "Multi-Model Support",
description: "Doubao, DeepSeek, OpenAI, Gemini, etc.", description: "Doubao, DeepSeek, OpenAI, Gemini, etc.",
}, },
{ {
color: COLOR, color: COLOR,
label: "Free", label: "Free",
title: "Open Source", title: "Open Source",
description: "MIT License, self-hosted, full control", description: "MIT License, self-hosted, full control",
}, },
]; ];
export function WhatsNewSection({ className }: { className?: string }) { export function WhatsNewSection({ className }: { className?: string }) {
return ( return (
<Section <Section
className={cn("", className)} className={cn("", className)}
title="CM 助手" title="CM业务中心-问答管理"
subtitle="CM 助手" subtitle="CM业务中心-问答管理"
> >
<div className="flex w-full items-center justify-center"> <div className="flex w-full items-center justify-center">
<MagicBento data={features} /> <MagicBento data={features} />
</div> </div>
</Section> </Section>
); );
} }
-323
View File
@@ -1,323 +0,0 @@
.sidebar-contnet {
position: absolute;
left: 0;
top: 4rem;
z-index: 40;
width: 98px;
height: calc(100vh - 4rem);
flex-shrink: 0;
}
.sidebar-contents {
width: 98px;
min-height: calc(100% - 20px);
background: rgba(3, 38, 80, 0.8);
border: 1px solid;
border-color: rgba(43, 143, 254, 0.6);
border-radius: 4px;
backdrop-filter: blur(2px);
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 10px;
position: relative;
height: calc(100% - 20px);
box-sizing: border-box;
transform: translate(10px, 10px);
}
.item-nav-group {
width: 100%;
}
.item-nav-wrap {
position: relative;
width: 100%;
}
.item-nav-group:first-child .item-nav {
margin-top: 0;
}
.item-nav {
width: 100%;
height: 75px;
margin-top: 13px;
font-family: Noto Sans S Chinese;
font-weight: 500;
color: #fff;
font-size: 20px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
background: #000c1c26;
border: 1px solid;
border-color: #55a6fe33;
box-shadow: 0 0 20px #00b9ff26 inset;
position: relative;
}
.item-nav:hover,
.item-nav-active,
.item-nav-hovered {
color: #ffffff;
background: linear-gradient(134.25deg,
rgba(0, 98, 217, 0.59) 2.28%,
rgba(0, 98, 217, 0.3) 99.59%) !important;
background-size: cover;
border: 1px solid;
border-color: rgba(255, 255, 255, 0.2);
position: relative;
}
.item-nav:hover::before,
.item-nav-active::before,
.item-nav-hovered::before {
content: '';
position: absolute;
right: -10px;
top: 50%;
transform: translateY(-50%);
width: 0;
height: 0;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-left: 10px solid rgba(0, 98, 217, 0.45);
pointer-events: none;
}
.item-nav:hover::after,
.item-nav-active::after,
.item-nav-hovered::after {
content: '';
position: absolute;
left: 0;
top: 29px;
width: 77px;
height: 45px;
background: url('../assets/icon/nav-line-logo.svg') no-repeat;
background-size: cover;
}
.item-nav__label {
display: block;
width: 3em;
margin: 0 auto;
padding-left: 6px;
box-sizing: border-box;
letter-spacing: 6px;
word-break: break-all;
}
.item-nav-content {
width: 155px;
height: calc(100vh - 4rem - 20px);
min-height: calc(100vh - 4rem - 20px);
background: rgba(3, 38, 80, 0.8);
border: 1px solid;
border-color: rgba(43, 143, 254, 0.6);
border-radius: 4px;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
position: absolute;
left: 108px;
top: 0;
z-index: 1000;
padding: 14px 13px 18px 16px;
overflow-y: auto;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.2s ease, visibility 0.2s ease;
}
.item-nav-content::before {
content: '';
position: absolute;
left: -30px;
top: 0;
width: 30px;
height: 100%;
}
.item-nav-hover-bridge {
position: absolute;
right: -30px;
top: 0;
width: 30px;
height: 100%;
z-index: 999;
}
.item-nav-content--visible {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.sidebar-flyout-list {
display: flex;
flex-direction: column;
gap: 2px;
position: relative;
}
.sidebar-flyout-list--level-1,
.sidebar-flyout-list--level-2,
.sidebar-flyout-list--level-3 {
margin-left: 19px;
padding-left: 14px;
}
.sidebar-flyout-node {
position: relative;
}
.sidebar-flyout-node--has-children.sidebar-flyout-node--expanded>.sidebar-flyout-list::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 1px;
height: 100%;
background: rgba(43, 143, 254, 0.22);
}
.sidebar-flyout-item {
width: 100%;
min-height: 34px;
display: flex;
align-items: center;
gap: 9px;
border: 0;
background: transparent;
padding: 3px 0;
font-family: Noto Sans S Chinese;
font-size: 18px;
line-height: 16px;
font-weight: 500;
letter-spacing: 0;
color: rgba(255, 255, 255, 0.8);
text-align: left;
cursor: pointer;
transition: color 0.2s ease, text-shadow 0.2s ease;
}
.sidebar-flyout-item:hover,
.sidebar-flyout-item--active {
color: #48ddff;
text-shadow: 0 0 8px rgba(72, 221, 255, 0.25);
}
.sidebar-flyout-item--leaf {
justify-content: center;
padding-left: 0;
}
.sidebar-flyout-list--level-1>.sidebar-flyout-node>.sidebar-flyout-item--leaf,
.sidebar-flyout-list--level-2>.sidebar-flyout-node>.sidebar-flyout-item--leaf,
.sidebar-flyout-list--level-3>.sidebar-flyout-node>.sidebar-flyout-item--leaf {
padding-left: 0;
}
.sidebar-flyout-arrow {
width: 9px;
height: 9px;
flex: 0 0 9px;
background: url('../assets/icon/down-logo.svg') center / contain no-repeat;
transform: rotate(-90deg);
transition: transform 0.2s ease;
}
.sidebar-flyout-node--expanded>.sidebar-flyout-item>.sidebar-flyout-arrow {
transform: rotate(0deg);
}
.sidebar-flyout-label {
flex: 1;
white-space: nowrap;
}
.sidebar-collapse-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 8px;
cursor: pointer;
}
.sidebar-collapse-btn__icon {
width: 16px;
height: 16px;
flex: 0 0 16px;
color: #80e9ff;
}
.sidebar-collapse-btn__label {
font-size: 14px;
line-height: 1;
white-space: nowrap;
}
.sidebar-collapse-btn--inline {
width: calc(100% - 6px);
min-height: 58px;
margin-top: auto;
margin-bottom: 2px;
flex-direction: column;
gap: 6px;
border: 1px solid #55a6fe33;
background: #000c1c26;
color: rgba(255, 255, 255, 0.9);
box-shadow: inset 0 0 16px #00b9ff1f;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.sidebar-collapse-btn--inline .sidebar-collapse-btn__label {
font-size: 16px;
line-height: 1.1;
}
.sidebar-collapse-btn--inline:hover {
background: rgba(0, 98, 217, 0.3);
border-color: rgba(255, 255, 255, 0.25);
}
.sidebar-collapse-btn--floating {
position: fixed;
left: 10px;
bottom: 10px;
z-index: 60;
width: 36px;
height: 36px;
min-height: 36px;
padding: 0 10px;
border: 1px solid rgba(128, 233, 255, 0.35);
background: rgba(3, 38, 80, 0.82);
color: rgba(205, 245, 255, 0.95);
backdrop-filter: blur(2px);
transition: width 0.22s ease, opacity 0.2s ease, background-color 0.2s ease, border-color 0.2s ease;
overflow: hidden;
}
.sidebar-collapse-btn--floating:hover {
width: 104px;
background: rgba(7, 52, 110, 0.9);
border-color: rgba(128, 233, 255, 0.65);
}
.sidebar-collapse-btn--floating .sidebar-collapse-btn__label {
max-width: 0;
opacity: 0;
transform: translateX(-4px);
transition: max-width 0.22s ease, opacity 0.16s ease, transform 0.22s ease;
}
.sidebar-collapse-btn--floating:hover .sidebar-collapse-btn__label {
max-width: 64px;
opacity: 1;
transform: translateX(0);
}
Loaded 100 of 2378 files, more files were not shown because too many files have changed in this diff. Show more